feat: add favicons, drag-to-reorder sites, and SVG support

- Load site favicons (svg → png → ico fallback) via Coil 3 with
  SubcomposeAsyncImage; globe icon as final fallback
- Register SvgDecoder in ImplausiblyApp for SVG favicon support
- Add drag-to-reorder via sh.calvin.reorderable library with a
  drag handle per site row; order persisted to sort_order column
- Add sort_order column to stored_sites with schema migration (1.sqm)
- New SiteRepository methods: reorderSites(), deleteSitesForInstance(),
  getAllSites() now includes sort_order
- Dependencies: coil-compose, coil-network-okhttp, coil-svg,
  reorderable (all Apache 2.0)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-20 14:52:06 +01:00
commit 26467d9047
9 changed files with 248 additions and 36 deletions

View file

@ -91,6 +91,14 @@ dependencies {
implementation(libs.sqldelight.android.driver) implementation(libs.sqldelight.android.driver)
implementation(libs.sqldelight.coroutines) implementation(libs.sqldelight.coroutines)
// Coil (favicon loading)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.coil.svg)
// Reorderable (drag-to-reorder sites)
implementation(libs.reorderable)
// Serialization // Serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)

View file

@ -2,7 +2,19 @@
package no.naiv.implausibly package no.naiv.implausibly
import android.app.Application import android.app.Application
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.svg.SvgDecoder
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp @HiltAndroidApp
class ImplausiblyApp : Application() class ImplausiblyApp : Application(), SingletonImageLoader.Factory {
override fun newImageLoader(context: PlatformContext): ImageLoader {
return ImageLoader.Builder(context)
.components {
add(SvgDecoder.Factory())
}
.build()
}
}

View file

@ -17,21 +17,28 @@ class SiteRepository @Inject constructor(
fun getAllSites(): List<Site> { fun getAllSites(): List<Site> {
return database.storedSiteQueries.selectAll() return database.storedSiteQueries.selectAll()
.executeAsList() .executeAsList()
.map { Site(id = it.site_id, instanceId = it.instance_id) } .map { Site(id = it.site_id, instanceId = it.instance_id, sortOrder = it.sort_order.toInt()) }
} }
fun getSitesForInstance(instanceId: String): List<Site> { fun getSitesForInstance(instanceId: String): List<Site> {
return database.storedSiteQueries.selectByInstance(instanceId) return database.storedSiteQueries.selectByInstance(instanceId)
.executeAsList() .executeAsList()
.map { Site(id = it.site_id, instanceId = it.instance_id) } .map { Site(id = it.site_id, instanceId = it.instance_id, sortOrder = it.sort_order.toInt()) }
} }
fun addSite(siteId: String, instanceId: String): Site { fun addSite(siteId: String, instanceId: String): Site {
val maxOrder = database.storedSiteQueries
.maxSortOrderForInstance(instanceId)
.executeAsOne()
.max_order ?: 0L
val nextOrder = maxOrder + 1
database.storedSiteQueries.insert( database.storedSiteQueries.insert(
site_id = siteId, site_id = siteId,
instance_id = instanceId instance_id = instanceId,
sort_order = nextOrder
) )
return Site(id = siteId, instanceId = instanceId) return Site(id = siteId, instanceId = instanceId, sortOrder = nextOrder.toInt())
} }
fun removeSite(siteId: String, instanceId: String) { fun removeSite(siteId: String, instanceId: String) {
@ -46,7 +53,29 @@ class SiteRepository @Inject constructor(
} }
fun updateSite(oldSiteId: String, newSiteId: String, instanceId: String) { fun updateSite(oldSiteId: String, newSiteId: String, instanceId: String) {
val sites = getSitesForInstance(instanceId)
val oldSortOrder = sites.find { it.id == oldSiteId }?.sortOrder?.toLong() ?: 0L
database.storedSiteQueries.delete(site_id = oldSiteId, instance_id = instanceId) database.storedSiteQueries.delete(site_id = oldSiteId, instance_id = instanceId)
database.storedSiteQueries.insert(site_id = newSiteId, instance_id = instanceId) database.storedSiteQueries.insert(
site_id = newSiteId,
instance_id = instanceId,
sort_order = oldSortOrder
)
}
/**
* Persist a new ordering for all sites within an instance.
* Called after drag-and-drop reorder.
*/
fun reorderSites(instanceId: String, orderedSiteIds: List<String>) {
database.storedSiteQueries.transaction {
orderedSiteIds.forEachIndexed { index, siteId ->
database.storedSiteQueries.updateSortOrder(
sort_order = index.toLong(),
site_id = siteId,
instance_id = instanceId
)
}
}
} }
} }

View file

@ -7,5 +7,6 @@ package no.naiv.implausibly.domain.model
*/ */
data class Site( data class Site(
val id: String, val id: String,
val instanceId: String val instanceId: String,
val sortOrder: Int = 0
) )

View file

@ -13,12 +13,15 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
@ -38,13 +41,22 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.SubcomposeAsyncImage
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import no.naiv.implausibly.ui.common.LoadingIndicator import no.naiv.implausibly.ui.common.LoadingIndicator
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -124,7 +136,30 @@ fun SitePickerScreen(
return@Scaffold return@Scaffold
} }
val lazyListState = rememberLazyListState()
// Build a lookup from site key to (instanceId, local index within that group)
val siteKeyMap = remember(uiState.groups) {
buildMap {
uiState.groups.forEach { group ->
group.sites.forEachIndexed { index, site ->
put("site_${group.instance.id}_${site.id}", group.instance.id to index)
}
}
}
}
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
val fromInfo = siteKeyMap[from.key]
val toInfo = siteKeyMap[to.key]
// Only reorder within the same instance group
if (fromInfo != null && toInfo != null && fromInfo.first == toInfo.first) {
viewModel.moveSite(fromInfo.first, fromInfo.second, toInfo.second)
}
}
LazyColumn( LazyColumn(
state = lazyListState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
@ -144,17 +179,22 @@ fun SitePickerScreen(
if (group.isExpanded) { if (group.isExpanded) {
items( items(
items = group.sites, items = group.sites,
key = { "${group.instance.id}_${it.id}" } key = { "site_${group.instance.id}_${it.id}" }
) { site -> ) { site ->
val isSelected = val isSelected =
uiState.currentInstanceId == group.instance.id && uiState.currentInstanceId == group.instance.id &&
uiState.currentSiteId == site.id uiState.currentSiteId == site.id
val isEditing = uiState.editingSiteId == site.id val isEditing = uiState.editingSiteId == site.id
ReorderableItem(
reorderableLazyListState,
key = "site_${group.instance.id}_${site.id}"
) { isDragging ->
SiteRow( SiteRow(
siteId = site.id, siteId = site.id,
isSelected = isSelected, isSelected = isSelected,
isEditing = isEditing, isEditing = isEditing,
isDragging = isDragging,
editValue = uiState.editSiteValue, editValue = uiState.editSiteValue,
onSelect = { onSelect = {
viewModel.selectSite(group.instance.id, site.id) viewModel.selectSite(group.instance.id, site.id)
@ -166,8 +206,14 @@ fun SitePickerScreen(
onCancelEdit = viewModel::cancelEdit, onCancelEdit = viewModel::cancelEdit,
onDelete = { onDelete = {
viewModel.requestDeleteSite(group.instance.id, site.id) viewModel.requestDeleteSite(group.instance.id, site.id)
},
dragModifier = Modifier.draggableHandle(
onDragStopped = {
viewModel.saveSiteOrder(group.instance.id)
} }
) )
)
}
} }
// Inline add-site input with validation // Inline add-site input with validation
@ -260,13 +306,15 @@ private fun SiteRow(
siteId: String, siteId: String,
isSelected: Boolean, isSelected: Boolean,
isEditing: Boolean, isEditing: Boolean,
isDragging: Boolean,
editValue: String, editValue: String,
onSelect: () -> Unit, onSelect: () -> Unit,
onStartEdit: () -> Unit, onStartEdit: () -> Unit,
onEditValueChanged: (String) -> Unit, onEditValueChanged: (String) -> Unit,
onConfirmEdit: () -> Unit, onConfirmEdit: () -> Unit,
onCancelEdit: () -> Unit, onCancelEdit: () -> Unit,
onDelete: () -> Unit onDelete: () -> Unit,
dragModifier: Modifier = Modifier
) { ) {
Card( Card(
modifier = Modifier modifier = Modifier
@ -279,7 +327,10 @@ private fun SiteRow(
) )
} else { } else {
CardDefaults.cardColors() CardDefaults.cardColors()
} },
elevation = CardDefaults.cardElevation(
defaultElevation = if (isDragging) 8.dp else 0.dp
)
) { ) {
if (isEditing) { if (isEditing) {
Row( Row(
@ -309,14 +360,13 @@ private fun SiteRow(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( SiteFavicon(
Icons.Default.Language, siteId = siteId,
contentDescription = null, isSelected = isSelected,
tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer modifier = Modifier.size(24.dp)
else MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text( Text(
@ -336,6 +386,12 @@ private fun SiteRow(
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error
) )
} }
Icon(
Icons.Default.DragHandle,
contentDescription = "Reorder",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = dragModifier
)
} }
} }
} }
@ -393,3 +449,53 @@ private fun AddSiteRow(
} }
} }
} }
/**
* Tries to load a favicon in order: svg png ico.
* Falls back to a globe icon if all formats fail.
*/
@Composable
private fun SiteFavicon(
siteId: String,
isSelected: Boolean,
modifier: Modifier = Modifier
) {
val tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.primary
val urls = remember(siteId) {
listOf(
"https://$siteId/favicon.svg",
"https://$siteId/favicon.png",
"https://$siteId/favicon.ico"
)
}
var urlIndex by remember(siteId) { mutableIntStateOf(0) }
val globeIcon: @Composable () -> Unit = {
Icon(
Icons.Default.Language,
contentDescription = null,
tint = tint,
modifier = modifier
)
}
if (urlIndex < urls.size) {
SubcomposeAsyncImage(
model = urls[urlIndex],
contentDescription = null,
modifier = modifier.clip(CircleShape),
contentScale = ContentScale.Fit,
loading = { globeIcon() },
error = {
if (urlIndex < urls.size - 1) {
LaunchedEffect(urlIndex) { urlIndex++ }
}
globeIcon()
}
)
} else {
globeIcon()
}
}

View file

@ -268,6 +268,38 @@ class SitePickerViewModel @Inject constructor(
_uiState.update { it.copy(editingSiteId = null, editSiteValue = "") } _uiState.update { it.copy(editingSiteId = null, editSiteValue = "") }
} }
// -- Site reordering --
/**
* Move a site within an instance's list. Updates UI state immediately
* for responsive drag feedback persisted on drag end via saveSiteOrder.
*/
fun moveSite(instanceId: String, fromIndex: Int, toIndex: Int) {
_uiState.update { state ->
state.copy(
groups = state.groups.map { group ->
if (group.instance.id == instanceId) {
val reordered = group.sites.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
group.copy(sites = reordered)
} else group
}
)
}
}
/**
* Persist the current site order to the database after drag ends.
*/
fun saveSiteOrder(instanceId: String) {
val group = _uiState.value.groups.find { it.instance.id == instanceId } ?: return
val orderedIds = group.sites.map { it.id }
viewModelScope.launch {
siteRepository.reorderSites(instanceId, orderedIds)
}
}
/** /**
* Persist the user's selection so the app opens directly to Dashboard next time. * Persist the user's selection so the app opens directly to Dashboard next time.
*/ */

View file

@ -0,0 +1,3 @@
-- Add sort_order column to stored_sites for user-defined site ordering.
-- Default 0 means all existing sites sort alphabetically (fallback to site_id).
ALTER TABLE stored_sites ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;

View file

@ -3,14 +3,15 @@
CREATE TABLE stored_sites ( CREATE TABLE stored_sites (
site_id TEXT NOT NULL, site_id TEXT NOT NULL,
instance_id TEXT NOT NULL, instance_id TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (site_id, instance_id) PRIMARY KEY (site_id, instance_id)
); );
selectByInstance: selectByInstance:
SELECT * FROM stored_sites WHERE instance_id = ? ORDER BY site_id ASC; SELECT * FROM stored_sites WHERE instance_id = ? ORDER BY sort_order ASC, site_id ASC;
insert: insert:
INSERT OR IGNORE INTO stored_sites VALUES (?, ?); INSERT OR IGNORE INTO stored_sites(site_id, instance_id, sort_order) VALUES (?, ?, ?);
delete: delete:
DELETE FROM stored_sites WHERE site_id = ? AND instance_id = ?; DELETE FROM stored_sites WHERE site_id = ? AND instance_id = ?;
@ -19,4 +20,10 @@ deleteByInstance:
DELETE FROM stored_sites WHERE instance_id = ?; DELETE FROM stored_sites WHERE instance_id = ?;
selectAll: selectAll:
SELECT * FROM stored_sites ORDER BY instance_id ASC, site_id ASC; SELECT * FROM stored_sites ORDER BY instance_id ASC, sort_order ASC, site_id ASC;
updateSortOrder:
UPDATE stored_sites SET sort_order = ? WHERE site_id = ? AND instance_id = ?;
maxSortOrderForInstance:
SELECT MAX(sort_order) AS max_order FROM stored_sites WHERE instance_id = ?;

View file

@ -25,6 +25,12 @@ ktor = "3.0.3"
# SQLDelight # SQLDelight
sqldelight = "2.0.2" sqldelight = "2.0.2"
# Coil
coil = "3.0.4"
# Reorderable
reorderable = "2.4.3"
# Testing # Testing
junit = "4.13.2" junit = "4.13.2"
mockk = "1.13.13" mockk = "1.13.13"
@ -70,6 +76,14 @@ ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref =
sqldelight-android-driver = { group = "app.cash.sqldelight", name = "android-driver", version.ref = "sqldelight" } sqldelight-android-driver = { group = "app.cash.sqldelight", name = "android-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { group = "app.cash.sqldelight", name = "coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { group = "app.cash.sqldelight", name = "coroutines-extensions", version.ref = "sqldelight" }
# Coil
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" }
# Reorderable
reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" }
# Serialization # Serialization
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }