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