Add tag autocomplete to the Android app
Both tag fields (post and edit) now suggest existing tags as you type,
matching the web upload/manage forms. Suggestions come from a new
GET /api/tags.json that serves the generated public/tags.json. It lives under
/api/ so nginx proxies it to the app, reaching non-browser clients even when a
front-door botwall gates the static site (the same approach as /api/thumb/).
/api/tags.json and /api/thumb/{slug} are both left unguarded: they serve the
same public data as the static site (/tags.json and /thumbs/), so there is
nothing to protect, and keeping them off the auth path means they can never
return 401 — otherwise an nginx auth-fail jail (fail2ban) would ban the app
over an autocomplete poll or a per-row thumbnail load.
A reusable TagField composable filters on the token after the last comma and
completes it on tap; fetchTags() returns an empty list on any failure, so the
field degrades to a plain tags input when the endpoint is absent.
This commit is contained in:
1 parent
f1e946228a
commit
bb8c7109fa
11 files changed
+251
-17
No files matched your search
@@ -78,6 +78,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
showManage -> ManageScreen(
|
||||
state = viewModel.manage,
|
||||
tagSuggestions = viewModel.tagSuggestions,
|
||||
onBack = { showManage = false },
|
||||
onRefresh = viewModel::loadManage,
|
||||
onSave = viewModel::saveEdit,
|
||||
@@ -87,6 +88,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
else -> PostScreen(
|
||||
state = viewModel.post,
|
||||
tagSuggestions = viewModel.tagSuggestions,
|
||||
onPickImages = {
|
||||
picker.launch(
|
||||
PickVisualMediaRequest(
|
||||
|
||||
@@ -234,6 +234,36 @@ class UploadClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the known tag names for autocomplete, alphabetical. Reads
|
||||
* /api/tags.json (not the static /tags.json) so a front-door botwall on
|
||||
* `location /` can't blank it for this non-browser client, mirroring how
|
||||
* thumbnails go through /api/thumb/. That endpoint is unguarded on the
|
||||
* server (public tag data), so it never 401s and can't get the app banned
|
||||
* by an auth-fail jail; the Basic header still rides along harmlessly.
|
||||
* Returns an empty list on any failure — autocomplete is a convenience,
|
||||
* never a hard dependency.
|
||||
*/
|
||||
fun fetchTags(): List<String> {
|
||||
val request = Request.Builder().url("$base/api/tags.json").get().build()
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return emptyList()
|
||||
val arr = JSONArray(response.body?.string().orEmpty())
|
||||
val out = ArrayList<String>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val name = arr.getJSONObject(i).optString("tag")
|
||||
if (name.isNotEmpty()) out.add(name)
|
||||
}
|
||||
out
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
emptyList()
|
||||
} catch (e: JSONException) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches a thumbnail by its site-relative [path], or null on any failure. */
|
||||
fun fetchThumbnail(path: String): Bitmap? {
|
||||
val request = Request.Builder().url("$base$path").get().build()
|
||||
|
||||
@@ -49,6 +49,7 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM
|
||||
@Composable
|
||||
fun ManageScreen(
|
||||
state: ManageUiState,
|
||||
tagSuggestions: List<String>,
|
||||
onBack: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onSave: (slug: String, caption: String, date: LocalDate, tags: String) -> Unit,
|
||||
@@ -120,6 +121,7 @@ fun ManageScreen(
|
||||
editing?.let { post ->
|
||||
EditDialog(
|
||||
post = post,
|
||||
tagSuggestions = tagSuggestions,
|
||||
onDismiss = { editing = null },
|
||||
onSave = { caption, date, tags ->
|
||||
onSave(post.slug, caption, date, tags)
|
||||
@@ -209,6 +211,7 @@ private fun PostRow(post: ManagePost, onEdit: () -> Unit, onDelete: () -> Unit)
|
||||
@Composable
|
||||
private fun EditDialog(
|
||||
post: ManagePost,
|
||||
tagSuggestions: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (caption: String, date: LocalDate, tags: String) -> Unit,
|
||||
) {
|
||||
@@ -243,12 +246,11 @@ private fun EditDialog(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
TagField(
|
||||
value = tags,
|
||||
onValueChange = { tags = it },
|
||||
label = { Text("Tags") },
|
||||
placeholder = { Text("comma-separated") },
|
||||
singleLine = true,
|
||||
suggestions = tagSuggestions,
|
||||
enabled = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -49,6 +49,7 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM
|
||||
@Composable
|
||||
fun PostScreen(
|
||||
state: PostUiState,
|
||||
tagSuggestions: List<String>,
|
||||
onPickImages: () -> Unit,
|
||||
onCaptionChange: (id: String, value: String) -> Unit,
|
||||
onTagsChange: (id: String, value: String) -> Unit,
|
||||
@@ -111,6 +112,7 @@ fun PostScreen(
|
||||
items(state.items, key = { it.id }) { item ->
|
||||
ItemCard(
|
||||
item = item,
|
||||
tagSuggestions = tagSuggestions,
|
||||
onCaptionChange = { onCaptionChange(item.id, it) },
|
||||
onTagsChange = { onTagsChange(item.id, it) },
|
||||
onEditDate = { dateEditFor = item },
|
||||
@@ -190,6 +192,7 @@ fun PostScreen(
|
||||
@Composable
|
||||
private fun ItemCard(
|
||||
item: PickedItem,
|
||||
tagSuggestions: List<String>,
|
||||
onCaptionChange: (String) -> Unit,
|
||||
onTagsChange: (String) -> Unit,
|
||||
onEditDate: () -> Unit,
|
||||
@@ -227,12 +230,10 @@ private fun ItemCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
TagField(
|
||||
value = item.tags,
|
||||
onValueChange = onTagsChange,
|
||||
label = { Text("Tags") },
|
||||
placeholder = { Text("comma-separated") },
|
||||
singleLine = true,
|
||||
suggestions = tagSuggestions,
|
||||
enabled = item.status !is ItemStatus.Uploading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package ws.inflo.photogallery.ui
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.SuggestionChipDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private const val MAX_SUGGESTIONS = 8
|
||||
|
||||
/**
|
||||
* A comma-separated tags input with inline autocomplete. Suggestions filter on
|
||||
* the token currently being typed (the text after the last comma) and exclude
|
||||
* tags already entered; tapping one completes that token and starts the next.
|
||||
* The raw string is passed straight through — the server normalizes it.
|
||||
*/
|
||||
@Composable
|
||||
fun TagField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
suggestions: List<String>,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
|
||||
val already = remember(value) {
|
||||
value.split(',').map { it.trim().lowercase() }.filter { it.isNotEmpty() }.toSet()
|
||||
}
|
||||
val token = value.substringAfterLast(',').trim()
|
||||
val matches = remember(token, suggestions, already) {
|
||||
if (token.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
suggestions.asSequence()
|
||||
.filter { it.lowercase().contains(token.lowercase()) }
|
||||
.filter { it.lowercase() !in already }
|
||||
.distinct()
|
||||
.take(MAX_SUGGESTIONS)
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text("Tags") },
|
||||
placeholder = { Text("comma-separated") },
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focused = it.isFocused },
|
||||
)
|
||||
if (enabled && focused && matches.isNotEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
for (tag in matches) {
|
||||
SuggestionChip(
|
||||
onClick = { onValueChange(completeToken(value, tag)) },
|
||||
label = { Text(tag) },
|
||||
colors = SuggestionChipDefaults.suggestionChipColors(
|
||||
labelColor = GruvAqua,
|
||||
containerColor = GruvBg1,
|
||||
),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the in-progress token (after the last comma) with [chosen] + ", ". */
|
||||
private fun completeToken(current: String, chosen: String): String {
|
||||
val lastComma = current.lastIndexOf(',')
|
||||
val head = if (lastComma >= 0) current.substring(0, lastComma + 1) + " " else ""
|
||||
return head + chosen + ", "
|
||||
}
|
||||
@@ -109,11 +109,30 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
val isConfigured: Boolean get() = settings.isConfigured
|
||||
|
||||
// Known tag names for autocomplete, shared by the Post and Manage screens.
|
||||
// Best-effort: fetched from /api/tags.json, empty until it arrives.
|
||||
var tagSuggestions by mutableStateOf<List<String>>(emptyList())
|
||||
private set
|
||||
|
||||
init {
|
||||
refreshTags()
|
||||
}
|
||||
|
||||
/** Refreshes the autocomplete tag list; silent on any failure. */
|
||||
fun refreshTags() {
|
||||
if (!settings.isConfigured) return
|
||||
viewModelScope.launch {
|
||||
val tags = withContext(Dispatchers.IO) { client().fetchTags() }
|
||||
if (tags.isNotEmpty()) tagSuggestions = tags
|
||||
}
|
||||
}
|
||||
|
||||
// ── Post screen ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Replaces the current selection with [uris] and loads each preview. */
|
||||
fun selectImages(uris: List<Uri>) {
|
||||
if (uris.isEmpty()) return
|
||||
refreshTags()
|
||||
val items = uris.map { PickedItem(source = it) }
|
||||
post = post.copy(items = items)
|
||||
|
||||
@@ -264,6 +283,7 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
|
||||
return
|
||||
}
|
||||
manage = manage.copy(loading = true, error = null, message = null)
|
||||
refreshTags()
|
||||
viewModelScope.launch {
|
||||
val result = withContext(Dispatchers.IO) { client().listPhotos() }
|
||||
when (result) {
|
||||
@@ -283,9 +303,11 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private fun loadThumbnails(photos: List<RemotePhoto>) {
|
||||
for (p in photos) {
|
||||
viewModelScope.launch {
|
||||
// Fetch via the authed /api/thumb/<slug> endpoint, not the static
|
||||
// Fetch via the /api/thumb/<slug> endpoint, not the static
|
||||
// /thumbs/ path: a front-door botwall can block this non-browser
|
||||
// client from the static site while /api/ is proxied to the app.
|
||||
// The endpoint is unguarded (public image data), so it never
|
||||
// 401s and can't get the app banned by an auth-fail jail.
|
||||
val bmp = withContext(Dispatchers.IO) { client().fetchThumbnail("/api/thumb/" + p.slug) }
|
||||
if (bmp != null) updateManage(p.slug) { it.copy(preview = bmp) }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user