Compare commits
24 Commits
0ff5eb7bc9
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c76f2d816 | |||
| c46f14f6a6 | |||
| b2b00d321e | |||
| 1a11b9ac60 | |||
| a0bc46f522 | |||
| 1458d8ce49 | |||
| 6ae396e605 | |||
| ed2badfd35 | |||
| 1ff57b29f9 | |||
| c50a850ac9 | |||
| 15833f3685 | |||
| bf28a678c9 | |||
| 14090b96f4 | |||
| 8e6b1877cc | |||
| 9accb488f2 | |||
| 79a6d9a594 | |||
| 7d02e70459 | |||
| 4c190de479 | |||
| b36d1e02f5 | |||
| 53b290ab49 | |||
| b422430994 | |||
| 151678f0fe | |||
| cb2c38aaa1 | |||
| 00664c66a8 |
@@ -2,7 +2,9 @@
|
|||||||
<application
|
<application
|
||||||
android:label="Marianum Fulda"
|
android:label="Marianum Fulda"
|
||||||
android:name="${applicationName}"
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -23,23 +25,85 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</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>
|
</queries>
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<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>
|
</manifest>
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
package eu.mhsl.marianum.mobile.client
|
package eu.mhsl.marianum.mobile.client
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
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" />
|
||||||
@@ -9,6 +9,29 @@ rootProject.buildDir = '../build'
|
|||||||
subprojects {
|
subprojects {
|
||||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
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 {
|
subprojects {
|
||||||
project.evaluationDependsOn(':app')
|
project.evaluationDependsOn(':app')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ plugins {
|
|||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
id "com.android.application" version '8.13.2' apply false
|
id "com.android.application" version '8.13.2' apply false
|
||||||
id "com.android.library" 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'
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -34,6 +34,10 @@ target 'Runner' do
|
|||||||
pod 'PhoneNumberKit', '~> 3.7.6'
|
pod 'PhoneNumberKit', '~> 3.7.6'
|
||||||
|
|
||||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||||
|
|
||||||
|
target 'Share Extension' do
|
||||||
|
inherit! :search_paths
|
||||||
|
end
|
||||||
# target 'RunnerTests' do
|
# target 'RunnerTests' do
|
||||||
# inherit! :search_paths
|
# inherit! :search_paths
|
||||||
# end
|
# end
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<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>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
|||||||
@@ -4,5 +4,10 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# iOS Share Extension — Xcode Setup
|
||||||
|
|
||||||
|
Die Quellen unter `ios/Share Extension/` müssen einmalig in Xcode als **Share Extension Target** verdrahtet werden — analog zur `TimetableWidgetExtension`. Erst danach taucht „Marianum Fulda" im System-Share-Sheet auf.
|
||||||
|
|
||||||
|
## Schritt 1 — Share-Extension-Target anlegen
|
||||||
|
|
||||||
|
1. `ios/Runner.xcworkspace` in Xcode öffnen.
|
||||||
|
2. Projekt-Sidebar → `Runner` (Projekt-Root) → **+ Add Target** unten links.
|
||||||
|
3. **iOS → Share Extension** wählen.
|
||||||
|
4. Eigenschaften:
|
||||||
|
- Product Name: `Share Extension` (mit Leerzeichen, exakt so — der Ordnername und Podfile-Eintrag matchen).
|
||||||
|
- Bundle Identifier: `eu.mhsl.marianum.mobile.client.Share-Extension`.
|
||||||
|
- Language: Swift.
|
||||||
|
- Embed in: Runner.
|
||||||
|
5. Beim Activate-Scheme-Dialog auf **Cancel** klicken.
|
||||||
|
6. Deployment Target = mind. iOS 12.0 (Plugin-Mindestanforderung).
|
||||||
|
|
||||||
|
## Schritt 2 — Vorhandene Quelldateien ins Target ziehen
|
||||||
|
|
||||||
|
Xcode legt Dummy-Dateien an. Diese **löschen** (Move to Trash). Dann:
|
||||||
|
|
||||||
|
1. Sidebar → Rechtsklick auf den Ordner `Share Extension` → **Add Files to "Runner"…**
|
||||||
|
2. Im File-Picker zu `ios/Share Extension/` navigieren und folgende Dateien selektieren:
|
||||||
|
- `ShareViewController.swift`
|
||||||
|
- `Info.plist`
|
||||||
|
- `MainInterface.storyboard`
|
||||||
|
- `Share Extension.entitlements`
|
||||||
|
3. **Wichtig**: bei „Add to targets" nur `Share Extension` ankreuzen, **nicht** Runner.
|
||||||
|
|
||||||
|
## Schritt 3 — App Group aktivieren
|
||||||
|
|
||||||
|
Beide Targets brauchen die App-Group-Berechtigung, damit die Extension geteilte Dateien für die Hauptapp im gemeinsamen Container ablegen kann.
|
||||||
|
|
||||||
|
1. **Runner**-Target → **Signing & Capabilities** → **+ Capability** → **App Groups**.
|
||||||
|
- Group-ID hinzufügen: `group.eu.mhsl.marianum.mobile.client.share` (zusätzlich zur bereits existierenden Widget-Group).
|
||||||
|
2. Dasselbe für **Share Extension**-Target — mit derselben Group-ID `group.eu.mhsl.marianum.mobile.client.share`.
|
||||||
|
|
||||||
|
Im Apple-Developer-Portal muss diese App-Group bei beiden App-IDs eingetragen sein, sonst schlägt das Provisioning fehl.
|
||||||
|
|
||||||
|
## Schritt 4 — User-Defined Build Setting `CUSTOM_GROUP_ID`
|
||||||
|
|
||||||
|
Beide Targets brauchen das User-Defined Setting, das in `Runner/Info.plist` und `Share Extension/Info.plist` als `$(CUSTOM_GROUP_ID)` referenziert wird.
|
||||||
|
|
||||||
|
1. **Runner** → Build Settings → `+` (oben links) → **Add User-Defined Setting**.
|
||||||
|
- Name: `CUSTOM_GROUP_ID`
|
||||||
|
- Wert: `group.eu.mhsl.marianum.mobile.client.share`
|
||||||
|
2. Dasselbe für **Share Extension**-Target.
|
||||||
|
|
||||||
|
## Schritt 5 — Entitlements verlinken
|
||||||
|
|
||||||
|
1. **Runner** → Build Settings → `CODE_SIGN_ENTITLEMENTS` zeigt bereits auf `Runner/Runner.entitlements` (jetzt mit beiden Groups).
|
||||||
|
2. **Share Extension** → Build Settings → `CODE_SIGN_ENTITLEMENTS` → auf `Share Extension/Share Extension.entitlements` setzen.
|
||||||
|
|
||||||
|
## Schritt 6 — Info.plist-Pfad
|
||||||
|
|
||||||
|
**Share Extension** → Build Settings → `INFOPLIST_FILE` → auf `Share Extension/Info.plist` setzen.
|
||||||
|
|
||||||
|
## Schritt 7 — Build Phases reorder
|
||||||
|
|
||||||
|
Damit das Plugin-Modul vom Extension-Target gefunden wird:
|
||||||
|
|
||||||
|
1. **Runner**-Target → **Build Phases**.
|
||||||
|
2. `Embed Foundation Extensions` per Drag-and-Drop **vor** `Thin Binary` ziehen.
|
||||||
|
|
||||||
|
## Schritt 8 — Pods installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ios && pod install
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Podfile-Eintrag (`target 'Share Extension' do inherit! :search_paths end`) ist bereits vorhanden.
|
||||||
|
|
||||||
|
## Schritt 9 — Build & Run
|
||||||
|
|
||||||
|
1. Scheme `Runner` wählen → Run auf Device oder Simulator (≥ iOS 12).
|
||||||
|
2. Foto in der Fotos-App auswählen → Teilen → „Marianum Fulda" sollte erscheinen.
|
||||||
|
3. Auswahl → App öffnet sich, ShareTargetPage erscheint.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Error: No such module 'receive_sharing_intent'**
|
||||||
|
→ Schritt 7 (Build Phases reorder) wurde übersprungen.
|
||||||
|
- **Error: ‚Frameworks' not allowed in extension**
|
||||||
|
→ In Build Settings der Share Extension `Other Linker Flags` und `Framework Search Paths` leeren (nur die geerbten Pod-Pfade behalten).
|
||||||
|
- **Share-Sheet zeigt App nicht an**
|
||||||
|
→ `NSExtensionActivationRule`-Limits in `Share Extension/Info.plist` zu klein? Werte testweise erhöhen. Außerdem: App muss **mindestens einmal nach Install** geöffnet worden sein, sonst wird die Extension von iOS nicht registriert.
|
||||||
|
- **Files kommen mit `nil` Pfad an**
|
||||||
|
→ App-Group nicht konsistent. Prüfen, dass `CUSTOM_GROUP_ID` in beiden Targets identisch ist und die Entitlement-Files dieselbe Group enthalten.
|
||||||
|
|
||||||
|
## Was am Mac noch zu tun ist
|
||||||
|
|
||||||
|
- Schritte 1–8 oben (~15 Min).
|
||||||
|
- Auf physischem iPhone testen — Simulator-Share-Sheet ist eingeschränkt.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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 |
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# iOS Widget Extension — Xcode Setup
|
||||||
|
|
||||||
|
Die Swift-Quellen unter `ios/TimetableWidgetExtension/` müssen einmalig in Xcode als **Widget Extension Target** verdrahtet werden — ohne diesen Schritt bleibt der Code unkompiliert.
|
||||||
|
|
||||||
|
## Schritt 1 — Widget-Extension-Target anlegen
|
||||||
|
|
||||||
|
1. `ios/Runner.xcworkspace` in Xcode öffnen.
|
||||||
|
2. Projekt-Sidebar → `Runner` (Projekt-Root) → **+ Add Target** unten links.
|
||||||
|
3. **iOS → Widget Extension** wählen.
|
||||||
|
4. Eigenschaften:
|
||||||
|
- Product Name: `TimetableWidgetExtension`
|
||||||
|
- Bundle Identifier: `eu.mhsl.marianum.mobile.client.TimetableWidgetExtension`
|
||||||
|
- Language: Swift
|
||||||
|
- Include Configuration Intent: **OFF** (StaticConfiguration reicht)
|
||||||
|
- Embed in: Runner
|
||||||
|
5. Beim Activate-Scheme-Dialog auf **Cancel** klicken.
|
||||||
|
|
||||||
|
## Schritt 2 — Vorhandene Quelldateien ins Target ziehen
|
||||||
|
|
||||||
|
Xcode hat zunächst Dummy-Dateien (`TimetableWidgetExtension.swift`, `TimetableWidgetExtensionBundle.swift`) angelegt. Diese **löschen** (Move to Trash). Dann:
|
||||||
|
|
||||||
|
1. Sidebar → Rechtsklick auf den Ordner `TimetableWidgetExtension` → **Add Files to "Runner"…**
|
||||||
|
2. Im File-Picker zu `ios/TimetableWidgetExtension/` navigieren und alle `.swift`-Dateien, die `Info.plist`, `TimetableWidgetExtension.entitlements` **und den `Assets.xcassets`-Ordner** selektieren (mit `marianum_m`-Asset darin — gleicher Asset-Name wie auf Android-Seite).
|
||||||
|
3. **Wichtig**: bei „Add to targets" nur `TimetableWidgetExtension` ankreuzen, **nicht** Runner.
|
||||||
|
|
||||||
|
## Schritt 3 — App Group aktivieren
|
||||||
|
|
||||||
|
Beide Targets brauchen die App-Group-Berechtigung, damit Hauptapp und Widget über `UserDefaults(suiteName:)` schreiben/lesen können.
|
||||||
|
|
||||||
|
1. **Runner**-Target → **Signing & Capabilities** → **+ Capability** → **App Groups**.
|
||||||
|
- Group-ID hinzufügen: `group.eu.mhsl.marianum.mobile.client.widget`
|
||||||
|
2. Dasselbe für **TimetableWidgetExtension** — mit derselben Group-ID.
|
||||||
|
|
||||||
|
Im Apple-Developer-Portal muss die App-Group bei beiden App-IDs eingetragen sein, sonst schlägt das Provisioning fehl.
|
||||||
|
|
||||||
|
## Schritt 4 — Entitlements verlinken
|
||||||
|
|
||||||
|
1. **Runner** → Build Settings → `CODE_SIGN_ENTITLEMENTS` sollte bereits auf `Runner/Runner.entitlements` zeigen.
|
||||||
|
2. **TimetableWidgetExtension** → Build Settings → `CODE_SIGN_ENTITLEMENTS` → auf `TimetableWidgetExtension/TimetableWidgetExtension.entitlements` setzen.
|
||||||
|
|
||||||
|
## Schritt 5 — Info.plist + Deployment Target
|
||||||
|
|
||||||
|
1. **TimetableWidgetExtension** → Build Settings → `INFOPLIST_FILE` → auf `TimetableWidgetExtension/Info.plist` setzen.
|
||||||
|
2. Build Settings → `IPHONEOS_DEPLOYMENT_TARGET` ≥ 16.0 (Code gated `.containerBackground` mit `if #available(iOS 17, *)`, läuft also auch auf 16).
|
||||||
|
|
||||||
|
## Schritt 6 — Build & Run
|
||||||
|
|
||||||
|
- Scheme `Runner` (nicht das Widget-Scheme) wählen → Run.
|
||||||
|
- Auf Home-Screen langes Drücken → Widget hinzufügen → "Marianum · Heute" / "Marianum · Woche".
|
||||||
|
- Widget-Tap öffnet die App im zuletzt sichtbaren Tab. Eine Tab-Navigation auf den Stundenplan ist bewusst nicht implementiert (Android nutzt Intent-Extras, iOS würde dafür ein URL-Scheme oder AppIntent brauchen — beides bewusst ausgespart).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Widget zeigt „Lade…"** auch nach Refresh: App-Group greift nicht. Prüfen, ob beide Targets dieselbe Group-ID haben und das Provisioning aktualisiert wurde.
|
||||||
|
- **Stale-Daten nach Logout**: `WidgetSync.clear()` schreibt `widget_data_logged_in_v1 = false`; Widget zeigt dann den Login-Placeholder.
|
||||||
|
- **Lessons um 1–2 Stunden verschoben**: Date-Parser-Bug. Sollte gefixt sein in `WidgetData.swift::parseDartDate` — verifizieren, dass die ISO-8601-Strings ohne Z-Suffix als `TimeZone.current` geparsed werden.
|
||||||
|
- **App-Store-Submit später**: `Runner.entitlements` `aps-environment` von `development` auf `production` umbiegen.
|
||||||
|
|
||||||
|
## Was bereits im Repo erledigt ist
|
||||||
|
|
||||||
|
- Alle Swift-Quellen, Info.plist, Entitlements liegen unter `ios/TimetableWidgetExtension/`.
|
||||||
|
- App-Group-ID konsistent zwischen Dart (`WidgetSync.iosAppGroupId`), Swift (`WidgetDataKey.appGroupId`) und der Entitlements-Datei.
|
||||||
|
- `home_widget`-Plugin auf der Dart-Seite konfiguriert; ruft `HomeWidget.setAppGroupId` beim ersten Sync.
|
||||||
|
- `containerBackground` für iOS 17+ gegated, fällt auf iOS 16 sauber zurück.
|
||||||
|
- Date-Parser fixt das fehlende Z-Suffix (Dart schreibt lokale Zeit ohne TZ-Marker).
|
||||||
|
|
||||||
|
## Was am Mac noch zu tun ist
|
||||||
|
|
||||||
|
- Schritte 1–5 oben in Xcode durchklicken (10–15 Min).
|
||||||
|
- `flutter pub get` + `cd ios && pod install`.
|
||||||
|
- Auf physischem Gerät oder iOS-Simulator (≥ 16.0) bauen.
|
||||||
|
- Widget aufs Home-Screen ziehen, prüfen dass Lesson-Zeiten korrekt rendern.
|
||||||
@@ -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) {
|
static GetChatResponseObject getDateDummy(int timestamp) {
|
||||||
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
||||||
return getTextDummy(elementDate.formatDate());
|
return getTextDummy(elementDate.formatDateRelativeShort());
|
||||||
}
|
}
|
||||||
|
|
||||||
static GetChatResponseObject getTextDummy(String text) =>
|
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,
|
Uri uri,
|
||||||
Object? body,
|
Object? body,
|
||||||
Map<String, String>? headers,
|
Map<String, String>? headers,
|
||||||
) {
|
) => readState ? http.post(uri, headers: headers) : http.delete(uri, headers: headers);
|
||||||
if (readState) {
|
|
||||||
return http.post(uri, headers: headers);
|
|
||||||
} else {
|
|
||||||
return 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.onNetworkData,
|
||||||
super.onError,
|
super.onError,
|
||||||
required String path,
|
required String path,
|
||||||
|
super.renew = false,
|
||||||
}) : super(
|
}) : super(
|
||||||
cacheTime: RequestCache.cacheNothing,
|
cacheTime: _cacheTimeFor(path),
|
||||||
loader: () => ListFiles(ListFilesParams(path)).run(),
|
loader: () => ListFiles(ListFilesParams(path)).run(),
|
||||||
fromJson: ListFilesResponse.fromJson,
|
fromJson: ListFilesResponse.fromJson,
|
||||||
onUpdate: onUpdate,
|
onUpdate: onUpdate,
|
||||||
@@ -25,6 +26,44 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
|
|||||||
start(_documentId(path));
|
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) {
|
static String _documentId(String path) {
|
||||||
final cacheName = md5
|
final cacheName = md5
|
||||||
.convert(utf8.encode('MarianumMobile-$path'))
|
.convert(utf8.encode('MarianumMobile-$path'))
|
||||||
|
|||||||
+112
-41
@@ -13,15 +13,20 @@ import 'model/data_cleaner.dart';
|
|||||||
import 'notification/notification_controller.dart';
|
import 'notification/notification_controller.dart';
|
||||||
import 'notification/notification_tasks.dart';
|
import 'notification/notification_tasks.dart';
|
||||||
import 'notification/notify_updater.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/app_modules.dart';
|
||||||
import 'state/app/modules/breaker/bloc/breaker_bloc.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/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import 'state/app/modules/settings/bloc/settings_cubit.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_bloc.dart';
|
||||||
|
import 'state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
import 'storage/settings.dart' as model;
|
import 'storage/settings.dart' as model;
|
||||||
import 'utils/debouncer.dart';
|
import 'utils/debouncer.dart';
|
||||||
import 'view/pages/overhang.dart';
|
import 'view/pages/overhang.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
|
import 'widget_data/widget_navigation.dart';
|
||||||
|
import 'widget_data/widget_publisher.dart';
|
||||||
|
|
||||||
class App extends StatefulWidget {
|
class App extends StatefulWidget {
|
||||||
const App({super.key});
|
const App({super.key});
|
||||||
@@ -31,16 +36,33 @@ class App extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||||
late Timer _refetchChats;
|
|
||||||
late Timer _updateTimings;
|
late Timer _updateTimings;
|
||||||
// Tracked via the bottom-nav controller's listener so it always reflects the
|
StreamSubscription<dynamic>? _timetableWidgetSync;
|
||||||
// user's actual position, even between rapid setting emits where the
|
StreamSubscription<RemoteMessage>? _onMessageSub;
|
||||||
// controller hasn't caught up to a scheduled jump yet.
|
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
|
||||||
|
StreamSubscription<String>? _fcmTokenRefreshSub;
|
||||||
int _knownTotalTabs = 1;
|
int _knownTotalTabs = 1;
|
||||||
bool _userOnLastTab = false;
|
bool _userOnLastTab = false;
|
||||||
|
|
||||||
|
static const Duration _chatListActiveInterval = Duration(seconds: 15);
|
||||||
|
static const Duration _chatListIdleInterval = Duration(seconds: 60);
|
||||||
|
|
||||||
void _onTabControllerChanged() {
|
void _onTabControllerChanged() {
|
||||||
_userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1;
|
_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
|
@override
|
||||||
@@ -52,9 +74,36 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
log('Refreshing due to LifecycleChange');
|
log('Refreshing due to LifecycleChange');
|
||||||
NotificationTasks.updateProviders(context);
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -66,32 +115,56 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.read<BreakerBloc>().refresh();
|
context.read<BreakerBloc>().refresh();
|
||||||
context.read<ChatListBloc>().refresh();
|
context.read<ChatListBloc>().refresh();
|
||||||
// App is freshly mounted on every login (BlocConsumer in main.dart
|
// Re-mounts on every login, so this also covers post-logout state reset.
|
||||||
// swaps it in for Login), so this also covers the post-logout case
|
final timetable = context.read<TimetableBloc>();
|
||||||
// where the bloc was reset to an empty state and needs a fresh fetch.
|
timetable.refresh();
|
||||||
context.read<TimetableBloc>().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), (_) {
|
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
});
|
});
|
||||||
|
|
||||||
_refetchChats = Timer.periodic(const Duration(seconds: 60), (_) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
context.read<ChatListBloc>().refresh();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
UpdateUserIndex.index();
|
UpdateUserIndex.index();
|
||||||
|
|
||||||
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
|
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
|
||||||
void update() => NotifyUpdater.registerToServer();
|
void update() => NotifyUpdater.registerToServer();
|
||||||
FirebaseMessaging.instance.onTokenRefresh.listen((_) => update());
|
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(
|
||||||
|
(_) => update(),
|
||||||
|
);
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
FirebaseMessaging.onMessage.listen((message) {
|
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
NotificationController.onForegroundMessageHandler(message, context);
|
NotificationController.onForegroundMessageHandler(message, context);
|
||||||
});
|
});
|
||||||
@@ -99,7 +172,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
NotificationController.onBackgroundMessageHandler,
|
NotificationController.onBackgroundMessageHandler,
|
||||||
);
|
);
|
||||||
|
|
||||||
FirebaseMessaging.onMessageOpenedApp.listen((message) {
|
_onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen((
|
||||||
|
message,
|
||||||
|
) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
NotificationController.onAppOpenedByNotification(message, context);
|
NotificationController.onAppOpenedByNotification(message, context);
|
||||||
});
|
});
|
||||||
@@ -113,8 +188,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_refetchChats.cancel();
|
|
||||||
_updateTimings.cancel();
|
_updateTimings.cancel();
|
||||||
|
_timetableWidgetSync?.cancel();
|
||||||
|
_onMessageSub?.cancel();
|
||||||
|
_onMessageOpenedAppSub?.cancel();
|
||||||
|
_fcmTokenRefreshSub?.cancel();
|
||||||
|
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
||||||
|
ShareIntentListener.instance.detach();
|
||||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -129,17 +209,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
final totalTabs = bottomBarModules.length + 1;
|
final totalTabs = bottomBarModules.length + 1;
|
||||||
final currentIndex = Main.bottomNavigator.index;
|
final currentIndex = Main.bottomNavigator.index;
|
||||||
|
|
||||||
// The bottom-bar layout is identified by the ordered list of module
|
// PersistentTabView caches per-tab navigators by index and only
|
||||||
// names plus the trailing 'more' slot. Whenever this layout changes
|
// appends/trims at the end, so reordering/hiding leaves stale
|
||||||
// — slot count, reordering, or hiding a module — we recreate the
|
// route stacks under the wrong tabs. Re-key on layout to remount.
|
||||||
// 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.
|
|
||||||
final layoutKey = ValueKey(
|
final layoutKey = ValueKey(
|
||||||
'${bottomBarModules.map((m) => m.module.name).join('|')}|more',
|
'${bottomBarModules.map((m) => m.module.name).join('|')}|more',
|
||||||
);
|
);
|
||||||
@@ -151,12 +223,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
} else if (currentIndex >= totalTabs) {
|
} else if (currentIndex >= totalTabs) {
|
||||||
targetIndex = totalTabs - 1;
|
targetIndex = totalTabs - 1;
|
||||||
}
|
}
|
||||||
// Re-mounting PTV with a new key constructs fresh internals from
|
// Replace the controller atomically: a stale index past the new
|
||||||
// its controller's current index. If the controller still points
|
// tab list crashes Style6BottomNavBar's initState.
|
||||||
// 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.
|
|
||||||
if (targetIndex != currentIndex) {
|
if (targetIndex != currentIndex) {
|
||||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||||
Main.bottomNavigator = PersistentTabController(
|
Main.bottomNavigator = PersistentTabController(
|
||||||
@@ -192,14 +260,17 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
navBarBuilder: (config) => Style6BottomNavBar(
|
navBarBuilder: (config) => Style6BottomNavBar(
|
||||||
// Style6BottomNavBar builds its internal animation controller list
|
// Animation controllers are built once in initState and never
|
||||||
// in initState and never grows it on didUpdateWidget. Keying by the
|
// grown — re-key on item count to avoid RangeError on growth.
|
||||||
// item count forces a fresh State whenever the slot count changes,
|
|
||||||
// which avoids a RangeError when more tabs slide in.
|
|
||||||
key: ValueKey(config.items.length),
|
key: ValueKey(config.items.length),
|
||||||
navBarConfig: config,
|
navBarConfig: config,
|
||||||
navBarDecoration: NavBarDecoration(
|
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,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,4 +46,18 @@ extension DateTimeFormatting on DateTime {
|
|||||||
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
|
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
|
||||||
|
|
||||||
String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
|
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
@@ -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:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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 'api/mhsl/breaker/get_breakers/get_breakers_response.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'background/widget_background_task.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'model/account_data.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_bloc.dart';
|
||||||
import 'state/app/modules/account/bloc/account_state.dart';
|
import 'state/app/modules/account/bloc/account_state.dart';
|
||||||
import 'state/app/modules/breaker/bloc/breaker_bloc.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/app_progress_indicator.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
import 'widget/debug/cache_view.dart';
|
import 'widget/debug/cache_view.dart';
|
||||||
|
import 'widget_data/widget_sync.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
log('MarianumMobile started');
|
log('MarianumMobile started');
|
||||||
@@ -66,18 +71,42 @@ Future<void> main() async {
|
|||||||
HydratedBloc.storage = storage;
|
HydratedBloc.storage = storage;
|
||||||
}),
|
}),
|
||||||
AccountData().waitForPopulation(),
|
AccountData().waitForPopulation(),
|
||||||
|
ShareIntentListener.instance.initialize(),
|
||||||
];
|
];
|
||||||
|
|
||||||
log('starting app initialisation...');
|
log('starting app initialisation...');
|
||||||
await Future.wait(initialisationTasks);
|
await Future.wait(initialisationTasks);
|
||||||
log('app initialisation done!');
|
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(
|
unawaited(
|
||||||
FirebaseMessaging.instance.getToken().then(
|
FirebaseMessaging.instance.getToken().then(
|
||||||
(token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
|
(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) {
|
if (kReleaseMode) {
|
||||||
ErrorWidget.builder = (error) => Material(
|
ErrorWidget.builder = (error) => Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -125,7 +154,9 @@ Future<void> main() async {
|
|||||||
),
|
),
|
||||||
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
||||||
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
||||||
BlocProvider<ChatBloc>(create: (_) => ChatBloc()),
|
BlocProvider<ChatBloc>(
|
||||||
|
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
||||||
|
),
|
||||||
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
|
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
|
||||||
],
|
],
|
||||||
child: const Main(),
|
child: const Main(),
|
||||||
@@ -171,6 +202,8 @@ class _MainState extends State<Main> {
|
|||||||
checkerboardRasterCacheImages:
|
checkerboardRasterCacheImages:
|
||||||
devToolsSettings.checkerboardRasterCacheImages,
|
devToolsSettings.checkerboardRasterCacheImages,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
||||||
|
navigatorObservers: [AppRoutes.chatRouteObserver],
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
...GlobalMaterialLocalizations.delegates,
|
...GlobalMaterialLocalizations.delegates,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
@@ -198,6 +231,10 @@ class _MainState extends State<Main> {
|
|||||||
previous.status != current.status,
|
previous.status != current.status,
|
||||||
listener: (context, accountState) {
|
listener: (context, accountState) {
|
||||||
if (accountState.status != AccountStatus.loggedOut) return;
|
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
|
// Routes pushed via AppRoutes (e.g. Settings) live on the
|
||||||
// root navigator and survive the home swap below, so they
|
// root navigator and survive the home swap below, so they
|
||||||
// would still cover the Login screen after logout. Pop
|
// would still cover the Login screen after logout. Pop
|
||||||
@@ -287,6 +324,12 @@ Future<void> _wipeUserState({
|
|||||||
await prefs.clear();
|
await prefs.clear();
|
||||||
await HydratedBloc.storage.clear();
|
await HydratedBloc.storage.clear();
|
||||||
await const CacheView().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) {
|
} catch (e, s) {
|
||||||
log('User state wipe failed: $e', stackTrace: s);
|
log('User state wipe failed: $e', stackTrace: s);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.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/debug_tile.dart';
|
||||||
import '../widget/debug/json_viewer.dart';
|
import '../widget/debug/json_viewer.dart';
|
||||||
import '../widget/info_dialog.dart';
|
import '../widget/info_dialog.dart';
|
||||||
import 'notification_tasks.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 {
|
class NotificationController {
|
||||||
// Notification display is handled by the Firebase SDK using server-generated payloads.
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
static Future<void> onBackgroundMessageHandler(RemoteMessage message) async {
|
static Future<void> onBackgroundMessageHandler(RemoteMessage message) async {
|
||||||
NotificationTasks.updateBadgeCount(message);
|
NotificationTasks.updateBadgeCount(message);
|
||||||
@@ -17,8 +23,26 @@ class NotificationController {
|
|||||||
RemoteMessage message,
|
RemoteMessage message,
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
) async {
|
) 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);
|
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(
|
static Future<void> onAppOpenedByNotification(
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:eraser/eraser.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../routing/app_routes.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 '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
|
import 'notification_service.dart';
|
||||||
|
|
||||||
class NotificationTasks {
|
class NotificationTasks {
|
||||||
static void updateBadgeCount(RemoteMessage notification) {
|
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) {
|
static void updateProviders(BuildContext context) {
|
||||||
context.read<ChatListBloc>().refresh();
|
context.read<ChatListBloc>().refresh();
|
||||||
context.read<ChatBloc>().refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches to the Talk tab. If [chatToken] is provided, also schedules
|
/// Switches to the Talk tab. If [chatToken] is provided, also schedules
|
||||||
|
|||||||
@@ -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 '../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../model/account_data.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/app_modules.dart';
|
||||||
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../state/app/modules/chat_list/bloc/chat_list_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/more/share/qr_share_view.dart';
|
||||||
import '../view/pages/settings/modules_settings_page.dart';
|
import '../view/pages/settings/modules_settings_page.dart';
|
||||||
import '../view/pages/settings/settings.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/chat_view.dart';
|
||||||
import '../view/pages/talk/details/message_reactions.dart';
|
import '../view/pages/talk/details/message_reactions.dart';
|
||||||
import '../view/pages/talk/talk_navigator.dart';
|
import '../view/pages/talk/talk_navigator.dart';
|
||||||
@@ -34,6 +40,11 @@ class AppRoutes {
|
|||||||
/// by `ChatList` once the matching room is loaded.
|
/// by `ChatList` once the matching room is loaded.
|
||||||
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
|
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) {
|
static void openFolder(BuildContext context, List<String> path) {
|
||||||
pushScreen(context, withNavBar: false, screen: Files(path: path));
|
pushScreen(context, withNavBar: false, screen: Files(path: path));
|
||||||
}
|
}
|
||||||
@@ -42,11 +53,16 @@ class AppRoutes {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
String localPath, {
|
String localPath, {
|
||||||
bool openExternal = false,
|
bool openExternal = false,
|
||||||
|
RemoteFileRef? remoteFile,
|
||||||
}) {
|
}) {
|
||||||
pushScreen(
|
pushScreen(
|
||||||
context,
|
context,
|
||||||
withNavBar: false,
|
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());
|
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(
|
static void openMessageReactions(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String token,
|
String token,
|
||||||
@@ -109,6 +183,12 @@ class AppRoutes {
|
|||||||
required UserAvatar avatar,
|
required UserAvatar avatar,
|
||||||
bool overrideToSingleSubScreen = true,
|
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(
|
TalkNavigator.pushSplitView(
|
||||||
context,
|
context,
|
||||||
ChatView(room: room, selfId: selfId, avatar: avatar),
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/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/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.dart';
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.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 '../repository/chat_repository.dart';
|
||||||
import 'chat_event.dart';
|
import 'chat_event.dart';
|
||||||
import 'chat_state.dart';
|
import 'chat_state.dart';
|
||||||
|
|
||||||
class ChatBloc
|
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);
|
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
|
@override
|
||||||
ChatRepository repository() => ChatRepository();
|
ChatRepository repository() => ChatRepository();
|
||||||
|
|
||||||
@@ -33,24 +71,70 @@ class ChatBloc
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setToken(String token) {
|
void setToken(String token) {
|
||||||
|
_chatViewActive = true;
|
||||||
if (token == (innerState?.currentToken ?? '')) {
|
if (token == (innerState?.currentToken ?? '')) {
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_stopLongPoll();
|
||||||
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
|
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
|
||||||
add(RefetchStarted<ChatState>());
|
add(RefetchStarted<ChatState>());
|
||||||
_loadChat(token);
|
_scheduleLoad(token);
|
||||||
}
|
|
||||||
|
|
||||||
void setReferenceMessageId(int? messageId) {
|
|
||||||
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void refresh() {
|
void refresh() {
|
||||||
final token = innerState?.currentToken ?? '';
|
final token = innerState?.currentToken ?? '';
|
||||||
if (token.isEmpty) return;
|
if (token.isEmpty) return;
|
||||||
add(RefetchStarted<ChatState>());
|
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 {
|
Future<void> _loadChat(String token) async {
|
||||||
@@ -69,14 +153,25 @@ class ChatBloc
|
|||||||
token: token,
|
token: token,
|
||||||
onCacheData: (data) {
|
onCacheData: (data) {
|
||||||
if (!stillCurrent()) return;
|
if (!stillCurrent()) return;
|
||||||
// Cache hit: show data immediately but preserve lastFetch — the
|
// Skip cache paint over already-merged long-poll data — would
|
||||||
// cached payload may be stale and we don't want the UI to claim a
|
// visibly drop those messages until the network call resolves.
|
||||||
// fresh fetch just happened.
|
if (innerState?.chatResponse != null) return;
|
||||||
add(Emit((s) => s.copyWith(chatResponse: data)));
|
add(Emit((s) => s.copyWith(chatResponse: data)));
|
||||||
},
|
},
|
||||||
onNetworkData: (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;
|
if (!stillCurrent()) return;
|
||||||
add(DataGathered((s) => s.copyWith(chatResponse: data)));
|
_applyChatResponse(data);
|
||||||
|
if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId);
|
||||||
},
|
},
|
||||||
onError: (e) => capturedError = e,
|
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 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||||
|
|
||||||
import '../../../../../api/errors/error_mapper.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 '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../infrastructure/loadable_state/loading_error.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.dart';
|
||||||
@@ -15,6 +17,8 @@ class ChatListBloc
|
|||||||
extends
|
extends
|
||||||
LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
|
LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
|
||||||
bool _forceRenew = false;
|
bool _forceRenew = false;
|
||||||
|
Timer? _autoRefreshTimer;
|
||||||
|
Duration? _autoRefreshInterval;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void retry() {
|
void retry() {
|
||||||
@@ -22,6 +26,25 @@ class ChatListBloc
|
|||||||
super.retry();
|
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
|
@override
|
||||||
ChatListRepository repository() => ChatListRepository();
|
ChatListRepository repository() => ChatListRepository();
|
||||||
|
|
||||||
@@ -51,8 +74,8 @@ class ChatListBloc
|
|||||||
if (capturedError != null) throw capturedError!;
|
if (capturedError != null) throw capturedError!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh({bool renew = true}) async {
|
Future<void> refresh({bool renew = true, bool silent = false}) async {
|
||||||
add(RefetchStarted<ChatListState>());
|
if (!silent) add(RefetchStarted<ChatListState>());
|
||||||
Object? capturedError;
|
Object? capturedError;
|
||||||
try {
|
try {
|
||||||
final rooms = await repo.data.getRooms(
|
final rooms = await repo.data.getRooms(
|
||||||
@@ -82,6 +105,65 @@ class ChatListBloc
|
|||||||
await refresh();
|
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) {
|
void _updateAppBadge(GetRoomResponse rooms) {
|
||||||
try {
|
try {
|
||||||
final unread = rooms.data.fold<int>(
|
final unread = rooms.data.fold<int>(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
import '../../../../../api/errors/error_mapper.dart';
|
import '../../../../../api/errors/error_mapper.dart';
|
||||||
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||||
import '../../../infrastructure/loadable_state/loading_error.dart';
|
import '../../../infrastructure/loadable_state/loading_error.dart';
|
||||||
@@ -35,7 +37,9 @@ class FilesBloc
|
|||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
add(RefetchStarted<FilesState>());
|
add(RefetchStarted<FilesState>());
|
||||||
final path = innerState?.currentPath ?? initialPath;
|
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 {
|
Future<void> setPath(List<String> path) async {
|
||||||
@@ -50,15 +54,28 @@ class FilesBloc
|
|||||||
await refresh();
|
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('/');
|
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;
|
Object? capturedError;
|
||||||
ListFilesResponse? listing;
|
ListFilesResponse? listing;
|
||||||
try {
|
try {
|
||||||
listing = await repo.data.listFiles(
|
listing = await repo.data.listFiles(
|
||||||
pathString,
|
pathString,
|
||||||
|
renew: renew,
|
||||||
onCacheData: (cached) {
|
onCacheData: (cached) {
|
||||||
|
if (isStale()) return;
|
||||||
// Cached payload arrives before the network call settles. Surface it
|
// Cached payload arrives before the network call settles. Surface it
|
||||||
// immediately via Emit so the listing is visible while isLoading
|
// immediately via Emit so the listing is visible while isLoading
|
||||||
// stays true and the top loading bar keeps spinning.
|
// stays true and the top loading bar keeps spinning.
|
||||||
@@ -73,6 +90,8 @@ class FilesBloc
|
|||||||
capturedError = e;
|
capturedError = e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isStale()) return;
|
||||||
|
|
||||||
if (listing != null) {
|
if (listing != null) {
|
||||||
listing.files.removeWhere(
|
listing.files.removeWhere(
|
||||||
(file) => file.name.isEmpty || file.name == path.lastOrNull,
|
(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
|
/// network call is still pending. The Future itself resolves once both the
|
||||||
/// cache lookup and the network attempt have settled, throwing if no payload
|
/// cache lookup and the network attempt have settled, throwing if no payload
|
||||||
/// could be obtained at all.
|
/// 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(
|
Future<ListFilesResponse> listFiles(
|
||||||
String path, {
|
String path, {
|
||||||
void Function(ListFilesResponse)? onCacheData,
|
void Function(ListFilesResponse)? onCacheData,
|
||||||
void Function(Object)? onError,
|
void Function(Object)? onError,
|
||||||
|
bool renew = false,
|
||||||
}) => resolveFromCache<ListFilesResponse>(
|
}) => resolveFromCache<ListFilesResponse>(
|
||||||
(onUpdate, onError) => ListFilesCache(
|
(onUpdate, onError) => ListFilesCache(
|
||||||
path: path,
|
path: path,
|
||||||
onUpdate: onUpdate,
|
onUpdate: onUpdate,
|
||||||
onCacheData: onCacheData,
|
onCacheData: onCacheData,
|
||||||
onError: onError,
|
onError: onError,
|
||||||
|
renew: renew,
|
||||||
),
|
),
|
||||||
onError: onError,
|
onError: onError,
|
||||||
operationName: 'listFiles',
|
operationName: 'listFiles',
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart';
|
||||||
import '../../state/app/modules/account/bloc/account_state.dart';
|
import '../../state/app/modules/account/bloc/account_state.dart';
|
||||||
import '../../theming/light_app_theme.dart';
|
import '../../theming/light_app_theme.dart';
|
||||||
@@ -34,6 +37,11 @@ class _LoginState extends State<Login> {
|
|||||||
|
|
||||||
void _onLoginSuccess() {
|
void _onLoginSuccess() {
|
||||||
context.read<AccountBloc>().setStatus(AccountStatus.loggedIn);
|
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
|
@override
|
||||||
|
|||||||
@@ -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.dart';
|
||||||
import '../../api/marianumcloud/talk/room/get_room_params.dart';
|
import '../../api/marianumcloud/talk/room/get_room_params.dart';
|
||||||
import '../../model/account_data.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
|
/// 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.
|
/// 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();
|
final user = username.trim().toLowerCase();
|
||||||
try {
|
try {
|
||||||
await AccountData().removeData();
|
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 AccountData().setData(user, password);
|
||||||
await GetRoom(GetRoomParams(includeStatus: false)).run();
|
await GetRoom(GetRoomParams(includeStatus: false)).run();
|
||||||
_loading = false;
|
_loading = false;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class LoginDisclaimer extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text(
|
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,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withValues(alpha: 0.75),
|
color: Colors.white.withValues(alpha: 0.75),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../../utils/cache_invalidation_bus.dart';
|
|||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import 'data/sort_options.dart';
|
import 'data/sort_options.dart';
|
||||||
import 'files_upload_dialog.dart';
|
import 'files_upload_dialog.dart';
|
||||||
|
import 'search/files_search_delegate.dart';
|
||||||
import 'widgets/add_file_menu.dart';
|
import 'widgets/add_file_menu.dart';
|
||||||
import 'widgets/clipboard_banner.dart';
|
import 'widgets/clipboard_banner.dart';
|
||||||
import 'widgets/file_element.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(
|
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'),
|
title: const Text('Ordner erstellen'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(sheetCtx).pop();
|
Navigator.of(sheetCtx).pop();
|
||||||
_showCreateFolderDialog(context, bloc);
|
showCreateFolderDialog(context, bloc);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -56,7 +56,7 @@ void showAddFileSheet(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
|
void showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
|
||||||
final inputController = TextEditingController();
|
final inputController = TextEditingController();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -7,19 +7,32 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
|||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../model/endpoint_data.dart';
|
import '../../../../model/endpoint_data.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
|
import '../../../../share_intent/remote_file_ref.dart';
|
||||||
import '../../../../utils/download_manager.dart';
|
import '../../../../utils/download_manager.dart';
|
||||||
import '../../../../utils/file_clipboard.dart';
|
import '../../../../utils/file_clipboard.dart';
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../../widget/info_dialog.dart';
|
import '../../../../widget/info_dialog.dart';
|
||||||
|
import '../../talk/widgets/highlighted_linkify.dart';
|
||||||
import 'file_details_sheet.dart';
|
import 'file_details_sheet.dart';
|
||||||
|
|
||||||
class FileElement extends StatefulWidget {
|
class FileElement extends StatefulWidget {
|
||||||
final CacheableFile file;
|
final CacheableFile file;
|
||||||
final List<String> path;
|
final List<String> path;
|
||||||
final void Function() refetch;
|
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
|
@override
|
||||||
State<FileElement> createState() => _FileElementState();
|
State<FileElement> createState() => _FileElementState();
|
||||||
@@ -71,7 +84,11 @@ class _FileElementState extends State<FileElement> {
|
|||||||
if (status is DownloadDone) {
|
if (status is DownloadDone) {
|
||||||
DownloadManager.instance.clear(widget.file.path);
|
DownloadManager.instance.clear(widget.file.path);
|
||||||
_detachJob();
|
_detachJob();
|
||||||
AppRoutes.openFileViewer(context, status.localPath);
|
AppRoutes.openFileViewer(
|
||||||
|
context,
|
||||||
|
status.localPath,
|
||||||
|
remoteFile: RemoteFileRef.fromCacheable(widget.file),
|
||||||
|
);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
} else if (status is DownloadFailed) {
|
} else if (status is DownloadFailed) {
|
||||||
final message = status.message;
|
final message = status.message;
|
||||||
@@ -113,7 +130,7 @@ class _FileElementState extends State<FileElement> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _subtitle() {
|
Widget? _subtitle() {
|
||||||
final status = _job?.status.value;
|
final status = _job?.status.value;
|
||||||
if (status is DownloadInProgress) {
|
if (status is DownloadInProgress) {
|
||||||
return Row(
|
return Row(
|
||||||
@@ -130,10 +147,16 @@ class _FileElementState extends State<FileElement> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final modified = widget.file.modifiedAt ?? DateTime.now();
|
final modified = widget.file.modifiedAt;
|
||||||
return widget.file.isDirectory
|
final size = widget.file.size;
|
||||||
? Text('geändert ${modified.formatRelative()}')
|
if (widget.file.isDirectory) {
|
||||||
: Text('${filesize(widget.file.size)}, ${modified.formatRelative()}');
|
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() {
|
void _onTap() {
|
||||||
@@ -299,6 +322,18 @@ class _FileElementState extends State<FileElement> {
|
|||||||
_putOnClipboard(copy: true);
|
_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(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.delete_outline)),
|
leading: const CenteredLeading(Icon(Icons.delete_outline)),
|
||||||
title: const Text('Löschen'),
|
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
|
@override
|
||||||
Widget build(BuildContext context) => ListTile(
|
Widget build(BuildContext context) => ListTile(
|
||||||
leading: CenteredLeading(
|
leading: CenteredLeading(
|
||||||
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined),
|
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined),
|
||||||
),
|
),
|
||||||
title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis),
|
title: _title(context),
|
||||||
subtitle: _subtitle(),
|
subtitle: _subtitle(),
|
||||||
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
|
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
|
||||||
onTap: _onTap,
|
onTap: _onTap,
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget {
|
|||||||
initialDescription: event.description,
|
initialDescription: event.description,
|
||||||
initialStart: event.start,
|
initialStart: event.start,
|
||||||
initialEnd: event.end,
|
initialEnd: event.end,
|
||||||
|
initialAllDay: event.isAllDay,
|
||||||
),
|
),
|
||||||
barrierDismissible: false,
|
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/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_bloc.dart';
|
||||||
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
||||||
|
import 'search_marianum_messages.dart';
|
||||||
|
|
||||||
class MarianumMessageListView extends StatelessWidget {
|
class MarianumMessageListView extends StatelessWidget {
|
||||||
const MarianumMessageListView({super.key});
|
const MarianumMessageListView({super.key});
|
||||||
@@ -16,7 +17,25 @@ class MarianumMessageListView extends StatelessWidget {
|
|||||||
) => BlocModule<MarianumMessageBloc, LoadableState<MarianumMessageState>>(
|
) => BlocModule<MarianumMessageBloc, LoadableState<MarianumMessageState>>(
|
||||||
create: (context) => MarianumMessageBloc(),
|
create: (context) => MarianumMessageBloc(),
|
||||||
child: (context, bloc, state) => Scaffold(
|
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>(
|
body: LoadableStateConsumer<MarianumMessageBloc, MarianumMessageState>(
|
||||||
child: (state, loading) => ListView.builder(
|
child: (state, loading) => ListView.builder(
|
||||||
itemCount: state.messageList.messages.length,
|
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),
|
applicationIcon: const Icon(Icons.apps),
|
||||||
applicationName: 'MarianumMobile',
|
applicationName: 'MarianumMobile',
|
||||||
applicationVersion:
|
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:
|
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'
|
'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n'
|
||||||
"${kReleaseMode ? "Production" : "Development"} build\n"
|
"${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n"
|
||||||
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller',
|
'Marianum Fulda 2019-2020, 2023-${Jiffy.now().year}\nElias Müller',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ class AboutSection extends StatelessWidget {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.school_outlined)),
|
leading: const CenteredLeading(Icon(Icons.school_outlined)),
|
||||||
title: const Text('Infos zum Marianum Fulda'),
|
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),
|
trailing: const Icon(Icons.arrow_right),
|
||||||
onTap: () => PrivacyInfo(
|
onTap: () => PrivacyInfo(
|
||||||
providerText: 'Marianum',
|
providerText: 'Marianum',
|
||||||
@@ -92,7 +92,7 @@ class AboutSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
|
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'),
|
subtitle: const Text('Für den Stundenplan'),
|
||||||
trailing: const Icon(Icons.arrow_right),
|
trailing: const Icon(Icons.arrow_right),
|
||||||
onTap: () => PrivacyInfo(
|
onTap: () => PrivacyInfo(
|
||||||
@@ -106,7 +106,7 @@ class AboutSection extends StatelessWidget {
|
|||||||
Icon(Icons.send_time_extension_outlined),
|
Icon(Icons.send_time_extension_outlined),
|
||||||
),
|
),
|
||||||
title: const Text('Infos zu mhsl'),
|
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),
|
trailing: const Icon(Icons.arrow_right),
|
||||||
onTap: () => PrivacyInfo(
|
onTap: () => PrivacyInfo(
|
||||||
providerText: 'mhsl',
|
providerText: 'mhsl',
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class TalkSection extends StatelessWidget {
|
|||||||
context,
|
context,
|
||||||
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n"
|
"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'
|
'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'
|
'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!',
|
'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',
|
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
Reference in New Issue
Block a user