git.iko.soy/opencode-android
on 06fd41b help sign in
133 lines 5.4 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.padding
7import androidx.compose.foundation.layout.size
8import androidx.compose.foundation.layout.widthIn
9import androidx.compose.foundation.horizontalScroll
10import androidx.compose.foundation.rememberScrollState
11import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.filled.Close
13import androidx.compose.material.icons.filled.Description
14import androidx.compose.material3.CircularProgressIndicator
15import androidx.compose.material3.Icon
16import androidx.compose.material3.IconButton
17import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.Surface
19import androidx.compose.material3.Text
20import androidx.compose.runtime.Composable
21import androidx.compose.ui.Alignment
22import androidx.compose.ui.Modifier
23import androidx.compose.ui.draw.clip
24import androidx.compose.ui.layout.ContentScale
25import androidx.compose.ui.res.stringResource
26import androidx.compose.ui.semantics.contentDescription
27import androidx.compose.ui.semantics.semantics
28import androidx.compose.ui.text.style.TextOverflow
29import androidx.compose.ui.unit.dp
30import coil.compose.AsyncImage
31import soy.iko.opencode.R
33/**
34 * The staged-attachment UI for the chat composer — extracted from ChatScreen so that (large)
35 * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
36 */
37@Composable
38internal fun AttachmentStrip(
39 attachments: List<PendingAttachment>,
40 onRemove: (String) -> Unit,
41 staging: Boolean = false,
42) {
43 // Show the strip while an encode is in flight even before the first chip exists, so the
44 // user gets immediate feedback that their pick is being processed.
45 if (attachments.isEmpty() && !staging) return
46 // Horizontally-scrollable strip of staged attachment thumbnails/chips, each removable.
47 Row(
48 modifier = Modifier
49 .fillMaxWidth()
50 .horizontalScroll(rememberScrollState())
51 .padding(start = 8.dp, end = 8.dp, top = 8.dp),
52 horizontalArrangement = Arrangement.spacedBy(8.dp),
53 ) {
54 attachments.forEach { att ->
55 AttachmentChip(att, onRemove = { onRemove(att.id) })
56 }
57 // A placeholder chip with an indeterminate spinner while any pick is still being
58 // read + base64-encoded off the main thread (chips only materialize once done).
59 if (staging) StagingChip()
60 }
63/** A placeholder chip shown while an attachment is being encoded. */
64@Composable
65private fun StagingChip() {
66 val stagingLabel = stringResource(R.string.attachment_staging)
67 Surface(
68 shape = MaterialTheme.shapes.small,
69 color = MaterialTheme.colorScheme.surfaceVariant,
70 tonalElevation = 1.dp,
71 ) {
72 Row(
73 verticalAlignment = Alignment.CenterVertically,
74 modifier = Modifier.padding(horizontal = 10.dp),
75 ) {
76 CircularProgressIndicator(
77 Modifier.size(18.dp).semantics { contentDescription = stagingLabel },
78 strokeWidth = 2.dp,
79 )
80 Text(
81 stagingLabel,
82 style = MaterialTheme.typography.labelMedium,
83 maxLines = 1,
84 modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 10.dp),
85 )
86 }
87 }
90/** A single staged attachment: an image thumbnail (or a generic file icon) with its name
91 * and a remove button. */
92@Composable
93private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
94 Surface(
95 shape = MaterialTheme.shapes.small,
96 color = MaterialTheme.colorScheme.surfaceVariant,
97 tonalElevation = 1.dp,
98 ) {
99 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 6.dp)) {
100 if (attachment.previewModel != null) {
101 AsyncImage(
102 model = attachment.previewModel,
103 contentDescription = attachment.name,
104 contentScale = ContentScale.Crop,
105 modifier = Modifier.size(36.dp).clip(MaterialTheme.shapes.extraSmall),
106 )
107 } else {
108 Icon(
109 Icons.Filled.Description,
110 contentDescription = null,
111 modifier = Modifier.size(24.dp),
112 tint = MaterialTheme.colorScheme.onSurfaceVariant,
113 )
114 }
115 Text(
116 attachment.name,
117 style = MaterialTheme.typography.labelMedium,
118 maxLines = 1,
119 overflow = TextOverflow.Ellipsis,
120 modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
121 )
122 // No .size() override so the IconButton keeps its default 48dp touch target
123 // (a11y minimum) while the visual X stays small (16dp).
124 IconButton(onClick = onRemove) {
125 Icon(
126 Icons.Filled.Close,
127 contentDescription = stringResource(R.string.remove),
128 modifier = Modifier.size(16.dp),
129 )
130 }
131 }
132 }