diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1ba8ff2..7da4955 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/no/naiv/implausibly/ImplausiblyApp.kt b/app/src/main/java/no/naiv/implausibly/ImplausiblyApp.kt index 19f6ab1..f8cecda 100644 --- a/app/src/main/java/no/naiv/implausibly/ImplausiblyApp.kt +++ b/app/src/main/java/no/naiv/implausibly/ImplausiblyApp.kt @@ -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() + } +} diff --git a/app/src/main/java/no/naiv/implausibly/data/repository/SiteRepository.kt b/app/src/main/java/no/naiv/implausibly/data/repository/SiteRepository.kt index cdd12a1..1583084 100644 --- a/app/src/main/java/no/naiv/implausibly/data/repository/SiteRepository.kt +++ b/app/src/main/java/no/naiv/implausibly/data/repository/SiteRepository.kt @@ -17,21 +17,28 @@ class SiteRepository @Inject constructor( fun getAllSites(): List { 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 { 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) { + database.storedSiteQueries.transaction { + orderedSiteIds.forEachIndexed { index, siteId -> + database.storedSiteQueries.updateSortOrder( + sort_order = index.toLong(), + site_id = siteId, + instance_id = instanceId + ) + } + } } } diff --git a/app/src/main/java/no/naiv/implausibly/domain/model/Site.kt b/app/src/main/java/no/naiv/implausibly/domain/model/Site.kt index 58f03ac..9184078 100644 --- a/app/src/main/java/no/naiv/implausibly/domain/model/Site.kt +++ b/app/src/main/java/no/naiv/implausibly/domain/model/Site.kt @@ -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 ) diff --git a/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerScreen.kt b/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerScreen.kt index 7328d0b..87cebf2 100644 --- a/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerScreen.kt +++ b/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerScreen.kt @@ -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() + } +} diff --git a/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerViewModel.kt b/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerViewModel.kt index b7ad3d8..514610e 100644 --- a/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerViewModel.kt +++ b/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerViewModel.kt @@ -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. */ diff --git a/app/src/main/sqldelight/migrations/1.sqm b/app/src/main/sqldelight/migrations/1.sqm new file mode 100644 index 0000000..cca33ab --- /dev/null +++ b/app/src/main/sqldelight/migrations/1.sqm @@ -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; diff --git a/app/src/main/sqldelight/no/naiv/implausibly/StoredSite.sq b/app/src/main/sqldelight/no/naiv/implausibly/StoredSite.sq index 8ed7c9d..8aaff31 100644 --- a/app/src/main/sqldelight/no/naiv/implausibly/StoredSite.sq +++ b/app/src/main/sqldelight/no/naiv/implausibly/StoredSite.sq @@ -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 = ?; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 82f17e0..6420c3b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,12 @@ ktor = "3.0.3" # SQLDelight sqldelight = "2.0.2" +# Coil +coil = "3.0.4" + +# Reorderable +reorderable = "2.4.3" + # Testing junit = "4.13.2" 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-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 kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }