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.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
implementation(libs.kotlinx.serialization.json)

View file

@ -2,7 +2,19 @@
package no.naiv.implausibly
import android.app.Application
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.svg.SvgDecoder
import dagger.hilt.android.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> {
return database.storedSiteQueries.selectAll()
.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> {
return database.storedSiteQueries.selectByInstance(instanceId)
.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 {
val maxOrder = database.storedSiteQueries
.maxSortOrderForInstance(instanceId)
.executeAsOne()
.max_order ?: 0L
val nextOrder = maxOrder + 1
database.storedSiteQueries.insert(
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) {
@ -46,7 +53,29 @@ class SiteRepository @Inject constructor(
}
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.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(
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.lazy.LazyColumn
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
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.ExpandLess
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.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.SubcomposeAsyncImage
import androidx.hilt.navigation.compose.hiltViewModel
import no.naiv.implausibly.ui.common.LoadingIndicator
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -124,7 +136,30 @@ fun SitePickerScreen(
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(
state = lazyListState,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
@ -144,30 +179,41 @@ fun SitePickerScreen(
if (group.isExpanded) {
items(
items = group.sites,
key = { "${group.instance.id}_${it.id}" }
key = { "site_${group.instance.id}_${it.id}" }
) { site ->
val isSelected =
uiState.currentInstanceId == group.instance.id &&
uiState.currentSiteId == site.id
val isEditing = uiState.editingSiteId == site.id
SiteRow(
siteId = site.id,
isSelected = isSelected,
isEditing = isEditing,
editValue = uiState.editSiteValue,
onSelect = {
viewModel.selectSite(group.instance.id, site.id)
onSiteSelected(group.instance.id, site.id)
},
onStartEdit = { viewModel.startEditing(site.id) },
onEditValueChanged = viewModel::onEditSiteValueChanged,
onConfirmEdit = { viewModel.confirmEdit(group.instance.id) },
onCancelEdit = viewModel::cancelEdit,
onDelete = {
viewModel.requestDeleteSite(group.instance.id, site.id)
}
)
ReorderableItem(
reorderableLazyListState,
key = "site_${group.instance.id}_${site.id}"
) { isDragging ->
SiteRow(
siteId = site.id,
isSelected = isSelected,
isEditing = isEditing,
isDragging = isDragging,
editValue = uiState.editSiteValue,
onSelect = {
viewModel.selectSite(group.instance.id, site.id)
onSiteSelected(group.instance.id, site.id)
},
onStartEdit = { viewModel.startEditing(site.id) },
onEditValueChanged = viewModel::onEditSiteValueChanged,
onConfirmEdit = { viewModel.confirmEdit(group.instance.id) },
onCancelEdit = viewModel::cancelEdit,
onDelete = {
viewModel.requestDeleteSite(group.instance.id, site.id)
},
dragModifier = Modifier.draggableHandle(
onDragStopped = {
viewModel.saveSiteOrder(group.instance.id)
}
)
)
}
}
// Inline add-site input with validation
@ -260,13 +306,15 @@ private fun SiteRow(
siteId: String,
isSelected: Boolean,
isEditing: Boolean,
isDragging: Boolean,
editValue: String,
onSelect: () -> Unit,
onStartEdit: () -> Unit,
onEditValueChanged: (String) -> Unit,
onConfirmEdit: () -> Unit,
onCancelEdit: () -> Unit,
onDelete: () -> Unit
onDelete: () -> Unit,
dragModifier: Modifier = Modifier
) {
Card(
modifier = Modifier
@ -279,7 +327,10 @@ private fun SiteRow(
)
} else {
CardDefaults.cardColors()
}
},
elevation = CardDefaults.cardElevation(
defaultElevation = if (isDragging) 8.dp else 0.dp
)
) {
if (isEditing) {
Row(
@ -309,14 +360,13 @@ private fun SiteRow(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Language,
contentDescription = null,
tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.primary
SiteFavicon(
siteId = siteId,
isSelected = isSelected,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
@ -336,6 +386,12 @@ private fun SiteRow(
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 = "") }
}
// -- 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.
*/

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 (
site_id TEXT NOT NULL,
instance_id TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (site_id, instance_id)
);
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 OR IGNORE INTO stored_sites VALUES (?, ?);
INSERT OR IGNORE INTO stored_sites(site_id, instance_id, sort_order) VALUES (?, ?, ?);
delete:
DELETE FROM stored_sites WHERE site_id = ? AND instance_id = ?;
@ -19,4 +20,10 @@ deleteByInstance:
DELETE FROM stored_sites WHERE instance_id = ?;
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 = ?;