From 4126fe4f56a6eb9703a084d4793a597f37bf2867 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 11:18:37 +0000 Subject: feat(widget): add event detail popup showing recurrence schedule Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD --- android/app/src/main/AndroidManifest.xml | 6 + .../java/org/terst/doot/widget/data/WidgetItem.kt | 3 +- .../org/terst/doot/widget/data/WidgetRepository.kt | 20 +++ .../java/org/terst/doot/widget/ui/DootWidget.kt | 31 ++++- .../terst/doot/widget/ui/EventDetailActivity.kt | 145 +++++++++++++++++++++ 5 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt (limited to 'android/app/src') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6d35ff7..4b58442 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,12 @@ android:exported="false" android:windowSoftInputMode="adjustResize" /> + + + = + withContext(Dispatchers.IO) { + val encodedId = java.net.URLEncoder.encode(recurringEventId, "UTF-8") + val request = Request.Builder() + .url("$serverUrl/api/widget/recurrence?recurring_event_id=$encodedId") + .header("Authorization", "Bearer $token") + .build() + runCatching { + val response = client.newCall(request).execute() + check(response.isSuccessful) { "HTTP ${response.code}" } + val body = checkNotNull(response.body?.string()) { "Empty body" } + val parsed = json.decodeFromString(body) + parsed.recurrence + } + } } diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt index e326537..2dbc96b 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt @@ -2,7 +2,6 @@ package org.terst.doot.widget.ui import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -140,12 +139,20 @@ fun WidgetRoot(items: List, now: Instant, isRefreshing: Boolean) { @Composable fun AllDayRow(event: WidgetItem) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } val color = sourceColor(event.source) Row( modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 3.dp) - .clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(event.url.ifEmpty { "https://calendar.google.com" })))), + .clickable(actionStartActivity(detailIntent)), verticalAlignment = Alignment.CenterVertically ) { Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} @@ -260,13 +267,21 @@ fun HourRow( @Composable fun EventBlock(event: WidgetItem, isPast: Boolean) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } val color = sourceColor(event.source) val alpha = if (isPast) 0.5f else 1f Row( modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 4.dp) - .clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(event.url.ifEmpty { "https://calendar.google.com" })))), + .clickable(actionStartActivity(detailIntent)), verticalAlignment = Alignment.CenterVertically ) { Box( @@ -412,6 +427,14 @@ fun TomorrowSection(items: List, fragments: List, allD @Composable fun TomorrowEventRow(event: WidgetItem, zone: ZoneId) { + val context = LocalContext.current + val detailIntent = Intent(context, EventDetailActivity::class.java).apply { + putExtra(EventDetailActivity.EXTRA_TITLE, event.title) + putExtra(EventDetailActivity.EXTRA_SOURCE, event.source) + putExtra(EventDetailActivity.EXTRA_URL, event.url) + putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } val color = sourceColor(event.source) val timeLabel = event.start?.let { val t = Instant.parse(it).atZone(zone) @@ -421,7 +444,7 @@ fun TomorrowEventRow(event: WidgetItem, zone: ZoneId) { modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 3.dp) - .clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(event.url.ifEmpty { "https://calendar.google.com" })))), + .clickable(actionStartActivity(detailIntent)), verticalAlignment = Alignment.CenterVertically ) { Text( diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt b/android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt new file mode 100644 index 0000000..b8423ce --- /dev/null +++ b/android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt @@ -0,0 +1,145 @@ +package org.terst.doot.widget.ui + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.terst.doot.widget.data.Keys +import org.terst.doot.widget.data.WidgetRepository +import org.terst.doot.widget.data.dataStore + +class EventDetailActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val title = intent.getStringExtra(EXTRA_TITLE) ?: "" + val source = intent.getStringExtra(EXTRA_SOURCE) ?: "calendar" + val url = intent.getStringExtra(EXTRA_URL) ?: "" + val recurringEventId = intent.getStringExtra(EXTRA_RECURRING_EVENT_ID) + + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + EventDetailSheet( + title = title, + source = source, + recurringEventId = recurringEventId, + onOpenCalendar = { + startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url.ifEmpty { "https://calendar.google.com" })) + ) + finish() + }, + onDismiss = ::finish + ) + } + } + } + + companion object { + const val EXTRA_TITLE = "event_title" + const val EXTRA_SOURCE = "event_source" + const val EXTRA_URL = "event_url" + const val EXTRA_RECURRING_EVENT_ID = "event_recurring_id" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDetailSheet( + title: String, + source: String, + recurringEventId: String?, + onOpenCalendar: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + var recurrenceText by remember { mutableStateOf(null) } + + LaunchedEffect(recurringEventId) { + if (recurringEventId == null) return@LaunchedEffect + recurrenceText = "Loading…" + val prefs = context.dataStore.data.first() + val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@LaunchedEffect + val token = prefs[Keys.TOKEN] ?: return@LaunchedEffect + val repo = WidgetRepository(OkHttpClient(), url, token) + repo.getRecurrence(recurringEventId).onSuccess { text -> + recurrenceText = text + }.onFailure { + recurrenceText = null + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color(0xFF1E293B), + tonalElevation = 0.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .navigationBarsPadding() + ) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .background(sourceColor(source), RoundedCornerShape(5.dp)) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + modifier = Modifier.weight(1f) + ) + } + recurrenceText?.let { text -> + Spacer(Modifier.height(8.dp)) + Text( + text = text, + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.6f) + ) + } + Spacer(Modifier.height(20.dp)) + OutlinedButton( + onClick = onOpenCalendar, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + ) { + Text("Open in Calendar", fontSize = 15.sp) + } + Spacer(Modifier.height(20.dp)) + } + } +} -- cgit v1.2.3