Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions app/src/main/java/com/pulseloop/data/dao/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,44 @@ interface CoachConversationDao {

@Upsert
suspend fun upsert(conversation: CoachConversationEntity)

@Query("SELECT * FROM coach_conversations WHERE id = :id LIMIT 1")
suspend fun byId(id: String): CoachConversationEntity?

/**
* The coach's past conversations for the history sheet (issue #131), newest first. Only ones
* with at least one message: "+" persists its thread up front, so an unused one is an empty
* row that would otherwise list as a blank entry.
*/
@Query(
"""SELECT c.id, c.createdAt, c.updatedAt,
(SELECT m.body FROM coach_messages m WHERE m.conversationId = c.id AND m.role = 'user'
ORDER BY m.createdAt ASC LIMIT 1) AS firstQuestion,
(SELECT COUNT(*) FROM coach_messages m WHERE m.conversationId = c.id) AS messageCount
FROM coach_conversations c
WHERE EXISTS (SELECT 1 FROM coach_messages m WHERE m.conversationId = c.id)
ORDER BY c.updatedAt DESC"""
)
fun historyFlow(): Flow<List<CoachConversationSummary>>

/** Delete its messages and tool calls first — see [CoachMessageDao], [CoachToolCallDao]. */
@Query("DELETE FROM coach_conversations WHERE id = :id")
suspend fun deleteById(id: String)

@Query("DELETE FROM coach_conversations")
suspend fun deleteAll()
}

/** One row of the coach history sheet (issue #131). */
data class CoachConversationSummary(
val id: String,
val createdAt: Long,
val updatedAt: Long,
/** The first thing the user asked; null only if the thread holds no user message. */
val firstQuestion: String?,
val messageCount: Int,
)

@Dao
interface CoachMessageDao {
@Query("SELECT * FROM coach_messages WHERE conversationId = :convId ORDER BY createdAt ASC")
Expand All @@ -449,6 +485,20 @@ interface CoachMessageDao {

@Insert
suspend fun insert(message: CoachMessageEntity)

/** Attachment refs of a conversation's messages, so deleting it can delete the image files. */
@Query("SELECT attachmentsJson FROM coach_messages WHERE conversationId = :convId AND attachmentsJson IS NOT NULL")
suspend fun attachmentsJsonFor(convId: String): List<String>

@Query("SELECT attachmentsJson FROM coach_messages WHERE attachmentsJson IS NOT NULL")
suspend fun allAttachmentsJson(): List<String>

/** Explicit rather than trusting the cascade, which only fires with SQLite's foreign keys on. */
@Query("DELETE FROM coach_messages WHERE conversationId = :convId")
suspend fun deleteForConversation(convId: String)

@Query("DELETE FROM coach_messages")
suspend fun deleteAll()
}

@Dao
Expand Down Expand Up @@ -479,6 +529,13 @@ interface CoachToolCallDao {

@Insert
suspend fun insert(call: CoachToolCallEntity)

/** `coach_tool_calls` has no foreign key to its conversation, so a delete has to clear it. */
@Query("DELETE FROM coach_tool_calls WHERE conversationId = :convId")
suspend fun deleteForConversation(convId: String)

@Query("DELETE FROM coach_tool_calls")
suspend fun deleteAll()
}

@Dao
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ fun PulseLoopApp() {
com.pulseloop.coach.attachments.CoachAttachmentStore.payloads(context, refs)
},
environmentSnapshot = { weatherContextService.snapshot() },
deleteAttachments = { refs ->
refs.forEach { com.pulseloop.coach.attachments.CoachAttachmentStore.delete(context, it) }
},
)
}

Expand Down Expand Up @@ -391,10 +394,24 @@ fun PulseLoopApp() {
// frosted glass bar the other tabs use so the chat scrolls under it
// (owner choice, issue #22).
var showCoachUsage by remember { mutableStateOf(false) }
var showCoachHistory by remember { mutableStateOf(false) }
CoachHeader(
onNewChat = { coachVM.newConversation() },
onOpenUsage = { showCoachUsage = true },
onOpenHistory = { showCoachHistory = true },
)
if (showCoachHistory) {
val history by coachVM.history.collectAsState(initial = emptyList())
val coachState by coachVM.state.collectAsState()
com.pulseloop.ui.screens.CoachHistorySheet(
conversations = history,
currentId = coachState.conversationId,
onOpen = { id -> coachVM.openConversation(id); showCoachHistory = false },
onDelete = { id -> coachVM.deleteConversation(id) },
onDeleteAll = { coachVM.deleteAllConversations(); showCoachHistory = false },
onDismiss = { showCoachHistory = false },
)
}
if (showCoachUsage) {
var usageData by remember {
mutableStateOf<Pair<com.pulseloop.data.entity.CoachConversationEntity, List<com.pulseloop.data.entity.CoachMessageEntity>>?>(null)
Expand Down
202 changes: 202 additions & 0 deletions app/src/main/java/com/pulseloop/ui/screens/CoachHistorySheet.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package com.pulseloop.ui.screens

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
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.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.pulseloop.data.dao.CoachConversationSummary
import com.pulseloop.ui.theme.PulseColors
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale

/**
* The coach's past conversations (issue #131): tap one to reopen it, swipe it away to delete it,
* or delete them all. Every delete asks first — it takes the messages, the tool-call trace and any
* attached photos with it, and there is no undo.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CoachHistorySheet(
conversations: List<CoachConversationSummary>,
currentId: String?,
onOpen: (String) -> Unit,
onDelete: (String) -> Unit,
onDeleteAll: () -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var pendingDelete by remember { mutableStateOf<CoachConversationSummary?>(null) }
var confirmDeleteAll by remember { mutableStateOf(false) }

ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = PulseColors.background) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Past conversations", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = PulseColors.textPrimary)
if (conversations.isEmpty()) {
Text(
"No conversations yet. Ask the coach something and it will show up here.",
fontSize = 13.sp, color = PulseColors.textMuted,
)
} else {
Text("Swipe a conversation to delete it.", fontSize = 12.sp, color = PulseColors.textMuted)
LazyColumn(
Modifier.weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(conversations, key = { it.id }) { convo ->
HistoryRow(
convo = convo,
isCurrent = convo.id == currentId,
onOpen = { onOpen(convo.id) },
onSwipedAway = { pendingDelete = convo },
)
}
}
TextButton(
onClick = { confirmDeleteAll = true },
modifier = Modifier.align(Alignment.CenterHorizontally),
) { Text("Delete all conversations", color = PulseColors.danger) }
}
}
}

pendingDelete?.let { convo ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Delete this conversation?") },
text = { Text("“${CoachHistoryText.title(convo.firstQuestion)}” and its replies will be deleted. This can't be undone.") },
confirmButton = {
TextButton(onClick = { onDelete(convo.id); pendingDelete = null }) {
Text("Delete", color = PulseColors.danger)
}
},
dismissButton = { TextButton(onClick = { pendingDelete = null }) { Text("Cancel") } },
)
}
if (confirmDeleteAll) {
AlertDialog(
onDismissRequest = { confirmDeleteAll = false },
title = { Text("Delete all conversations?") },
text = { Text("All ${conversations.size} conversations and their replies will be deleted. This can't be undone.") },
confirmButton = {
TextButton(onClick = { confirmDeleteAll = false; onDeleteAll() }) {
Text("Delete all", color = PulseColors.danger)
}
},
dismissButton = { TextButton(onClick = { confirmDeleteAll = false }) { Text("Cancel") } },
)
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HistoryRow(
convo: CoachConversationSummary,
isCurrent: Boolean,
onOpen: () -> Unit,
onSwipedAway: () -> Unit,
) {
// The swipe only asks; the row snaps back and the dialog decides. Returning false keeps the
// state from settling on "dismissed", which would leave a cancelled row swiped off screen.
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value != SwipeToDismissBoxValue.Settled) onSwipedAway()
false
},
)
val shape = RoundedCornerShape(12.dp)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
Box(
Modifier
.fillMaxSize()
.clip(shape)
.background(PulseColors.danger.copy(alpha = 0.25f))
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterEnd,
) {
Icon(Icons.Filled.Delete, "Delete", tint = PulseColors.danger)
}
},
) {
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.background(PulseColors.card)
.border(1.dp, if (isCurrent) PulseColors.accent else PulseColors.borderSubtle, shape)
.clickable(onClick = onOpen)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
CoachHistoryText.title(convo.firstQuestion),
fontSize = 14.sp, color = PulseColors.textPrimary,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
CoachHistoryText.subtitle(convo, isCurrent),
fontSize = 12.sp, color = PulseColors.textMuted,
)
}
}
}

/** The history row's text, kept out of the composable so it can be unit-tested. */
internal object CoachHistoryText {

fun title(firstQuestion: String?): String =
firstQuestion?.replace(Regex("\\s+"), " ")?.trim()?.takeIf { it.isNotEmpty() }
?: "Untitled conversation"

fun subtitle(
convo: CoachConversationSummary,
isCurrent: Boolean,
zone: ZoneId = ZoneId.systemDefault(),
today: LocalDate = LocalDate.now(zone),
): String {
val parts = mutableListOf(whenText(convo.updatedAt, zone, today))
parts += if (convo.messageCount == 1) "1 message" else "${convo.messageCount} messages"
if (isCurrent) parts += "open now"
return parts.joinToString(" · ")
}

fun whenText(epochMs: Long, zone: ZoneId, today: LocalDate): String {
val at = Instant.ofEpochMilli(epochMs).atZone(zone)
val day = at.toLocalDate()
return when {
day == today -> "Today " + at.format(TIME)
day == today.minusDays(1) -> "Yesterday " + at.format(TIME)
day.year == today.year -> at.format(SAME_YEAR)
else -> at.format(OTHER_YEAR)
}
}

private val TIME = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private val SAME_YEAR = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault())
private val OTHER_YEAR = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
}
20 changes: 19 additions & 1 deletion app/src/main/java/com/pulseloop/ui/screens/CoachScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
Expand Down Expand Up @@ -131,7 +132,11 @@ fun CoachScreen(
* chat then scrolls under it.
*/
@Composable
fun CoachHeader(onNewChat: () -> Unit, onOpenUsage: (() -> Unit)? = null) {
fun CoachHeader(
onNewChat: () -> Unit,
onOpenUsage: (() -> Unit)? = null,
onOpenHistory: (() -> Unit)? = null,
) {
Row(
Modifier
.fillMaxWidth()
Expand Down Expand Up @@ -171,6 +176,19 @@ fun CoachHeader(onNewChat: () -> Unit, onOpenUsage: (() -> Unit)? = null) {
Icon(Icons.Filled.Info, "Usage & cost", tint = PulseColors.textSecondary, modifier = Modifier.size(16.dp))
}
}
if (onOpenHistory != null) {
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(PulseColors.card)
.border(1.dp, PulseColors.borderSubtle, CircleShape)
.clickable(onClick = onOpenHistory),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.History, "Past conversations", tint = PulseColors.textSecondary, modifier = Modifier.size(18.dp))
}
}
Box(
Modifier
.size(36.dp)
Expand Down
Loading
Loading