Add Photos gallery (grid browse + full-screen viewer); tidy Agenda top bar
New Photos section reachable from Agenda's overflow menu: reuses FilesRepository's WebDAV browsing (folders + image-filtered files) in a grid, with a swipeable full-screen viewer (HorizontalPager) on tap. Thumbnails/full images load via Coil with a custom OkHttpClient that injects the same Basic Auth used everywhere else - no new network client needed, just two small synchronous accessors added to FilesRepository (rawFileUrl/basicAuthHeader) since Coil needs a plain URL + header rather than a suspend call. Also moved Talk/Files/Photos/Calendars into an overflow menu on Agenda's top bar - it had grown to 7 icons across this session and the title had started wrapping to two lines. Real bug found and fixed live: the photo viewer's close button was completely untappable (visually present, but taps never registered). Root cause was the same class of issue as an earlier Snackbar gotcha this session - MainActivity calls enableEdgeToEdge(), so content without explicit inset padding draws (and the close button's touch target sat) underneath the real system status bar, which intercepted the taps before the app ever saw them. Fixed with statusBarsPadding() and by moving the button into its own row above the pager instead of overlaid on top of it.
This commit is contained in:
parent
b8fdd9b421
commit
aaaa034d95
|
|
@ -88,6 +88,9 @@ dependencies {
|
||||||
implementation("androidx.compose.material:material-icons-extended")
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
|
// Image loading (Photos gallery thumbnails/full-screen viewer)
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,8 @@ class FilesClient(
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val authHeader = Credentials.basic(username, appPassword)
|
private val authHeader = Credentials.basic(username, appPassword)
|
||||||
private val filesHomeUrl = "${serverUrl.trimEnd('/')}/remote.php/dav/files/$username/"
|
|
||||||
|
|
||||||
private fun urlFor(path: String): String {
|
private fun urlFor(path: String): String = buildUrl(serverUrl, username, path)
|
||||||
val encoded = path.trim('/').split('/').filter { it.isNotEmpty() }
|
|
||||||
.joinToString("/") { URLEncoder.encode(it, "UTF-8").replace("+", "%20") }
|
|
||||||
return filesHomeUrl + encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lists the contents of [path] ("" for the root of the Files space). */
|
/** Lists the contents of [path] ("" for the root of the Files space). */
|
||||||
fun listDirectory(path: String): List<DavFile> {
|
fun listDirectory(path: String): List<DavFile> {
|
||||||
|
|
@ -147,7 +142,16 @@ class FilesClient(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
companion object {
|
||||||
val XML_MEDIA = "application/xml; charset=utf-8".toMediaType()
|
private val XML_MEDIA = "application/xml; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
|
/** Pure URL construction, exposed so callers (e.g. an authenticated image loader for
|
||||||
|
* the Photos gallery) can build a file's WebDAV URL without needing a full client. */
|
||||||
|
fun buildUrl(serverUrl: String, username: String, path: String): String {
|
||||||
|
val filesHomeUrl = "${serverUrl.trimEnd('/')}/remote.php/dav/files/$username/"
|
||||||
|
val encoded = path.trim('/').split('/').filter { it.isNotEmpty() }
|
||||||
|
.joinToString("/") { URLEncoder.encode(it, "UTF-8").replace("+", "%20") }
|
||||||
|
return filesHomeUrl + encoded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import com.homelab.ncal.data.network.FilesClient
|
||||||
import com.homelab.ncal.data.prefs.SecurePrefs
|
import com.homelab.ncal.data.prefs.SecurePrefs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Credentials
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
/** Thin pass-through to [FilesClient] - Files browsing has no local cache, same as Talk. */
|
/** Thin pass-through to [FilesClient] - Files browsing has no local cache, same as Talk. */
|
||||||
|
|
@ -43,4 +44,19 @@ class FilesRepository(context: Context) {
|
||||||
suspend fun move(fromPath: String, toPath: String) = withContext(Dispatchers.IO) {
|
suspend fun move(fromPath: String, toPath: String) = withContext(Dispatchers.IO) {
|
||||||
client().move(fromPath, toPath)
|
client().move(fromPath, toPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Direct WebDAV URL for [path] - for an authenticated image loader (Photos gallery), not
|
||||||
|
* a network call itself, so deliberately synchronous. */
|
||||||
|
fun rawFileUrl(path: String): String {
|
||||||
|
val server = prefs.serverUrl ?: return ""
|
||||||
|
val user = prefs.username ?: return ""
|
||||||
|
return FilesClient.buildUrl(server, user, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Basic Auth header value matching [rawFileUrl] - same credentials as every other call here. */
|
||||||
|
fun basicAuthHeader(): String {
|
||||||
|
val user = prefs.username ?: return ""
|
||||||
|
val pass = prefs.appPassword ?: return ""
|
||||||
|
return Credentials.basic(user, pass)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import androidx.compose.material.icons.filled.Chat
|
||||||
import androidx.compose.material.icons.filled.Event
|
import androidx.compose.material.icons.filled.Event
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
import androidx.compose.material.icons.filled.List
|
import androidx.compose.material.icons.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Photo
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
|
@ -23,6 +25,8 @@ import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.ViewWeek
|
import androidx.compose.material.icons.filled.ViewWeek
|
||||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
|
@ -37,6 +41,9 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.mutableStateOf
|
||||||
|
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.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
|
@ -60,9 +67,11 @@ fun AgendaScreen(
|
||||||
onOpenWeek: () -> Unit,
|
onOpenWeek: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onOpenTalk: () -> Unit,
|
onOpenTalk: () -> Unit,
|
||||||
onOpenFiles: () -> Unit
|
onOpenFiles: () -> Unit,
|
||||||
|
onOpenPhotos: () -> Unit
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
var showMoreMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.refresh()
|
viewModel.refresh()
|
||||||
|
|
@ -73,13 +82,35 @@ fun AgendaScreen(
|
||||||
CenterAlignedTopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
title = { Text("Agenda") },
|
title = { Text("Agenda") },
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = onOpenFiles) { Icon(Icons.Filled.Folder, "Files") }
|
|
||||||
IconButton(onClick = onOpenTalk) { Icon(Icons.Filled.Chat, "Talk") }
|
|
||||||
IconButton(onClick = onOpenSearch) { Icon(Icons.Filled.Search, "Search") }
|
IconButton(onClick = onOpenSearch) { Icon(Icons.Filled.Search, "Search") }
|
||||||
IconButton(onClick = onOpenWeek) { Icon(Icons.Filled.ViewWeek, "Week view") }
|
IconButton(onClick = onOpenWeek) { Icon(Icons.Filled.ViewWeek, "Week view") }
|
||||||
IconButton(onClick = onOpenMonth) { Icon(Icons.Filled.CalendarMonth, "Month view") }
|
IconButton(onClick = onOpenMonth) { Icon(Icons.Filled.CalendarMonth, "Month view") }
|
||||||
IconButton(onClick = onOpenTasks) { Icon(Icons.Filled.List, "Tasks") }
|
IconButton(onClick = onOpenTasks) { Icon(Icons.Filled.List, "Tasks") }
|
||||||
IconButton(onClick = onOpenCollections) { Icon(Icons.Filled.Settings, "Calendars") }
|
Box {
|
||||||
|
IconButton(onClick = { showMoreMenu = true }) { Icon(Icons.Filled.MoreVert, "More") }
|
||||||
|
DropdownMenu(expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Talk") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Chat, null) },
|
||||||
|
onClick = { showMoreMenu = false; onOpenTalk() }
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Files") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Folder, null) },
|
||||||
|
onClick = { showMoreMenu = false; onOpenFiles() }
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Photos") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Photo, null) },
|
||||||
|
onClick = { showMoreMenu = false; onOpenPhotos() }
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Calendars") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Settings, null) },
|
||||||
|
onClick = { showMoreMenu = false; onOpenCollections() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import com.homelab.ncal.ui.agenda.AgendaScreen
|
||||||
import com.homelab.ncal.ui.agenda.AgendaViewModel
|
import com.homelab.ncal.ui.agenda.AgendaViewModel
|
||||||
import com.homelab.ncal.ui.files.FilesBrowserScreen
|
import com.homelab.ncal.ui.files.FilesBrowserScreen
|
||||||
import com.homelab.ncal.ui.files.FilesBrowserViewModel
|
import com.homelab.ncal.ui.files.FilesBrowserViewModel
|
||||||
|
import com.homelab.ncal.ui.photos.PhotosGridScreen
|
||||||
|
import com.homelab.ncal.ui.photos.PhotosGridViewModel
|
||||||
import com.homelab.ncal.ui.collections.CollectionsScreen
|
import com.homelab.ncal.ui.collections.CollectionsScreen
|
||||||
import com.homelab.ncal.ui.collections.CollectionsViewModel
|
import com.homelab.ncal.ui.collections.CollectionsViewModel
|
||||||
import com.homelab.ncal.ui.detail.ItemEditScreen
|
import com.homelab.ncal.ui.detail.ItemEditScreen
|
||||||
|
|
@ -64,6 +66,7 @@ private object Routes {
|
||||||
const val TALK_ROOMS = "talk"
|
const val TALK_ROOMS = "talk"
|
||||||
const val TALK_CHAT = "talk/{token}/{name}"
|
const val TALK_CHAT = "talk/{token}/{name}"
|
||||||
const val FILES = "files"
|
const val FILES = "files"
|
||||||
|
const val PHOTOS = "photos"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun editRoute(type: String, href: String?, calendarUrl: String? = null): String {
|
private fun editRoute(type: String, href: String?, calendarUrl: String? = null): String {
|
||||||
|
|
@ -141,7 +144,8 @@ fun NcalNavGraph(
|
||||||
onOpenWeek = { navController.navigate(Routes.WEEK) },
|
onOpenWeek = { navController.navigate(Routes.WEEK) },
|
||||||
onOpenSearch = { navController.navigate(Routes.SEARCH) },
|
onOpenSearch = { navController.navigate(Routes.SEARCH) },
|
||||||
onOpenTalk = { navController.navigate(Routes.TALK_ROOMS) },
|
onOpenTalk = { navController.navigate(Routes.TALK_ROOMS) },
|
||||||
onOpenFiles = { navController.navigate(Routes.FILES) }
|
onOpenFiles = { navController.navigate(Routes.FILES) },
|
||||||
|
onOpenPhotos = { navController.navigate(Routes.PHOTOS) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,6 +219,11 @@ fun NcalNavGraph(
|
||||||
FilesBrowserScreen(viewModel = vm, onExitRoot = { navController.popBackStack() })
|
FilesBrowserScreen(viewModel = vm, onExitRoot = { navController.popBackStack() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(Routes.PHOTOS) {
|
||||||
|
val vm: PhotosGridViewModel = viewModel(factory = FilesViewModelFactory(app) { repo, _ -> PhotosGridViewModel(repo) })
|
||||||
|
PhotosGridScreen(viewModel = vm, onExitRoot = { navController.popBackStack() })
|
||||||
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = Routes.EDIT,
|
route = Routes.EDIT,
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
package com.homelab.ncal.ui.photos
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.size.Size
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun PhotosGridScreen(
|
||||||
|
viewModel: PhotosGridViewModel,
|
||||||
|
onExitRoot: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val imageLoader = remember(viewModel) {
|
||||||
|
val authHeader = viewModel.authHeader()
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
chain.proceed(chain.request().newBuilder().header("Authorization", authHeader).build())
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
ImageLoader.Builder(context).okHttpClient(client).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { viewModel.refresh() }
|
||||||
|
BackHandler(enabled = state.path.isNotBlank() && state.viewerIndex == null) { viewModel.navigateUp() }
|
||||||
|
BackHandler(enabled = state.viewerIndex != null) { viewModel.closeViewer() }
|
||||||
|
|
||||||
|
if (state.viewerIndex != null) {
|
||||||
|
PhotoViewer(
|
||||||
|
images = state.images,
|
||||||
|
startIndex = state.viewerIndex!!,
|
||||||
|
imageLoader = imageLoader,
|
||||||
|
urlFor = viewModel::imageUrl,
|
||||||
|
onClose = viewModel::closeViewer
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = { Text(viewModel.currentFolderName) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { if (!viewModel.navigateUp()) onExitRoot() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
if (state.loading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.TopCenter).padding(16.dp))
|
||||||
|
}
|
||||||
|
if (state.folders.isEmpty() && state.images.isEmpty() && !state.loading && state.error == null) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("No folders or photos here.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.error?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp))
|
||||||
|
}
|
||||||
|
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(state.folders, key = { "folder-${it.path}" }) { folder ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.padding(2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable { viewModel.openFolder(folder) },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Folder, contentDescription = folder.name)
|
||||||
|
Text(
|
||||||
|
folder.name,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(state.images, key = { "image-${it.path}" }) { image ->
|
||||||
|
val index = state.images.indexOf(image)
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(context).data(viewModel.imageUrl(image)).size(Size(300, 300)).crossfade(true).build(),
|
||||||
|
imageLoader = imageLoader,
|
||||||
|
contentDescription = image.name,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.padding(2.dp)
|
||||||
|
.clickable { viewModel.openViewer(index) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun PhotoViewer(
|
||||||
|
images: List<com.homelab.ncal.data.network.DavFile>,
|
||||||
|
startIndex: Int,
|
||||||
|
imageLoader: ImageLoader,
|
||||||
|
urlFor: (com.homelab.ncal.data.network.DavFile) -> String,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val pagerState = rememberPagerState(initialPage = startIndex) { images.size }
|
||||||
|
|
||||||
|
// The close button lives in its own row above the pager rather than overlaid on top of
|
||||||
|
// it, and needs statusBarsPadding() - MainActivity calls enableEdgeToEdge(), so without
|
||||||
|
// it this button renders right under the real status bar and never receives taps at all
|
||||||
|
// (verified live: the button was visually present but un-tappable until this was added;
|
||||||
|
// the system back gesture worked throughout, since it doesn't go through the status bar).
|
||||||
|
Column(Modifier.fillMaxSize().background(Color.Black).statusBarsPadding()) {
|
||||||
|
IconButton(onClick = onClose, modifier = Modifier.padding(4.dp)) {
|
||||||
|
Icon(Icons.Filled.Close, "Close", tint = Color.White)
|
||||||
|
}
|
||||||
|
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f).fillMaxWidth()) { page ->
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(context).data(urlFor(images[page])).crossfade(true).build(),
|
||||||
|
imageLoader = imageLoader,
|
||||||
|
contentDescription = images[page].name,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
package com.homelab.ncal.ui.photos
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.homelab.ncal.data.network.DavFile
|
||||||
|
import com.homelab.ncal.data.repository.FilesRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "bmp")
|
||||||
|
|
||||||
|
data class PhotosGridUiState(
|
||||||
|
val path: String = "",
|
||||||
|
val folders: List<DavFile> = emptyList(),
|
||||||
|
val images: List<DavFile> = emptyList(),
|
||||||
|
val loading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
/** Index into [images] currently shown full-screen, or null when the grid is showing. */
|
||||||
|
val viewerIndex: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class PhotosGridViewModel(private val repo: FilesRepository) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(PhotosGridUiState())
|
||||||
|
val state: StateFlow<PhotosGridUiState> = _state
|
||||||
|
|
||||||
|
val currentFolderName: String
|
||||||
|
get() = _state.value.path.substringAfterLast('/').ifBlank { "Photos" }
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
val path = _state.value.path
|
||||||
|
_state.value = _state.value.copy(loading = true, error = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val entries = repo.listDirectory(path)
|
||||||
|
val folders = entries.filter { it.isDirectory }
|
||||||
|
val images = entries.filter { !it.isDirectory && it.name.substringAfterLast('.', "").lowercase() in IMAGE_EXTENSIONS }
|
||||||
|
_state.value = _state.value.copy(folders = folders, images = images, loading = false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(loading = false, error = e.message ?: "Couldn't load folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFolder(file: DavFile) {
|
||||||
|
if (!file.isDirectory) return
|
||||||
|
_state.value = _state.value.copy(path = file.path)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pops one directory level. Returns false if already at the root (caller should navigate away). */
|
||||||
|
fun navigateUp(): Boolean {
|
||||||
|
val path = _state.value.path
|
||||||
|
if (path.isBlank()) return false
|
||||||
|
val parent = path.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
_state.value = _state.value.copy(path = parent)
|
||||||
|
refresh()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openViewer(index: Int) {
|
||||||
|
_state.value = _state.value.copy(viewerIndex = index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeViewer() {
|
||||||
|
_state.value = _state.value.copy(viewerIndex = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun imageUrl(file: DavFile): String = repo.rawFileUrl(file.path)
|
||||||
|
|
||||||
|
fun authHeader(): String = repo.basicAuthHeader()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue