Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b6f26da99 | |||
| 83c3428b05 | |||
| f4c2780e34 | |||
| d26714f043 | |||
| 5aa31153e3 |
@@ -121,19 +121,6 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Per-widget residence picker (gitea#6). Each widget provider
|
||||
XML declares `android:configure` pointing at this activity,
|
||||
so the system launches it whenever the user pins a new
|
||||
tile or hits "Edit Widget" on an existing one. -->
|
||||
<activity
|
||||
android:name=".widget.WidgetConfigActivity"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Small Widget Receiver (2x1) -->
|
||||
<receiver
|
||||
android:name=".widget.HoneyDueSmallWidgetReceiver"
|
||||
|
||||
@@ -45,14 +45,8 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val repo = WidgetDataRepository.get(context)
|
||||
// Per-instance residence scoping (gitea#6). Stats are computed
|
||||
// off the same filtered list so the bottom-tile counters
|
||||
// ("Overdue / 7 days / 30 days") match the visible tasks
|
||||
// instead of aggregating across every residence.
|
||||
val appWidgetId =
|
||||
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||
val tasks = repo.loadTasksForWidget(appWidgetId)
|
||||
val stats = repo.computeStatsFromTasks(tasks)
|
||||
val tasks = repo.loadTasks()
|
||||
val stats = repo.computeStats()
|
||||
val tier = repo.loadTierState()
|
||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||
|
||||
@@ -141,9 +135,4 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
|
||||
/** AppWidget receiver for the large widget. */
|
||||
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
super.onDeleted(context, appWidgetIds)
|
||||
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,7 @@ class HoneyDueMediumWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val repo = WidgetDataRepository.get(context)
|
||||
// Per-instance residence scoping (gitea#6). See small widget for rationale.
|
||||
val appWidgetId =
|
||||
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||
val tasks = repo.loadTasksForWidget(appWidgetId)
|
||||
val tasks = repo.loadTasks()
|
||||
val tier = repo.loadTierState()
|
||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||
|
||||
@@ -125,9 +122,4 @@ class HoneyDueMediumWidget : GlanceAppWidget() {
|
||||
/** AppWidget receiver for the medium widget. */
|
||||
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
super.onDeleted(context, appWidgetIds)
|
||||
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceId
|
||||
@@ -44,13 +43,7 @@ class HoneyDueSmallWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val repo = WidgetDataRepository.get(context)
|
||||
// Resolve which residence this widget instance is scoped to
|
||||
// (gitea#6). `loadTasksForWidget` falls back to "All residences"
|
||||
// when no scope is saved, matching pre-#6 behaviour for tiles
|
||||
// that haven't been configured yet.
|
||||
val appWidgetId =
|
||||
androidx.glance.appwidget.GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||
val tasks = repo.loadTasksForWidget(appWidgetId)
|
||||
val tasks = repo.loadTasks()
|
||||
val tier = repo.loadTierState()
|
||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||
|
||||
@@ -132,35 +125,4 @@ class OpenAppAction : ActionCallback {
|
||||
/** AppWidget receiver for the small widget. */
|
||||
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget()
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
super.onDeleted(context, appWidgetIds)
|
||||
// Clean per-instance residence scope when the user removes a tile
|
||||
// so dangling `widget_residence_id_<n>` keys don't accumulate in
|
||||
// the DataStore (gitea#6).
|
||||
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helpers for honeyDue Glance widget receivers. Kept in a
|
||||
* top-level utility so every receiver size (Small / Medium / Large)
|
||||
* uses identical cleanup logic.
|
||||
*/
|
||||
internal object WidgetReceiverHelpers {
|
||||
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
|
||||
fun purgeResidenceScopes(context: Context, appWidgetIds: IntArray) {
|
||||
if (appWidgetIds.isEmpty()) return
|
||||
val repo = WidgetDataRepository.get(context)
|
||||
// Fire-and-forget on a background dispatcher — `onDeleted` runs
|
||||
// on the broadcast thread which doesn't permit suspend calls.
|
||||
// GlobalScope is correct here: the IO is short-lived (one
|
||||
// DataStore edit per removed appWidgetId) and there's no
|
||||
// coroutine-scope tied to a long-lived receiver to attach to.
|
||||
kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
for (id in appWidgetIds) {
|
||||
repo.clearResidenceIdFor(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.app.Activity
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||
import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
||||
import com.tt.honeyDue.ui.theme.ThemeManager
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
|
||||
/**
|
||||
* Per-widget residence selector. Launched by the system when the user
|
||||
* pins a new honeyDue widget (because each widget provider XML now
|
||||
* declares `android:configure`) and again when they hit "Edit Widget".
|
||||
*
|
||||
* Saves the chosen residence id under
|
||||
* `widget_residence_id_<appWidgetId>` in [WidgetDataStore] so each
|
||||
* widget instance can independently scope its task list (gitea#6).
|
||||
* Selecting "All residences" clears the key and the widget reverts to
|
||||
* the legacy unscoped behaviour.
|
||||
*/
|
||||
class WidgetConfigActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// The system passes the just-created widget id in the extras.
|
||||
// Without it we don't know which widget to configure — bail
|
||||
// with CANCELED so the system removes the placeholder tile.
|
||||
val appWidgetId = intent?.extras?.getInt(
|
||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
setResult(Activity.RESULT_CANCELED)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
// Default a CANCEL result so the widget is removed if the user
|
||||
// dismisses without saving (matches the Android convention).
|
||||
setResult(
|
||||
Activity.RESULT_CANCELED,
|
||||
Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||
)
|
||||
|
||||
setContent {
|
||||
val theme = ThemeManager.currentTheme
|
||||
HoneyDueTheme(themeColors = theme) {
|
||||
WidgetConfigScreen(
|
||||
appWidgetId = appWidgetId,
|
||||
onCommit = { residenceId ->
|
||||
lifecycleScope.launch {
|
||||
val repo = WidgetDataRepository.get(this@WidgetConfigActivity)
|
||||
repo.saveResidenceIdFor(appWidgetId, residenceId)
|
||||
// Repaint every widget tile so this one
|
||||
// picks up the new scope on the next frame
|
||||
// (Glance handles which `appWidgetId` we
|
||||
// belong to via the per-instance state).
|
||||
HoneyDueSmallWidget().updateAll(this@WidgetConfigActivity)
|
||||
HoneyDueMediumWidget().updateAll(this@WidgetConfigActivity)
|
||||
HoneyDueLargeWidget().updateAll(this@WidgetConfigActivity)
|
||||
|
||||
setResult(
|
||||
Activity.RESULT_OK,
|
||||
Intent().putExtra(
|
||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
appWidgetId
|
||||
)
|
||||
)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual picker UI. Loads residences from [WidgetDataRepository]
|
||||
* and offers an "All residences" option above them. Empty state shows
|
||||
* a helper message instead of an empty list (user hasn't created any
|
||||
* residences yet, or the main app hasn't synced).
|
||||
*/
|
||||
@Composable
|
||||
private fun WidgetConfigScreen(
|
||||
appWidgetId: Int,
|
||||
onCommit: (Long?) -> Unit
|
||||
) {
|
||||
var residences by remember { mutableStateOf<List<WidgetResidenceDto>?>(null) }
|
||||
var selectedId by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
LaunchedEffect(appWidgetId) {
|
||||
val repo = WidgetDataRepository.get(context)
|
||||
// Pre-select whatever the user picked last time they configured
|
||||
// this same widget; falls back to "All residences" on first run.
|
||||
selectedId = repo.loadResidenceIdFor(appWidgetId)
|
||||
residences = repo.loadResidences()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(AppSpacing.lg)
|
||||
) {
|
||||
Text(
|
||||
text = "Choose a residence",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(AppSpacing.sm))
|
||||
Text(
|
||||
text = "This widget will only show tasks for the residence you pick. " +
|
||||
"Choose \"All residences\" to keep showing every home.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(AppSpacing.lg))
|
||||
|
||||
val items = residences
|
||||
if (items == null) {
|
||||
// Loading state — DataStore reads off the IO dispatcher.
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f, fill = true),
|
||||
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
|
||||
) {
|
||||
// "All residences" — selecting clears the per-widget key.
|
||||
item {
|
||||
ResidenceRow(
|
||||
title = "All residences",
|
||||
isSelected = selectedId == null,
|
||||
onClick = { selectedId = null }
|
||||
)
|
||||
}
|
||||
if (items.isEmpty()) {
|
||||
item {
|
||||
EmptyResidencesNote()
|
||||
}
|
||||
} else {
|
||||
items(items, key = { it.id }) { residence ->
|
||||
ResidenceRow(
|
||||
title = residence.name,
|
||||
isSelected = selectedId == residence.id,
|
||||
onClick = { selectedId = residence.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(AppSpacing.lg))
|
||||
Button(
|
||||
onClick = { onCommit(selectedId) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResidenceRow(
|
||||
title: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(AppSpacing.md))
|
||||
.background(
|
||||
if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(AppSpacing.lg)
|
||||
) {
|
||||
androidx.compose.foundation.layout.Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Home,
|
||||
contentDescription = null,
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.fillMaxWidth(0.04f))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyResidencesNote() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(AppSpacing.md))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(AppSpacing.lg)
|
||||
) {
|
||||
Text(
|
||||
text = "No residences yet — open honeyDue and add a property first, " +
|
||||
"then come back to configure this widget.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -115,66 +115,6 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
||||
return all.filterNot { it.id in pending }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the cached task list filtered by [residenceId]. Pass `null` to
|
||||
* return every residence's tasks (the "All residences" widget option,
|
||||
* matching pre-gitea#6 behaviour).
|
||||
*
|
||||
* Pending completions are excluded — same contract as [loadTasks].
|
||||
*/
|
||||
suspend fun loadTasksForResidence(residenceId: Long?): List<WidgetTaskDto> {
|
||||
val all = loadTasks()
|
||||
return filterTasksForResidence(all, residenceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the residence scope for [appWidgetId] and return only its
|
||||
* tasks. The widget [GlanceAppWidget.provideGlance] looks up its
|
||||
* `appWidgetId` and calls this; configuration changes take effect on
|
||||
* the next `updateAll` invocation.
|
||||
*/
|
||||
suspend fun loadTasksForWidget(appWidgetId: Int): List<WidgetTaskDto> {
|
||||
val residenceId = store.readResidenceIdFor(appWidgetId)
|
||||
return loadTasksForResidence(residenceId)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Residence sidecar (gitea#6)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Persist the user's residences so [WidgetConfigActivity] can offer
|
||||
* them in its picker. Called from the main app whenever
|
||||
* `DataManager.myResidences` updates.
|
||||
*/
|
||||
suspend fun saveResidences(residences: List<WidgetResidenceDto>) {
|
||||
store.writeResidencesJson(json.encodeToString(residences))
|
||||
}
|
||||
|
||||
/** Read the persisted residence list (empty when never written or after logout). */
|
||||
suspend fun loadResidences(): List<WidgetResidenceDto> {
|
||||
val raw = store.readResidencesJson()
|
||||
return try {
|
||||
json.decodeFromString<List<WidgetResidenceDto>>(raw)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Read which residence this widget instance is currently scoped to (null = All). */
|
||||
suspend fun loadResidenceIdFor(appWidgetId: Int): Long? =
|
||||
store.readResidenceIdFor(appWidgetId)
|
||||
|
||||
/** Persist the chosen residence for this widget instance. */
|
||||
suspend fun saveResidenceIdFor(appWidgetId: Int, residenceId: Long?) {
|
||||
store.writeResidenceIdFor(appWidgetId, residenceId)
|
||||
}
|
||||
|
||||
/** Drop the per-widget residence selection when the widget is removed. */
|
||||
suspend fun clearResidenceIdFor(appWidgetId: Int) {
|
||||
store.clearResidenceIdFor(appWidgetId)
|
||||
}
|
||||
|
||||
/** Queue a task id for optimistic completion. See [loadTasks]. */
|
||||
suspend fun markPendingCompletion(taskId: Long) {
|
||||
val current = store.readPendingCompletionIds().toMutableSet()
|
||||
@@ -201,15 +141,8 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
||||
*
|
||||
* Pending-completion tasks are excluded (via [loadTasks]).
|
||||
*/
|
||||
suspend fun computeStats(): WidgetStats = computeStatsFromTasks(loadTasks())
|
||||
|
||||
/**
|
||||
* Compute the same stats off a pre-filtered task list. Used by
|
||||
* [HoneyDueLargeWidget] after applying the per-widget residence
|
||||
* scope (gitea#6) so the stat tiles reflect only the residence the
|
||||
* user picked.
|
||||
*/
|
||||
fun computeStatsFromTasks(tasks: List<WidgetTaskDto>): WidgetStats {
|
||||
suspend fun computeStats(): WidgetStats {
|
||||
val tasks = loadTasks()
|
||||
var overdue = 0
|
||||
var within7 = 0
|
||||
var within8To30 = 0
|
||||
@@ -324,18 +257,5 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
||||
|
||||
/** Legacy accessor — delegates to [get]. */
|
||||
fun getInstance(context: Context): WidgetDataRepository = get(context)
|
||||
|
||||
/**
|
||||
* Pure filter — exposed for unit-test coverage. Mirrors iOS'
|
||||
* `WidgetDataManager.filterTasks(_:forResidenceId:)` semantics
|
||||
* (gitea#6).
|
||||
*/
|
||||
fun filterTasksForResidence(
|
||||
tasks: List<WidgetTaskDto>,
|
||||
residenceId: Long?
|
||||
): List<WidgetTaskDto> {
|
||||
if (residenceId == null) return tasks
|
||||
return tasks.filter { it.residenceId == residenceId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,15 +32,6 @@ internal object WidgetDataStoreKeys {
|
||||
val PENDING_COMPLETION_IDS = stringPreferencesKey("pending_completion_ids")
|
||||
val LAST_REFRESH_TIME = longPreferencesKey("last_refresh_time")
|
||||
val USER_TIER = stringPreferencesKey("user_tier")
|
||||
/** JSON-serialized List<WidgetResidenceDto> for the configuration picker (gitea#6). */
|
||||
val WIDGET_RESIDENCES_JSON = stringPreferencesKey("widget_residences_json")
|
||||
|
||||
/**
|
||||
* Returns a key for the `Long` residence id this `appWidgetId` is
|
||||
* scoped to. Missing key = "All residences" (legacy behaviour).
|
||||
*/
|
||||
fun residenceIdKeyFor(appWidgetId: Int) =
|
||||
longPreferencesKey("widget_residence_id_$appWidgetId")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,56 +90,6 @@ class WidgetDataStore(private val context: Context) {
|
||||
prefs.remove(WidgetDataStoreKeys.PENDING_COMPLETION_IDS)
|
||||
prefs.remove(WidgetDataStoreKeys.LAST_REFRESH_TIME)
|
||||
prefs.remove(WidgetDataStoreKeys.USER_TIER)
|
||||
prefs.remove(WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON)
|
||||
// Per-widget residence ids are added dynamically as
|
||||
// `widget_residence_id_<appWidgetId>` keys; sweep them by
|
||||
// prefix so logout doesn't leave dangling per-instance
|
||||
// scoping behind.
|
||||
prefs.asMap().keys
|
||||
.filter { it.name.startsWith("widget_residence_id_") }
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Per-residence widget configuration (gitea#6)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Read the user's residences (id + name) as persisted by the main
|
||||
* app. Used by [WidgetConfigActivity] to populate its picker.
|
||||
*/
|
||||
suspend fun readResidencesJson(): String =
|
||||
store.data.first()[WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON] ?: "[]"
|
||||
|
||||
suspend fun writeResidencesJson(json: String) {
|
||||
store.edit { prefs ->
|
||||
prefs[WidgetDataStoreKeys.WIDGET_RESIDENCES_JSON] = json
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the residence id this widget instance is scoped to, or `null`
|
||||
* for "All residences" (no scoping — the legacy default).
|
||||
*/
|
||||
suspend fun readResidenceIdFor(appWidgetId: Int): Long? =
|
||||
store.data.first()[WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId)]
|
||||
|
||||
suspend fun writeResidenceIdFor(appWidgetId: Int, residenceId: Long?) {
|
||||
store.edit { prefs ->
|
||||
val key = WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId)
|
||||
if (residenceId == null) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = residenceId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear scoping for a removed widget instance (called from `onDeleted`). */
|
||||
suspend fun clearResidenceIdFor(appWidgetId: Int) {
|
||||
store.edit { prefs ->
|
||||
prefs.remove(WidgetDataStoreKeys.residenceIdKeyFor(appWidgetId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,6 @@ interface WidgetRefreshDataSource {
|
||||
suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>>
|
||||
/** Fetch the current user's subscription tier ("free" | "premium"). */
|
||||
suspend fun fetchTier(): String
|
||||
/**
|
||||
* Fetch the user's residences for the widget configuration picker
|
||||
* (gitea#6). Returning an empty list is non-fatal — the worker will
|
||||
* just skip the residence sidecar update and pre-existing scopes
|
||||
* keep working until next refresh.
|
||||
*/
|
||||
suspend fun fetchResidences(): List<WidgetResidenceDto> = emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,16 +48,6 @@ internal object DefaultWidgetRefreshDataSource : WidgetRefreshDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchResidences(): List<WidgetResidenceDto> {
|
||||
val result = APILayer.getMyResidences(forceRefresh = false)
|
||||
return when (result) {
|
||||
is ApiResult.Success -> result.data.residences.map { r ->
|
||||
WidgetResidenceDto(id = r.id.toLong(), name = r.name)
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapToWidgetTasks(response: TaskColumnsResponse): List<WidgetTaskDto> {
|
||||
val out = mutableListOf<WidgetTaskDto>()
|
||||
for (column in response.columns) {
|
||||
@@ -129,16 +112,6 @@ class WidgetRefreshWorker(
|
||||
val repo = WidgetDataRepository.get(ctx)
|
||||
repo.saveTasks(tasksResult.data)
|
||||
repo.saveTierState(tier)
|
||||
// Best-effort residence sidecar update — failure is
|
||||
// non-fatal because pre-existing scopes (and the
|
||||
// "All residences" fallback) keep working with stale
|
||||
// data until the next refresh succeeds (gitea#6).
|
||||
runCatching {
|
||||
val residences = dataSource.fetchResidences()
|
||||
if (residences.isNotEmpty()) {
|
||||
repo.saveResidences(residences)
|
||||
}
|
||||
}
|
||||
refreshGlanceWidgets(ctx)
|
||||
// Chain the next scheduled refresh so cadence keeps ticking
|
||||
// even if the OS evicts our periodic request. Wrapped in
|
||||
|
||||
@@ -34,20 +34,6 @@ data class WidgetTaskDto(
|
||||
val completed: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* Lightweight residence identifier persisted to the widget DataStore.
|
||||
*
|
||||
* Written by the main app whenever [com.tt.honeyDue.data.DataManager.myResidences]
|
||||
* updates so the widget configuration activity can offer the current
|
||||
* residence list (gitea#6 — per-residence widget selection). Mirrors
|
||||
* iOS' `WidgetDataManager.WidgetResidence` shape.
|
||||
*/
|
||||
@Serializable
|
||||
data class WidgetResidenceDto(
|
||||
val id: Long,
|
||||
val name: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Summary metrics computed from the cached task list.
|
||||
*
|
||||
|
||||
@@ -14,5 +14,4 @@
|
||||
android:previewLayout="@layout/widget_large_preview"
|
||||
android:description="@string/widget_large_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable" />
|
||||
|
||||
@@ -14,5 +14,4 @@
|
||||
android:previewLayout="@layout/widget_medium_preview"
|
||||
android:description="@string/widget_medium_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable" />
|
||||
|
||||
@@ -14,5 +14,4 @@
|
||||
android:previewLayout="@layout/widget_small_preview"
|
||||
android:description="@string/widget_small_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable" />
|
||||
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/**
|
||||
* Coverage for the per-residence widget filter added in gitea#6.
|
||||
*
|
||||
* Two surfaces under test:
|
||||
*
|
||||
* 1. `WidgetDataRepository.filterTasksForResidence` — the pure filter
|
||||
* used by `loadTasksForResidence` and (transitively) by the
|
||||
* timeline provider. Mirrors iOS' `WidgetDataManager.filterTasks`.
|
||||
* 2. The per-`appWidgetId` DataStore key — verifies round-tripping
|
||||
* a saved residence id and clearing it for a removed widget.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class WidgetResidenceFilterTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var repo: WidgetDataRepository
|
||||
|
||||
@Before
|
||||
fun setUp() = runTest {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
repo = WidgetDataRepository.get(context)
|
||||
repo.clearAll()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = runTest {
|
||||
repo.clearAll()
|
||||
}
|
||||
|
||||
private fun task(id: Long, residenceId: Long): WidgetTaskDto =
|
||||
WidgetTaskDto(
|
||||
id = id,
|
||||
title = "Task $id",
|
||||
priority = 0,
|
||||
dueDate = null,
|
||||
isOverdue = false,
|
||||
daysUntilDue = 0,
|
||||
residenceId = residenceId,
|
||||
residenceName = "",
|
||||
categoryIcon = "",
|
||||
completed = false
|
||||
)
|
||||
|
||||
// --- pure filter -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun filter_nullResidenceReturnsAllTasks() {
|
||||
val tasks = listOf(task(1, 10), task(2, 20), task(3, 30))
|
||||
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = null)
|
||||
assertEquals(listOf(1L, 2L, 3L), result.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filter_matchingResidenceKeepsOnlyMatchingTasks() {
|
||||
val tasks = listOf(task(1, 10), task(2, 20), task(3, 10), task(4, 30))
|
||||
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 10)
|
||||
assertEquals(listOf(1L, 3L), result.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filter_unknownResidenceReturnsEmpty() {
|
||||
val tasks = listOf(task(1, 10), task(2, 20))
|
||||
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 999)
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filter_preservesInputOrder() {
|
||||
// Subset only — the timeline provider relies on this so its
|
||||
// own sort step ("overdue first, then by due date") operates
|
||||
// on already-filtered tasks.
|
||||
val tasks = listOf(task(5, 1), task(3, 1), task(7, 1), task(1, 2))
|
||||
val result = WidgetDataRepository.filterTasksForResidence(tasks, residenceId = 1)
|
||||
assertEquals(listOf(5L, 3L, 7L), result.map { it.id })
|
||||
}
|
||||
|
||||
// --- DataStore round-trip ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun perWidgetResidenceId_roundTripsThroughDataStore() = runTest {
|
||||
// Initial state: no scope persisted → returns null ("All residences").
|
||||
assertNull(repo.loadResidenceIdFor(appWidgetId = 42))
|
||||
|
||||
repo.saveResidenceIdFor(appWidgetId = 42, residenceId = 7L)
|
||||
assertEquals(7L, repo.loadResidenceIdFor(appWidgetId = 42))
|
||||
|
||||
// Different widget id stays unscoped — keys are per-instance.
|
||||
assertNull(repo.loadResidenceIdFor(appWidgetId = 99))
|
||||
|
||||
// Save null clears the scope ("All residences" selected after a
|
||||
// previously residence-scoped tile).
|
||||
repo.saveResidenceIdFor(appWidgetId = 42, residenceId = null)
|
||||
assertNull(repo.loadResidenceIdFor(appWidgetId = 42))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadTasksForWidget_appliesPerInstanceScope() = runTest {
|
||||
repo.saveTasks(listOf(task(1, 10), task(2, 20), task(3, 10)))
|
||||
repo.saveResidenceIdFor(appWidgetId = 1, residenceId = 10L)
|
||||
repo.saveResidenceIdFor(appWidgetId = 2, residenceId = 20L)
|
||||
|
||||
assertEquals(listOf(1L, 3L), repo.loadTasksForWidget(1).map { it.id })
|
||||
assertEquals(listOf(2L), repo.loadTasksForWidget(2).map { it.id })
|
||||
// Unconfigured tile defaults to "All residences" — every task.
|
||||
assertEquals(listOf(1L, 2L, 3L), repo.loadTasksForWidget(3).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearResidenceIdFor_dropsScope() = runTest {
|
||||
repo.saveResidenceIdFor(appWidgetId = 5, residenceId = 11L)
|
||||
assertEquals(11L, repo.loadResidenceIdFor(5))
|
||||
|
||||
repo.clearResidenceIdFor(5)
|
||||
assertNull(repo.loadResidenceIdFor(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveResidences_roundTripsResidenceList() = runTest {
|
||||
val payload = listOf(
|
||||
WidgetResidenceDto(id = 1, name = "Home"),
|
||||
WidgetResidenceDto(id = 2, name = "Cabin")
|
||||
)
|
||||
repo.saveResidences(payload)
|
||||
assertEquals(payload, repo.loadResidences())
|
||||
}
|
||||
}
|
||||
@@ -59,12 +59,29 @@ object HoneyDueShareCodec {
|
||||
|
||||
/**
|
||||
* Build a filesystem-safe package filename with `.honeydue` extension.
|
||||
*
|
||||
* Strips only the characters that are actually unsafe on iOS / Android
|
||||
* filesystems (`/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, control
|
||||
* chars). Spaces and apostrophes are kept intact so the recipient sees
|
||||
* the original residence / contractor name in the iOS QuickLook title
|
||||
* bar — gitea#7 called out the previous behaviour rendering
|
||||
* "The_Tartt's" instead of "The Tartt's". Internal whitespace is
|
||||
* collapsed to single spaces and trimmed; falls back to "honeyDue" if
|
||||
* the input is blank after sanitising.
|
||||
*/
|
||||
fun safeShareFileName(displayName: String): String {
|
||||
val safeName = displayName
|
||||
.replace(" ", "_")
|
||||
.replace("/", "-")
|
||||
// Keep whitespace through the filter so adjacent space+tab
|
||||
// sequences survive to the regex-collapse step below. Drop
|
||||
// only non-whitespace control chars (NUL etc.) plus the
|
||||
// explicit filesystem-unsafe set.
|
||||
.filter { it !in UNSAFE_FILENAME_CHARS && (it.isWhitespace() || !it.isISOControl()) }
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.take(50)
|
||||
.ifBlank { "honeyDue" }
|
||||
return "$safeName.honeydue"
|
||||
}
|
||||
|
||||
private val UNSAFE_FILENAME_CHARS = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -10,79 +10,9 @@ import AppIntents
|
||||
import Foundation
|
||||
|
||||
// MARK: - Widget Configuration Intent
|
||||
|
||||
/// Per-instance widget configuration. The `residence` parameter (added
|
||||
/// for gitea#6) lets users with multiple residences pick which one a
|
||||
/// given widget tile shows tasks for. When unset the widget continues
|
||||
/// to display tasks across every residence — that's the single-home
|
||||
/// default and matches pre-#6 behaviour for users who only have one
|
||||
/// property.
|
||||
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource { "honeyDue Configuration" }
|
||||
static var description: IntentDescription {
|
||||
IntentDescription("Pick which residence this widget shows tasks for.")
|
||||
}
|
||||
|
||||
@Parameter(title: "Residence")
|
||||
var residence: WidgetResidenceEntity?
|
||||
}
|
||||
|
||||
// MARK: - Residence Entity (configuration picker)
|
||||
|
||||
/// `AppEntity` exposing the user's residences to the widget's
|
||||
/// configuration sheet. Reads from the `widget_residences.json`
|
||||
/// sidecar that the main app writes via
|
||||
/// `WidgetDataManager.saveResidences(...)`.
|
||||
struct WidgetResidenceEntity: AppEntity, Identifiable, Hashable {
|
||||
/// Backing residence id (matches `Residence.id` on the server). Stored
|
||||
/// as `String` because `AppEntity.id` requires `Hashable`-conformance
|
||||
/// for stable widget reconfiguration — Apple's docs recommend a stable
|
||||
/// string identifier over `Int` so the widget timeline survives
|
||||
/// device-id changes.
|
||||
var id: String
|
||||
|
||||
var name: String
|
||||
|
||||
/// Convenience integer form for `CacheManager.getUpcomingTasks`.
|
||||
var intId: Int? { Int(id) }
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation {
|
||||
TypeDisplayRepresentation(name: "Residence")
|
||||
}
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(title: "\(name)")
|
||||
}
|
||||
|
||||
static var defaultQuery = WidgetResidenceEntityQuery()
|
||||
}
|
||||
|
||||
/// Provides the residence choices the configuration sheet displays. The
|
||||
/// list is sourced from the App-Group-shared `widget_residences.json`
|
||||
/// the main app maintains; on a brand-new install (or signed-out state)
|
||||
/// the sheet falls back to showing only the "All residences" implicit
|
||||
/// option exposed by the optional parameter.
|
||||
struct WidgetResidenceEntityQuery: EntityQuery {
|
||||
/// Look up specific residences by id (used when the system needs to
|
||||
/// re-resolve a saved configuration after the user reopens the
|
||||
/// widget edit sheet).
|
||||
func entities(for identifiers: [WidgetResidenceEntity.ID]) async throws -> [WidgetResidenceEntity] {
|
||||
let known = loadAll()
|
||||
return identifiers.compactMap { id in known.first(where: { $0.id == id }) }
|
||||
}
|
||||
|
||||
/// Populate the picker. Sorted alphabetically so the list is stable
|
||||
/// across refreshes — `WidgetDataManager.saveResidences` writes in
|
||||
/// the order the API returned, which can shuffle on server-side
|
||||
/// re-sorts.
|
||||
func suggestedEntities() async throws -> [WidgetResidenceEntity] {
|
||||
loadAll().sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
private func loadAll() -> [WidgetResidenceEntity] {
|
||||
let raw = CacheManager.getResidences()
|
||||
return raw.map { WidgetResidenceEntity(id: String($0.id), name: $0.name) }
|
||||
}
|
||||
static var description: IntentDescription { "Configure your honeyDue widget" }
|
||||
}
|
||||
|
||||
// MARK: - Complete Task Intent
|
||||
|
||||
@@ -84,12 +84,6 @@ class CacheManager {
|
||||
let inProgress: Bool
|
||||
let dueDate: String?
|
||||
let category: String?
|
||||
/// Owning residence id. Decoded from `residence_id` in the widget
|
||||
/// cache. Optional so older app builds (pre-gitea#6) that omitted
|
||||
/// the key still decode successfully — in that case the widget
|
||||
/// behaves like the legacy "all residences" mode regardless of
|
||||
/// what the configuration intent picks.
|
||||
let residenceId: Int?
|
||||
let residenceName: String?
|
||||
let isOverdue: Bool
|
||||
let isDueWithin7Days: Bool
|
||||
@@ -99,45 +93,12 @@ class CacheManager {
|
||||
case id, title, description, priority, category
|
||||
case inProgress = "in_progress"
|
||||
case dueDate = "due_date"
|
||||
case residenceId = "residence_id"
|
||||
case residenceName = "residence_name"
|
||||
case isOverdue = "is_overdue"
|
||||
case isDueWithin7Days = "is_due_within_7_days"
|
||||
case isDue8To30Days = "is_due_8_to_30_days"
|
||||
}
|
||||
|
||||
/// Custom init with a default `residenceId` so the existing
|
||||
/// #Preview literal-task sites compile without each having to
|
||||
/// add the new parameter. Production decode uses the synthesized
|
||||
/// `Decodable` path.
|
||||
init(
|
||||
id: Int,
|
||||
title: String,
|
||||
description: String?,
|
||||
priority: String?,
|
||||
inProgress: Bool,
|
||||
dueDate: String?,
|
||||
category: String?,
|
||||
residenceId: Int? = nil,
|
||||
residenceName: String?,
|
||||
isOverdue: Bool,
|
||||
isDueWithin7Days: Bool,
|
||||
isDue8To30Days: Bool
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.priority = priority
|
||||
self.inProgress = inProgress
|
||||
self.dueDate = dueDate
|
||||
self.category = category
|
||||
self.residenceId = residenceId
|
||||
self.residenceName = residenceName
|
||||
self.isOverdue = isOverdue
|
||||
self.isDueWithin7Days = isDueWithin7Days
|
||||
self.isDue8To30Days = isDue8To30Days
|
||||
}
|
||||
|
||||
/// Whether this task is pending completion (tapped on widget, waiting for sync)
|
||||
var isPendingCompletion: Bool {
|
||||
WidgetActionManager.shared.isTaskPendingCompletion(taskId: id)
|
||||
@@ -186,19 +147,14 @@ class CacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
static func getUpcomingTasks(forResidenceId residenceId: Int? = nil) -> [CustomTask] {
|
||||
static func getUpcomingTasks() -> [CustomTask] {
|
||||
let allTasks = getData()
|
||||
|
||||
// Filter for actionable tasks (not completed, including in-progress and overdue)
|
||||
// Also exclude tasks that are pending completion via widget.
|
||||
// When a residence is configured for this widget instance
|
||||
// (gitea#6), drop tasks owned by other residences.
|
||||
// Also exclude tasks that are pending completion via widget
|
||||
let upcoming = allTasks.filter { task in
|
||||
guard task.shouldShow else { return false }
|
||||
if let residenceId, let taskResidenceId = task.residenceId {
|
||||
return taskResidenceId == residenceId
|
||||
}
|
||||
return true
|
||||
// Include if: not pending completion
|
||||
return task.shouldShow
|
||||
}
|
||||
|
||||
// Sort by due date (earliest first), with overdue at top
|
||||
@@ -215,36 +171,6 @@ class CacheManager {
|
||||
return date1 < date2
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Residence sidecar (gitea#6)
|
||||
|
||||
private static let residencesFileName = "widget_residences.json"
|
||||
|
||||
private static var residencesFileURL: URL? {
|
||||
sharedContainerURL?.appendingPathComponent(residencesFileName)
|
||||
}
|
||||
|
||||
struct WidgetResidence: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// Synchronously load every residence the main app has persisted for
|
||||
/// widget configuration. Empty when the user is signed out or the
|
||||
/// sidecar has not yet been written.
|
||||
static func getResidences() -> [WidgetResidence] {
|
||||
guard let fileURL = residencesFileURL,
|
||||
FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
return []
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
return try JSONDecoder().decode([WidgetResidence].self, from: data)
|
||||
} catch {
|
||||
print("CacheManager: Error decoding residences - \(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Provider: AppIntentTimelineProvider {
|
||||
@@ -258,7 +184,7 @@ struct Provider: AppIntentTimelineProvider {
|
||||
}
|
||||
|
||||
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
|
||||
let tasks = CacheManager.getUpcomingTasks(forResidenceId: configuration.residence?.intId)
|
||||
let tasks = CacheManager.getUpcomingTasks()
|
||||
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
|
||||
return SimpleEntry(
|
||||
date: Date(),
|
||||
@@ -269,7 +195,7 @@ struct Provider: AppIntentTimelineProvider {
|
||||
}
|
||||
|
||||
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
|
||||
let tasks = CacheManager.getUpcomingTasks(forResidenceId: configuration.residence?.intId)
|
||||
let tasks = CacheManager.getUpcomingTasks()
|
||||
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
|
||||
|
||||
// Use a longer refresh interval during overnight hours (11pm-6am)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppLogo@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -263,13 +263,40 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
||||
}
|
||||
|
||||
private func updateUIForResidence(with residence: ResidencePreviewData) {
|
||||
// Update icon
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
||||
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
|
||||
// Brand icon. Prefer the bundled honeyDue logo so the preview
|
||||
// reads as a HoneyDue invite at a glance; fall back to a tinted
|
||||
// SF Symbol for accessibility / asset-load failures.
|
||||
if let logo = UIImage(named: "AppLogo") {
|
||||
iconImageView.image = logo.withRenderingMode(.alwaysOriginal)
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
iconImageView.layer.cornerRadius = 16
|
||||
iconImageView.layer.masksToBounds = true
|
||||
} else {
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
||||
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
|
||||
}
|
||||
|
||||
titleLabel.text = residence.residenceName
|
||||
subtitleLabel.text = "honeyDue Residence Invite"
|
||||
instructionLabel.text = "Tap the share button below, then select \"honeyDue\" to join this residence."
|
||||
|
||||
// Branch the copy on whether the share link has already lapsed.
|
||||
// Active invites get the standard "How to join" numbered steps;
|
||||
// expired invites get a clear dead-end message asking the
|
||||
// recipient to ping the sender for a new link — no point
|
||||
// showing share-sheet directions for a link the server will
|
||||
// reject.
|
||||
let expiredAgo = Self.expiredRelativePhraseOrNil(residence.expiresAt)
|
||||
if let expiredAgo {
|
||||
instructionLabel.attributedText = Self.makeExpiredInstructions(sharedBy: residence.sharedBy)
|
||||
// The down-chevron points at the Share button as a visual
|
||||
// cue to tap it; in the expired state there's nothing
|
||||
// useful to share (the server will reject the bundled
|
||||
// code) so the arrow becomes misleading. Hide it.
|
||||
arrowImageView.isHidden = true
|
||||
} else {
|
||||
instructionLabel.attributedText = Self.makeResidenceInstructions()
|
||||
arrowImageView.isHidden = false
|
||||
}
|
||||
|
||||
// Clear existing details
|
||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
@@ -280,9 +307,183 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
||||
}
|
||||
|
||||
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
||||
addDetailRow(icon: "clock", text: "Expires: \(expiresAt)")
|
||||
if let expiredAgo {
|
||||
// "Expired 1 hour ago" — capitalised past-tense; no
|
||||
// "Expires " prefix because the share link no longer
|
||||
// expires, it has already done so (gitea#7 review).
|
||||
addDetailRow(icon: "clock", text: "Expired \(expiredAgo)")
|
||||
} else {
|
||||
let formatted = Self.formatActiveExpiry(expiresAt)
|
||||
addDetailRow(icon: "clock", text: "Expires \(formatted)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatting helpers
|
||||
|
||||
/// Render an *active* (not-yet-expired) share-link expiry as a
|
||||
/// human-readable phrase. Within a day uses
|
||||
/// `RelativeDateTimeFormatter` ("in 23 hours" / "in 12 minutes");
|
||||
/// further out switches to absolute date + time so users planning
|
||||
/// ahead see exactly when the invite lapses. Falls back to the raw
|
||||
/// ISO string if parsing fails so the row never goes blank.
|
||||
///
|
||||
/// Callers must check [expiredRelativePhraseOrNil] first — this
|
||||
/// function assumes a future expiry and produces wording that only
|
||||
/// makes sense in that case.
|
||||
static func formatActiveExpiry(_ isoString: String) -> String {
|
||||
guard let date = parseIsoDate(isoString) else { return isoString }
|
||||
let now = Date()
|
||||
let elapsed = date.timeIntervalSince(now)
|
||||
if elapsed < 24 * 60 * 60 {
|
||||
return relativeFormatter.localizedString(for: date, relativeTo: now)
|
||||
}
|
||||
return "on \(absoluteFormatter.string(from: date))"
|
||||
}
|
||||
|
||||
/// If the share link has already lapsed, return the relative
|
||||
/// "X ago" phrase. `nil` means active (or unparseable) — callers
|
||||
/// should fall back to [formatActiveExpiry] for those cases. The
|
||||
/// split lets `updateUIForResidence` branch the entire UI block
|
||||
/// (row text + instruction card) on the same signal (gitea#7
|
||||
/// review: an expired link should send the recipient back to the
|
||||
/// sender for a new invite, not show share-sheet directions for a
|
||||
/// link the server will reject).
|
||||
static func expiredRelativePhraseOrNil(_ isoString: String?) -> String? {
|
||||
guard let isoString, let date = parseIsoDate(isoString) else { return nil }
|
||||
let now = Date()
|
||||
if date.timeIntervalSince(now) > 0 { return nil }
|
||||
return relativeFormatter.localizedString(for: date, relativeTo: now)
|
||||
}
|
||||
|
||||
private static func parseIsoDate(_ raw: String) -> Date? {
|
||||
if let d = isoFormatterWithFraction.date(from: raw) { return d }
|
||||
if let d = isoFormatterNoFraction.date(from: raw) { return d }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static let isoFormatterWithFraction: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .full
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let absoluteFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .short
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Builds the "How to join" instruction copy as an attributed
|
||||
/// string with the iOS share-icon glyph (square + up-arrow) inlined
|
||||
/// next to "Tap [icon]". The glyph is the universal share symbol
|
||||
/// across iOS, so the recipient finds the right control whether
|
||||
/// it's at the top, bottom, or behind a More menu — instead of us
|
||||
/// claiming a fixed position the chrome can move (gitea#7 review
|
||||
/// feedback).
|
||||
private static func makeResidenceInstructions() -> NSAttributedString {
|
||||
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 2
|
||||
paragraph.alignment = .left
|
||||
|
||||
let result = NSMutableAttributedString()
|
||||
|
||||
func appendText(_ s: String) {
|
||||
result.append(NSAttributedString(
|
||||
string: s,
|
||||
attributes: [
|
||||
.font: bodyFont,
|
||||
.foregroundColor: tint,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
}
|
||||
|
||||
appendText("How to join:\n1. Tap ")
|
||||
|
||||
let shareImage = UIImage(
|
||||
systemName: "square.and.arrow.up",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
|
||||
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
|
||||
if let shareImage {
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = shareImage
|
||||
// Align the glyph baseline with the surrounding text by
|
||||
// nudging the bounds down a few points; the SF Symbol's
|
||||
// natural bounds sit a hair above the cap height.
|
||||
attachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: -3,
|
||||
width: shareImage.size.width,
|
||||
height: shareImage.size.height
|
||||
)
|
||||
result.append(NSAttributedString(attachment: attachment))
|
||||
}
|
||||
|
||||
appendText("\n2. Choose \"honeyDue\" from the share sheet")
|
||||
appendText("\n3. Sign in if prompted — the app finishes the rest")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Expired-state copy for the instruction card. Tells the recipient
|
||||
/// the share link is no longer valid and to ping the sender (by
|
||||
/// email if we know it) for a new one — replaces the active "How to
|
||||
/// join" steps since the server will reject the bundled code
|
||||
/// anyway.
|
||||
private static func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
|
||||
// Slightly warmer tint than the active instruction copy — the
|
||||
// app's `appError` red would feel alarmist for "just ask again",
|
||||
// and the secondary-label gray reads as muted/disabled which is
|
||||
// accurate to the link's actual state.
|
||||
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
let tint = UIColor.secondaryLabel
|
||||
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
|
||||
let titleTint = UIColor.label
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 2
|
||||
paragraph.alignment = .left
|
||||
|
||||
let result = NSMutableAttributedString()
|
||||
result.append(NSAttributedString(
|
||||
string: "This invite has expired.\n",
|
||||
attributes: [
|
||||
.font: titleFont,
|
||||
.foregroundColor: titleTint,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
|
||||
let body = if let s = sharedBy, !s.isEmpty {
|
||||
"Ask \(s) to send a new link."
|
||||
} else {
|
||||
"Ask the sender to share a new link."
|
||||
}
|
||||
result.append(NSAttributedString(
|
||||
string: body,
|
||||
attributes: [
|
||||
.font: bodyFont,
|
||||
.foregroundColor: tint,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Type Discriminator
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
//
|
||||
// Issue7PreviewScreenshotTest.swift
|
||||
// HoneyDueTests
|
||||
//
|
||||
// Records a single PNG screenshot of the post-fix QL-preview layout
|
||||
// used by `HoneyDueQLPreview/PreviewViewController.swift` so it can be
|
||||
// attached to gitea issue #7 for the reviewer to see the new look
|
||||
// without having to AirDrop a `.honeydue` file to a device.
|
||||
//
|
||||
// How it works:
|
||||
// * Faithfully recreates the UIKit layout `PreviewViewController.updateUIForResidence`
|
||||
// builds in production — same colors, same fonts, same constraints,
|
||||
// same image asset (copied into `HoneyDueTests/Resources/AppLogo.png`
|
||||
// so it is reachable from this target's bundle).
|
||||
// * Runs the same `formatExpiresAt` style (ISO parse → relative phrase
|
||||
// when within a day, absolute medium-date + short-time otherwise),
|
||||
// using a fixed reference Date so the rendering is deterministic
|
||||
// across runs / time zones.
|
||||
// * `SnapshotTesting.assertSnapshot(of: viewController, as: .image)`
|
||||
// writes the PNG to
|
||||
// `iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/`.
|
||||
//
|
||||
// The first run (no committed golden) records the PNG and the test
|
||||
// reports "failed - No reference was found on disk. Automatically
|
||||
// recorded snapshot:" — that's the file we attach to the issue.
|
||||
//
|
||||
// Note on faithfulness: this snapshot is a programmatic reproduction
|
||||
// of `PreviewViewController.updateUIForResidence`, not the QL
|
||||
// extension instance itself, because the QL extension's bundle is a
|
||||
// separate Xcode target from `HoneyDueTests` and can't be `@testable
|
||||
// import`ed without project-file surgery. The reproduction uses the
|
||||
// same UIKit primitives, colors, fonts, and asset, so the rendered
|
||||
// output matches what users see when iOS opens a `.honeydue` invite.
|
||||
//
|
||||
|
||||
@preconcurrency import SnapshotTesting
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class Issue7PreviewScreenshotTest: XCTestCase {
|
||||
|
||||
/// Force record mode for this test only — we want the PNG written
|
||||
/// regardless of whether a golden exists.
|
||||
override func invokeTest() {
|
||||
withSnapshotTesting(record: .all) {
|
||||
super.invokeTest()
|
||||
}
|
||||
}
|
||||
|
||||
func test_residence_invite_preview_after_issue7_fix() {
|
||||
let vc = MockPreviewViewController(
|
||||
residence: ResidencePreview.fixtureForIssue7,
|
||||
state: .active
|
||||
)
|
||||
vc.overrideUserInterfaceStyle = .dark
|
||||
|
||||
assertSnapshot(
|
||||
of: vc,
|
||||
as: .image(
|
||||
on: .iPhone13,
|
||||
precision: 1.0,
|
||||
perceptualPrecision: 1.0,
|
||||
traits: .init(traitsFrom: [
|
||||
UITraitCollection(userInterfaceStyle: .dark),
|
||||
UITraitCollection(displayScale: 2.0),
|
||||
])
|
||||
),
|
||||
named: "issue7_residence_invite_preview_dark"
|
||||
)
|
||||
}
|
||||
|
||||
func test_residence_invite_preview_expired_state() {
|
||||
// Same residence + sender, but expiry already 1 hour in the
|
||||
// past. Verifies the expired branch: the instruction card
|
||||
// swaps to "ask the sender for a new link" and the detail row
|
||||
// reads "Expired 1 hour ago" instead of the future-tense
|
||||
// "Expires in …" phrasing.
|
||||
let vc = MockPreviewViewController(
|
||||
residence: ResidencePreview.fixtureForIssue7,
|
||||
state: .expired(elapsedSecondsSinceExpiry: 60 * 60)
|
||||
)
|
||||
vc.overrideUserInterfaceStyle = .dark
|
||||
|
||||
assertSnapshot(
|
||||
of: vc,
|
||||
as: .image(
|
||||
on: .iPhone13,
|
||||
precision: 1.0,
|
||||
perceptualPrecision: 1.0,
|
||||
traits: .init(traitsFrom: [
|
||||
UITraitCollection(userInterfaceStyle: .dark),
|
||||
UITraitCollection(displayScale: 2.0),
|
||||
])
|
||||
),
|
||||
named: "issue7_residence_invite_preview_expired_dark"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample residence (matches the gitea#7 screenshot setup)
|
||||
|
||||
private struct ResidencePreview {
|
||||
let residenceName: String
|
||||
let sharedBy: String?
|
||||
let expiresAt: String?
|
||||
|
||||
/// Mirrors the data shown in the original gitea#7 screenshot — the
|
||||
/// post-fix version of the same payload.
|
||||
static let fixtureForIssue7 = ResidencePreview(
|
||||
residenceName: "The Tartt's",
|
||||
sharedBy: "honey@hollie37.com",
|
||||
expiresAt: "2026-05-12T17:11:02.067272789Z"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Mock view controller (UIKit copy of `updateUIForResidence`)
|
||||
|
||||
/// Renderer state for the screenshot fixture. Active = link still
|
||||
/// valid; expired = link lapsed `elapsedSecondsSinceExpiry` seconds
|
||||
/// ago. Both render with deterministic data so the recorded PNG is
|
||||
/// stable across runs.
|
||||
private enum PreviewRenderState {
|
||||
case active
|
||||
case expired(elapsedSecondsSinceExpiry: TimeInterval)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockPreviewViewController: UIViewController {
|
||||
|
||||
private let residence: ResidencePreview
|
||||
private let state: PreviewRenderState
|
||||
|
||||
private let containerView = UIView()
|
||||
private let iconImageView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let dividerView = UIView()
|
||||
private let detailsStackView = UIStackView()
|
||||
private let instructionCard = UIView()
|
||||
private let instructionLabel = UILabel()
|
||||
private let arrowImageView = UIImageView()
|
||||
|
||||
init(residence: ResidencePreview, state: PreviewRenderState) {
|
||||
self.residence = residence
|
||||
self.state = state
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("not used") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
applyResidence()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
iconImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
iconImageView.tintColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
||||
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
|
||||
titleLabel.textColor = .label
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.numberOfLines = 2
|
||||
|
||||
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
subtitleLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
subtitleLabel.textColor = .secondaryLabel
|
||||
subtitleLabel.textAlignment = .center
|
||||
|
||||
dividerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
dividerView.backgroundColor = .separator
|
||||
|
||||
detailsStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
detailsStackView.axis = .vertical
|
||||
detailsStackView.spacing = 12
|
||||
detailsStackView.alignment = .leading
|
||||
|
||||
instructionCard.translatesAutoresizingMaskIntoConstraints = false
|
||||
instructionCard.backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 0.1)
|
||||
instructionCard.layer.cornerRadius = 12
|
||||
|
||||
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
instructionLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
instructionLabel.textColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
||||
instructionLabel.textAlignment = .left
|
||||
instructionLabel.numberOfLines = 0
|
||||
|
||||
arrowImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
arrowImageView.contentMode = .scaleAspectFit
|
||||
arrowImageView.tintColor = .secondaryLabel
|
||||
let arrowConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
|
||||
arrowImageView.image = UIImage(systemName: "arrow.down", withConfiguration: arrowConfig)
|
||||
|
||||
view.addSubview(containerView)
|
||||
containerView.addSubview(iconImageView)
|
||||
containerView.addSubview(titleLabel)
|
||||
containerView.addSubview(subtitleLabel)
|
||||
containerView.addSubview(dividerView)
|
||||
containerView.addSubview(detailsStackView)
|
||||
containerView.addSubview(instructionCard)
|
||||
instructionCard.addSubview(instructionLabel)
|
||||
containerView.addSubview(arrowImageView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
|
||||
containerView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
|
||||
containerView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
|
||||
containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 340),
|
||||
|
||||
iconImageView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
iconImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
||||
iconImageView.widthAnchor.constraint(equalToConstant: 80),
|
||||
iconImageView.heightAnchor.constraint(equalToConstant: 80),
|
||||
|
||||
titleLabel.topAnchor.constraint(equalTo: iconImageView.bottomAnchor, constant: 16),
|
||||
titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
||||
subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
dividerView.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 20),
|
||||
dividerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
dividerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
dividerView.heightAnchor.constraint(equalToConstant: 1),
|
||||
|
||||
detailsStackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20),
|
||||
detailsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
detailsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
instructionCard.topAnchor.constraint(equalTo: detailsStackView.bottomAnchor, constant: 24),
|
||||
instructionCard.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
instructionCard.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
|
||||
instructionLabel.topAnchor.constraint(equalTo: instructionCard.topAnchor, constant: 16),
|
||||
instructionLabel.leadingAnchor.constraint(equalTo: instructionCard.leadingAnchor, constant: 16),
|
||||
instructionLabel.trailingAnchor.constraint(equalTo: instructionCard.trailingAnchor, constant: -16),
|
||||
instructionLabel.bottomAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: -16),
|
||||
|
||||
arrowImageView.topAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: 16),
|
||||
arrowImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
||||
arrowImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func applyResidence() {
|
||||
// Mirror the post-fix branding choice: bundled honeyDue logo
|
||||
// rendered in its actual colors. The image ships with the test
|
||||
// target at `Resources/AppLogo.png`.
|
||||
if let path = Bundle(for: Self.self).path(forResource: "AppLogo", ofType: "png"),
|
||||
let logo = UIImage(contentsOfFile: path) {
|
||||
iconImageView.image = logo
|
||||
iconImageView.layer.cornerRadius = 16
|
||||
iconImageView.layer.masksToBounds = true
|
||||
} else {
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
||||
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
|
||||
}
|
||||
|
||||
titleLabel.text = residence.residenceName
|
||||
subtitleLabel.text = "honeyDue Residence Invite"
|
||||
|
||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
|
||||
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
|
||||
}
|
||||
|
||||
switch state {
|
||||
case .active:
|
||||
instructionLabel.attributedText = makeResidenceInstructions()
|
||||
arrowImageView.isHidden = false
|
||||
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
||||
addDetailRow(icon: "clock", text: "Expires \(formatActiveExpiry(expiresAt))")
|
||||
}
|
||||
case .expired(let elapsed):
|
||||
instructionLabel.attributedText = makeExpiredInstructions(sharedBy: residence.sharedBy)
|
||||
// Arrow points at the Share button — no point telling the
|
||||
// user to tap it for a dead link. Matches PreviewViewController.
|
||||
arrowImageView.isHidden = true
|
||||
addDetailRow(icon: "clock", text: "Expired \(relativePhrase(secondsAgo: elapsed))")
|
||||
}
|
||||
}
|
||||
|
||||
private func relativePhrase(secondsAgo: TimeInterval) -> String {
|
||||
// Deterministic relative phrase — we set "now" to be exactly
|
||||
// `secondsAgo` after the (fake) expiry, so the formatter says
|
||||
// "1 hour ago" instead of whatever the real clock would give.
|
||||
let fakeNow = Date()
|
||||
let pastExpiry = fakeNow.addingTimeInterval(-secondsAgo)
|
||||
let relative = RelativeDateTimeFormatter()
|
||||
relative.unitsStyle = .full
|
||||
return relative.localizedString(for: pastExpiry, relativeTo: fakeNow)
|
||||
}
|
||||
|
||||
/// Expired-state copy mirroring `PreviewViewController.makeExpiredInstructions`.
|
||||
private func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString {
|
||||
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold)
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 2
|
||||
paragraph.alignment = .left
|
||||
|
||||
let result = NSMutableAttributedString()
|
||||
result.append(NSAttributedString(
|
||||
string: "This invite has expired.\n",
|
||||
attributes: [
|
||||
.font: titleFont,
|
||||
.foregroundColor: UIColor.label,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
let body = if let s = sharedBy, !s.isEmpty {
|
||||
"Ask \(s) to send a new link."
|
||||
} else {
|
||||
"Ask the sender to share a new link."
|
||||
}
|
||||
result.append(NSAttributedString(
|
||||
string: body,
|
||||
attributes: [
|
||||
.font: bodyFont,
|
||||
.foregroundColor: UIColor.secondaryLabel,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
return result
|
||||
}
|
||||
|
||||
private func addDetailRow(icon: String, text: String) {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.spacing = 12
|
||||
row.alignment = .center
|
||||
|
||||
let iv = UIImageView()
|
||||
iv.translatesAutoresizingMaskIntoConstraints = false
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
|
||||
iv.image = UIImage(systemName: icon, withConfiguration: config)
|
||||
iv.tintColor = .secondaryLabel
|
||||
iv.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
iv.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
|
||||
let label = UILabel()
|
||||
label.font = .systemFont(ofSize: 15)
|
||||
label.textColor = .label
|
||||
label.text = text
|
||||
label.numberOfLines = 1
|
||||
|
||||
row.addArrangedSubview(iv)
|
||||
row.addArrangedSubview(label)
|
||||
detailsStackView.addArrangedSubview(row)
|
||||
}
|
||||
|
||||
/// Mirrors `PreviewViewController.makeResidenceInstructions()` — see
|
||||
/// the rationale comment there. Inlined here because the QL
|
||||
/// extension target can't be `@testable import`ed without
|
||||
/// project-file surgery.
|
||||
private func makeResidenceInstructions() -> NSAttributedString {
|
||||
let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
let tint = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 2
|
||||
paragraph.alignment = .left
|
||||
|
||||
let result = NSMutableAttributedString()
|
||||
|
||||
func appendText(_ s: String) {
|
||||
result.append(NSAttributedString(
|
||||
string: s,
|
||||
attributes: [
|
||||
.font: bodyFont,
|
||||
.foregroundColor: tint,
|
||||
.paragraphStyle: paragraph,
|
||||
]
|
||||
))
|
||||
}
|
||||
|
||||
appendText("How to join:\n1. Tap ")
|
||||
|
||||
let shareImage = UIImage(
|
||||
systemName: "square.and.arrow.up",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
|
||||
)?.withTintColor(tint, renderingMode: .alwaysOriginal)
|
||||
if let shareImage {
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = shareImage
|
||||
attachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: -3,
|
||||
width: shareImage.size.width,
|
||||
height: shareImage.size.height
|
||||
)
|
||||
result.append(NSAttributedString(attachment: attachment))
|
||||
}
|
||||
|
||||
appendText("\n2. Choose \"honeyDue\" from the share sheet")
|
||||
appendText("\n3. Sign in if prompted — the app finishes the rest")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Mirrors PreviewViewController.formatActiveExpiry with a fixed
|
||||
// "now" so the rendering is identical regardless of when the test
|
||||
// runs. The expired branch uses [relativePhrase(secondsAgo:)]
|
||||
// instead — see the active/expired switch in `applyResidence`.
|
||||
private func formatActiveExpiry(_ raw: String) -> String {
|
||||
let isoWithFraction: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
let isoNoFraction: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
guard let date = isoWithFraction.date(from: raw)
|
||||
?? isoNoFraction.date(from: raw) else {
|
||||
return raw
|
||||
}
|
||||
|
||||
// Deterministic "now": 23 hours before the fixture's expiry, so
|
||||
// the relative formatter always produces "in 23 hours".
|
||||
let fakeNow = date.addingTimeInterval(-23 * 60 * 60)
|
||||
let relative = RelativeDateTimeFormatter()
|
||||
relative.unitsStyle = .full
|
||||
return relative.localizedString(for: date, relativeTo: fakeNow)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -1,90 +0,0 @@
|
||||
import XCTest
|
||||
@testable import honeyDue
|
||||
|
||||
/// Tests for the per-residence widget filter added in gitea#6.
|
||||
///
|
||||
/// `WidgetDataManager.filterTasks(_:forResidenceId:)` is the pure
|
||||
/// function the widget timeline provider calls when a configuration
|
||||
/// intent has a residence selected. These tests guarantee the contract
|
||||
/// stays stable: nil → pass-through, matching id → only matching tasks,
|
||||
/// no match → empty, missing residenceId on a task → never leaks into
|
||||
/// a residence-scoped widget.
|
||||
final class WidgetResidenceFilterTests: XCTestCase {
|
||||
|
||||
private func makeTask(
|
||||
id: Int,
|
||||
residenceId: Int? = nil
|
||||
) -> WidgetDataManager.WidgetTask {
|
||||
WidgetDataManager.WidgetTask(
|
||||
id: id,
|
||||
title: "Task \(id)",
|
||||
description: nil,
|
||||
priority: nil,
|
||||
inProgress: false,
|
||||
dueDate: nil,
|
||||
category: nil,
|
||||
residenceId: residenceId,
|
||||
residenceName: nil,
|
||||
isOverdue: false,
|
||||
isDueWithin7Days: false,
|
||||
isDue8To30Days: false
|
||||
)
|
||||
}
|
||||
|
||||
func testNilResidenceReturnsAllTasks() {
|
||||
// "All residences" config — widget passes nil, gets every task.
|
||||
let tasks = [
|
||||
makeTask(id: 1, residenceId: 10),
|
||||
makeTask(id: 2, residenceId: 20),
|
||||
makeTask(id: 3, residenceId: nil),
|
||||
]
|
||||
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: nil)
|
||||
XCTAssertEqual(result.map(\.id), [1, 2, 3])
|
||||
}
|
||||
|
||||
func testMatchingResidenceKeepsOnlyMatchingTasks() {
|
||||
let tasks = [
|
||||
makeTask(id: 1, residenceId: 10),
|
||||
makeTask(id: 2, residenceId: 20),
|
||||
makeTask(id: 3, residenceId: 10),
|
||||
makeTask(id: 4, residenceId: 30),
|
||||
]
|
||||
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 10)
|
||||
XCTAssertEqual(result.map(\.id), [1, 3])
|
||||
}
|
||||
|
||||
func testUnknownResidenceReturnsEmpty() {
|
||||
let tasks = [
|
||||
makeTask(id: 1, residenceId: 10),
|
||||
makeTask(id: 2, residenceId: 20),
|
||||
]
|
||||
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 999)
|
||||
XCTAssertTrue(result.isEmpty)
|
||||
}
|
||||
|
||||
func testNilResidenceIdOnTaskDoesNotMatchScopedConfiguration() {
|
||||
// A task written by an older app build (no `residence_id` in JSON)
|
||||
// must NOT leak into a residence-scoped widget — we'd rather hide
|
||||
// it than misattribute it to the wrong home.
|
||||
let tasks = [
|
||||
makeTask(id: 1, residenceId: 10),
|
||||
makeTask(id: 2, residenceId: nil),
|
||||
]
|
||||
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 10)
|
||||
XCTAssertEqual(result.map(\.id), [1])
|
||||
}
|
||||
|
||||
func testFilterPreservesInputOrder() {
|
||||
// The filter is a pure subset op — no sorting side effects.
|
||||
// Timeline provider relies on this so its sort step (overdue
|
||||
// first, then by due date) operates on already-filtered tasks.
|
||||
let tasks = [
|
||||
makeTask(id: 5, residenceId: 1),
|
||||
makeTask(id: 3, residenceId: 1),
|
||||
makeTask(id: 7, residenceId: 1),
|
||||
makeTask(id: 1, residenceId: 2),
|
||||
]
|
||||
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 1)
|
||||
XCTAssertEqual(result.map(\.id), [5, 3, 7])
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
@@ -274,14 +274,11 @@ class DataManagerObservable: ObservableObject {
|
||||
}
|
||||
observationTasks.append(residencesTask)
|
||||
|
||||
// MyResidences. Mirror every update into the widget App Group
|
||||
// sidecar so the widget's configuration intent (gitea#6) can
|
||||
// offer the current residence list without making a network call.
|
||||
// MyResidences
|
||||
let myResidencesTask = Task { [weak self] in
|
||||
for await response in DataManager.shared.myResidences {
|
||||
guard let self else { return }
|
||||
self.myResidences = response
|
||||
WidgetDataManager.shared.saveResidences(from: response)
|
||||
}
|
||||
}
|
||||
observationTasks.append(myResidencesTask)
|
||||
@@ -735,7 +732,6 @@ class DataManagerObservable: ObservableObject {
|
||||
inProgress: task.inProgress,
|
||||
dueDate: task.effectiveDueDate,
|
||||
category: task.categoryName,
|
||||
residenceId: Int(task.residenceId),
|
||||
residenceName: nil,
|
||||
isOverdue: overdueIds.contains(task.id),
|
||||
isDueWithin7Days: isDueWithin7Days,
|
||||
|
||||
@@ -24,7 +24,6 @@ final class WidgetDataManager {
|
||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
||||
}()
|
||||
private let tasksFileName = "widget_tasks.json"
|
||||
private let residencesFileName = "widget_residences.json"
|
||||
private let actionsFileName = "widget_pending_actions.json"
|
||||
private let pendingTasksFileName = "widget_pending_tasks.json"
|
||||
private let tokenKey = "widget_auth_token"
|
||||
@@ -296,15 +295,7 @@ final class WidgetDataManager {
|
||||
!loadPendingActionsSync().isEmpty
|
||||
}
|
||||
|
||||
/// Task model for widget display - simplified version of TaskDetail.
|
||||
///
|
||||
/// `residenceId` (added for gitea#6 per-residence widget selection)
|
||||
/// is encoded as `residence_id` to match the widget extension's
|
||||
/// `CacheManager.CustomTask` JSON shape. The extension uses it to
|
||||
/// filter the timeline when the user picks a specific residence in
|
||||
/// the widget configuration intent. Older JSON written by previous
|
||||
/// app versions omitted the key — the field is optional so decode
|
||||
/// of pre-existing widget caches still succeeds.
|
||||
/// Task model for widget display - simplified version of TaskDetail
|
||||
struct WidgetTask: Codable {
|
||||
let id: Int
|
||||
let title: String
|
||||
@@ -313,7 +304,6 @@ final class WidgetDataManager {
|
||||
let inProgress: Bool
|
||||
let dueDate: String?
|
||||
let category: String?
|
||||
let residenceId: Int?
|
||||
let residenceName: String?
|
||||
let isOverdue: Bool
|
||||
let isDueWithin7Days: Bool
|
||||
@@ -323,53 +313,11 @@ final class WidgetDataManager {
|
||||
case id, title, description, priority, category
|
||||
case inProgress = "in_progress"
|
||||
case dueDate = "due_date"
|
||||
case residenceId = "residence_id"
|
||||
case residenceName = "residence_name"
|
||||
case isOverdue = "is_overdue"
|
||||
case isDueWithin7Days = "is_due_within_7_days"
|
||||
case isDue8To30Days = "is_due_8_to_30_days"
|
||||
}
|
||||
|
||||
/// Custom init with a default `residenceId` so existing test
|
||||
/// literals (TaskMetricsTests) compile without each adding the
|
||||
/// new field. Production code that has the residence id passes
|
||||
/// it explicitly.
|
||||
init(
|
||||
id: Int,
|
||||
title: String,
|
||||
description: String?,
|
||||
priority: String?,
|
||||
inProgress: Bool,
|
||||
dueDate: String?,
|
||||
category: String?,
|
||||
residenceId: Int? = nil,
|
||||
residenceName: String?,
|
||||
isOverdue: Bool,
|
||||
isDueWithin7Days: Bool,
|
||||
isDue8To30Days: Bool
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.priority = priority
|
||||
self.inProgress = inProgress
|
||||
self.dueDate = dueDate
|
||||
self.category = category
|
||||
self.residenceId = residenceId
|
||||
self.residenceName = residenceName
|
||||
self.isOverdue = isOverdue
|
||||
self.isDueWithin7Days = isDueWithin7Days
|
||||
self.isDue8To30Days = isDue8To30Days
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight residence identifier for widget configuration. Persists
|
||||
/// `(id, name)` of every residence the user belongs to so the widget
|
||||
/// extension can populate its `ResidenceEntityQuery` without making
|
||||
/// a network call (gitea#6).
|
||||
struct WidgetResidence: Codable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// Metrics calculated from an array of tasks - shared between app and widget
|
||||
@@ -470,12 +418,6 @@ final class WidgetDataManager {
|
||||
}
|
||||
}
|
||||
|
||||
// `task.residenceId` is non-optional Int32 on Kotlin so always
|
||||
// promotes safely. `residenceName` is left blank because the
|
||||
// widget already resolves it via the saved residences file
|
||||
// (gitea#6) — keeping the field around for forward-compat
|
||||
// with the existing JSON shape consumed by older widget
|
||||
// builds.
|
||||
let widgetTask = WidgetTask(
|
||||
id: Int(task.id),
|
||||
title: task.title,
|
||||
@@ -484,7 +426,6 @@ final class WidgetDataManager {
|
||||
inProgress: task.inProgress,
|
||||
dueDate: task.effectiveDueDate,
|
||||
category: task.categoryName ?? "",
|
||||
residenceId: Int(task.residenceId),
|
||||
residenceName: "",
|
||||
isOverdue: isOverdue,
|
||||
isDueWithin7Days: isDueWithin7Days,
|
||||
@@ -599,94 +540,10 @@ final class WidgetDataManager {
|
||||
print("WidgetDataManager: Error clearing cache - \(error)")
|
||||
}
|
||||
|
||||
// Also clear residences so the configuration intent stops
|
||||
// offering stale options after sign-out.
|
||||
if let resURL = self.residencesFileURL {
|
||||
try? FileManager.default.removeItem(at: resURL)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Residences (per-residence widget selection, gitea#6)
|
||||
|
||||
/// Path to the residence sidecar file inside the App Group container.
|
||||
private var residencesFileURL: URL? {
|
||||
sharedContainerURL?.appendingPathComponent(residencesFileName)
|
||||
}
|
||||
|
||||
/// Persist the user's residences (id + name) to the App Group so the
|
||||
/// widget extension's configuration intent can offer them as choices.
|
||||
/// Call whenever `DataManagerObservable.myResidences` updates.
|
||||
func saveResidences(_ residences: [WidgetResidence]) {
|
||||
guard let fileURL = residencesFileURL else {
|
||||
print("WidgetDataManager: Unable to access shared container for residences")
|
||||
return
|
||||
}
|
||||
|
||||
fileQueue.async {
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
let data = try encoder.encode(residences)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
print("WidgetDataManager: Saved \(residences.count) residences for widget config")
|
||||
} catch {
|
||||
print("WidgetDataManager: Error saving residences - \(error)")
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
// Configuration intent reads on-demand, but reload the
|
||||
// currently-pinned widgets so the visible task list
|
||||
// refreshes against any rename.
|
||||
self.reloadWidgetTimelinesIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: save from a Kotlin `MyResidencesResponse` directly.
|
||||
func saveResidences(from myResidences: MyResidencesResponse?) {
|
||||
let residences = (myResidences?.residences ?? []).map { r in
|
||||
WidgetResidence(id: Int(r.id), name: r.name)
|
||||
}
|
||||
saveResidences(residences)
|
||||
}
|
||||
|
||||
/// Load the persisted residences synchronously. Used by the widget
|
||||
/// extension's `ResidenceEntityQuery` (`AppIntents` requires sync
|
||||
/// reads).
|
||||
func loadResidencesSync() -> [WidgetResidence] {
|
||||
guard let fileURL = residencesFileURL else { return [] }
|
||||
|
||||
return fileQueue.sync {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
return try JSONDecoder().decode([WidgetResidence].self, from: data)
|
||||
} catch {
|
||||
print("WidgetDataManager: Error loading residences - \(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pure filter (covered by tests)
|
||||
|
||||
/// Return only the tasks for `residenceId`. When `residenceId` is
|
||||
/// `nil`, returns the input unchanged — that's the "All residences"
|
||||
/// configuration option in the widget.
|
||||
///
|
||||
/// Factored out as a pure function so it can be exercised from unit
|
||||
/// tests without booting the widget timeline provider.
|
||||
static func filterTasks(
|
||||
_ tasks: [WidgetTask],
|
||||
forResidenceId residenceId: Int?
|
||||
) -> [WidgetTask] {
|
||||
guard let residenceId else { return tasks }
|
||||
return tasks.filter { $0.residenceId == residenceId }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user