From aaaa034d95b2fc9e90ab20f21937921523e87588 Mon Sep 17 00:00:00 2001 From: RomanNum3ral Date: Sat, 18 Jul 2026 16:16:48 -0400 Subject: [PATCH] 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. --- ncal/app/build.gradle.kts | 3 + .../homelab/ncal/data/network/FilesClient.kt | 20 +- .../ncal/data/repository/FilesRepository.kt | 16 ++ .../homelab/ncal/ui/agenda/AgendaScreen.kt | 39 +++- .../com/homelab/ncal/ui/nav/NcalNavGraph.kt | 11 +- .../ncal/ui/photos/PhotosGridScreen.kt | 174 ++++++++++++++++++ .../ncal/ui/photos/PhotosGridViewModel.kt | 73 ++++++++ 7 files changed, 323 insertions(+), 13 deletions(-) create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/photos/PhotosGridScreen.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/photos/PhotosGridViewModel.kt diff --git a/ncal/app/build.gradle.kts b/ncal/app/build.gradle.kts index ec8517b..d4c4ab0 100644 --- a/ncal/app/build.gradle.kts +++ b/ncal/app/build.gradle.kts @@ -88,6 +88,9 @@ dependencies { implementation("androidx.compose.material:material-icons-extended") debugImplementation("androidx.compose.ui:ui-tooling") + // Image loading (Photos gallery thumbnails/full-screen viewer) + implementation("io.coil-kt:coil-compose:2.7.0") + // Navigation implementation("androidx.navigation:navigation-compose:2.7.7") diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/network/FilesClient.kt b/ncal/app/src/main/java/com/homelab/ncal/data/network/FilesClient.kt index 60f15e7..f34dd72 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/network/FilesClient.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/network/FilesClient.kt @@ -35,13 +35,8 @@ class FilesClient( .build() private val authHeader = Credentials.basic(username, appPassword) - private val filesHomeUrl = "${serverUrl.trimEnd('/')}/remote.php/dav/files/$username/" - private fun urlFor(path: String): String { - val encoded = path.trim('/').split('/').filter { it.isNotEmpty() } - .joinToString("/") { URLEncoder.encode(it, "UTF-8").replace("+", "%20") } - return filesHomeUrl + encoded - } + private fun urlFor(path: String): String = buildUrl(serverUrl, username, path) /** Lists the contents of [path] ("" for the root of the Files space). */ fun listDirectory(path: String): List { @@ -147,7 +142,16 @@ class FilesClient( } } - private companion object { - val XML_MEDIA = "application/xml; charset=utf-8".toMediaType() + companion object { + 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 + } } } diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/repository/FilesRepository.kt b/ncal/app/src/main/java/com/homelab/ncal/data/repository/FilesRepository.kt index 227ee37..12caaa6 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/repository/FilesRepository.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/repository/FilesRepository.kt @@ -6,6 +6,7 @@ import com.homelab.ncal.data.network.FilesClient import com.homelab.ncal.data.prefs.SecurePrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.Credentials import java.io.OutputStream /** 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) { 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) + } } diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt index e7fcd07..b5b6280 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.material.icons.filled.Chat import androidx.compose.material.icons.filled.Event import androidx.compose.material.icons.filled.Folder 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.Refresh 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.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -37,6 +41,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.Modifier import androidx.compose.ui.text.style.TextDecoration @@ -60,9 +67,11 @@ fun AgendaScreen( onOpenWeek: () -> Unit, onOpenSearch: () -> Unit, onOpenTalk: () -> Unit, - onOpenFiles: () -> Unit + onOpenFiles: () -> Unit, + onOpenPhotos: () -> Unit ) { val state by viewModel.state.collectAsState() + var showMoreMenu by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.refresh() @@ -73,13 +82,35 @@ fun AgendaScreen( CenterAlignedTopAppBar( title = { Text("Agenda") }, 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 = onOpenWeek) { Icon(Icons.Filled.ViewWeek, "Week view") } IconButton(onClick = onOpenMonth) { Icon(Icons.Filled.CalendarMonth, "Month view") } 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() } + ) + } + } } ) }, diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt index 07d8276..dd3bf17 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt @@ -31,6 +31,8 @@ import com.homelab.ncal.ui.agenda.AgendaScreen import com.homelab.ncal.ui.agenda.AgendaViewModel import com.homelab.ncal.ui.files.FilesBrowserScreen 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.CollectionsViewModel import com.homelab.ncal.ui.detail.ItemEditScreen @@ -64,6 +66,7 @@ private object Routes { const val TALK_ROOMS = "talk" const val TALK_CHAT = "talk/{token}/{name}" const val FILES = "files" + const val PHOTOS = "photos" } private fun editRoute(type: String, href: String?, calendarUrl: String? = null): String { @@ -141,7 +144,8 @@ fun NcalNavGraph( onOpenWeek = { navController.navigate(Routes.WEEK) }, onOpenSearch = { navController.navigate(Routes.SEARCH) }, 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() }) } + composable(Routes.PHOTOS) { + val vm: PhotosGridViewModel = viewModel(factory = FilesViewModelFactory(app) { repo, _ -> PhotosGridViewModel(repo) }) + PhotosGridScreen(viewModel = vm, onExitRoot = { navController.popBackStack() }) + } + composable( route = Routes.EDIT, arguments = listOf( diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/photos/PhotosGridScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/photos/PhotosGridScreen.kt new file mode 100644 index 0000000..60d3af4 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/photos/PhotosGridScreen.kt @@ -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, + 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() + ) + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/photos/PhotosGridViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/photos/PhotosGridViewModel.kt new file mode 100644 index 0000000..01f07b1 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/photos/PhotosGridViewModel.kt @@ -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 = emptyList(), + val images: List = 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 = _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() +}