Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90a1d98322 | |||
| 05cc4311a7 | |||
| f364ab05dc | |||
| 0b6f26da99 | |||
| 83c3428b05 | |||
| f4c2780e34 | |||
| d26714f043 | |||
| 3a5e33af93 | |||
| bd27f32caa | |||
| 9c9e6009c7 | |||
| 498e6b8064 | |||
| 5aa31153e3 | |||
| 23f4d70ac1 | |||
| fdcf82757d | |||
| 3890dd6f52 |
@@ -121,6 +121,19 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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) -->
|
<!-- Small Widget Receiver (2x1) -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.HoneyDueSmallWidgetReceiver"
|
android:name=".widget.HoneyDueSmallWidgetReceiver"
|
||||||
|
|||||||
@@ -314,9 +314,10 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
|
|||||||
return ImageLoader.Builder(context)
|
return ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
// Auth interceptor runs before the network fetcher so every
|
// Auth interceptor runs before the network fetcher so every
|
||||||
// image request carries the current Authorization header, with
|
// image request carries the current X-Session-Token header
|
||||||
// 401 -> refresh-token -> retry handled transparently. Mirrors
|
// (Kratos session token), with 401 -> session re-check ->
|
||||||
// iOS AuthenticatedImage.swift (Stream U).
|
// retry handled transparently. Mirrors iOS
|
||||||
|
// AuthenticatedImage.swift.
|
||||||
add(
|
add(
|
||||||
CoilAuthInterceptor(
|
CoilAuthInterceptor(
|
||||||
tokenProvider = { TokenStorage.getToken() },
|
tokenProvider = { TokenStorage.getToken() },
|
||||||
@@ -324,7 +325,6 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory {
|
|||||||
val r = APILayer.refreshToken()
|
val r = APILayer.refreshToken()
|
||||||
if (r is ApiResult.Success) r.data else null
|
if (r is ApiResult.Success) r.data else null
|
||||||
},
|
},
|
||||||
authScheme = "Token",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
add(KtorNetworkFetcherFactory())
|
add(KtorNetworkFetcherFactory())
|
||||||
|
|||||||
@@ -45,8 +45,14 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val repo = WidgetDataRepository.get(context)
|
val repo = WidgetDataRepository.get(context)
|
||||||
val tasks = repo.loadTasks()
|
// Per-instance residence scoping (gitea#6). Stats are computed
|
||||||
val stats = repo.computeStats()
|
// 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 tier = repo.loadTierState()
|
val tier = repo.loadTierState()
|
||||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||||
|
|
||||||
@@ -135,4 +141,9 @@ class HoneyDueLargeWidget : GlanceAppWidget() {
|
|||||||
/** AppWidget receiver for the large widget. */
|
/** AppWidget receiver for the large widget. */
|
||||||
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
|
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
|
||||||
|
|
||||||
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
|
super.onDeleted(context, appWidgetIds)
|
||||||
|
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ class HoneyDueMediumWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val repo = WidgetDataRepository.get(context)
|
val repo = WidgetDataRepository.get(context)
|
||||||
val tasks = repo.loadTasks()
|
// 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 tier = repo.loadTierState()
|
val tier = repo.loadTierState()
|
||||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||||
|
|
||||||
@@ -122,4 +125,9 @@ class HoneyDueMediumWidget : GlanceAppWidget() {
|
|||||||
/** AppWidget receiver for the medium widget. */
|
/** AppWidget receiver for the medium widget. */
|
||||||
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
|
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
|
||||||
|
|
||||||
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
|
super.onDeleted(context, appWidgetIds)
|
||||||
|
WidgetReceiverHelpers.purgeResidenceScopes(context, appWidgetIds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.tt.honeyDue.widget
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
@@ -43,7 +44,13 @@ class HoneyDueSmallWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val repo = WidgetDataRepository.get(context)
|
val repo = WidgetDataRepository.get(context)
|
||||||
val tasks = repo.loadTasks()
|
// 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 tier = repo.loadTierState()
|
val tier = repo.loadTierState()
|
||||||
val isPremium = tier.equals("premium", ignoreCase = true)
|
val isPremium = tier.equals("premium", ignoreCase = true)
|
||||||
|
|
||||||
@@ -125,4 +132,35 @@ class OpenAppAction : ActionCallback {
|
|||||||
/** AppWidget receiver for the small widget. */
|
/** AppWidget receiver for the small widget. */
|
||||||
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
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,6 +115,66 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
|||||||
return all.filterNot { it.id in pending }
|
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]. */
|
/** Queue a task id for optimistic completion. See [loadTasks]. */
|
||||||
suspend fun markPendingCompletion(taskId: Long) {
|
suspend fun markPendingCompletion(taskId: Long) {
|
||||||
val current = store.readPendingCompletionIds().toMutableSet()
|
val current = store.readPendingCompletionIds().toMutableSet()
|
||||||
@@ -141,8 +201,15 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
|||||||
*
|
*
|
||||||
* Pending-completion tasks are excluded (via [loadTasks]).
|
* Pending-completion tasks are excluded (via [loadTasks]).
|
||||||
*/
|
*/
|
||||||
suspend fun computeStats(): WidgetStats {
|
suspend fun computeStats(): WidgetStats = computeStatsFromTasks(loadTasks())
|
||||||
val tasks = 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 {
|
||||||
var overdue = 0
|
var overdue = 0
|
||||||
var within7 = 0
|
var within7 = 0
|
||||||
var within8To30 = 0
|
var within8To30 = 0
|
||||||
@@ -257,5 +324,18 @@ class WidgetDataRepository internal constructor(private val context: Context) {
|
|||||||
|
|
||||||
/** Legacy accessor — delegates to [get]. */
|
/** Legacy accessor — delegates to [get]. */
|
||||||
fun getInstance(context: Context): WidgetDataRepository = get(context)
|
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,6 +32,15 @@ internal object WidgetDataStoreKeys {
|
|||||||
val PENDING_COMPLETION_IDS = stringPreferencesKey("pending_completion_ids")
|
val PENDING_COMPLETION_IDS = stringPreferencesKey("pending_completion_ids")
|
||||||
val LAST_REFRESH_TIME = longPreferencesKey("last_refresh_time")
|
val LAST_REFRESH_TIME = longPreferencesKey("last_refresh_time")
|
||||||
val USER_TIER = stringPreferencesKey("user_tier")
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,6 +99,56 @@ class WidgetDataStore(private val context: Context) {
|
|||||||
prefs.remove(WidgetDataStoreKeys.PENDING_COMPLETION_IDS)
|
prefs.remove(WidgetDataStoreKeys.PENDING_COMPLETION_IDS)
|
||||||
prefs.remove(WidgetDataStoreKeys.LAST_REFRESH_TIME)
|
prefs.remove(WidgetDataStoreKeys.LAST_REFRESH_TIME)
|
||||||
prefs.remove(WidgetDataStoreKeys.USER_TIER)
|
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,6 +18,13 @@ interface WidgetRefreshDataSource {
|
|||||||
suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>>
|
suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>>
|
||||||
/** Fetch the current user's subscription tier ("free" | "premium"). */
|
/** Fetch the current user's subscription tier ("free" | "premium"). */
|
||||||
suspend fun fetchTier(): String
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +55,16 @@ 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> {
|
private fun mapToWidgetTasks(response: TaskColumnsResponse): List<WidgetTaskDto> {
|
||||||
val out = mutableListOf<WidgetTaskDto>()
|
val out = mutableListOf<WidgetTaskDto>()
|
||||||
for (column in response.columns) {
|
for (column in response.columns) {
|
||||||
@@ -112,6 +129,16 @@ class WidgetRefreshWorker(
|
|||||||
val repo = WidgetDataRepository.get(ctx)
|
val repo = WidgetDataRepository.get(ctx)
|
||||||
repo.saveTasks(tasksResult.data)
|
repo.saveTasks(tasksResult.data)
|
||||||
repo.saveTierState(tier)
|
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)
|
refreshGlanceWidgets(ctx)
|
||||||
// Chain the next scheduled refresh so cadence keeps ticking
|
// Chain the next scheduled refresh so cadence keeps ticking
|
||||||
// even if the OS evicts our periodic request. Wrapped in
|
// even if the OS evicts our periodic request. Wrapped in
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ data class WidgetTaskDto(
|
|||||||
val completed: Boolean
|
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.
|
* Summary metrics computed from the cached task list.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,4 +14,5 @@
|
|||||||
android:previewLayout="@layout/widget_large_preview"
|
android:previewLayout="@layout/widget_large_preview"
|
||||||
android:description="@string/widget_large_description"
|
android:description="@string/widget_large_description"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||||
android:widgetFeatures="reconfigurable" />
|
android:widgetFeatures="reconfigurable" />
|
||||||
|
|||||||
@@ -14,4 +14,5 @@
|
|||||||
android:previewLayout="@layout/widget_medium_preview"
|
android:previewLayout="@layout/widget_medium_preview"
|
||||||
android:description="@string/widget_medium_description"
|
android:description="@string/widget_medium_description"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||||
android:widgetFeatures="reconfigurable" />
|
android:widgetFeatures="reconfigurable" />
|
||||||
|
|||||||
@@ -14,4 +14,5 @@
|
|||||||
android:previewLayout="@layout/widget_small_preview"
|
android:previewLayout="@layout/widget_small_preview"
|
||||||
android:description="@string/widget_small_description"
|
android:description="@string/widget_small_description"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:configure="com.tt.honeyDue.widget.WidgetConfigActivity"
|
||||||
android:widgetFeatures="reconfigurable" />
|
android:widgetFeatures="reconfigurable" />
|
||||||
|
|||||||
+34
-33
@@ -25,10 +25,14 @@ import kotlin.test.assertTrue
|
|||||||
/**
|
/**
|
||||||
* Unit tests for [CoilAuthInterceptor].
|
* Unit tests for [CoilAuthInterceptor].
|
||||||
*
|
*
|
||||||
|
* Identity is owned by Ory Kratos. Authenticated honeyDue media is gated on
|
||||||
|
* the Kratos session token, carried on the `X-Session-Token` header (the old
|
||||||
|
* `Authorization: Token …` scheme is gone).
|
||||||
|
*
|
||||||
* The interceptor is responsible for:
|
* The interceptor is responsible for:
|
||||||
* 1. Attaching `Authorization: <scheme> <token>` to image requests.
|
* 1. Attaching `X-Session-Token: <token>` to image requests.
|
||||||
* 2. On HTTP 401, calling the refresh callback once and retrying the
|
* 2. On HTTP 401, calling the re-validation callback once and retrying the
|
||||||
* request with the new token.
|
* request with the returned token.
|
||||||
* 3. Not looping: if the retry also returns 401, the error is returned.
|
* 3. Not looping: if the retry also returns 401, the error is returned.
|
||||||
* 4. When no token is available, the request proceeds unauthenticated.
|
* 4. When no token is available, the request proceeds unauthenticated.
|
||||||
*
|
*
|
||||||
@@ -96,7 +100,7 @@ class CoilAuthInterceptorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun interceptor_attaches_authorization_header_when_token_present() = runTest {
|
fun interceptor_attaches_session_token_header_when_token_present() = runTest {
|
||||||
val request = makeRequest()
|
val request = makeRequest()
|
||||||
val chain = FakeChain(
|
val chain = FakeChain(
|
||||||
initialRequest = request,
|
initialRequest = request,
|
||||||
@@ -105,7 +109,6 @@ class CoilAuthInterceptorTest {
|
|||||||
val interceptor = CoilAuthInterceptor(
|
val interceptor = CoilAuthInterceptor(
|
||||||
tokenProvider = { "abc123" },
|
tokenProvider = { "abc123" },
|
||||||
refreshToken = { null },
|
refreshToken = { null },
|
||||||
authScheme = "Token",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = interceptor.intercept(chain)
|
val result = interceptor.intercept(chain)
|
||||||
@@ -113,7 +116,9 @@ class CoilAuthInterceptorTest {
|
|||||||
assertTrue(result is SuccessResult, "Expected success result")
|
assertTrue(result is SuccessResult, "Expected success result")
|
||||||
assertEquals(1, chain.capturedRequests.size)
|
assertEquals(1, chain.capturedRequests.size)
|
||||||
val sent = chain.capturedRequests.first()
|
val sent = chain.capturedRequests.first()
|
||||||
assertEquals("Token abc123", sent.httpHeaders["Authorization"])
|
// Token is sent bare (no scheme prefix) on the X-Session-Token header.
|
||||||
|
assertEquals("abc123", sent.httpHeaders["X-Session-Token"])
|
||||||
|
assertNull(sent.httpHeaders["Authorization"], "Legacy Authorization header must not be set")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -126,7 +131,6 @@ class CoilAuthInterceptorTest {
|
|||||||
val interceptor = CoilAuthInterceptor(
|
val interceptor = CoilAuthInterceptor(
|
||||||
tokenProvider = { null },
|
tokenProvider = { null },
|
||||||
refreshToken = { null },
|
refreshToken = { null },
|
||||||
authScheme = "Token",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = interceptor.intercept(chain)
|
val result = interceptor.intercept(chain)
|
||||||
@@ -134,12 +138,12 @@ class CoilAuthInterceptorTest {
|
|||||||
assertTrue(result is SuccessResult)
|
assertTrue(result is SuccessResult)
|
||||||
assertEquals(1, chain.capturedRequests.size)
|
assertEquals(1, chain.capturedRequests.size)
|
||||||
val sent = chain.capturedRequests.first()
|
val sent = chain.capturedRequests.first()
|
||||||
// No Authorization header should have been added
|
// No session-token header should have been added.
|
||||||
assertNull(sent.httpHeaders["Authorization"])
|
assertNull(sent.httpHeaders["X-Session-Token"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun interceptor_refreshes_and_retries_on_401() = runTest {
|
fun interceptor_revalidates_and_retries_on_401() = runTest {
|
||||||
val request = makeRequest()
|
val request = makeRequest()
|
||||||
var refreshCallCount = 0
|
var refreshCallCount = 0
|
||||||
val chain = FakeChain(
|
val chain = FakeChain(
|
||||||
@@ -150,25 +154,25 @@ class CoilAuthInterceptorTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
val interceptor = CoilAuthInterceptor(
|
val interceptor = CoilAuthInterceptor(
|
||||||
tokenProvider = { "old-token" },
|
tokenProvider = { "session-tok" },
|
||||||
refreshToken = {
|
refreshToken = {
|
||||||
refreshCallCount++
|
refreshCallCount++
|
||||||
"new-token"
|
// Kratos session tokens are not rotated — same token echoed back.
|
||||||
|
"session-tok"
|
||||||
},
|
},
|
||||||
authScheme = "Token",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = interceptor.intercept(chain)
|
val result = interceptor.intercept(chain)
|
||||||
|
|
||||||
assertTrue(result is SuccessResult, "Expected retry to succeed")
|
assertTrue(result is SuccessResult, "Expected retry to succeed")
|
||||||
assertEquals(1, refreshCallCount, "refreshToken should be invoked exactly once")
|
assertEquals(1, refreshCallCount, "session re-check should be invoked exactly once")
|
||||||
assertEquals(2, chain.capturedRequests.size, "Expected original + 1 retry")
|
assertEquals(2, chain.capturedRequests.size, "Expected original + 1 retry")
|
||||||
assertEquals("Token old-token", chain.capturedRequests[0].httpHeaders["Authorization"])
|
assertEquals("session-tok", chain.capturedRequests[0].httpHeaders["X-Session-Token"])
|
||||||
assertEquals("Token new-token", chain.capturedRequests[1].httpHeaders["Authorization"])
|
assertEquals("session-tok", chain.capturedRequests[1].httpHeaders["X-Session-Token"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun interceptor_returns_error_when_refresh_returns_null() = runTest {
|
fun interceptor_returns_error_when_revalidation_returns_null() = runTest {
|
||||||
val request = makeRequest()
|
val request = makeRequest()
|
||||||
var refreshCallCount = 0
|
var refreshCallCount = 0
|
||||||
val chain = FakeChain(
|
val chain = FakeChain(
|
||||||
@@ -176,19 +180,18 @@ class CoilAuthInterceptorTest {
|
|||||||
responses = mutableListOf({ req -> make401Error(req) })
|
responses = mutableListOf({ req -> make401Error(req) })
|
||||||
)
|
)
|
||||||
val interceptor = CoilAuthInterceptor(
|
val interceptor = CoilAuthInterceptor(
|
||||||
tokenProvider = { "old-token" },
|
tokenProvider = { "session-tok" },
|
||||||
refreshToken = {
|
refreshToken = {
|
||||||
refreshCallCount++
|
refreshCallCount++
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
authScheme = "Token",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = interceptor.intercept(chain)
|
val result = interceptor.intercept(chain)
|
||||||
|
|
||||||
assertTrue(result is ErrorResult, "Expected error result when refresh fails")
|
assertTrue(result is ErrorResult, "Expected error result when session is gone")
|
||||||
assertEquals(1, refreshCallCount, "refreshToken should be attempted once")
|
assertEquals(1, refreshCallCount, "session re-check should be attempted once")
|
||||||
// Only the first attempt should have gone through
|
// Only the first attempt should have gone through.
|
||||||
assertEquals(1, chain.capturedRequests.size)
|
assertEquals(1, chain.capturedRequests.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,23 +207,22 @@ class CoilAuthInterceptorTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
val interceptor = CoilAuthInterceptor(
|
val interceptor = CoilAuthInterceptor(
|
||||||
tokenProvider = { "old-token" },
|
tokenProvider = { "session-tok" },
|
||||||
refreshToken = {
|
refreshToken = {
|
||||||
refreshCallCount++
|
refreshCallCount++
|
||||||
"new-token"
|
"session-tok"
|
||||||
},
|
},
|
||||||
authScheme = "Token",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = interceptor.intercept(chain)
|
val result = interceptor.intercept(chain)
|
||||||
|
|
||||||
assertTrue(result is ErrorResult, "Second 401 should surface as ErrorResult")
|
assertTrue(result is ErrorResult, "Second 401 should surface as ErrorResult")
|
||||||
assertEquals(1, refreshCallCount, "refreshToken should be called exactly once — no infinite loop")
|
assertEquals(1, refreshCallCount, "session re-check should be called exactly once — no infinite loop")
|
||||||
assertEquals(2, chain.capturedRequests.size, "Expected original + exactly one retry")
|
assertEquals(2, chain.capturedRequests.size, "Expected original + exactly one retry")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun interceptor_passes_through_non_401_errors_without_refresh() = runTest {
|
fun interceptor_passes_through_non_401_errors_without_revalidation() = runTest {
|
||||||
val request = makeRequest()
|
val request = makeRequest()
|
||||||
var refreshCallCount = 0
|
var refreshCallCount = 0
|
||||||
val chain = FakeChain(
|
val chain = FakeChain(
|
||||||
@@ -241,33 +243,32 @@ class CoilAuthInterceptorTest {
|
|||||||
refreshCallCount++
|
refreshCallCount++
|
||||||
"should-not-be-called"
|
"should-not-be-called"
|
||||||
},
|
},
|
||||||
authScheme = "Token",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = interceptor.intercept(chain)
|
val result = interceptor.intercept(chain)
|
||||||
|
|
||||||
assertTrue(result is ErrorResult)
|
assertTrue(result is ErrorResult)
|
||||||
assertEquals(0, refreshCallCount, "refreshToken should not be invoked on non-401 errors")
|
assertEquals(0, refreshCallCount, "session re-check should not be invoked on non-401 errors")
|
||||||
assertEquals(1, chain.capturedRequests.size)
|
assertEquals(1, chain.capturedRequests.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun interceptor_supports_bearer_scheme() = runTest {
|
fun interceptor_supports_custom_header_name() = runTest {
|
||||||
val request = makeRequest()
|
val request = makeRequest()
|
||||||
val chain = FakeChain(
|
val chain = FakeChain(
|
||||||
initialRequest = request,
|
initialRequest = request,
|
||||||
responses = mutableListOf({ req -> makeSuccess(req) })
|
responses = mutableListOf({ req -> makeSuccess(req) })
|
||||||
)
|
)
|
||||||
val interceptor = CoilAuthInterceptor(
|
val interceptor = CoilAuthInterceptor(
|
||||||
tokenProvider = { "jwt.payload.sig" },
|
tokenProvider = { "tok-value" },
|
||||||
refreshToken = { null },
|
refreshToken = { null },
|
||||||
authScheme = "Bearer",
|
headerName = "X-Custom-Auth",
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = interceptor.intercept(chain)
|
val result = interceptor.intercept(chain)
|
||||||
|
|
||||||
assertTrue(result is SuccessResult)
|
assertTrue(result is SuccessResult)
|
||||||
val sent = chain.capturedRequests.first()
|
val sent = chain.capturedRequests.first()
|
||||||
assertEquals("Bearer jwt.payload.sig", sent.httpHeaders["Authorization"])
|
assertEquals("tok-value", sent.httpHeaders["X-Custom-Auth"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
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.
|
* 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 {
|
fun safeShareFileName(displayName: String): String {
|
||||||
val safeName = displayName
|
val safeName = displayName
|
||||||
.replace(" ", "_")
|
// Keep whitespace through the filter so adjacent space+tab
|
||||||
.replace("/", "-")
|
// 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)
|
.take(50)
|
||||||
|
.ifBlank { "honeyDue" }
|
||||||
return "$safeName.honeydue"
|
return "$safeName.honeydue"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val UNSAFE_FILENAME_CHARS = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package com.tt.honeyDue.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models for Ory Kratos native (`api`) self-service flows.
|
||||||
|
*
|
||||||
|
* honeyDue's Go API no longer owns identity — Ory Kratos does. The mobile
|
||||||
|
* client drives Kratos' native flows directly:
|
||||||
|
*
|
||||||
|
* 1. `GET {kratos}/self-service/{flow}/api` -> a flow object (id + ui.action)
|
||||||
|
* 2. `POST {ui.action}` -> success body or a re-rendered
|
||||||
|
* flow carrying validation messages in `ui.nodes[].messages` / `ui.messages`.
|
||||||
|
*
|
||||||
|
* Only the fields the client actually needs are modelled; `ignoreUnknownKeys`
|
||||||
|
* on the Json instance tolerates the rest of Kratos' (large) payloads.
|
||||||
|
*
|
||||||
|
* Kratos docs: https://www.ory.sh/docs/kratos/self-service
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== Flow envelope ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Kratos self-service flow (login / registration / recovery / verification).
|
||||||
|
* Returned by the initial `GET .../{flow}/api` call.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosFlow(
|
||||||
|
val id: String,
|
||||||
|
val type: String? = null,
|
||||||
|
@SerialName("expires_at") val expiresAt: String? = null,
|
||||||
|
@SerialName("issued_at") val issuedAt: String? = null,
|
||||||
|
val ui: KratosUi,
|
||||||
|
/** Present on a verification/recovery flow that is already complete. */
|
||||||
|
val state: String? = null,
|
||||||
|
/**
|
||||||
|
* Post-submission instructions. On a completed recovery flow this carries
|
||||||
|
* the privileged session token (`set_ory_session_token`) and the settings
|
||||||
|
* flow to finish the password change in (`show_settings_ui`).
|
||||||
|
*/
|
||||||
|
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The renderable UI of a flow. `action` is the absolute URL the client must
|
||||||
|
* POST the method payload to; `messages` carries flow-level errors.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosUi(
|
||||||
|
val action: String,
|
||||||
|
val method: String = "POST",
|
||||||
|
val nodes: List<KratosUiNode> = emptyList(),
|
||||||
|
val messages: List<KratosMessage> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KratosUiNode(
|
||||||
|
val type: String? = null,
|
||||||
|
val group: String? = null,
|
||||||
|
val attributes: KratosUiNodeAttributes? = null,
|
||||||
|
val messages: List<KratosMessage> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KratosUiNodeAttributes(
|
||||||
|
val name: String? = null,
|
||||||
|
val type: String? = null,
|
||||||
|
val value: JsonElement? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Kratos UI message. `type` is `info`, `error`, or `success`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosMessage(
|
||||||
|
val id: Long? = null,
|
||||||
|
val text: String,
|
||||||
|
val type: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Flow success bodies ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity traits as configured in the Kratos identity schema for honeyDue.
|
||||||
|
* `email` is the primary identifier; `name` mirrors the schema's nested object.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosTraits(
|
||||||
|
val email: String,
|
||||||
|
val name: KratosName? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KratosName(
|
||||||
|
val first: String = "",
|
||||||
|
val last: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Kratos identity (subset). Returned nested inside [KratosSession].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosIdentity(
|
||||||
|
val id: String,
|
||||||
|
@SerialName("schema_id") val schemaId: String? = null,
|
||||||
|
val state: String? = null,
|
||||||
|
val traits: KratosTraits? = null,
|
||||||
|
@SerialName("verifiable_addresses") val verifiableAddresses: List<KratosVerifiableAddress>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KratosVerifiableAddress(
|
||||||
|
val id: String? = null,
|
||||||
|
val value: String? = null,
|
||||||
|
val verified: Boolean = false,
|
||||||
|
val via: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Kratos session. `active` + `expires_at` describe validity; `identity`
|
||||||
|
* carries the authenticated user's traits.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosSession(
|
||||||
|
val id: String,
|
||||||
|
val active: Boolean = true,
|
||||||
|
@SerialName("expires_at") val expiresAt: String? = null,
|
||||||
|
@SerialName("authenticated_at") val authenticatedAt: String? = null,
|
||||||
|
val identity: KratosIdentity? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success body of a native login flow submission
|
||||||
|
* (`POST .../self-service/login/api`).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosLoginSuccess(
|
||||||
|
val session: KratosSession,
|
||||||
|
@SerialName("session_token") val sessionToken: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success body of a native registration flow submission
|
||||||
|
* (`POST .../self-service/registration/api`). With the
|
||||||
|
* `session` after-hook enabled, Kratos returns a session + token here.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosRegistrationSuccess(
|
||||||
|
val session: KratosSession? = null,
|
||||||
|
@SerialName("session_token") val sessionToken: String? = null,
|
||||||
|
val identity: KratosIdentity? = null,
|
||||||
|
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `continue_with` item. `action` is one of `show_verification_ui`,
|
||||||
|
* `show_settings_ui`, `set_ory_session_token`, etc. — see Kratos docs.
|
||||||
|
* `flow` is present for the `show_*_ui` actions; `orySessionToken` is present
|
||||||
|
* for `set_ory_session_token` (the privileged session a recovery flow issues).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosContinueWith(
|
||||||
|
val action: String? = null,
|
||||||
|
val flow: KratosContinueWithFlow? = null,
|
||||||
|
@SerialName("ory_session_token") val orySessionToken: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KratosContinueWithFlow(
|
||||||
|
val id: String? = null,
|
||||||
|
@SerialName("verifiable_address") val verifiableAddress: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Submit payloads ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for submitting the `password` method to a login flow.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosPasswordLoginBody(
|
||||||
|
val method: String = "password",
|
||||||
|
val identifier: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for submitting the `password` method to a registration flow.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosPasswordRegistrationBody(
|
||||||
|
val method: String = "password",
|
||||||
|
val traits: KratosTraits,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for submitting an OIDC (`apple` / `google`) provider to a
|
||||||
|
* login or registration flow using a native `id_token` from the
|
||||||
|
* platform sign-in SDK.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosOidcBody(
|
||||||
|
val method: String = "oidc",
|
||||||
|
val provider: String,
|
||||||
|
@SerialName("id_token") val idToken: String,
|
||||||
|
/** Optional traits — sent on registration so Kratos can seed the identity. */
|
||||||
|
val traits: KratosTraits? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for submitting the `code` method to a recovery flow.
|
||||||
|
* The first POST omits [code] (sends just the email); the second
|
||||||
|
* POST includes the code the user received by email.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosRecoveryBody(
|
||||||
|
val method: String = "code",
|
||||||
|
val email: String? = null,
|
||||||
|
val code: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for submitting the `code` method to a verification flow.
|
||||||
|
* As with recovery: first POST sends the email, second sends the code.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosVerificationBody(
|
||||||
|
val method: String = "code",
|
||||||
|
val email: String? = null,
|
||||||
|
val code: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for updating an identity's password from within a settings flow
|
||||||
|
* (used after a recovery flow hands the client a privileged session).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class KratosSettingsPasswordBody(
|
||||||
|
val method: String = "password",
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
@@ -34,15 +34,20 @@ data class PresignUploadRequest(
|
|||||||
/**
|
/**
|
||||||
* Presigned upload session — response from POST /api/uploads/presign.
|
* Presigned upload session — response from POST /api/uploads/presign.
|
||||||
*
|
*
|
||||||
* The client uses [uploadUrl] + [fields] to perform a multipart/form-data
|
* The client makes one PUT request to [uploadUrl] with the raw object
|
||||||
* POST directly to B2, then passes [id] back in the upload_ids[] field of
|
* bytes as the body and [headers] as the request headers. On success,
|
||||||
* the next /api/task-completions/ or /api/documents/ create call.
|
* pass [id] back in the upload_ids[] field of the next
|
||||||
|
* /api/task-completions/ or /api/documents/ create call.
|
||||||
|
*
|
||||||
|
* PUT (not POST) because B2's S3-compatible endpoint does not implement
|
||||||
|
* the S3 POST Object form upload (returns HTTP 501).
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PresignUploadResponse(
|
data class PresignUploadResponse(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
@SerialName("upload_url") val uploadUrl: String,
|
@SerialName("upload_url") val uploadUrl: String,
|
||||||
val fields: Map<String, String>,
|
val method: String = "PUT",
|
||||||
|
val headers: Map<String, String> = emptyMap(),
|
||||||
val key: String,
|
val key: String,
|
||||||
@SerialName("expires_at") val expiresAt: String
|
@SerialName("expires_at") val expiresAt: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -85,7 +85,13 @@ data class AuthResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token refresh response - returned by POST /api/auth/refresh/
|
* Token refresh response.
|
||||||
|
*
|
||||||
|
* Identity is owned by Ory Kratos. Native Kratos session tokens are
|
||||||
|
* long-lived and not rotated — there is no refresh endpoint. This type is
|
||||||
|
* retained as the return shape of [com.tt.honeyDue.network.AuthApi.refreshToken],
|
||||||
|
* which now re-validates the session via Kratos `/sessions/whoami` and echoes
|
||||||
|
* the same (unchanged) token back when the session is still active.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TokenRefreshResponse(
|
data class TokenRefreshResponse(
|
||||||
|
|||||||
@@ -1276,20 +1276,24 @@ object APILayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh the current auth token.
|
* Re-validate the current Kratos session.
|
||||||
* Calls POST /api/auth/refresh/ with the current token.
|
*
|
||||||
* On success, saves the new token to DataManager and TokenStorage.
|
* Identity is owned by Ory Kratos. Native Kratos session tokens are
|
||||||
* On failure, returns an error (caller decides whether to trigger logout).
|
* long-lived and there is no native refresh endpoint — "refresh" here
|
||||||
|
* means: ask Kratos whether the session is still active (`/sessions/whoami`).
|
||||||
|
*
|
||||||
|
* - Session still valid → returns the same (unchanged) token.
|
||||||
|
* - Session gone → returns an error; the caller should sign out.
|
||||||
|
*
|
||||||
|
* The method name is kept so the Coil image interceptor and the
|
||||||
|
* `ApiClient` 401 plumbing continue to compile.
|
||||||
*/
|
*/
|
||||||
suspend fun refreshToken(): ApiResult<String> {
|
suspend fun refreshToken(): ApiResult<String> {
|
||||||
val currentToken = getToken() ?: return ApiResult.Error("No token", 401)
|
val currentToken = getToken() ?: return ApiResult.Error("No token", 401)
|
||||||
val result = authApi.refreshToken(currentToken)
|
return when (val result = authApi.refreshToken(currentToken)) {
|
||||||
if (result is ApiResult.Success) {
|
// Kratos session tokens are never rotated — echo the same token
|
||||||
DataManager.setAuthToken(result.data.token)
|
// back when the session is confirmed still valid.
|
||||||
com.tt.honeyDue.storage.TokenStorage.saveToken(result.data.token)
|
is ApiResult.Success -> ApiResult.Success(currentToken)
|
||||||
}
|
|
||||||
return when (result) {
|
|
||||||
is ApiResult.Success -> ApiResult.Success(result.data.token)
|
|
||||||
is ApiResult.Error -> ApiResult.Error(result.message, result.code)
|
is ApiResult.Error -> ApiResult.Error(result.message, result.code)
|
||||||
else -> ApiResult.Error("Unexpected state")
|
else -> ApiResult.Error("Unexpected state")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package com.tt.honeyDue.network
|
package com.tt.honeyDue.network
|
||||||
|
|
||||||
import com.tt.honeyDue.data.DataManager
|
import com.tt.honeyDue.data.DataManager
|
||||||
import com.tt.honeyDue.models.TokenRefreshResponse
|
|
||||||
import com.tt.honeyDue.storage.TokenStorage
|
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.plugins.*
|
import io.ktor.client.plugins.*
|
||||||
@@ -34,6 +32,30 @@ expect fun getDeviceLanguage(): String
|
|||||||
*/
|
*/
|
||||||
expect fun getDeviceTimezone(): String
|
expect fun getDeviceTimezone(): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTTP header the honeyDue API expects on authenticated requests.
|
||||||
|
*
|
||||||
|
* Identity is owned by Ory Kratos; the honeyDue API now authenticates a
|
||||||
|
* request by validating the Kratos **session token** carried on this header.
|
||||||
|
* This replaces the old `Authorization: Token <token>` scheme.
|
||||||
|
*
|
||||||
|
* Every honeyDue `*Api.kt` client sends this header via [authHeader]; image
|
||||||
|
* loading uses it through [CoilAuthInterceptor].
|
||||||
|
*/
|
||||||
|
const val SESSION_TOKEN_HEADER: String = "X-Session-Token"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the honeyDue session-token header on a request.
|
||||||
|
*
|
||||||
|
* Usage in an `*Api.kt` client:
|
||||||
|
* ```kotlin
|
||||||
|
* client.get("$baseUrl/tasks/") { authHeader(token) }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun HttpRequestBuilder.authHeader(token: String) {
|
||||||
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutex to prevent multiple concurrent token refresh attempts.
|
* Mutex to prevent multiple concurrent token refresh attempts.
|
||||||
* When one request triggers a 401, only one refresh call is made;
|
* When one request triggers a 401, only one refresh call is made;
|
||||||
@@ -80,37 +102,30 @@ fun HttpClientConfig<*>.installCommonPlugins() {
|
|||||||
socketTimeoutMillis = 30_000 // 30 seconds
|
socketTimeoutMillis = 30_000 // 30 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task 3: Token refresh on 401 responses
|
// Task 3: Kratos session validation on 401 responses.
|
||||||
|
//
|
||||||
|
// The honeyDue API now authenticates via the Kratos session token on the
|
||||||
|
// X-Session-Token header. A 401 from the API means that token was
|
||||||
|
// rejected. We confirm with Kratos whether the session is genuinely gone:
|
||||||
|
// - still valid -> throw TokenExpiredException(refreshed = true) (retry)
|
||||||
|
// - gone -> clear auth, throw TokenExpiredException(false) (re-login)
|
||||||
HttpResponseValidator {
|
HttpResponseValidator {
|
||||||
validateResponse { response ->
|
validateResponse { response ->
|
||||||
if (response.status.value == 401) {
|
if (response.status.value == 401) {
|
||||||
// Check if this is a token_expired error (not invalid credentials)
|
|
||||||
val bodyText = response.bodyAsText()
|
|
||||||
val isTokenExpired = bodyText.contains("token_expired") ||
|
|
||||||
bodyText.contains("Token has expired") ||
|
|
||||||
bodyText.contains("expired")
|
|
||||||
|
|
||||||
if (isTokenExpired) {
|
|
||||||
val currentToken = DataManager.authToken.value
|
val currentToken = DataManager.authToken.value
|
||||||
if (currentToken != null) {
|
if (currentToken != null) {
|
||||||
// Use mutex to prevent concurrent refresh attempts
|
// Use the mutex so concurrent 401s only trigger one
|
||||||
val refreshed = tokenRefreshMutex.withLock {
|
// whoami check against Kratos.
|
||||||
// Double-check: another coroutine may have already refreshed
|
val stillValid = tokenRefreshMutex.withLock {
|
||||||
val tokenAfterLock = DataManager.authToken.value
|
|
||||||
if (tokenAfterLock != currentToken) {
|
|
||||||
// Token was already refreshed by another coroutine
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
attemptTokenRefresh(currentToken)
|
attemptTokenRefresh(currentToken)
|
||||||
}
|
}
|
||||||
}
|
if (!stillValid) {
|
||||||
if (!refreshed) {
|
// Session is gone — clear auth and route to login.
|
||||||
// Refresh failed — clear auth and trigger logout
|
|
||||||
DataManager.clear()
|
DataManager.clear()
|
||||||
}
|
}
|
||||||
// Throw so the caller can retry (or handle the logout)
|
// Throw so the caller can retry (still valid) or handle
|
||||||
throw TokenExpiredException(refreshed)
|
// the forced logout (session gone).
|
||||||
}
|
throw TokenExpiredException(stillValid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,13 +133,25 @@ fun HttpClientConfig<*>.installCommonPlugins() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to refresh the auth token by calling POST /api/auth/refresh/.
|
* Re-validate the current Kratos session.
|
||||||
* Returns true if refresh succeeded and the new token was saved.
|
*
|
||||||
|
* Identity is owned by Ory Kratos. Native Kratos session tokens are
|
||||||
|
* long-lived and there is **no native refresh endpoint** — when a session
|
||||||
|
* genuinely expires the user must sign in again. So "refresh" here means:
|
||||||
|
* ask Kratos `GET /sessions/whoami` whether the session is still active.
|
||||||
|
*
|
||||||
|
* - returns `true` → the session is still valid; the original 401 was
|
||||||
|
* transient (e.g. a brief replication lag) and the caller may retry.
|
||||||
|
* - returns `false` → the session is gone; the caller should clear auth and
|
||||||
|
* route the user back to login.
|
||||||
|
*
|
||||||
|
* The token itself is never rotated — [DataManager]/[TokenStorage] keep the
|
||||||
|
* same value either way.
|
||||||
*/
|
*/
|
||||||
private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
|
private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
// Use a minimal client to avoid recursive interceptor triggers
|
// Use a minimal client to avoid recursive interceptor triggers.
|
||||||
val refreshClient = HttpClient {
|
val whoamiClient = HttpClient {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(Json {
|
json(Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
@@ -137,38 +164,40 @@ private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
|
|||||||
socketTimeoutMillis = 15_000
|
socketTimeoutMillis = 15_000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val baseUrl = ApiConfig.getBaseUrl()
|
val kratosUrl = ApiConfig.getKratosBaseUrl()
|
||||||
val response = refreshClient.post("$baseUrl/auth/refresh/") {
|
val response = whoamiClient.get("$kratosUrl/sessions/whoami") {
|
||||||
header("Authorization", "Token $currentToken")
|
header(SESSION_TOKEN_HEADER, currentToken)
|
||||||
contentType(ContentType.Application.Json)
|
accept(ContentType.Application.Json)
|
||||||
}
|
}
|
||||||
refreshClient.close()
|
whoamiClient.close()
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
val tokenResponse = response.body<TokenRefreshResponse>()
|
// Session still valid — keep the same token.
|
||||||
// Save the new token to both DataManager and persistent storage
|
println("[ApiClient] Kratos session still valid")
|
||||||
DataManager.setAuthToken(tokenResponse.token)
|
|
||||||
TokenStorage.saveToken(tokenResponse.token)
|
|
||||||
println("[ApiClient] Token refreshed successfully")
|
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
println("[ApiClient] Token refresh failed: ${response.status.value}")
|
println("[ApiClient] Kratos session invalid: ${response.status.value}")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("[ApiClient] Token refresh error: ${e.message}")
|
println("[ApiClient] Kratos session check error: ${e.message}")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exception thrown when a 401 response indicates an expired token.
|
* Exception thrown when a 401 response indicates an expired/invalid session.
|
||||||
* [refreshed] indicates whether the token was successfully refreshed.
|
*
|
||||||
* Callers can catch this and retry the request if refreshed is true.
|
* [refreshed] indicates whether the Kratos session was re-validated and is
|
||||||
|
* still usable. Callers can catch this and retry the request when `refreshed`
|
||||||
|
* is true; when false the user must re-authenticate.
|
||||||
|
*
|
||||||
|
* The name is retained from the pre-Kratos token scheme so existing callers
|
||||||
|
* ([CoilAuthInterceptor] plumbing, tests) continue to compile.
|
||||||
*/
|
*/
|
||||||
class TokenExpiredException(val refreshed: Boolean) : Exception(
|
class TokenExpiredException(val refreshed: Boolean) : Exception(
|
||||||
if (refreshed) "Token was expired but has been refreshed — retry the request"
|
if (refreshed) "Session was briefly rejected but is still valid — retry the request"
|
||||||
else "Token expired and refresh failed — user must re-authenticate"
|
else "Session expired — user must re-authenticate"
|
||||||
)
|
)
|
||||||
|
|
||||||
object ApiClient {
|
object ApiClient {
|
||||||
@@ -185,6 +214,13 @@ object ApiClient {
|
|||||||
*/
|
*/
|
||||||
fun getMediaBaseUrl(): String = ApiConfig.getMediaBaseUrl()
|
fun getMediaBaseUrl(): String = ApiConfig.getMediaBaseUrl()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Ory Kratos public API base URL. Identity flows (login,
|
||||||
|
* registration, recovery, verification, OIDC sign-in) run against this
|
||||||
|
* host — NOT [getBaseUrl].
|
||||||
|
*/
|
||||||
|
fun getKratosBaseUrl(): String = ApiConfig.getKratosBaseUrl()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print current environment configuration
|
* Print current environment configuration
|
||||||
*/
|
*/
|
||||||
@@ -193,5 +229,6 @@ object ApiClient {
|
|||||||
println("Environment: ${ApiConfig.getEnvironmentName()}")
|
println("Environment: ${ApiConfig.getEnvironmentName()}")
|
||||||
println("Base URL: ${getBaseUrl()}")
|
println("Base URL: ${getBaseUrl()}")
|
||||||
println("Media URL: ${getMediaBaseUrl()}")
|
println("Media URL: ${getMediaBaseUrl()}")
|
||||||
|
println("Kratos URL: ${getKratosBaseUrl()}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ package com.tt.honeyDue.network
|
|||||||
*/
|
*/
|
||||||
object ApiConfig {
|
object ApiConfig {
|
||||||
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
||||||
val CURRENT_ENV = Environment.LOCAL
|
val CURRENT_ENV = Environment.PROD
|
||||||
|
|
||||||
enum class Environment {
|
enum class Environment {
|
||||||
LOCAL,
|
LOCAL,
|
||||||
@@ -40,6 +40,28 @@ object ApiConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Ory Kratos public API base URL.
|
||||||
|
*
|
||||||
|
* Identity (login, registration, recovery, verification, OIDC sign-in) is
|
||||||
|
* owned by Ory Kratos — NOT the honeyDue Go API. The native (`api`)
|
||||||
|
* self-service flows live under `{kratosBaseUrl}/self-service/...`.
|
||||||
|
*
|
||||||
|
* - LOCAL: a Kratos instance running on the dev machine (default public
|
||||||
|
* port `4433`). The Android emulator reaches the host via `10.0.2.2`,
|
||||||
|
* the iOS simulator via `127.0.0.1` — both resolved by [getLocalhostAddress].
|
||||||
|
* - DEV / PROD: the hosted Kratos at `auth.myhoneydue.com`.
|
||||||
|
*
|
||||||
|
* No trailing slash — callers append `/self-service/...`.
|
||||||
|
*/
|
||||||
|
fun getKratosBaseUrl(): String {
|
||||||
|
return when (CURRENT_ENV) {
|
||||||
|
Environment.LOCAL -> "http://${getLocalhostAddress()}:4433"
|
||||||
|
Environment.DEV -> "https://auth.myhoneydue.com"
|
||||||
|
Environment.PROD -> "https://auth.myhoneydue.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get environment name for logging
|
* Get environment name for logging
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,71 +4,533 @@ import com.tt.honeyDue.models.*
|
|||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication API client.
|
||||||
|
*
|
||||||
|
* Identity for honeyDue is owned by **Ory Kratos** — NOT the honeyDue Go API.
|
||||||
|
* This client drives Kratos' **native (`api`) self-service flows**:
|
||||||
|
*
|
||||||
|
* - **Login** — `GET .../self-service/login/api` → `POST ui.action`
|
||||||
|
* with `{method:"password", identifier, password}`.
|
||||||
|
* - **Registration** — `GET .../self-service/registration/api` → `POST ui.action`
|
||||||
|
* with `{method:"password", traits:{email,name{first,last}}, password}`.
|
||||||
|
* - **Recovery** — `.../self-service/recovery/api` with `method:"code"`.
|
||||||
|
* - **Verification** — `.../self-service/verification/api` with `method:"code"`.
|
||||||
|
* - **OIDC** — Apple/Google: the platform SDK obtains a native
|
||||||
|
* `id_token`, submitted to the login/registration flow with
|
||||||
|
* `{method:"oidc", provider, id_token}`.
|
||||||
|
*
|
||||||
|
* On success Kratos returns a **`session_token`**. That token is what the
|
||||||
|
* honeyDue API now expects on the `X-Session-Token` header. The session token
|
||||||
|
* is stored via [com.tt.honeyDue.storage.TokenManager]; see [APILayer].
|
||||||
|
*
|
||||||
|
* Endpoints that still live on the honeyDue API — `GET /api/auth/me/`, profile
|
||||||
|
* update, account deletion — are also driven from here, sending the Kratos
|
||||||
|
* session token on the `X-Session-Token` header.
|
||||||
|
*
|
||||||
|
* The public method signatures are deliberately unchanged from the old
|
||||||
|
* hand-rolled auth client so [APILayer], [com.tt.honeyDue.viewmodel.AuthViewModel]
|
||||||
|
* and the UI continue to compile against the same `ApiResult<...>` shapes.
|
||||||
|
*/
|
||||||
class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||||
private val baseUrl = ApiClient.getBaseUrl()
|
|
||||||
|
|
||||||
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
|
/** honeyDue Go API base — used only for `/auth/me`, profile, delete. */
|
||||||
return try {
|
private val apiBaseUrl = ApiClient.getBaseUrl()
|
||||||
val response = client.post("$baseUrl/auth/register/") {
|
|
||||||
contentType(ContentType.Application.Json)
|
/** Ory Kratos public API base — used for all identity flows. */
|
||||||
setBody(request)
|
private val kratosBaseUrl = ApiConfig.getKratosBaseUrl()
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
encodeDefaults = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action URL of the recovery flow started by [forgotPassword]. Kratos
|
||||||
|
* binds the emailed recovery code to that specific flow, so [verifyResetCode]
|
||||||
|
* must submit the code back to the SAME flow — not a fresh one. Held in
|
||||||
|
* memory only: a process restart between the two steps simply means the
|
||||||
|
* user requests a new code (recovery flows are short-lived regardless).
|
||||||
|
*/
|
||||||
|
private var pendingRecoveryAction: String? = null
|
||||||
|
|
||||||
|
// ==================== Kratos flow plumbing ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a fresh native self-service flow from Kratos.
|
||||||
|
*
|
||||||
|
* @param flow one of `login`, `registration`, `recovery`, `verification`.
|
||||||
|
* @param refresh when true (login only) forces re-authentication of an
|
||||||
|
* already-valid session.
|
||||||
|
*/
|
||||||
|
private suspend fun initFlow(flow: String, refresh: Boolean = false): ApiResult<KratosFlow> {
|
||||||
|
return try {
|
||||||
|
val response = client.get("$kratosBaseUrl/self-service/$flow/api") {
|
||||||
|
accept(ContentType.Application.Json)
|
||||||
|
if (refresh) parameter("refresh", "true")
|
||||||
|
}
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
ApiResult.Success(response.body())
|
ApiResult.Success(json.decodeFromString(KratosFlow.serializer(), response.bodyAsText()))
|
||||||
} else {
|
} else {
|
||||||
// Parse actual error message from backend
|
ApiResult.Error(
|
||||||
val errorMessage = ErrorParser.parseError(response)
|
"Could not start $flow (Kratos ${response.status.value})",
|
||||||
ApiResult.Error(errorMessage, response.status.value)
|
response.status.value,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
ApiResult.Error(e.message ?: "Could not reach the authentication server")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a method payload to a flow's `ui.action` URL and decode the
|
||||||
|
* result with [decode]. On a 4xx Kratos re-renders the flow with
|
||||||
|
* validation messages — those are extracted via [extractKratosError].
|
||||||
|
*/
|
||||||
|
private suspend fun <T> submitFlow(
|
||||||
|
actionUrl: String,
|
||||||
|
bodyJson: String,
|
||||||
|
decode: (String) -> T,
|
||||||
|
): ApiResult<T> {
|
||||||
|
return try {
|
||||||
|
val response = client.post(actionUrl) {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
accept(ContentType.Application.Json)
|
||||||
|
setBody(bodyJson)
|
||||||
|
}
|
||||||
|
val text = response.bodyAsText()
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(decode(text))
|
||||||
|
} else {
|
||||||
|
ApiResult.Error(extractKratosError(text, response.status.value), response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Authentication request failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a human-readable error out of a Kratos error/flow body.
|
||||||
|
*
|
||||||
|
* Kratos surfaces validation problems in `ui.messages` and
|
||||||
|
* `ui.nodes[].messages`; hard errors (expired flow, etc.) come back as
|
||||||
|
* `{ error: { message, reason } }`.
|
||||||
|
*/
|
||||||
|
private fun extractKratosError(body: String, statusCode: Int): String {
|
||||||
|
// 1. Re-rendered flow with field/flow messages.
|
||||||
|
runCatching {
|
||||||
|
val flow = json.decodeFromString(KratosFlow.serializer(), body)
|
||||||
|
val msgs = buildList {
|
||||||
|
flow.ui.messages.filter { it.type == "error" || it.type == null }
|
||||||
|
.forEach { add(it.text) }
|
||||||
|
flow.ui.nodes.flatMap { it.messages }
|
||||||
|
.filter { it.type == "error" || it.type == null }
|
||||||
|
.forEach { add(it.text) }
|
||||||
|
}.distinct()
|
||||||
|
if (msgs.isNotEmpty()) return msgs.joinToString(". ")
|
||||||
|
}
|
||||||
|
// 2. Generic Kratos error envelope.
|
||||||
|
runCatching {
|
||||||
|
val env = json.decodeFromString(KratosErrorEnvelope.serializer(), body)
|
||||||
|
val msg = env.error?.reason ?: env.error?.message
|
||||||
|
if (!msg.isNullOrBlank()) return msg
|
||||||
|
}
|
||||||
|
return "Authentication failed ($statusCode)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Login ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native password login against Kratos.
|
||||||
|
*
|
||||||
|
* [LoginRequest.username] is treated as the Kratos identifier (the user's
|
||||||
|
* email — honeyDue identities are keyed by email).
|
||||||
|
*/
|
||||||
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {
|
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {
|
||||||
return try {
|
val flow = when (val f = initFlow("login")) {
|
||||||
val response = client.post("$baseUrl/auth/login/") {
|
is ApiResult.Success -> f.data
|
||||||
contentType(ContentType.Application.Json)
|
is ApiResult.Error -> return f
|
||||||
setBody(request)
|
else -> return ApiResult.Error("Could not start login")
|
||||||
|
}
|
||||||
|
val body = json.encodeToString(
|
||||||
|
KratosPasswordLoginBody.serializer(),
|
||||||
|
KratosPasswordLoginBody(identifier = request.username.trim(), password = request.password),
|
||||||
|
)
|
||||||
|
val success = submitFlow(flow.ui.action, body) {
|
||||||
|
json.decodeFromString(KratosLoginSuccess.serializer(), it)
|
||||||
|
}
|
||||||
|
return when (success) {
|
||||||
|
is ApiResult.Success -> resolveSession(success.data.sessionToken, success.data.session)
|
||||||
|
is ApiResult.Error -> success
|
||||||
|
else -> ApiResult.Error("Login failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
// ==================== Registration ====================
|
||||||
ApiResult.Success(response.body())
|
|
||||||
|
/**
|
||||||
|
* Native password registration against Kratos.
|
||||||
|
*
|
||||||
|
* Kratos identity traits are `{ email, name: { first, last } }`. The
|
||||||
|
* legacy [RegisterRequest.username] is preserved for the UI but is not a
|
||||||
|
* Kratos trait — the email is the identifier.
|
||||||
|
*/
|
||||||
|
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
|
||||||
|
val flow = when (val f = initFlow("registration")) {
|
||||||
|
is ApiResult.Success -> f.data
|
||||||
|
is ApiResult.Error -> return f
|
||||||
|
else -> return ApiResult.Error("Could not start registration")
|
||||||
|
}
|
||||||
|
val traits = KratosTraits(
|
||||||
|
email = request.email.trim(),
|
||||||
|
name = KratosName(
|
||||||
|
first = request.firstName ?: "",
|
||||||
|
last = request.lastName ?: "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val body = json.encodeToString(
|
||||||
|
KratosPasswordRegistrationBody.serializer(),
|
||||||
|
KratosPasswordRegistrationBody(traits = traits, password = request.password),
|
||||||
|
)
|
||||||
|
val success = submitFlow(flow.ui.action, body) {
|
||||||
|
json.decodeFromString(KratosRegistrationSuccess.serializer(), it)
|
||||||
|
}
|
||||||
|
return when (success) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val token = success.data.sessionToken
|
||||||
|
if (token.isNullOrBlank()) {
|
||||||
|
// Kratos was configured without the `session` after-hook —
|
||||||
|
// the identity exists but no session was issued. The caller
|
||||||
|
// must complete a verification flow then log in.
|
||||||
|
ApiResult.Error(
|
||||||
|
"Account created. Please verify your email, then sign in.",
|
||||||
|
200,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// Parse actual error message from backend
|
resolveSession(token, success.data.session)
|
||||||
val errorMessage = ErrorParser.parseError(response)
|
|
||||||
ApiResult.Error(errorMessage, response.status.value)
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
}
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
is ApiResult.Error -> success
|
||||||
|
else -> ApiResult.Error("Registration failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== OIDC (Apple / Google) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a native OIDC `id_token` to Kratos.
|
||||||
|
*
|
||||||
|
* The platform SDK (Sign in with Apple / Google Sign-In) obtains the
|
||||||
|
* `id_token`; Kratos verifies it and either logs the user in or registers
|
||||||
|
* a new identity. We try the login flow first; if Kratos reports the
|
||||||
|
* identity does not exist we fall through to the registration flow.
|
||||||
|
*
|
||||||
|
* @param provider `"apple"` or `"google"`.
|
||||||
|
* @param idToken the platform-issued OpenID Connect ID token.
|
||||||
|
* @param traits optional traits used to seed a new identity on first sign-in.
|
||||||
|
*/
|
||||||
|
private suspend fun oidcSignIn(
|
||||||
|
provider: String,
|
||||||
|
idToken: String,
|
||||||
|
traits: KratosTraits?,
|
||||||
|
): ApiResult<AuthResponse> {
|
||||||
|
// 1. Attempt the login flow.
|
||||||
|
val loginFlow = when (val f = initFlow("login")) {
|
||||||
|
is ApiResult.Success -> f.data
|
||||||
|
is ApiResult.Error -> return f
|
||||||
|
else -> return ApiResult.Error("Could not start sign-in")
|
||||||
|
}
|
||||||
|
val loginBody = json.encodeToString(
|
||||||
|
KratosOidcBody.serializer(),
|
||||||
|
KratosOidcBody(provider = provider, idToken = idToken),
|
||||||
|
)
|
||||||
|
val loginResult = submitFlow(loginFlow.ui.action, loginBody) {
|
||||||
|
json.decodeFromString(KratosLoginSuccess.serializer(), it)
|
||||||
|
}
|
||||||
|
if (loginResult is ApiResult.Success) {
|
||||||
|
return resolveSession(loginResult.data.sessionToken, loginResult.data.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. No identity yet — drive the registration flow with the same token.
|
||||||
|
val regFlow = when (val f = initFlow("registration")) {
|
||||||
|
is ApiResult.Success -> f.data
|
||||||
|
is ApiResult.Error -> return (loginResult as? ApiResult.Error) ?: f
|
||||||
|
else -> return ApiResult.Error("Could not start sign-up")
|
||||||
|
}
|
||||||
|
val regBody = json.encodeToString(
|
||||||
|
KratosOidcBody.serializer(),
|
||||||
|
KratosOidcBody(provider = provider, idToken = idToken, traits = traits),
|
||||||
|
)
|
||||||
|
val regResult = submitFlow(regFlow.ui.action, regBody) {
|
||||||
|
json.decodeFromString(KratosRegistrationSuccess.serializer(), it)
|
||||||
|
}
|
||||||
|
return when (regResult) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val token = regResult.data.sessionToken
|
||||||
|
if (token.isNullOrBlank()) {
|
||||||
|
(loginResult as? ApiResult.Error)
|
||||||
|
?: ApiResult.Error("Sign-in did not return a session")
|
||||||
|
} else {
|
||||||
|
resolveSession(token, regResult.data.session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> regResult
|
||||||
|
else -> ApiResult.Error("Sign-in failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apple Sign In via Kratos OIDC. The Apple `id_token` is obtained natively
|
||||||
|
* by the platform; [AppleSignInRequest.userId]/email/name seed the
|
||||||
|
* identity on first sign-in.
|
||||||
|
*/
|
||||||
|
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
|
||||||
|
val traits = request.email?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
KratosTraits(
|
||||||
|
email = it,
|
||||||
|
name = KratosName(
|
||||||
|
first = request.firstName ?: "",
|
||||||
|
last = request.lastName ?: "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return when (val r = oidcSignIn("apple", request.idToken, traits)) {
|
||||||
|
is ApiResult.Success -> ApiResult.Success(
|
||||||
|
AppleSignInResponse(token = r.data.token, user = r.data.user, isNewUser = false),
|
||||||
|
)
|
||||||
|
is ApiResult.Error -> r
|
||||||
|
else -> ApiResult.Error("Apple Sign In failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Sign In via Kratos OIDC. The Google `id_token` is obtained
|
||||||
|
* natively by the platform Google Sign-In SDK.
|
||||||
|
*/
|
||||||
|
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
|
||||||
|
return when (val r = oidcSignIn("google", request.idToken, traits = null)) {
|
||||||
|
is ApiResult.Success -> ApiResult.Success(
|
||||||
|
GoogleSignInResponse(token = r.data.token, user = r.data.user, isNewUser = false),
|
||||||
|
)
|
||||||
|
is ApiResult.Error -> r
|
||||||
|
else -> ApiResult.Error("Google Sign In failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Logout ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminate the Kratos session.
|
||||||
|
*
|
||||||
|
* The native logout flow is a single call:
|
||||||
|
* `DELETE .../self-service/logout/api` with `{ session_token }`.
|
||||||
|
* A failure here is non-fatal — the caller drops the token locally
|
||||||
|
* regardless (see [APILayer.logout]).
|
||||||
|
*/
|
||||||
suspend fun logout(token: String): ApiResult<Unit> {
|
suspend fun logout(token: String): ApiResult<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/auth/logout/") {
|
client.delete("$kratosBaseUrl/self-service/logout/api") {
|
||||||
header("Authorization", "Token $token")
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(json.encodeToString(LogoutBody.serializer(), LogoutBody(token)))
|
||||||
|
}
|
||||||
|
// Treat any outcome as success — the token is being discarded
|
||||||
|
// locally anyway, and a stale-token DELETE is harmless.
|
||||||
|
ApiResult.Success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Success(Unit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
// ==================== Recovery (forgot password) ====================
|
||||||
ApiResult.Success(Unit)
|
|
||||||
|
/**
|
||||||
|
* Start a Kratos recovery flow and submit the user's email so Kratos
|
||||||
|
* mails them a recovery code.
|
||||||
|
*
|
||||||
|
* Mirrors the legacy `forgotPassword` signature. The returned message is
|
||||||
|
* Kratos' confirmation text.
|
||||||
|
*/
|
||||||
|
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<ForgotPasswordResponse> {
|
||||||
|
val flow = when (val f = initFlow("recovery")) {
|
||||||
|
is ApiResult.Success -> f.data
|
||||||
|
is ApiResult.Error -> return f
|
||||||
|
else -> return ApiResult.Error("Could not start password recovery")
|
||||||
|
}
|
||||||
|
val body = json.encodeToString(
|
||||||
|
KratosRecoveryBody.serializer(),
|
||||||
|
KratosRecoveryBody(email = request.email.trim()),
|
||||||
|
)
|
||||||
|
// Kratos returns the re-rendered flow (200) carrying an info message
|
||||||
|
// that the code was sent. A 4xx means the email was malformed.
|
||||||
|
val result = submitFlow(flow.ui.action, body) {
|
||||||
|
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
|
||||||
|
}
|
||||||
|
return when (result) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
// Remember this flow's action — verifyResetCode submits the
|
||||||
|
// emailed code back to the SAME flow Kratos bound it to.
|
||||||
|
pendingRecoveryAction = flow.ui.action
|
||||||
|
val info = result.data?.ui?.messages?.firstOrNull()?.text
|
||||||
|
ApiResult.Success(
|
||||||
|
ForgotPasswordResponse(
|
||||||
|
message = info ?: "If that email exists, a recovery code has been sent.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> result
|
||||||
|
else -> ApiResult.Error("Could not send recovery code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit the recovery code the user received by email.
|
||||||
|
*
|
||||||
|
* The code is submitted back to the SAME recovery flow [forgotPassword]
|
||||||
|
* started ([pendingRecoveryAction]) — Kratos binds the emailed code to that
|
||||||
|
* flow. A valid code drives the flow to `passed_challenge`, and Kratos then
|
||||||
|
* returns, via `continue_with`, the privileged session token plus the
|
||||||
|
* settings flow to finish the password change in. Both are packed into the
|
||||||
|
* opaque `resetToken` that [resetPassword] consumes.
|
||||||
|
*/
|
||||||
|
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
|
||||||
|
val action = pendingRecoveryAction
|
||||||
|
?: return ApiResult.Error("Your recovery session expired. Request a new code.")
|
||||||
|
val body = json.encodeToString(
|
||||||
|
KratosRecoveryBody.serializer(),
|
||||||
|
KratosRecoveryBody(code = request.code.trim()),
|
||||||
|
)
|
||||||
|
val result = submitFlow(action, body) {
|
||||||
|
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
|
||||||
|
}
|
||||||
|
return when (result) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val flow = result.data
|
||||||
|
val settingsFlowId = flow?.continueWith
|
||||||
|
?.firstOrNull { it.action == "show_settings_ui" }?.flow?.id
|
||||||
|
val sessionToken = flow?.continueWith
|
||||||
|
?.firstOrNull { it.action == "set_ory_session_token" }?.orySessionToken
|
||||||
|
if (settingsFlowId != null && sessionToken != null) {
|
||||||
|
pendingRecoveryAction = null
|
||||||
|
ApiResult.Success(
|
||||||
|
VerifyResetCodeResponse(
|
||||||
|
message = "Code verified.",
|
||||||
|
// Opaque to the UI: carries the settings flow id and
|
||||||
|
// the privileged session token resetPassword needs,
|
||||||
|
// packed as "<settingsFlowId>|<sessionToken>".
|
||||||
|
resetToken = "$settingsFlowId|$sessionToken",
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
ApiResult.Error("Logout failed", response.status.value)
|
// No continue_with → the code was wrong or the flow expired;
|
||||||
|
// surface Kratos' own validation message when present.
|
||||||
|
val msg = flow?.ui?.messages?.firstOrNull { it.type == "error" }?.text
|
||||||
|
?: flow?.ui?.nodes?.flatMap { it.messages }
|
||||||
|
?.firstOrNull { it.type == "error" }?.text
|
||||||
|
ApiResult.Error(msg ?: "Invalid or expired code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> result
|
||||||
|
else -> ApiResult.Error("Invalid or expired code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a password reset.
|
||||||
|
*
|
||||||
|
* [verifyResetCode] packed the settings flow id and the privileged session
|
||||||
|
* token Kratos issued into [ResetPasswordRequest.resetToken] as
|
||||||
|
* `"<settingsFlowId>|<sessionToken>"`. This submits the new password to
|
||||||
|
* that settings flow, authenticating with the privileged session token.
|
||||||
|
*/
|
||||||
|
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
|
||||||
|
val parts = request.resetToken.split("|", limit = 2)
|
||||||
|
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) {
|
||||||
|
return ApiResult.Error("This password reset session has expired. Request a new code.")
|
||||||
|
}
|
||||||
|
val settingsFlowId = parts[0]
|
||||||
|
val sessionToken = parts[1]
|
||||||
|
return try {
|
||||||
|
val response = client.post("$kratosBaseUrl/self-service/settings") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
accept(ContentType.Application.Json)
|
||||||
|
// The privileged session Kratos issued on code verification
|
||||||
|
// authorizes the settings flow that changes the password.
|
||||||
|
header("X-Session-Token", sessionToken)
|
||||||
|
parameter("flow", settingsFlowId)
|
||||||
|
setBody(
|
||||||
|
json.encodeToString(
|
||||||
|
KratosSettingsPasswordBody.serializer(),
|
||||||
|
KratosSettingsPasswordBody(password = request.newPassword),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val text = response.bodyAsText()
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(ResetPasswordResponse(message = "Password updated. You can now sign in."))
|
||||||
|
} else {
|
||||||
|
ApiResult.Error(extractKratosError(text, response.status.value), response.status.value)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
ApiResult.Error(e.message ?: "Could not reset password")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Email verification ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit an email-verification code to Kratos' verification flow.
|
||||||
|
*
|
||||||
|
* Note: the [token] parameter (a session token) is unused for the Kratos
|
||||||
|
* verification flow — verification is anonymous and keyed by the code —
|
||||||
|
* but the parameter is kept so [APILayer]/`AuthViewModel` need no change.
|
||||||
|
*/
|
||||||
|
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
|
||||||
|
val flow = when (val f = initFlow("verification")) {
|
||||||
|
is ApiResult.Success -> f.data
|
||||||
|
is ApiResult.Error -> return f
|
||||||
|
else -> return ApiResult.Error("Could not start verification")
|
||||||
|
}
|
||||||
|
val body = json.encodeToString(
|
||||||
|
KratosVerificationBody.serializer(),
|
||||||
|
KratosVerificationBody(code = request.code.trim()),
|
||||||
|
)
|
||||||
|
val result = submitFlow(flow.ui.action, body) {
|
||||||
|
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
|
||||||
|
}
|
||||||
|
return when (result) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val verified = result.data?.state == "passed_challenge"
|
||||||
|
ApiResult.Success(
|
||||||
|
VerifyEmailResponse(
|
||||||
|
message = if (verified) "Email verified." else "Verification submitted.",
|
||||||
|
verified = verified,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> result
|
||||||
|
else -> ApiResult.Error("Verification failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== honeyDue API (still session-token-gated) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the current honeyDue user from the honeyDue Go API.
|
||||||
|
*
|
||||||
|
* Identity lives in Kratos, but the honeyDue API still owns the
|
||||||
|
* application-level user record (numeric id, profile, verified flag). The
|
||||||
|
* Kratos session token is sent on the `X-Session-Token` header.
|
||||||
|
*/
|
||||||
suspend fun getCurrentUser(token: String): ApiResult<User> {
|
suspend fun getCurrentUser(token: String): ApiResult<User> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/auth/me/") {
|
val response = client.get("$apiBaseUrl/auth/me/") {
|
||||||
header("Authorization", "Token $token")
|
header(HEADER_SESSION_TOKEN, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
ApiResult.Success(response.body())
|
ApiResult.Success(response.body())
|
||||||
} else {
|
} else {
|
||||||
@@ -79,214 +541,152 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
|
/**
|
||||||
return try {
|
* Update the honeyDue user profile. Profile data lives on the honeyDue
|
||||||
val response = client.post("$baseUrl/auth/verify-email/") {
|
* API, not Kratos, so this still targets the Go API.
|
||||||
header("Authorization", "Token $token")
|
*/
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
|
||||||
ApiResult.Success(response.body())
|
|
||||||
} else {
|
|
||||||
val errorBody = try {
|
|
||||||
response.body<Map<String, String>>()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
mapOf("error" to "Verification failed")
|
|
||||||
}
|
|
||||||
ApiResult.Error(errorBody["error"] ?: "Verification failed", response.status.value)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> {
|
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.put("$baseUrl/auth/profile/") {
|
val response = client.put("$apiBaseUrl/auth/profile/") {
|
||||||
header("Authorization", "Token $token")
|
header(HEADER_SESSION_TOKEN, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
ApiResult.Success(response.body())
|
ApiResult.Success(response.body())
|
||||||
} else {
|
} else {
|
||||||
val errorBody = try {
|
ApiResult.Error(ErrorParser.parseError(response), response.status.value)
|
||||||
response.body<Map<String, String>>()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
mapOf("error" to "Profile update failed")
|
|
||||||
}
|
|
||||||
ApiResult.Error(errorBody["error"] ?: "Profile update failed", response.status.value)
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password Reset Methods
|
/**
|
||||||
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<ForgotPasswordResponse> {
|
* Delete the honeyDue account. The honeyDue API is responsible for
|
||||||
return try {
|
* tearing down its own user record and asking Kratos to delete the
|
||||||
val response = client.post("$baseUrl/auth/forgot-password/") {
|
* backing identity.
|
||||||
contentType(ContentType.Application.Json)
|
*/
|
||||||
setBody(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
|
||||||
ApiResult.Success(response.body())
|
|
||||||
} else {
|
|
||||||
val errorBody = try {
|
|
||||||
response.body<Map<String, String>>()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
mapOf("error" to "Failed to send reset code")
|
|
||||||
}
|
|
||||||
ApiResult.Error(errorBody["error"] ?: "Failed to send reset code", response.status.value)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
|
|
||||||
return try {
|
|
||||||
val response = client.post("$baseUrl/auth/verify-reset-code/") {
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
|
||||||
ApiResult.Success(response.body())
|
|
||||||
} else {
|
|
||||||
val errorBody = try {
|
|
||||||
response.body<Map<String, String>>()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
mapOf("error" to "Invalid code")
|
|
||||||
}
|
|
||||||
ApiResult.Error(errorBody["error"] ?: "Invalid code", response.status.value)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
|
|
||||||
return try {
|
|
||||||
val response = client.post("$baseUrl/auth/reset-password/") {
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
|
||||||
ApiResult.Success(response.body())
|
|
||||||
} else {
|
|
||||||
// Try to parse Django validation errors (Map<String, List<String>>)
|
|
||||||
val errorMessage = try {
|
|
||||||
val validationErrors = response.body<Map<String, List<String>>>()
|
|
||||||
// Flatten all error messages into a single string
|
|
||||||
validationErrors.flatMap { (field, errors) ->
|
|
||||||
errors.map { error ->
|
|
||||||
if (field == "non_field_errors") error else "$field: $error"
|
|
||||||
}
|
|
||||||
}.joinToString(". ")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Try simple error format {error: "message"}
|
|
||||||
try {
|
|
||||||
val simpleError = response.body<Map<String, String>>()
|
|
||||||
simpleError["error"] ?: "Failed to reset password"
|
|
||||||
} catch (e2: Exception) {
|
|
||||||
"Failed to reset password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ApiResult.Error(errorMessage, response.status.value)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apple Sign In
|
|
||||||
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
|
|
||||||
return try {
|
|
||||||
val response = client.post("$baseUrl/auth/apple-sign-in/") {
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
|
||||||
ApiResult.Success(response.body())
|
|
||||||
} else {
|
|
||||||
val errorBody = try {
|
|
||||||
response.body<Map<String, String>>()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
mapOf("error" to "Apple Sign In failed")
|
|
||||||
}
|
|
||||||
ApiResult.Error(errorBody["error"] ?: "Apple Sign In failed", response.status.value)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete Account
|
|
||||||
suspend fun deleteAccount(token: String, request: DeleteAccountRequest): ApiResult<Unit> {
|
suspend fun deleteAccount(token: String, request: DeleteAccountRequest): ApiResult<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.delete("$baseUrl/auth/account/") {
|
val response = client.delete("$apiBaseUrl/auth/account/") {
|
||||||
header("Authorization", "Token $token")
|
header(HEADER_SESSION_TOKEN, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
ApiResult.Success(Unit)
|
ApiResult.Success(Unit)
|
||||||
} else {
|
} else {
|
||||||
val errorMessage = ErrorParser.parseError(response)
|
ApiResult.Error(ErrorParser.parseError(response), response.status.value)
|
||||||
ApiResult.Error(errorMessage, response.status.value)
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token Refresh
|
/**
|
||||||
|
* Legacy token-refresh shim.
|
||||||
|
*
|
||||||
|
* Kratos native session tokens are long-lived and there is no native
|
||||||
|
* refresh endpoint — when a session expires the user must re-authenticate.
|
||||||
|
* This method is kept so [ApiClient]'s 401 plumbing and the Coil image
|
||||||
|
* interceptor still compile; it simply re-validates the current session
|
||||||
|
* via Kratos `/sessions/whoami` and echoes the same token back if still
|
||||||
|
* valid, or fails otherwise.
|
||||||
|
*/
|
||||||
suspend fun refreshToken(token: String): ApiResult<TokenRefreshResponse> {
|
suspend fun refreshToken(token: String): ApiResult<TokenRefreshResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/auth/refresh/") {
|
val response = client.get("$kratosBaseUrl/sessions/whoami") {
|
||||||
header("Authorization", "Token $token")
|
header(HEADER_SESSION_TOKEN, token)
|
||||||
contentType(ContentType.Application.Json)
|
accept(ContentType.Application.Json)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
ApiResult.Success(response.body())
|
ApiResult.Success(TokenRefreshResponse(token = token))
|
||||||
} else {
|
} else {
|
||||||
val errorMessage = ErrorParser.parseError(response)
|
ApiResult.Error("Session expired — please sign in again", response.status.value)
|
||||||
ApiResult.Error(errorMessage, response.status.value)
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
ApiResult.Error(e.message ?: "Could not validate session")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google Sign In
|
// ==================== Session → User resolution ====================
|
||||||
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
|
|
||||||
return try {
|
/**
|
||||||
val response = client.post("$baseUrl/auth/google-sign-in/") {
|
* Given a freshly issued Kratos `session_token`, fetch the honeyDue
|
||||||
contentType(ContentType.Application.Json)
|
* application user that backs the Kratos identity and assemble an
|
||||||
setBody(request)
|
* [AuthResponse].
|
||||||
|
*
|
||||||
|
* The honeyDue API maps Kratos identities to its own numeric user records;
|
||||||
|
* `/auth/me` is the source of truth for `User.id`, profile and the
|
||||||
|
* `verified` flag. If `/auth/me` is unreachable we fall back to a
|
||||||
|
* best-effort [User] synthesised from the Kratos identity traits so the
|
||||||
|
* app can still proceed.
|
||||||
|
*/
|
||||||
|
private suspend fun resolveSession(
|
||||||
|
sessionToken: String,
|
||||||
|
session: KratosSession?,
|
||||||
|
): ApiResult<AuthResponse> {
|
||||||
|
return when (val me = getCurrentUser(sessionToken)) {
|
||||||
|
is ApiResult.Success -> ApiResult.Success(AuthResponse(token = sessionToken, user = me.data))
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
val fallback = session?.identity?.let { userFromKratosIdentity(it) }
|
||||||
|
if (fallback != null) {
|
||||||
|
ApiResult.Success(AuthResponse(token = sessionToken, user = fallback))
|
||||||
|
} else {
|
||||||
|
me
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> ApiResult.Error("Could not load profile after sign-in")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
/**
|
||||||
ApiResult.Success(response.body())
|
* Best-effort [User] built from a Kratos identity when the honeyDue
|
||||||
} else {
|
* `/auth/me` lookup is unavailable. `id` is `0` (Kratos ids are UUIDs,
|
||||||
val errorBody = try {
|
* not the honeyDue numeric id) — callers should re-fetch via
|
||||||
response.body<Map<String, String>>()
|
* [getCurrentUser] as soon as the API is reachable.
|
||||||
} catch (e: Exception) {
|
*/
|
||||||
mapOf("error" to "Google Sign In failed")
|
private fun userFromKratosIdentity(identity: KratosIdentity): User {
|
||||||
}
|
val traits = identity.traits
|
||||||
ApiResult.Error(errorBody["error"] ?: "Google Sign In failed", response.status.value)
|
return User(
|
||||||
}
|
id = 0,
|
||||||
} catch (e: Exception) {
|
username = traits?.email ?: "",
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
email = traits?.email ?: "",
|
||||||
|
firstName = traits?.name?.first ?: "",
|
||||||
|
lastName = traits?.name?.last ?: "",
|
||||||
|
isActive = identity.state == null || identity.state == "active",
|
||||||
|
dateJoined = "",
|
||||||
|
authProvider = "kratos",
|
||||||
|
profile = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* The header the honeyDue API now expects on authenticated requests —
|
||||||
|
* carries the Kratos session token. Replaces `Authorization: Token …`.
|
||||||
|
*/
|
||||||
|
const val HEADER_SESSION_TOKEN = "X-Session-Token"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Body for Kratos' native `DELETE /self-service/logout/api`. */
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
private data class LogoutBody(
|
||||||
|
@kotlinx.serialization.SerialName("session_token") val sessionToken: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Generic Kratos error envelope: `{ "error": { "message", "reason", ... } }`. */
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
private data class KratosErrorEnvelope(
|
||||||
|
val error: KratosErrorDetail? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
private data class KratosErrorDetail(
|
||||||
|
val message: String? = null,
|
||||||
|
val reason: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val code: Int? = null,
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ import coil3.request.ErrorResult
|
|||||||
import coil3.request.ImageResult
|
import coil3.request.ImageResult
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coil3 [Interceptor] that attaches an `Authorization` header to every
|
* Coil3 [Interceptor] that attaches the honeyDue session-token header to every
|
||||||
* outgoing image request and, on an HTTP 401 response, refreshes the token
|
* outgoing image request and, on an HTTP 401 response, re-validates the
|
||||||
* and retries exactly once.
|
* session and retries exactly once.
|
||||||
|
*
|
||||||
|
* honeyDue's identity is owned by Ory Kratos. Authenticated honeyDue API
|
||||||
|
* requests — including authenticated media — carry the Kratos session token
|
||||||
|
* on the **`X-Session-Token`** header (the old `Authorization: Token …` scheme
|
||||||
|
* is gone). This interceptor centralises that concern so individual
|
||||||
|
* composables don't thread the token through themselves.
|
||||||
*
|
*
|
||||||
* Mirrors the behavior of the iOS `AuthenticatedImage` in
|
* Mirrors the behavior of the iOS `AuthenticatedImage` in
|
||||||
* `iosApp/iosApp/Components/AuthenticatedImage.swift`, centralising the
|
* `iosApp/iosApp/Components/AuthenticatedImage.swift`.
|
||||||
* concern so individual composables don't need to thread the token through
|
|
||||||
* themselves.
|
|
||||||
*
|
*
|
||||||
* Usage — install on the singleton [coil3.ImageLoader]:
|
* Usage — install on the singleton [coil3.ImageLoader]:
|
||||||
* ```kotlin
|
* ```kotlin
|
||||||
@@ -23,24 +27,28 @@ import coil3.request.ImageResult
|
|||||||
* add(CoilAuthInterceptor(
|
* add(CoilAuthInterceptor(
|
||||||
* tokenProvider = { TokenStorage.getToken() },
|
* tokenProvider = { TokenStorage.getToken() },
|
||||||
* refreshToken = { (APILayer.refreshToken() as? ApiResult.Success)?.data },
|
* refreshToken = { (APILayer.refreshToken() as? ApiResult.Success)?.data },
|
||||||
* authScheme = "Token",
|
|
||||||
* ))
|
* ))
|
||||||
* add(KtorNetworkFetcherFactory())
|
* add(KtorNetworkFetcherFactory())
|
||||||
* }
|
* }
|
||||||
* .build()
|
* .build()
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param tokenProvider Suspending supplier of the current auth token. Returning
|
* @param tokenProvider Suspending supplier of the current session token.
|
||||||
* `null` means "no token available" — the request proceeds unauthenticated.
|
* Returning `null` means "no token available" — the request proceeds
|
||||||
* @param refreshToken Suspending supplier that refreshes the backing session and
|
* unauthenticated so anonymous endpoints still work.
|
||||||
* returns a fresh token, or `null` if refresh failed.
|
* @param refreshToken Suspending supplier that re-validates the session and
|
||||||
* @param authScheme The auth scheme to prefix the token with (default `Token`
|
* returns a still-valid token, or `null` if the session is gone. With
|
||||||
* to match the existing Go backend — use `Bearer` for JWT deployments).
|
* Kratos, session tokens are not rotated — this typically echoes the same
|
||||||
|
* token back when the session is still active.
|
||||||
|
* @param headerName The HTTP header carrying the token. Defaults to
|
||||||
|
* [SESSION_TOKEN_HEADER] (`X-Session-Token`). The token is sent as the bare
|
||||||
|
* header value — there is no `<scheme> ` prefix under the Kratos
|
||||||
|
* session-token scheme.
|
||||||
*/
|
*/
|
||||||
class CoilAuthInterceptor(
|
class CoilAuthInterceptor(
|
||||||
private val tokenProvider: suspend () -> String?,
|
private val tokenProvider: suspend () -> String?,
|
||||||
private val refreshToken: suspend () -> String?,
|
private val refreshToken: suspend () -> String?,
|
||||||
private val authScheme: String = "Token",
|
private val headerName: String = SESSION_TOKEN_HEADER,
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||||
@@ -55,26 +63,26 @@ class CoilAuthInterceptor(
|
|||||||
val authed = chain.request.newBuilder()
|
val authed = chain.request.newBuilder()
|
||||||
.httpHeaders(
|
.httpHeaders(
|
||||||
chain.request.httpHeaders.newBuilder()
|
chain.request.httpHeaders.newBuilder()
|
||||||
.set(HEADER_AUTHORIZATION, "$authScheme $token")
|
.set(headerName, token)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val result = chain.withRequest(authed).proceed()
|
val result = chain.withRequest(authed).proceed()
|
||||||
|
|
||||||
// If the server rejected the token, try refreshing once.
|
// If the server rejected the token, re-validate the session once.
|
||||||
if (result.isUnauthorized()) {
|
if (result.isUnauthorized()) {
|
||||||
val newToken = refreshToken() ?: return result
|
val newToken = refreshToken() ?: return result
|
||||||
val retried = authed.newBuilder()
|
val retried = authed.newBuilder()
|
||||||
.httpHeaders(
|
.httpHeaders(
|
||||||
authed.httpHeaders.newBuilder()
|
authed.httpHeaders.newBuilder()
|
||||||
.set(HEADER_AUTHORIZATION, "$authScheme $newToken")
|
.set(headerName, newToken)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
// Only retry *once* — whatever comes back from this call is final,
|
// Only retry *once* — whatever comes back from this call is final,
|
||||||
// even if it is itself a 401. This guards against an infinite loop
|
// even if it is itself a 401. This guards against an infinite loop
|
||||||
// when refresh succeeds but the backing account is still revoked.
|
// when the session is still revoked.
|
||||||
return chain.withRequest(retried).proceed()
|
return chain.withRequest(retried).proceed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +96,6 @@ class CoilAuthInterceptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val HEADER_AUTHORIZATION = "Authorization"
|
|
||||||
private const val HTTP_UNAUTHORIZED = 401
|
private const val HTTP_UNAUTHORIZED = 401
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
): ApiResult<List<ContractorSummary>> {
|
): ApiResult<List<ContractorSummary>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/contractors/") {
|
val response = client.get("$baseUrl/contractors/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
specialty?.let { parameter("specialty", it) }
|
specialty?.let { parameter("specialty", it) }
|
||||||
isFavorite?.let { parameter("is_favorite", it) }
|
isFavorite?.let { parameter("is_favorite", it) }
|
||||||
isActive?.let { parameter("is_active", it) }
|
isActive?.let { parameter("is_active", it) }
|
||||||
@@ -38,7 +38,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getContractor(token: String, id: Int): ApiResult<Contractor> {
|
suspend fun getContractor(token: String, id: Int): ApiResult<Contractor> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/contractors/$id/") {
|
val response = client.get("$baseUrl/contractors/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -54,7 +54,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun createContractor(token: String, request: ContractorCreateRequest): ApiResult<Contractor> {
|
suspend fun createContractor(token: String, request: ContractorCreateRequest): ApiResult<Contractor> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/contractors/") {
|
val response = client.post("$baseUrl/contractors/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun updateContractor(token: String, id: Int, request: ContractorUpdateRequest): ApiResult<Contractor> {
|
suspend fun updateContractor(token: String, id: Int, request: ContractorUpdateRequest): ApiResult<Contractor> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.patch("$baseUrl/contractors/$id/") {
|
val response = client.patch("$baseUrl/contractors/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun deleteContractor(token: String, id: Int): ApiResult<Unit> {
|
suspend fun deleteContractor(token: String, id: Int): ApiResult<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.delete("$baseUrl/contractors/$id/") {
|
val response = client.delete("$baseUrl/contractors/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -116,7 +116,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun toggleFavorite(token: String, id: Int): ApiResult<Contractor> {
|
suspend fun toggleFavorite(token: String, id: Int): ApiResult<Contractor> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") {
|
val response = client.post("$baseUrl/contractors/$id/toggle-favorite/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -132,7 +132,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getContractorTasks(token: String, id: Int): ApiResult<List<TaskResponse>> {
|
suspend fun getContractorTasks(token: String, id: Int): ApiResult<List<TaskResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/contractors/$id/tasks/") {
|
val response = client.get("$baseUrl/contractors/$id/tasks/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -148,7 +148,7 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getContractorsByResidence(token: String, residenceId: Int): ApiResult<List<ContractorSummary>> {
|
suspend fun getContractorsByResidence(token: String, residenceId: Int): ApiResult<List<ContractorSummary>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/contractors/by-residence/$residenceId/") {
|
val response = client.get("$baseUrl/contractors/by-residence/$residenceId/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
): ApiResult<List<Document>> {
|
): ApiResult<List<Document>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/documents/") {
|
val response = client.get("$baseUrl/documents/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
residenceId?.let { parameter("residence", it) }
|
residenceId?.let { parameter("residence", it) }
|
||||||
documentType?.let { parameter("document_type", it) }
|
documentType?.let { parameter("document_type", it) }
|
||||||
isActive?.let { parameter("is_active", it) }
|
isActive?.let { parameter("is_active", it) }
|
||||||
@@ -47,7 +47,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getDocument(token: String, id: Int): ApiResult<Document> {
|
suspend fun getDocument(token: String, id: Int): ApiResult<Document> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/documents/$id/") {
|
val response = client.get("$baseUrl/documents/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -127,7 +127,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If no file, use JSON
|
// If no file, use JSON
|
||||||
@@ -143,7 +143,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
residenceId = residenceId
|
residenceId = residenceId
|
||||||
)
|
)
|
||||||
client.post("$baseUrl/documents/") {
|
client.post("$baseUrl/documents/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
expiryDate = endDate // Map endDate to expiryDate
|
expiryDate = endDate // Map endDate to expiryDate
|
||||||
)
|
)
|
||||||
val response = client.patch("$baseUrl/documents/$id/") {
|
val response = client.patch("$baseUrl/documents/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -224,7 +224,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun deleteDocument(token: String, id: Int): ApiResult<Unit> {
|
suspend fun deleteDocument(token: String, id: Int): ApiResult<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.delete("$baseUrl/documents/$id/") {
|
val response = client.delete("$baseUrl/documents/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -240,7 +240,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun downloadDocument(token: String, url: String): ApiResult<ByteArray> {
|
suspend fun downloadDocument(token: String, url: String): ApiResult<ByteArray> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get(url) {
|
val response = client.get(url) {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -256,7 +256,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun activateDocument(token: String, id: Int): ApiResult<Document> {
|
suspend fun activateDocument(token: String, id: Int): ApiResult<Document> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/documents/$id/activate/") {
|
val response = client.post("$baseUrl/documents/$id/activate/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -274,7 +274,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun deactivateDocument(token: String, id: Int): ApiResult<Document> {
|
suspend fun deactivateDocument(token: String, id: Int): ApiResult<Document> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/documents/$id/deactivate/") {
|
val response = client.post("$baseUrl/documents/$id/deactivate/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -308,7 +308,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
caption?.let { append("caption", it) }
|
caption?.let { append("caption", it) }
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -329,7 +329,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun deleteDocumentImage(token: String, documentId: Int, imageId: Int): ApiResult<Document> {
|
suspend fun deleteDocumentImage(token: String, documentId: Int, imageId: Int): ApiResult<Document> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.delete("$baseUrl/documents/$documentId/images/$imageId/") {
|
val response = client.delete("$baseUrl/documents/$documentId/images/$imageId/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> {
|
suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/residences/types/") {
|
val response = client.get("$baseUrl/residences/types/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -52,7 +52,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getTaskFrequencies(token: String): ApiResult<List<TaskFrequency>> {
|
suspend fun getTaskFrequencies(token: String): ApiResult<List<TaskFrequency>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/tasks/frequencies/") {
|
val response = client.get("$baseUrl/tasks/frequencies/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -68,7 +68,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getTaskPriorities(token: String): ApiResult<List<TaskPriority>> {
|
suspend fun getTaskPriorities(token: String): ApiResult<List<TaskPriority>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/tasks/priorities/") {
|
val response = client.get("$baseUrl/tasks/priorities/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -84,7 +84,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getTaskCategories(token: String): ApiResult<List<TaskCategory>> {
|
suspend fun getTaskCategories(token: String): ApiResult<List<TaskCategory>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/tasks/categories/") {
|
val response = client.get("$baseUrl/tasks/categories/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -100,7 +100,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getContractorSpecialties(token: String): ApiResult<List<ContractorSpecialty>> {
|
suspend fun getContractorSpecialties(token: String): ApiResult<List<ContractorSpecialty>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/contractors/specialties/") {
|
val response = client.get("$baseUrl/contractors/specialties/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -117,7 +117,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/static_data/") {
|
val response = client.get("$baseUrl/static_data/") {
|
||||||
// Token is optional - endpoint is public
|
// Token is optional - endpoint is public
|
||||||
token?.let { header("Authorization", "Token $it") }
|
token?.let { header(SESSION_TOKEN_HEADER, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -145,7 +145,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
return try {
|
return try {
|
||||||
val response: HttpResponse = client.get("$baseUrl/static_data/") {
|
val response: HttpResponse = client.get("$baseUrl/static_data/") {
|
||||||
// Token is optional - endpoint is public
|
// Token is optional - endpoint is public
|
||||||
token?.let { header("Authorization", "Token $it") }
|
token?.let { header(SESSION_TOKEN_HEADER, it) }
|
||||||
// Send If-None-Match header for conditional request
|
// Send If-None-Match header for conditional request
|
||||||
currentETag?.let { header("If-None-Match", it) }
|
currentETag?.let { header("If-None-Match", it) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
): ApiResult<DeviceRegistrationResponse> {
|
): ApiResult<DeviceRegistrationResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/notifications/devices/register/") {
|
val response = client.post("$baseUrl/notifications/devices/register/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
): ApiResult<Unit> {
|
): ApiResult<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.delete("$baseUrl/notifications/devices/$deviceId/") {
|
val response = client.delete("$baseUrl/notifications/devices/$deviceId/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -63,7 +63,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> {
|
suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/notifications/preferences/") {
|
val response = client.get("$baseUrl/notifications/preferences/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -85,7 +85,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
): ApiResult<NotificationPreference> {
|
): ApiResult<NotificationPreference> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.put("$baseUrl/notifications/preferences/") {
|
val response = client.put("$baseUrl/notifications/preferences/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getNotificationHistory(token: String): ApiResult<List<Notification>> {
|
suspend fun getNotificationHistory(token: String): ApiResult<List<Notification>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/notifications/") {
|
val response = client.get("$baseUrl/notifications/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -123,7 +123,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
): ApiResult<MessageResponse> {
|
): ApiResult<MessageResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/notifications/$notificationId/read/") {
|
val response = client.post("$baseUrl/notifications/$notificationId/read/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -139,7 +139,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun markAllNotificationsAsRead(token: String): ApiResult<MessageResponse> {
|
suspend fun markAllNotificationsAsRead(token: String): ApiResult<MessageResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/notifications/mark-all-read/") {
|
val response = client.post("$baseUrl/notifications/mark-all-read/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -155,7 +155,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getUnreadCount(token: String): ApiResult<UnreadCountResponse> {
|
suspend fun getUnreadCount(token: String): ApiResult<UnreadCountResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/notifications/unread-count/") {
|
val response = client.get("$baseUrl/notifications/unread-count/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getResidences(token: String): ApiResult<List<ResidenceResponse>> {
|
suspend fun getResidences(token: String): ApiResult<List<ResidenceResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/residences/") {
|
val response = client.get("$baseUrl/residences/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -28,7 +28,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getResidence(token: String, id: Int): ApiResult<ResidenceResponse> {
|
suspend fun getResidence(token: String, id: Int): ApiResult<ResidenceResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/residences/$id/") {
|
val response = client.get("$baseUrl/residences/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -44,7 +44,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun createResidence(token: String, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> {
|
suspend fun createResidence(token: String, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/residences/") {
|
val response = client.post("$baseUrl/residences/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun updateResidence(token: String, id: Int, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> {
|
suspend fun updateResidence(token: String, id: Int, request: ResidenceCreateRequest): ApiResult<WithSummaryResponse<ResidenceResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.put("$baseUrl/residences/$id/") {
|
val response = client.put("$baseUrl/residences/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun deleteResidence(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
|
suspend fun deleteResidence(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.delete("$baseUrl/residences/$id/") {
|
val response = client.delete("$baseUrl/residences/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -96,7 +96,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getSummary(token: String): ApiResult<TotalSummary> {
|
suspend fun getSummary(token: String): ApiResult<TotalSummary> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/residences/summary/") {
|
val response = client.get("$baseUrl/residences/summary/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -112,7 +112,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getMyResidences(token: String): ApiResult<MyResidencesResponse> {
|
suspend fun getMyResidences(token: String): ApiResult<MyResidencesResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/residences/my-residences/") {
|
val response = client.get("$baseUrl/residences/my-residences/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -129,7 +129,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun generateSharePackage(token: String, residenceId: Int): ApiResult<SharedResidence> {
|
suspend fun generateSharePackage(token: String, residenceId: Int): ApiResult<SharedResidence> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/residences/$residenceId/generate-share-package/") {
|
val response = client.post("$baseUrl/residences/$residenceId/generate-share-package/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -146,7 +146,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun generateShareCode(token: String, residenceId: Int): ApiResult<GenerateShareCodeResponse> {
|
suspend fun generateShareCode(token: String, residenceId: Int): ApiResult<GenerateShareCodeResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") {
|
val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -163,7 +163,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getShareCode(token: String, residenceId: Int): ApiResult<ShareCodeResponse> {
|
suspend fun getShareCode(token: String, residenceId: Int): ApiResult<ShareCodeResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/residences/$residenceId/share-code/") {
|
val response = client.get("$baseUrl/residences/$residenceId/share-code/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -186,7 +186,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun acceptResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
|
suspend fun acceptResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/residences/$residenceId/invite/accept/") {
|
val response = client.post("$baseUrl/residences/$residenceId/invite/accept/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -207,7 +207,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun declineResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
|
suspend fun declineResidenceInvite(token: String, residenceId: Int): ApiResult<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/residences/$residenceId/invite/decline/") {
|
val response = client.post("$baseUrl/residences/$residenceId/invite/decline/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -224,7 +224,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun joinWithCode(token: String, code: String): ApiResult<JoinResidenceResponse> {
|
suspend fun joinWithCode(token: String, code: String): ApiResult<JoinResidenceResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/residences/join-with-code/") {
|
val response = client.post("$baseUrl/residences/join-with-code/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(JoinResidenceRequest(code))
|
setBody(JoinResidenceRequest(code))
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getResidenceUsers(token: String, residenceId: Int): ApiResult<ResidenceUsersResponse> {
|
suspend fun getResidenceUsers(token: String, residenceId: Int): ApiResult<ResidenceUsersResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/residences/$residenceId/users/") {
|
val response = client.get("$baseUrl/residences/$residenceId/users/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -261,7 +261,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun removeUser(token: String, residenceId: Int, userId: Int): ApiResult<RemoveUserResponse> {
|
suspend fun removeUser(token: String, residenceId: Int, userId: Int): ApiResult<RemoveUserResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.delete("$baseUrl/residences/$residenceId/users/$userId/") {
|
val response = client.delete("$baseUrl/residences/$residenceId/users/$userId/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -279,7 +279,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun generateTasksReport(token: String, residenceId: Int, email: String? = null): ApiResult<GenerateReportResponse> {
|
suspend fun generateTasksReport(token: String, residenceId: Int, email: String? = null): ApiResult<GenerateReportResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/residences/$residenceId/generate-tasks-report/") {
|
val response = client.post("$baseUrl/residences/$residenceId/generate-tasks-report/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
if (email != null) {
|
if (email != null) {
|
||||||
setBody(mapOf("email" to email))
|
setBody(mapOf("email" to email))
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getSubscriptionStatus(token: String): ApiResult<SubscriptionStatus> {
|
suspend fun getSubscriptionStatus(token: String): ApiResult<SubscriptionStatus> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/subscription/status/") {
|
val response = client.get("$baseUrl/subscription/status/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -29,7 +29,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/subscription/upgrade-triggers/") {
|
val response = client.get("$baseUrl/subscription/upgrade-triggers/") {
|
||||||
// Token is optional - endpoint is public
|
// Token is optional - endpoint is public
|
||||||
token?.let { header("Authorization", "Token $it") }
|
token?.let { header(SESSION_TOKEN_HEADER, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -45,7 +45,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getFeatureBenefits(token: String): ApiResult<List<FeatureBenefit>> {
|
suspend fun getFeatureBenefits(token: String): ApiResult<List<FeatureBenefit>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/subscription/features/") {
|
val response = client.get("$baseUrl/subscription/features/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -61,7 +61,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getActivePromotions(token: String): ApiResult<List<Promotion>> {
|
suspend fun getActivePromotions(token: String): ApiResult<List<Promotion>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/subscription/promotions/") {
|
val response = client.get("$baseUrl/subscription/promotions/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -85,7 +85,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
): ApiResult<VerificationResponse> {
|
): ApiResult<VerificationResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/subscription/purchase/") {
|
val response = client.post("$baseUrl/subscription/purchase/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(mapOf(
|
setBody(mapOf(
|
||||||
"platform" to "ios",
|
"platform" to "ios",
|
||||||
@@ -115,7 +115,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
): ApiResult<VerificationResponse> {
|
): ApiResult<VerificationResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/subscription/purchase/") {
|
val response = client.post("$baseUrl/subscription/purchase/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(mapOf(
|
setBody(mapOf(
|
||||||
"platform" to "android",
|
"platform" to "android",
|
||||||
@@ -154,7 +154,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val response = client.post("$baseUrl/subscription/restore/") {
|
val response = client.post("$baseUrl/subscription/restore/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(body)
|
setBody(body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
): ApiResult<TaskColumnsResponse> {
|
): ApiResult<TaskColumnsResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/tasks/") {
|
val response = client.get("$baseUrl/tasks/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
days?.let { parameter("days", it) }
|
days?.let { parameter("days", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getTask(token: String, id: Int): ApiResult<TaskResponse> {
|
suspend fun getTask(token: String, id: Int): ApiResult<TaskResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/tasks/$id/") {
|
val response = client.get("$baseUrl/tasks/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -50,7 +50,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
|
suspend fun createTask(token: String, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/tasks/") {
|
val response = client.post("$baseUrl/tasks/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
suspend fun bulkCreateTasks(token: String, request: BulkCreateTasksRequest): ApiResult<BulkCreateTasksResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/tasks/bulk/") {
|
val response = client.post("$baseUrl/tasks/bulk/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
|
suspend fun updateTask(token: String, id: Int, request: TaskCreateRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.put("$baseUrl/tasks/$id/") {
|
val response = client.put("$baseUrl/tasks/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun deleteTask(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
|
suspend fun deleteTask(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.delete("$baseUrl/tasks/$id/") {
|
val response = client.delete("$baseUrl/tasks/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -134,7 +134,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
): ApiResult<TaskColumnsResponse> {
|
): ApiResult<TaskColumnsResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") {
|
val response = client.get("$baseUrl/tasks/by-residence/$residenceId/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
days?.let { parameter("days", it) }
|
days?.let { parameter("days", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun patchTask(token: String, id: Int, request: TaskPatchRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
|
suspend fun patchTask(token: String, id: Int, request: TaskPatchRequest): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.patch("$baseUrl/tasks/$id/") {
|
val response = client.patch("$baseUrl/tasks/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
private suspend fun postTaskAction(token: String, id: Int, action: String): ApiResult<WithSummaryResponse<TaskResponse>> {
|
private suspend fun postTaskAction(token: String, id: Int, action: String): ApiResult<WithSummaryResponse<TaskResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/tasks/$id/$action/") {
|
val response = client.post("$baseUrl/tasks/$id/$action/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
}
|
}
|
||||||
when (response.status) {
|
when (response.status) {
|
||||||
@@ -233,7 +233,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getTaskCompletions(token: String, taskId: Int): ApiResult<List<TaskCompletionResponse>> {
|
suspend fun getTaskCompletions(token: String, taskId: Int): ApiResult<List<TaskCompletionResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/tasks/$taskId/completions/") {
|
val response = client.get("$baseUrl/tasks/$taskId/completions/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getCompletions(token: String): ApiResult<List<TaskCompletionResponse>> {
|
suspend fun getCompletions(token: String): ApiResult<List<TaskCompletionResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/task-completions/") {
|
val response = client.get("$baseUrl/task-completions/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -29,7 +29,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getCompletion(token: String, id: Int): ApiResult<TaskCompletionResponse> {
|
suspend fun getCompletion(token: String, id: Int): ApiResult<TaskCompletionResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/task-completions/$id/") {
|
val response = client.get("$baseUrl/task-completions/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
@@ -45,7 +45,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun createCompletion(token: String, request: TaskCompletionCreateRequest): ApiResult<WithSummaryResponse<TaskCompletionResponse>> {
|
suspend fun createCompletion(token: String, request: TaskCompletionCreateRequest): ApiResult<WithSummaryResponse<TaskCompletionResponse>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/task-completions/") {
|
val response = client.post("$baseUrl/task-completions/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun updateCompletion(token: String, id: Int, request: TaskCompletionCreateRequest): ApiResult<TaskCompletionResponse> {
|
suspend fun updateCompletion(token: String, id: Int, request: TaskCompletionCreateRequest): ApiResult<TaskCompletionResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.put("$baseUrl/task-completions/$id/") {
|
val response = client.put("$baseUrl/task-completions/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun deleteCompletion(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
|
suspend fun deleteCompletion(token: String, id: Int): ApiResult<WithSummaryResponse<String>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.delete("$baseUrl/task-completions/$id/") {
|
val response = client.delete("$baseUrl/task-completions/$id/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<TaskSuggestionsResponse> {
|
suspend fun getTaskSuggestions(token: String, residenceId: Int): ApiResult<TaskSuggestionsResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/tasks/suggestions/") {
|
val response = client.get("$baseUrl/tasks/suggestions/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
parameter("residence_id", residenceId)
|
parameter("residence_id", residenceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import com.tt.honeyDue.models.PresignUploadResponse
|
|||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.request.forms.*
|
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.utils.io.core.*
|
import io.ktor.utils.io.core.*
|
||||||
@@ -14,17 +13,16 @@ import io.ktor.utils.io.core.*
|
|||||||
* Three-step direct-to-B2 upload helper.
|
* Three-step direct-to-B2 upload helper.
|
||||||
*
|
*
|
||||||
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a
|
* Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a
|
||||||
* B2 POST policy plus form fields the client needs to perform the
|
* signed PUT URL plus the headers the client must send.
|
||||||
* direct upload.
|
* Step 2: [putToStorage] — single PUT straight to B2. Bytes never traverse
|
||||||
* Step 2: [postToStorage] — multipart/form-data POST straight to B2.
|
* our API server.
|
||||||
* Bytes never traverse our API server.
|
|
||||||
* Step 3: caller invokes the relevant entity-creation endpoint
|
* Step 3: caller invokes the relevant entity-creation endpoint
|
||||||
* (POST /api/task-completions/, POST /api/documents/) with the
|
* (POST /api/task-completions/, POST /api/documents/) with the
|
||||||
* returned upload_id in the `upload_ids` field.
|
* returned upload_id in the `upload_ids` field.
|
||||||
*
|
*
|
||||||
* iOS uses its own native equivalent (PresignedUploader.swift) for memory
|
* iOS uses its own native equivalent (PresignedUploader.swift). Both paths
|
||||||
* reasons — Swift can stream a multipart body without buffering. Android
|
* use PUT because B2's S3-compatible endpoint does not implement the S3
|
||||||
* uses this Kotlin path which works fine for ≤10 MB images.
|
* POST Object form upload (returns HTTP 501 for any POST).
|
||||||
*/
|
*/
|
||||||
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||||
private val baseUrl = ApiClient.getBaseUrl()
|
private val baseUrl = ApiClient.getBaseUrl()
|
||||||
@@ -38,7 +36,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
): ApiResult<PresignUploadResponse> {
|
): ApiResult<PresignUploadResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/uploads/presign/") {
|
val response = client.post("$baseUrl/uploads/presign/") {
|
||||||
header("Authorization", "Token $token")
|
header(SESSION_TOKEN_HEADER, token)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(PresignUploadRequest(category, contentType, contentLength))
|
setBody(PresignUploadRequest(category, contentType, contentLength))
|
||||||
}
|
}
|
||||||
@@ -61,38 +59,36 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 2 — POST `data` directly to B2 using the signed policy fields.
|
* Step 2 — PUT `data` directly to B2 using the signed URL + headers.
|
||||||
*
|
*
|
||||||
* The S3 POST policy spec requires every signed field to appear before
|
* The presign signature binds the headers exactly, so we send them
|
||||||
* the file part, and `key` + `Content-Type` must match the policy
|
* verbatim. Content-Length is filled in automatically by Ktor from
|
||||||
* exactly. Ktor's MultiPartFormDataContent preserves insertion order
|
* the body size, but we still pass through Content-Type which Ktor
|
||||||
* for the appended parts.
|
* would otherwise default to application/octet-stream.
|
||||||
*/
|
*/
|
||||||
suspend fun postToStorage(
|
suspend fun putToStorage(
|
||||||
uploadUrl: String,
|
uploadUrl: String,
|
||||||
fields: Map<String, String>,
|
headers: Map<String, String>,
|
||||||
data: ByteArray,
|
data: ByteArray,
|
||||||
contentType: String,
|
contentType: String,
|
||||||
fileName: String,
|
|
||||||
): ApiResult<Unit> {
|
): ApiResult<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val parts = formData {
|
val response = client.put(uploadUrl) {
|
||||||
// Stable order: signed fields first, then file. We rely on
|
// Apply server-supplied headers verbatim. Skip Content-Length
|
||||||
// Ktor preserving the order in which append() is called.
|
// — Ktor sets it automatically from the body and will refuse
|
||||||
fields.forEach { (k, v) -> append(k, v) }
|
// a manual override on most engines.
|
||||||
append(
|
headers.forEach { (k, v) ->
|
||||||
key = "file",
|
if (!k.equals("Content-Length", ignoreCase = true)) {
|
||||||
value = data,
|
header(k, v)
|
||||||
headers = Headers.build {
|
}
|
||||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
}
|
||||||
append(HttpHeaders.ContentType, contentType)
|
// Defensive: ensure Content-Type is set even if the server
|
||||||
},
|
// omits it. The signed value (if present) takes precedence.
|
||||||
)
|
if (!headers.keys.any { it.equals("Content-Type", ignoreCase = true) }) {
|
||||||
|
header(HttpHeaders.ContentType, contentType)
|
||||||
|
}
|
||||||
|
setBody(data)
|
||||||
}
|
}
|
||||||
val response = client.submitFormWithBinaryData(
|
|
||||||
url = uploadUrl,
|
|
||||||
formData = parts,
|
|
||||||
)
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
ApiResult.Success(Unit)
|
ApiResult.Success(Unit)
|
||||||
} else {
|
} else {
|
||||||
@@ -124,7 +120,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
category: String,
|
category: String,
|
||||||
contentType: String,
|
contentType: String,
|
||||||
data: ByteArray,
|
data: ByteArray,
|
||||||
fileName: String,
|
@Suppress("UNUSED_PARAMETER") fileName: String,
|
||||||
): ApiResult<Int> {
|
): ApiResult<Int> {
|
||||||
val presignResult = presign(token, category, contentType, data.size.toLong())
|
val presignResult = presign(token, category, contentType, data.size.toLong())
|
||||||
val presigned = (presignResult as? ApiResult.Success)?.data
|
val presigned = (presignResult as? ApiResult.Success)?.data
|
||||||
@@ -133,16 +129,15 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
(presignResult as? ApiResult.Error)?.code,
|
(presignResult as? ApiResult.Error)?.code,
|
||||||
)
|
)
|
||||||
|
|
||||||
val postResult = postToStorage(
|
val putResult = putToStorage(
|
||||||
uploadUrl = presigned.uploadUrl,
|
uploadUrl = presigned.uploadUrl,
|
||||||
fields = presigned.fields,
|
headers = presigned.headers,
|
||||||
data = data,
|
data = data,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
fileName = fileName,
|
|
||||||
)
|
)
|
||||||
return when (postResult) {
|
return when (putResult) {
|
||||||
is ApiResult.Success -> ApiResult.Success(presigned.id)
|
is ApiResult.Success -> ApiResult.Success(presigned.id)
|
||||||
is ApiResult.Error -> postResult
|
is ApiResult.Error -> putResult
|
||||||
else -> ApiResult.Error("Upload failed in unknown state")
|
else -> ApiResult.Error("Upload failed in unknown state")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -17,6 +17,7 @@ import coil3.request.ImageRequest
|
|||||||
import coil3.network.NetworkHeaders
|
import coil3.network.NetworkHeaders
|
||||||
import coil3.network.httpHeaders
|
import coil3.network.httpHeaders
|
||||||
import com.tt.honeyDue.network.ApiClient
|
import com.tt.honeyDue.network.ApiClient
|
||||||
|
import com.tt.honeyDue.network.SESSION_TOKEN_HEADER
|
||||||
import com.tt.honeyDue.storage.TokenStorage
|
import com.tt.honeyDue.storage.TokenStorage
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,9 +58,11 @@ fun AuthenticatedImage(
|
|||||||
.data(fullUrl)
|
.data(fullUrl)
|
||||||
.apply {
|
.apply {
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
|
// honeyDue media is gated on the Kratos session token,
|
||||||
|
// carried on the X-Session-Token header.
|
||||||
httpHeaders(
|
httpHeaders(
|
||||||
NetworkHeaders.Builder()
|
NetworkHeaders.Builder()
|
||||||
.set("Authorization", "Token $token")
|
.set(SESSION_TOKEN_HEADER, token)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -413,26 +413,30 @@ class HttpClientPluginsTest {
|
|||||||
client.close()
|
client.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Token Refresh / 401 Handling Tests ====================
|
// ==================== Kratos Session / 401 Handling Tests ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testTokenExpiredExceptionIsRefreshed() {
|
fun testTokenExpiredExceptionStillValid() {
|
||||||
|
// refreshed = true means the Kratos session was re-validated and is
|
||||||
|
// still usable — the caller may retry the request.
|
||||||
val exception = TokenExpiredException(refreshed = true)
|
val exception = TokenExpiredException(refreshed = true)
|
||||||
assertTrue(exception.refreshed)
|
assertTrue(exception.refreshed)
|
||||||
assertTrue(exception.message!!.contains("refreshed"))
|
assertTrue(exception.message!!.contains("still valid"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testTokenExpiredExceptionNotRefreshed() {
|
fun testTokenExpiredExceptionSessionGone() {
|
||||||
|
// refreshed = false means the Kratos session is gone — the user must
|
||||||
|
// sign in again.
|
||||||
val exception = TokenExpiredException(refreshed = false)
|
val exception = TokenExpiredException(refreshed = false)
|
||||||
assertTrue(!exception.refreshed)
|
assertTrue(!exception.refreshed)
|
||||||
assertTrue(exception.message!!.contains("re-authenticate"))
|
assertTrue(exception.message!!.contains("re-authenticate"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test401WithNonExpiredTokenDoesNotTriggerRefresh() = runTest {
|
fun test401WithoutValidatorReturnsResponse() = runTest {
|
||||||
// A 401 that does NOT contain "expired" or "token_expired" should NOT
|
// Without the Kratos session validator installed, a 401 simply
|
||||||
// throw TokenExpiredException — it should just return the 401 response.
|
// surfaces as the 401 response — it does not throw.
|
||||||
var requestCount = 0
|
var requestCount = 0
|
||||||
val client = HttpClient(MockEngine) {
|
val client = HttpClient(MockEngine) {
|
||||||
engine {
|
engine {
|
||||||
|
|||||||
@@ -10,9 +10,79 @@ import AppIntents
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Widget Configuration Intent
|
// 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 {
|
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
||||||
static var title: LocalizedStringResource { "honeyDue Configuration" }
|
static var title: LocalizedStringResource { "honeyDue Configuration" }
|
||||||
static var description: IntentDescription { "Configure your honeyDue widget" }
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Complete Task Intent
|
// MARK: - Complete Task Intent
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ class CacheManager {
|
|||||||
let inProgress: Bool
|
let inProgress: Bool
|
||||||
let dueDate: String?
|
let dueDate: String?
|
||||||
let category: 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 residenceName: String?
|
||||||
let isOverdue: Bool
|
let isOverdue: Bool
|
||||||
let isDueWithin7Days: Bool
|
let isDueWithin7Days: Bool
|
||||||
@@ -93,12 +99,45 @@ class CacheManager {
|
|||||||
case id, title, description, priority, category
|
case id, title, description, priority, category
|
||||||
case inProgress = "in_progress"
|
case inProgress = "in_progress"
|
||||||
case dueDate = "due_date"
|
case dueDate = "due_date"
|
||||||
|
case residenceId = "residence_id"
|
||||||
case residenceName = "residence_name"
|
case residenceName = "residence_name"
|
||||||
case isOverdue = "is_overdue"
|
case isOverdue = "is_overdue"
|
||||||
case isDueWithin7Days = "is_due_within_7_days"
|
case isDueWithin7Days = "is_due_within_7_days"
|
||||||
case isDue8To30Days = "is_due_8_to_30_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)
|
/// Whether this task is pending completion (tapped on widget, waiting for sync)
|
||||||
var isPendingCompletion: Bool {
|
var isPendingCompletion: Bool {
|
||||||
WidgetActionManager.shared.isTaskPendingCompletion(taskId: id)
|
WidgetActionManager.shared.isTaskPendingCompletion(taskId: id)
|
||||||
@@ -147,14 +186,19 @@ class CacheManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getUpcomingTasks() -> [CustomTask] {
|
static func getUpcomingTasks(forResidenceId residenceId: Int? = nil) -> [CustomTask] {
|
||||||
let allTasks = getData()
|
let allTasks = getData()
|
||||||
|
|
||||||
// Filter for actionable tasks (not completed, including in-progress and overdue)
|
// Filter for actionable tasks (not completed, including in-progress and overdue)
|
||||||
// Also exclude tasks that are pending completion via widget
|
// 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.
|
||||||
let upcoming = allTasks.filter { task in
|
let upcoming = allTasks.filter { task in
|
||||||
// Include if: not pending completion
|
guard task.shouldShow else { return false }
|
||||||
return task.shouldShow
|
if let residenceId, let taskResidenceId = task.residenceId {
|
||||||
|
return taskResidenceId == residenceId
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by due date (earliest first), with overdue at top
|
// Sort by due date (earliest first), with overdue at top
|
||||||
@@ -171,6 +215,36 @@ class CacheManager {
|
|||||||
return date1 < date2
|
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 {
|
struct Provider: AppIntentTimelineProvider {
|
||||||
@@ -184,7 +258,7 @@ struct Provider: AppIntentTimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
|
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
|
||||||
let tasks = CacheManager.getUpcomingTasks()
|
let tasks = CacheManager.getUpcomingTasks(forResidenceId: configuration.residence?.intId)
|
||||||
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
|
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
|
||||||
return SimpleEntry(
|
return SimpleEntry(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
@@ -195,7 +269,7 @@ struct Provider: AppIntentTimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
|
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
|
||||||
let tasks = CacheManager.getUpcomingTasks()
|
let tasks = CacheManager.getUpcomingTasks(forResidenceId: configuration.residence?.intId)
|
||||||
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
|
let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget()
|
||||||
|
|
||||||
// Use a longer refresh interval during overnight hours (11pm-6am)
|
// 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) {
|
private func updateUIForResidence(with residence: ResidencePreviewData) {
|
||||||
// Update icon
|
// 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)
|
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
||||||
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
|
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
|
||||||
|
}
|
||||||
|
|
||||||
titleLabel.text = residence.residenceName
|
titleLabel.text = residence.residenceName
|
||||||
subtitleLabel.text = "honeyDue Residence Invite"
|
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
|
// Clear existing details
|
||||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
@@ -280,9 +307,183 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
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
|
// 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 |
@@ -0,0 +1,90 @@
|
|||||||
|
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 |
@@ -153,9 +153,11 @@ private class AuthenticatedImageLoader: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create request with auth header
|
// Create request with the Kratos session-token header.
|
||||||
|
// Identity is owned by Ory Kratos; the honeyDue API authenticates
|
||||||
|
// requests via the session token on X-Session-Token.
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue(token, forHTTPHeaderField: "X-Session-Token")
|
||||||
request.timeoutInterval = 15
|
request.timeoutInterval = 15
|
||||||
request.cachePolicy = .returnCacheDataElseLoad
|
request.cachePolicy = .returnCacheDataElseLoad
|
||||||
|
|
||||||
|
|||||||
@@ -274,11 +274,14 @@ class DataManagerObservable: ObservableObject {
|
|||||||
}
|
}
|
||||||
observationTasks.append(residencesTask)
|
observationTasks.append(residencesTask)
|
||||||
|
|
||||||
// MyResidences
|
// 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.
|
||||||
let myResidencesTask = Task { [weak self] in
|
let myResidencesTask = Task { [weak self] in
|
||||||
for await response in DataManager.shared.myResidences {
|
for await response in DataManager.shared.myResidences {
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.myResidences = response
|
self.myResidences = response
|
||||||
|
WidgetDataManager.shared.saveResidences(from: response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
observationTasks.append(myResidencesTask)
|
observationTasks.append(myResidencesTask)
|
||||||
@@ -732,6 +735,7 @@ class DataManagerObservable: ObservableObject {
|
|||||||
inProgress: task.inProgress,
|
inProgress: task.inProgress,
|
||||||
dueDate: task.effectiveDueDate,
|
dueDate: task.effectiveDueDate,
|
||||||
category: task.categoryName,
|
category: task.categoryName,
|
||||||
|
residenceId: Int(task.residenceId),
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: overdueIds.contains(task.id),
|
isOverdue: overdueIds.contains(task.id),
|
||||||
isDueWithin7Days: isDueWithin7Days,
|
isDueWithin7Days: isDueWithin7Days,
|
||||||
|
|||||||
@@ -167,9 +167,10 @@ struct DocumentDetailView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create authenticated request
|
// Create authenticated request — the honeyDue API gates
|
||||||
|
// media on the Kratos session token (X-Session-Token).
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue(token, forHTTPHeaderField: "X-Session-Token")
|
||||||
|
|
||||||
// Download the file
|
// Download the file
|
||||||
let (tempURL, response) = try await URLSession.shared.download(for: request)
|
let (tempURL, response) = try await URLSession.shared.download(for: request)
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import ComposeApp
|
|||||||
/// Three-step direct-to-B2 image upload.
|
/// Three-step direct-to-B2 image upload.
|
||||||
///
|
///
|
||||||
/// Flow:
|
/// Flow:
|
||||||
/// 1. POST /api/uploads/presign → server returns a B2 POST policy + form
|
/// 1. POST /api/uploads/presign → server returns a signed PUT URL plus
|
||||||
/// fields scoped to a single object key with a content-length-range
|
/// the headers (Content-Type, Content-Length) the client must send.
|
||||||
/// condition that B2 enforces at the protocol level.
|
/// The signature binds those headers — B2 rejects the upload if the
|
||||||
/// 2. Multipart POST the bytes directly to B2, no API server in the data
|
/// bytes/headers don't match exactly.
|
||||||
/// path. B2 rejects the upload if the bytes don't match the policy.
|
/// 2. PUT the bytes directly to B2, no API server in the data path.
|
||||||
/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
|
/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
|
||||||
/// /api/documents/ via `upload_ids[]`. The server HEADs the object,
|
/// /api/documents/ via `upload_ids[]`. The server HEADs the object,
|
||||||
/// confirms the size, and creates the linked entity rows.
|
/// confirms the size, and creates the linked entity rows.
|
||||||
///
|
///
|
||||||
|
/// We use PUT (not POST) because B2's S3-compatible endpoint does not
|
||||||
|
/// implement the S3 POST Object form upload — every POST returns HTTP 501.
|
||||||
|
///
|
||||||
/// All errors map to `PresignedUploaderError` — the Swift call site can
|
/// All errors map to `PresignedUploaderError` — the Swift call site can
|
||||||
/// translate to user-facing copy without parsing nested HTTP details.
|
/// translate to user-facing copy without parsing nested HTTP details.
|
||||||
enum PresignedUploaderError: Error, LocalizedError {
|
enum PresignedUploaderError: Error, LocalizedError {
|
||||||
@@ -33,7 +36,7 @@ enum PresignedUploaderError: Error, LocalizedError {
|
|||||||
default: return "Couldn't start upload (server returned \(status))."
|
default: return "Couldn't start upload (server returned \(status))."
|
||||||
}
|
}
|
||||||
case .uploadFailed(let status, _):
|
case .uploadFailed(let status, _):
|
||||||
return "Upload failed (B2 returned \(status))."
|
return "Upload failed (storage returned \(status))."
|
||||||
case .sessionError(let err):
|
case .sessionError(let err):
|
||||||
return err.localizedDescription
|
return err.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -95,13 +98,12 @@ final class PresignedUploader {
|
|||||||
contentLength: Int64(data.count)
|
contentLength: Int64(data.count)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 2: direct POST to B2
|
// Step 2: direct PUT to B2
|
||||||
try await postToStorage(
|
try await putToStorage(
|
||||||
uploadURL: presigned.uploadUrl,
|
uploadURL: presigned.uploadUrl,
|
||||||
fields: presigned.fields,
|
headers: presigned.headers,
|
||||||
data: data,
|
data: data,
|
||||||
contentType: contentType,
|
contentType: contentType
|
||||||
fileName: fileName
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return Int32(presigned.id)
|
return Int32(presigned.id)
|
||||||
@@ -146,7 +148,8 @@ final class PresignedUploader {
|
|||||||
private struct PresignResponse: Decodable {
|
private struct PresignResponse: Decodable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let upload_url: String
|
let upload_url: String
|
||||||
let fields: [String: String]
|
let method: String?
|
||||||
|
let headers: [String: String]
|
||||||
let key: String
|
let key: String
|
||||||
let expires_at: String
|
let expires_at: String
|
||||||
|
|
||||||
@@ -167,7 +170,8 @@ final class PresignedUploader {
|
|||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.httpMethod = "POST"
|
req.httpMethod = "POST"
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization")
|
// honeyDue API auth: Kratos session token on X-Session-Token.
|
||||||
|
req.setValue(authToken, forHTTPHeaderField: "X-Session-Token")
|
||||||
req.httpBody = try JSONEncoder().encode(PresignBody(
|
req.httpBody = try JSONEncoder().encode(PresignBody(
|
||||||
category: category.rawValue,
|
category: category.rawValue,
|
||||||
content_type: contentType,
|
content_type: contentType,
|
||||||
@@ -196,64 +200,39 @@ final class PresignedUploader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Step 2: POST to B2
|
// MARK: - Step 2: PUT to B2
|
||||||
|
//
|
||||||
|
// The presign response includes the exact headers (Content-Type +
|
||||||
|
// Content-Length) that were signed. Send them verbatim — any deviation
|
||||||
|
// invalidates the signature and B2 will reject the upload.
|
||||||
|
//
|
||||||
|
// Content-Length is set automatically by URLSession from httpBody.count,
|
||||||
|
// so we don't manually echo it back; we still send Content-Type because
|
||||||
|
// URLSession will otherwise default it to application/x-www-form-urlencoded.
|
||||||
|
|
||||||
private func postToStorage(
|
private func putToStorage(
|
||||||
uploadURL: String,
|
uploadURL: String,
|
||||||
fields: [String: String],
|
headers: [String: String],
|
||||||
data: Data,
|
data: Data,
|
||||||
contentType: String,
|
contentType: String
|
||||||
fileName: String
|
|
||||||
) async throws {
|
) async throws {
|
||||||
guard let url = URL(string: uploadURL) else {
|
guard let url = URL(string: uploadURL) else {
|
||||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
|
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a multipart/form-data body with all policy fields followed
|
|
||||||
// by a single "file" part (S3 POST policy mandates the file part
|
|
||||||
// come last).
|
|
||||||
let boundary = "Boundary-\(UUID().uuidString)"
|
|
||||||
var body = Data()
|
|
||||||
let crlf = "\r\n"
|
|
||||||
let appendString: (String) -> Void = { s in
|
|
||||||
body.append(s.data(using: .utf8) ?? Data())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stable order: ensure "key" and "Content-Type" appear before the
|
|
||||||
// file part so the policy signature validates. Unspecified order
|
|
||||||
// for the rest — S3 accepts any.
|
|
||||||
let orderedKeys = ["key", "Content-Type", "policy", "x-amz-algorithm",
|
|
||||||
"x-amz-credential", "x-amz-date", "x-amz-signature",
|
|
||||||
"x-amz-meta-uid"]
|
|
||||||
var emitted = Set<String>()
|
|
||||||
for k in orderedKeys {
|
|
||||||
if let v = fields[k] {
|
|
||||||
appendString("--\(boundary)\(crlf)")
|
|
||||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
|
||||||
appendString(v)
|
|
||||||
appendString(crlf)
|
|
||||||
emitted.insert(k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (k, v) in fields where !emitted.contains(k) {
|
|
||||||
appendString("--\(boundary)\(crlf)")
|
|
||||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
|
||||||
appendString(v)
|
|
||||||
appendString(crlf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// file part — must be last
|
|
||||||
appendString("--\(boundary)\(crlf)")
|
|
||||||
appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\(crlf)")
|
|
||||||
appendString("Content-Type: \(contentType)\(crlf)\(crlf)")
|
|
||||||
body.append(data)
|
|
||||||
appendString(crlf)
|
|
||||||
appendString("--\(boundary)--\(crlf)")
|
|
||||||
|
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.httpMethod = "POST"
|
req.httpMethod = "PUT"
|
||||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
req.httpBody = data
|
||||||
req.httpBody = body
|
|
||||||
|
// Apply server-supplied headers verbatim. Skip Content-Length —
|
||||||
|
// URLSession sets it automatically and will refuse to override it.
|
||||||
|
for (k, v) in headers where k.lowercased() != "content-length" {
|
||||||
|
req.setValue(v, forHTTPHeaderField: k)
|
||||||
|
}
|
||||||
|
// Defensive: ensure Content-Type is set even if the server omits it.
|
||||||
|
if req.value(forHTTPHeaderField: "Content-Type") == nil {
|
||||||
|
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
let (respBody, response): (Data, URLResponse)
|
let (respBody, response): (Data, URLResponse)
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ final class WidgetDataManager {
|
|||||||
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev"
|
||||||
}()
|
}()
|
||||||
private let tasksFileName = "widget_tasks.json"
|
private let tasksFileName = "widget_tasks.json"
|
||||||
|
private let residencesFileName = "widget_residences.json"
|
||||||
private let actionsFileName = "widget_pending_actions.json"
|
private let actionsFileName = "widget_pending_actions.json"
|
||||||
private let pendingTasksFileName = "widget_pending_tasks.json"
|
private let pendingTasksFileName = "widget_pending_tasks.json"
|
||||||
private let tokenKey = "widget_auth_token"
|
private let tokenKey = "widget_auth_token"
|
||||||
@@ -295,7 +296,15 @@ final class WidgetDataManager {
|
|||||||
!loadPendingActionsSync().isEmpty
|
!loadPendingActionsSync().isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Task model for widget display - simplified version of TaskDetail
|
/// 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.
|
||||||
struct WidgetTask: Codable {
|
struct WidgetTask: Codable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let title: String
|
let title: String
|
||||||
@@ -304,6 +313,7 @@ final class WidgetDataManager {
|
|||||||
let inProgress: Bool
|
let inProgress: Bool
|
||||||
let dueDate: String?
|
let dueDate: String?
|
||||||
let category: String?
|
let category: String?
|
||||||
|
let residenceId: Int?
|
||||||
let residenceName: String?
|
let residenceName: String?
|
||||||
let isOverdue: Bool
|
let isOverdue: Bool
|
||||||
let isDueWithin7Days: Bool
|
let isDueWithin7Days: Bool
|
||||||
@@ -313,11 +323,53 @@ final class WidgetDataManager {
|
|||||||
case id, title, description, priority, category
|
case id, title, description, priority, category
|
||||||
case inProgress = "in_progress"
|
case inProgress = "in_progress"
|
||||||
case dueDate = "due_date"
|
case dueDate = "due_date"
|
||||||
|
case residenceId = "residence_id"
|
||||||
case residenceName = "residence_name"
|
case residenceName = "residence_name"
|
||||||
case isOverdue = "is_overdue"
|
case isOverdue = "is_overdue"
|
||||||
case isDueWithin7Days = "is_due_within_7_days"
|
case isDueWithin7Days = "is_due_within_7_days"
|
||||||
case isDue8To30Days = "is_due_8_to_30_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
|
/// Metrics calculated from an array of tasks - shared between app and widget
|
||||||
@@ -418,6 +470,12 @@ 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(
|
let widgetTask = WidgetTask(
|
||||||
id: Int(task.id),
|
id: Int(task.id),
|
||||||
title: task.title,
|
title: task.title,
|
||||||
@@ -426,6 +484,7 @@ final class WidgetDataManager {
|
|||||||
inProgress: task.inProgress,
|
inProgress: task.inProgress,
|
||||||
dueDate: task.effectiveDueDate,
|
dueDate: task.effectiveDueDate,
|
||||||
category: task.categoryName ?? "",
|
category: task.categoryName ?? "",
|
||||||
|
residenceId: Int(task.residenceId),
|
||||||
residenceName: "",
|
residenceName: "",
|
||||||
isOverdue: isOverdue,
|
isOverdue: isOverdue,
|
||||||
isDueWithin7Days: isDueWithin7Days,
|
isDueWithin7Days: isDueWithin7Days,
|
||||||
@@ -540,10 +599,94 @@ final class WidgetDataManager {
|
|||||||
print("WidgetDataManager: Error clearing cache - \(error)")
|
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 {
|
DispatchQueue.main.async {
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ struct CompleteTaskView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.keyboardDismissToolbar()
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField)
|
||||||
} label: {
|
} label: {
|
||||||
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
|
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
|
||||||
@@ -142,7 +141,6 @@ struct CompleteTaskView: View {
|
|||||||
TextEditor(text: $notes)
|
TextEditor(text: $notes)
|
||||||
.frame(minHeight: 100)
|
.frame(minHeight: 100)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.keyboardDismissToolbar()
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField)
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -289,6 +287,12 @@ struct CompleteTaskView: View {
|
|||||||
.background(WarmGradientBackground())
|
.background(WarmGradientBackground())
|
||||||
.navigationTitle(L10n.Tasks.completeTask)
|
.navigationTitle(L10n.Tasks.completeTask)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
// ONE keyboard "Done" toolbar at the form root — per-field
|
||||||
|
// `.keyboardDismissToolbar()` modifiers each install a
|
||||||
|
// separate `ToolbarItemGroup(placement: .keyboard)`, and
|
||||||
|
// SwiftUI stacks them on the responder chain so any focused
|
||||||
|
// field renders multiple Done buttons side-by-side (issue #5).
|
||||||
|
.keyboardDismissToolbar()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button(L10n.Common.cancel) {
|
Button(L10n.Common.cancel) {
|
||||||
|
|||||||
Reference in New Issue
Block a user