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:
parent
9110af7b8f
commit
26467d9047
9 changed files with 248 additions and 36 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
3
app/src/main/sqldelight/migrations/1.sqm
Normal file
3
app/src/main/sqldelight/migrations/1.sqm
Normal 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;
|
||||
|
|
@ -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 = ?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue