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:
RomanNum3ral 2026-07-18 16:16:48 -04:00
parent b8fdd9b421
commit aaaa034d95
Signed by: RomanNum3ral
GPG Key ID: B94562C9B7535805
7 changed files with 323 additions and 13 deletions

View File

@ -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")

View File

@ -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
}
} }
} }

View File

@ -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)
}
} }

View File

@ -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() }
)
}
}
} }
) )
}, },

View File

@ -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(

View File

@ -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()
)
}
}
}

View File

@ -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()
}