diff --git a/README.md b/README.md index 976563f..dc0016d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,14 @@ is the fourth option: one app, one account, one sync engine. parsing). Nothing phones home to a vendor. - **No third-party server.** The only network calls in the app go to whatever Nextcloud server URL *you* enter at login (`data/network/CalDavClient.kt`). There is - no hardcoded third-party endpoint anywhere in the source. + no hardcoded third-party endpoint anywhere in the source — this holds for Files, + Photos, Talk, and Auto-upload too, not just calendar sync. +- **Auto-upload is opt-in and scoped to exactly what you configure.** It uploads + nothing until you explicitly add at least one source → destination folder pair, and + only ever reads the specific photo folder(s)/local folder(s) you picked — a photo + folder needs the OS photo-library permission (asked for the first time you add one), + an arbitrary folder needs no broader permission at all, since the Storage Access + Framework grant is scoped to just the folder you picked via the system picker. - **Everything is encrypted at rest.** Server URL, username, and app password live in `EncryptedSharedPreferences` (`data/prefs/SecurePrefs.kt`, AES-256-GCM/SIV via an Android Keystore-backed `MasterKey`). The local Room cache itself is an @@ -41,13 +48,17 @@ is the fourth option: one app, one account, one sync engine. ## Features -- **Agenda** — unified, day-grouped list of events *and* tasks. Syncs automatically - every time the app is opened, plus pull to refresh any time. -- **Month view** — a full calendar grid with events rendered as spanning colored - pills and tasks as dot + text, exactly mirroring what's on the home screen widget - (they share the same layout algorithm). Tap a day to see its agenda below the grid. -- **Tasks** — dedicated checklist view. Tasks render as an indented tree under their - parent, not a flat list. +- **Calendar home screen** — Month, Week, List, and Tasks are tabs of one screen + (`ui/calendar/CalendarHomeScreen.kt`), not four separate destinations, so switching + how you're looking at the same data is a tap, not a navigation. + - **List** — unified, day-grouped list of events *and* tasks. Syncs automatically + every time the app is opened, plus pull to refresh any time. + - **Month** — a full calendar grid with events rendered as spanning colored pills + and tasks as dot + text, exactly mirroring what's on the home screen widget (they + share the same layout algorithm). Tap a day to see its agenda below the grid. + - **Week** — a scrollable week grid. + - **Tasks** — dedicated checklist view. Tasks render as an indented tree under + their parent, not a flat list. - **Subtasks** — assign any task a parent task (stored as the standard iCalendar `RELATED-TO` property, so it round-trips with Nextcloud's own Tasks app and other CalDAV clients). The parent picker excludes the task itself and its own descendants @@ -75,8 +86,36 @@ is the fourth option: one app, one account, one sync engine. - **Calendars** — pick which calendars/task-lists sync, mirroring what you'd see in Nextcloud's own sidebar. - **Recurring events and tasks** — `RRULE` (`FREQ=DAILY/WEEKLY/MONTHLY/YEARLY` with - `INTERVAL`/`COUNT`/`UNTIL`) is evaluated client-side to re-date each item to its - next upcoming occurrence. See "Editing recurring items" below for what this doesn't cover. + `INTERVAL`/`COUNT`/`UNTIL`, including ordinaled monthly rules like "the 3rd Thursday" + or "the last Friday") is evaluated client-side to re-date each item to its next + upcoming occurrence. See "Editing recurring items" below for what this doesn't cover. +- **Edit/delete a single occurrence** of a recurring event or task without touching the + rest of the series, using standard RFC 5545 mechanics (`RECURRENCE-ID` overrides and + `EXDATE` exclusions) rather than app-specific state, so it round-trips with any other + CalDAV client. +- **Search** — find any event or task across every enabled calendar/task-list by title. +- **Undo-after-delete** — deleting an event or task shows a snackbar to undo it; the + item is recreated server-side with a fresh UID (Nextcloud's calendar trashbin keeps + the original UID reserved for 30 days). +- **Import/export `.ics`** — export any calendar/task-list to a real `.ics` file via the + system share sheet, or import one into any calendar. +- **Files** — full WebDAV browsing of your Nextcloud Files space: browse folders, + download and open a file in whatever app handles its type, upload one or many files + at once, upload an entire local folder (subfolders included, recreated on the + server), delete, rename, and create folders. +- **Photos** — a dedicated photo grid + full-screen swipeable viewer over the same + Files space (filtered to images), with upload from the device and download back to + the device's gallery. +- **Talk** — read and send text messages in your Nextcloud Talk rooms (no voice/video + calls — see "What's deliberately not built" below). +- **Auto-upload** — a Wi-Fi-only background job that uploads new content to Nextcloud + automatically, fully opt-in and user-configured: any number of independent source → + destination folder pairs, each source either a device photo folder (Camera, + Screenshots, etc., picked from what's actually on the device) or an arbitrary local + folder of any file type (subfolders included, recreated on the server the same way + Files' manual folder upload does). Nothing is watched or uploaded until you + explicitly add at least one pair; see `data/prefs/AutoUploadPrefs.kt` and + `sync/PhotoAutoUploadWorker.kt`. ## Home screen widgets @@ -123,6 +162,26 @@ nothing. pending alarm per item to track. - **`MonthGridWidget` / `NextTasksWidget`** (`widget/`) — Glance widgets that read straight from the same repository the app uses. +- **`FilesClient` / `FilesRepository`** (`data/network/`, `data/repository/`) — plain + WebDAV browsing/CRUD (`PROPFIND`/`GET`/`PUT`/`DELETE`/`MKCOL`/`MOVE`) against + `/remote.php/dav/files//`, reusing the same `DavXml` multistatus parser the + CalDAV client uses (WebDAV XML parsing is generic, not calendar-specific). Backs + both the Files screen and the Photos gallery (Photos is Files' listing filtered to + image extensions and rendered as a grid). +- **`TalkClient` / `TalkRepository`** (`data/network/`, `data/repository/`) — Nextcloud + Talk chat over its OCS API (room list is `api/v4`, chat itself is `api/v1` — Talk + versions each feature area independently). No local cache; text-only, real-time-ish + via the chat endpoint's own long-poll (`lookIntoFuture=1`), no WebSocket/push infra. +- **`AutoUploadPrefs` / `PhotoAutoUploadWorker` / `PhotoAutoUploadScheduler`** + (`data/prefs/`, `sync/`) — the Wi-Fi auto-upload background job. User-configured + source → destination pairs (`UploadMapping`) are JSON-serialized into a single + preference (plain, unencrypted — nothing in it is sensitive). Photo-bucket sources + are queried via MediaStore and share one global watermark; arbitrary local-folder + sources (`sync/LocalFolderScanner.kt`, via Storage Access Framework + + `takePersistableUriPermission`) are recursively walked each run and each track their + own independent watermark, since unlike MediaStore they have no shared timeline to + merge against. Runs under a `NetworkType.UNMETERED` `WorkManager` constraint (the + closest available proxy for "Wi-Fi only"). ## Setup @@ -135,6 +194,11 @@ nothing. are enabled. 6. Optional: long-press your home screen → Widgets → NCal, then add "Calendar Month" and/or "Tasks". +7. Optional: on the same **Calendars** screen, turn on **Auto-upload** and add one or + more folder pairs to start uploading photos and/or files to Nextcloud on Wi-Fi. It + asks for photo-library access the first time you add a photo-folder pair; an + arbitrary-folder pair needs no extra permission (grant is scoped to the folder you + pick, via the system folder picker). Run the (small, growing) JVM unit test suite with `./gradlew test` — no emulator needed. @@ -155,6 +219,13 @@ Recurring items get two guarantees, both regression-tested in `IcsMapperTest`: `BYMONTH`/etc-based rules (or anything outside Daily/Weekly/Monthly/Yearly) show as "Does not repeat" in the picker but are never flattened or overwritten by it. +## What's deliberately not built + +- **Talk voice/video calls.** Text chat only. This server's Talk signaling has no + dedicated TURN relay configured (`turnservers: []`), so calls would only connect + over the same LAN, not realistically across networks — building the calling UI is + deferred until a TURN server (e.g. coturn) is set up. + ## Where to look first if you want to extend it - `data/network/CalDavClient.kt` — all server communication. @@ -171,6 +242,14 @@ Recurring items get two guarantees, both regression-tested in `IcsMapperTest`: - `data/repository/SaveConflictException.kt` — the 412-conflict data carried to the edit screen's dialog. - `util/ConflictDiff.kt` — computes which fields actually differ for the merge dialog. - `widget/` — the two Glance home screen widgets. +- `data/network/FilesClient.kt` / `data/repository/FilesRepository.kt` — WebDAV Files + browsing/CRUD, shared by the Files screen and the Photos gallery. +- `data/network/TalkClient.kt` / `data/repository/TalkRepository.kt` — Nextcloud Talk + chat. +- `sync/PhotoAutoUploadWorker.kt` / `data/prefs/AutoUploadPrefs.kt` — the Wi-Fi + auto-upload background job and its user-configured source → destination mappings. +- `sync/LocalFolderScanner.kt` — recursive local-folder walk (via SAF) for + arbitrary-folder auto-upload mappings. ## Building a signed release diff --git a/ncal/app/build.gradle.kts b/ncal/app/build.gradle.kts index d4c4ab0..143c640 100644 --- a/ncal/app/build.gradle.kts +++ b/ncal/app/build.gradle.kts @@ -74,6 +74,8 @@ android { dependencies { implementation("androidx.core:core-ktx:1.13.1") + // Folder upload (Files section) needs to walk a picked SAF tree recursively. + implementation("androidx.documentfile:documentfile:1.0.1") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3") implementation("androidx.activity:activity-compose:1.9.0") diff --git a/ncal/app/src/main/AndroidManifest.xml b/ncal/app/src/main/AndroidManifest.xml index 1377317..ba445c8 100644 --- a/ncal/app/src/main/AndroidManifest.xml +++ b/ncal/app/src/main/AndroidManifest.xml @@ -7,6 +7,13 @@ + + + + + one Nextcloud destination folder. Multiple of these can be configured at + * once, each independently uploading its own new content to its own destination. + * - [PHOTO_BUCKET]: [sourceKey] is a MediaStore bucket name (e.g. "Camera"), watched via + * MediaStore queries - no extra permission grant needed beyond the media-read runtime + * permission already required to list buckets. + * - [LOCAL_FOLDER]: [sourceKey] is a persisted SAF tree URI (`content://...`) for an arbitrary + * folder the user picked via the system folder picker - watched by recursively walking the + * tree and comparing each file's `lastModified()` to a watermark, since SAF has no MediaStore- + * style "what changed since X" query. + * [sourceLabel] is what's shown in the UI (the bucket name itself, or the picked folder's + * display name) - kept separate from [sourceKey] since the latter isn't human-readable for + * [LOCAL_FOLDER]. */ +data class UploadMapping( + val type: UploadSourceType, + val sourceKey: String, + val sourceLabel: String, + val destination: String +) + +/** + * Whether Wi-Fi-only auto-upload is on, which local source(s) upload to which Nextcloud + * folder(s), plus watermarks tracking how far each has gotten. Plain (unencrypted) prefs - + * unlike [SecurePrefs], nothing here is sensitive. + */ +class AutoUploadPrefs(context: Context) { + + private val prefs = context.getSharedPreferences("ncal_auto_upload_prefs", Context.MODE_PRIVATE) + + var enabled: Boolean + get() = prefs.getBoolean(KEY_ENABLED, false) + set(value) = prefs.edit().putBoolean(KEY_ENABLED, value).apply() + + /** MediaStore `DATE_ADDED` (epoch seconds) of the newest photo already uploaded, shared + * across every [UploadSourceType.PHOTO_BUCKET] mapping - only photos added after this get + * picked up on the next run. */ + var watermarkSeconds: Long + get() = prefs.getLong(KEY_WATERMARK, 0L) + set(value) = prefs.edit().putLong(KEY_WATERMARK, value).apply() + + /** Per-[UploadMapping.sourceKey] watermark (epoch millis, from `DocumentFile.lastModified()`) + * for [UploadSourceType.LOCAL_FOLDER] mappings - unlike photo buckets, these can't share one + * global watermark since each is an independent, arbitrarily-structured folder tree walked + * separately rather than one shared MediaStore timeline. */ + var folderWatermarks: Map + get() { + val raw = prefs.getString(KEY_FOLDER_WATERMARKS, null) ?: return emptyMap() + val obj = JSONObject(raw) + return obj.keys().asSequence().associateWith { obj.getLong(it) } + } + set(value) { + val obj = JSONObject() + value.forEach { (key, millis) -> obj.put(key, millis) } + prefs.edit().putString(KEY_FOLDER_WATERMARKS, obj.toString()).apply() + } + + /** The configured source -> Nextcloud-folder pairs - user chosen, see the picker in + * Collections/Settings. Empty by default: nothing uploads until the user configures at + * least one pair. At most one destination per source (picking a source that's already + * mapped replaces its destination rather than adding a duplicate entry). */ + var mappings: List + get() { + val raw = prefs.getString(KEY_MAPPINGS, null) ?: return emptyList() + val array = JSONArray(raw) + return (0 until array.length()).map { i -> + val obj = array.getJSONObject(i) + UploadMapping( + type = UploadSourceType.valueOf(obj.getString("type")), + sourceKey = obj.getString("sourceKey"), + sourceLabel = obj.getString("sourceLabel"), + destination = obj.getString("destination") + ) + } + } + set(value) { + val array = JSONArray() + value.forEach { mapping -> + array.put( + JSONObject() + .put("type", mapping.type.name) + .put("sourceKey", mapping.sourceKey) + .put("sourceLabel", mapping.sourceLabel) + .put("destination", mapping.destination) + ) + } + prefs.edit().putString(KEY_MAPPINGS, array.toString()).apply() + } + + companion object { + private const val KEY_ENABLED = "enabled" + private const val KEY_WATERMARK = "watermark_seconds" + private const val KEY_FOLDER_WATERMARKS = "folder_watermarks" + private const val KEY_MAPPINGS = "upload_mappings" + const val DEFAULT_BUCKET = "Camera" + const val DEFAULT_DESTINATION = "/InstantUpload" + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/repository/TalkRepository.kt b/ncal/app/src/main/java/com/homelab/ncal/data/repository/TalkRepository.kt index d179e3a..fdd167f 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/repository/TalkRepository.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/repository/TalkRepository.kt @@ -35,4 +35,8 @@ class TalkRepository(context: Context) { suspend fun sendMessage(token: String, message: String): TalkMessage = withContext(Dispatchers.IO) { client().sendMessage(token, message) } + + suspend fun fetchSignalingSettingsRaw(token: String): String = withContext(Dispatchers.IO) { + client().fetchSignalingSettingsRaw(token) + } } diff --git a/ncal/app/src/main/java/com/homelab/ncal/sync/CameraBuckets.kt b/ncal/app/src/main/java/com/homelab/ncal/sync/CameraBuckets.kt new file mode 100644 index 0000000..c051fea --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/sync/CameraBuckets.kt @@ -0,0 +1,24 @@ +package com.homelab.ncal.sync + +import android.content.Context +import android.provider.MediaStore + +/** Distinct MediaStore image bucket ("folder") names on the device, e.g. "Camera", + * "Screenshots", "WhatsApp Images" - lets the auto-upload settings UI offer a real list + * instead of the user having to guess a folder name. */ +object CameraBuckets { + fun listAvailable(context: Context): List { + val names = sortedSetOf() + context.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.Images.Media.BUCKET_DISPLAY_NAME), + null, null, null + )?.use { cursor -> + val col = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME) + while (cursor.moveToNext()) { + cursor.getString(col)?.let { names.add(it) } + } + } + return names.toList() + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/sync/LocalFolderScanner.kt b/ncal/app/src/main/java/com/homelab/ncal/sync/LocalFolderScanner.kt new file mode 100644 index 0000000..1ccb3eb --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/sync/LocalFolderScanner.kt @@ -0,0 +1,48 @@ +package com.homelab.ncal.sync + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile + +/** A file found under a watched local folder, changed since the mapping's watermark. */ +data class LocalFile( + val uri: Uri, + /** Path relative to the picked folder's root, e.g. "sub/dir/photo.jpg" - used to recreate + * the same subfolder structure on the Nextcloud side. */ + val relativePath: String, + val mimeType: String, + val lastModifiedMillis: Long +) + +/** Recursively walks an arbitrary SAF-picked folder tree (see [com.homelab.ncal.data.prefs. + * UploadSourceType.LOCAL_FOLDER]) for files changed since a watermark. Unlike MediaStore, SAF + * has no query for "what changed since X" - this has to walk every file every run and compare + * `lastModified()` itself, which doesn't scale to huge trees, but is fine for a personal + * auto-upload folder. */ +object LocalFolderScanner { + fun scan(context: Context, treeUri: Uri, sinceMillis: Long): List { + val root = DocumentFile.fromTreeUri(context, treeUri) ?: return emptyList() + val results = mutableListOf() + + fun walk(dir: DocumentFile, relativePrefix: String) { + dir.listFiles().forEach { child -> + val name = child.name ?: return@forEach + val relativePath = if (relativePrefix.isBlank()) name else "$relativePrefix/$name" + if (child.isDirectory) { + walk(child, relativePath) + } else if (child.lastModified() > sinceMillis) { + results.add( + LocalFile( + uri = child.uri, + relativePath = relativePath, + mimeType = child.type ?: "application/octet-stream", + lastModifiedMillis = child.lastModified() + ) + ) + } + } + } + walk(root, "") + return results.sortedBy { it.lastModifiedMillis } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/sync/PhotoAutoUploadScheduler.kt b/ncal/app/src/main/java/com/homelab/ncal/sync/PhotoAutoUploadScheduler.kt new file mode 100644 index 0000000..98800b1 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/sync/PhotoAutoUploadScheduler.kt @@ -0,0 +1,35 @@ +package com.homelab.ncal.sync + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +/** + * Schedules [PhotoAutoUploadWorker] to run roughly every 15 minutes (WorkManager's floor for + * periodic work) under a Wi-Fi-only constraint. [NetworkType.UNMETERED] is the closest match + * WorkManager offers to "Wi-Fi only" - it also permits an unmetered cellular connection, which + * in practice is never what a phone plan actually is, so this behaves as Wi-Fi-only for all + * real devices while staying off mobile data. + */ +object PhotoAutoUploadScheduler { + private const val WORK_NAME = "ncal_photo_auto_upload" + + fun schedule(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .build() + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(constraints) + .build() + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request) + } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/sync/PhotoAutoUploadWorker.kt b/ncal/app/src/main/java/com/homelab/ncal/sync/PhotoAutoUploadWorker.kt new file mode 100644 index 0000000..954f2dc --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/sync/PhotoAutoUploadWorker.kt @@ -0,0 +1,235 @@ +package com.homelab.ncal.sync + +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.homelab.ncal.NcalApplication +import com.homelab.ncal.data.network.CalDavException +import com.homelab.ncal.data.prefs.AutoUploadPrefs +import com.homelab.ncal.data.prefs.UploadSourceType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val TAG = "NCalAutoUpload" + +/** + * Scans the device's user-chosen photo folder(s) and/or arbitrary file folder(s) for content + * added since the last run and uploads it to each one's own user-chosen Nextcloud destination + * folder - only ever runs under a Wi-Fi ([androidx.work.NetworkType.UNMETERED]) constraint, see + * [PhotoAutoUploadScheduler]. + * + * Both which local sources are watched and where each one's content goes are user-configurable + * via [AutoUploadPrefs.mappings] (Collections/Settings screen) - deliberately not hardcoded, + * since silently uploading from/to a folder the user didn't choose would be a real privacy + * surprise. Several independent mappings can be configured at once, mixing + * [UploadSourceType.PHOTO_BUCKET] (MediaStore photo folders) and [UploadSourceType.LOCAL_FOLDER] + * (arbitrary SAF-picked folders, any file type, recursed into subfolders) freely. + */ +class PhotoAutoUploadWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val app = applicationContext as NcalApplication + val prefs = AutoUploadPrefs(applicationContext) + if (!prefs.enabled || !app.repository.isLoggedIn) { + android.util.Log.i(TAG, "Skipping: enabled=${prefs.enabled} loggedIn=${app.repository.isLoggedIn}") + return@withContext Result.success() + } + val mappings = prefs.mappings + if (mappings.isEmpty()) { + android.util.Log.i(TAG, "Skipping: no folder pairs configured") + return@withContext Result.success() + } + + runPhotoBucketMappings(app, prefs, mappings.filter { it.type == UploadSourceType.PHOTO_BUCKET }) + runLocalFolderMappings(app, prefs, mappings.filter { it.type == UploadSourceType.LOCAL_FOLDER }) + + Result.success() + } + + /** Every [UploadSourceType.PHOTO_BUCKET] mapping shares one watermark, so candidates from + * all of them are merged and processed together in time order - this way a partial failure + * only ever stops at a single correct boundary, regardless of which mapping it happened in, + * rather than each mapping tracking its own independent progress. */ + private suspend fun runPhotoBucketMappings( + app: NcalApplication, + prefs: AutoUploadPrefs, + mappings: List + ) { + if (mappings.isEmpty()) return + + val candidates = mappings.flatMap { mapping -> + queryNewPhotos(mapping.sourceKey, prefs.watermarkSeconds).map { it to mapping.destination } + }.sortedBy { (photo, _) -> photo.dateAddedSeconds } + + android.util.Log.i(TAG, "Found ${candidates.size} new photo(s) across ${mappings.size} folder pair(s) since watermark ${prefs.watermarkSeconds}") + if (candidates.isEmpty()) return + + val readyDestinations = mutableSetOf() + var highWaterMark = prefs.watermarkSeconds + for ((photo, destination) in candidates) { + if (destination !in readyDestinations) { + if (!ensureFolder(app, destination)) break + readyDestinations.add(destination) + } + + val bytes = try { + applicationContext.contentResolver.openInputStream(photo.uri)?.use { it.readBytes() } + } catch (e: Exception) { + android.util.Log.e(TAG, "Couldn't read ${photo.displayName}", e) + null + } + if (bytes == null) { + android.util.Log.e(TAG, "Couldn't read ${photo.displayName} from MediaStore, stopping for this run") + break // stop at the first unreadable/failed file - retry it (and everything after) next run + } + + try { + app.filesRepository.upload("$destination/${photo.displayName}", bytes, photo.mimeType) + highWaterMark = photo.dateAddedSeconds + } catch (e: Exception) { + android.util.Log.e(TAG, "Upload failed for ${photo.displayName}", e) + break + } + } + prefs.watermarkSeconds = highWaterMark + android.util.Log.i(TAG, "Photo buckets done, watermark now $highWaterMark") + } + + /** Each [UploadSourceType.LOCAL_FOLDER] mapping tracks its own watermark and is processed + * fully independently - one mapping's failure (e.g. a revoked SAF permission) shouldn't + * block another's progress, unlike photo buckets which share one timeline. */ + private suspend fun runLocalFolderMappings( + app: NcalApplication, + prefs: AutoUploadPrefs, + mappings: List + ) { + if (mappings.isEmpty()) return + + val watermarks = prefs.folderWatermarks.toMutableMap() + for (mapping in mappings) { + val treeUri = try { + Uri.parse(mapping.sourceKey) + } catch (e: Exception) { + android.util.Log.e(TAG, "Bad folder URI for ${mapping.sourceLabel}", e) + continue + } + val since = watermarks[mapping.sourceKey] ?: 0L + val candidates = try { + LocalFolderScanner.scan(applicationContext, treeUri, since) + } catch (e: Exception) { + // Most likely a revoked/expired SAF permission grant - log and move on to the + // next mapping rather than failing the whole run. + android.util.Log.e(TAG, "Couldn't scan ${mapping.sourceLabel}", e) + continue + } + android.util.Log.i(TAG, "Found ${candidates.size} new file(s) in ${mapping.sourceLabel} since $since") + if (candidates.isEmpty()) continue + + if (!ensureFolder(app, mapping.destination)) continue + val createdSubfolders = mutableSetOf() + var highWaterMark = since + for (file in candidates) { + val dirPart = file.relativePath.substringBeforeLast('/', "") + if (!ensureSubfolderChain(app, mapping.destination, dirPart, createdSubfolders)) break + + val bytes = try { + applicationContext.contentResolver.openInputStream(file.uri)?.use { it.readBytes() } + } catch (e: Exception) { + android.util.Log.e(TAG, "Couldn't read ${file.relativePath}", e) + null + } + if (bytes == null) { + android.util.Log.e(TAG, "Couldn't read ${file.relativePath} from ${mapping.sourceLabel}, stopping for this run") + break + } + + try { + app.filesRepository.upload("${mapping.destination}/${file.relativePath}", bytes, file.mimeType) + highWaterMark = file.lastModifiedMillis + } catch (e: Exception) { + android.util.Log.e(TAG, "Upload failed for ${file.relativePath}", e) + break + } + } + watermarks[mapping.sourceKey] = highWaterMark + android.util.Log.i(TAG, "${mapping.sourceLabel} done, watermark now $highWaterMark") + } + prefs.folderWatermarks = watermarks + } + + private suspend fun ensureFolder(app: NcalApplication, path: String): Boolean = try { + app.filesRepository.createFolder(path) + true + } catch (e: CalDavException) { + if (e.httpCode == 405) { + true + } else { + android.util.Log.e(TAG, "Couldn't create $path", e) + false + } + } + + /** Creates every level of [dirPart] (relative to [destination]) that doesn't already exist + * yet this run, shallowest-first - mirrors the same MKCOL pattern used for manual multi-file + * folder uploads in the Files screen. */ + private suspend fun ensureSubfolderChain( + app: NcalApplication, + destination: String, + dirPart: String, + createdSubfolders: MutableSet + ): Boolean { + if (dirPart.isBlank()) return true + val parts = dirPart.split('/') + for (i in parts.indices) { + val partial = parts.subList(0, i + 1).joinToString("/") + if (partial in createdSubfolders) continue + if (!ensureFolder(app, "$destination/$partial")) return false + createdSubfolders.add(partial) + } + return true + } + + private data class CameraPhoto( + val uri: android.net.Uri, + val displayName: String, + val mimeType: String, + val dateAddedSeconds: Long + ) + + private fun queryNewPhotos(bucket: String, sinceSeconds: Long): List { + val projection = arrayOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.MIME_TYPE, + MediaStore.Images.Media.DATE_ADDED + ) + val selection = "${MediaStore.Images.Media.BUCKET_DISPLAY_NAME} = ? AND ${MediaStore.Images.Media.DATE_ADDED} > ?" + val selectionArgs = arrayOf(bucket, sinceSeconds.toString()) + val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} ASC" + + val results = mutableListOf() + applicationContext.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, selection, selectionArgs, sortOrder + )?.use { cursor -> + val idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) + val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) + val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE) + val dateCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED) + while (cursor.moveToNext()) { + val id = cursor.getLong(idCol) + results.add( + CameraPhoto( + uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id), + displayName = cursor.getString(nameCol) ?: "IMG_$id.jpg", + mimeType = cursor.getString(mimeCol) ?: "image/jpeg", + dateAddedSeconds = cursor.getLong(dateCol) + ) + ) + } + } + return results + } +} 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 b5b6280..bd085f8 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 @@ -9,41 +9,19 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CheckCircle -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 -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 import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.pulltorefresh.PullToRefreshBox -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.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 @@ -52,110 +30,59 @@ import androidx.compose.ui.unit.dp import com.homelab.ncal.data.model.CalendarItem import com.homelab.ncal.data.model.ItemType import java.time.format.DateTimeFormatter -import java.time.format.TextStyle -import java.util.Locale -@OptIn(ExperimentalMaterial3Api::class) +/** The "List" tab's content inside [com.homelab.ncal.ui.calendar.CalendarHomeScreen] - no + * Scaffold/top bar of its own now that Agenda/Week/Month/Tasks share one. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable -fun AgendaScreen( +fun AgendaContent( viewModel: AgendaViewModel, onOpenItem: (CalendarItem) -> Unit, - onNewEvent: () -> Unit, - onOpenTasks: () -> Unit, - onOpenCollections: () -> Unit, - onOpenMonth: () -> Unit, - onOpenWeek: () -> Unit, - onOpenSearch: () -> Unit, - onOpenTalk: () -> Unit, - onOpenFiles: () -> Unit, - onOpenPhotos: () -> Unit + modifier: Modifier = Modifier ) { val state by viewModel.state.collectAsState() - var showMoreMenu by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.refresh() } - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text("Agenda") }, - actions = { - 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") } - 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() } - ) - } - } - } + Column(modifier.fillMaxSize()) { + state.error?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(16.dp) ) - }, - floatingActionButton = { - FloatingActionButton(onClick = onNewEvent) { Icon(Icons.Filled.Add, "New event") } } - ) { padding -> - Column(Modifier.padding(padding).fillMaxSize()) { - state.error?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(16.dp) - ) - } - PullToRefreshBox( - isRefreshing = state.refreshing, - onRefresh = viewModel::refresh, - modifier = Modifier.weight(1f) - ) { - if (state.itemsByDay.isEmpty() && !state.refreshing) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Nothing here yet. Pull to refresh, or check enabled calendars.") - } + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.weight(1f) + ) { + if (state.itemsByDay.isEmpty() && !state.refreshing) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Nothing here yet. Pull to refresh, or check enabled calendars.") } - LazyColumn(contentPadding = PaddingValues(bottom = 88.dp)) { - state.itemsByDay.forEach { (day, dayItems) -> - item(key = "header-$day") { - Text( - text = day.format(DateTimeFormatter.ofPattern("EEEE, MMM d")), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - } - items(dayItems, key = { "$day-${it.href.ifEmpty { it.uid }}" }) { agendaItem -> - AgendaRow( - item = agendaItem, - onClick = { onOpenItem(agendaItem) }, - onToggleComplete = { viewModel.toggleTask(agendaItem) } - ) - HorizontalDivider() - } + } + LazyColumn(contentPadding = PaddingValues(bottom = 88.dp)) { + state.itemsByDay.forEach { (day, dayItems) -> + item(key = "header-$day") { + Text( + text = day.format(DateTimeFormatter.ofPattern("EEEE, MMM d")), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + items(dayItems, key = { "$day-${it.href.ifEmpty { it.uid }}" }) { agendaItem -> + AgendaRow( + item = agendaItem, + onClick = { onOpenItem(agendaItem) }, + onToggleComplete = { viewModel.toggleTask(agendaItem) } + ) + HorizontalDivider() } } } diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/calendar/CalendarHomeScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/calendar/CalendarHomeScreen.kt new file mode 100644 index 0000000..99bdd7d --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/calendar/CalendarHomeScreen.kt @@ -0,0 +1,102 @@ +package com.homelab.ncal.ui.calendar + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.ui.agenda.AgendaContent +import com.homelab.ncal.ui.agenda.AgendaViewModel +import com.homelab.ncal.ui.month.MonthContent +import com.homelab.ncal.ui.month.MonthViewModel +import com.homelab.ncal.ui.tasks.TasksContent +import com.homelab.ncal.ui.tasks.TasksViewModel +import com.homelab.ncal.ui.week.WeekContent +import com.homelab.ncal.ui.week.WeekViewModel + +private enum class CalendarTab(val label: String) { + MONTH("Month"), WEEK("Week"), LIST("List"), TASKS("Tasks") +} + +/** + * The app's home screen: List/Week/Month/Tasks used to be four separate destinations reached + * by navigating away and back, which meant a lot of top-bar icons and full screen transitions + * just to change how you're looking at the same data. They're tabs of one screen instead now. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarHomeScreen( + agendaViewModel: AgendaViewModel, + weekViewModel: WeekViewModel, + monthViewModel: MonthViewModel, + tasksViewModel: TasksViewModel, + onOpenItem: (CalendarItem) -> Unit, + onOpenTask: (CalendarItem) -> Unit, + onNewEvent: () -> Unit, + onNewTask: () -> Unit, + onOpenSearch: () -> Unit, + onOpenTalk: () -> Unit, + onOpenFiles: () -> Unit, + onOpenPhotos: () -> Unit, + onOpenCollections: () -> Unit +) { + var tab by remember { mutableStateOf(CalendarTab.MONTH) } + + Scaffold( + topBar = { + Column { + CenterAlignedTopAppBar( + title = { Text("Calendar") }, + actions = { + IconButton(onClick = onOpenSearch) { Icon(Icons.Filled.Search, "Search") } + IconButton(onClick = onOpenTalk) { Icon(Icons.Filled.Chat, "Talk") } + IconButton(onClick = onOpenFiles) { Icon(Icons.Filled.Folder, "Files") } + IconButton(onClick = onOpenPhotos) { Icon(Icons.Filled.Photo, "Photos") } + IconButton(onClick = onOpenCollections) { Icon(Icons.Filled.Settings, "Calendars") } + } + ) + TabRow(selectedTabIndex = tab.ordinal) { + CalendarTab.entries.forEach { t -> + Tab(selected = tab == t, onClick = { tab = t }, text = { Text(t.label) }) + } + } + } + }, + floatingActionButton = { + FloatingActionButton(onClick = if (tab == CalendarTab.TASKS) onNewTask else onNewEvent) { + Icon(Icons.Filled.Add, if (tab == CalendarTab.TASKS) "New task" else "New event") + } + } + ) { padding -> + Box(Modifier.padding(padding).fillMaxSize()) { + when (tab) { + CalendarTab.LIST -> AgendaContent(agendaViewModel, onOpenItem) + CalendarTab.WEEK -> WeekContent(weekViewModel, onOpenItem) + CalendarTab.MONTH -> MonthContent(monthViewModel, onOpenItem) + CalendarTab.TASKS -> TasksContent(tasksViewModel, onOpenTask) + } + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt index 3955698..a2ec6d2 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt @@ -1,7 +1,10 @@ package com.homelab.ncal.ui.collections +import android.Manifest import android.content.Intent import android.net.Uri +import android.os.Build +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Canvas @@ -15,23 +18,32 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Photo import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.AlertDialog import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 @@ -45,9 +57,24 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import com.homelab.ncal.data.model.CalendarCollection +import com.homelab.ncal.data.network.DavFile +import com.homelab.ncal.data.prefs.AutoUploadPrefs +import com.homelab.ncal.data.prefs.UploadMapping +import com.homelab.ncal.data.prefs.UploadSourceType import kotlinx.coroutines.launch import java.io.File +/** Where the user currently is in the "add a folder pair" flow: choosing whether this pair + * watches a photo folder or an arbitrary file folder, picking the source, then picking a + * destination for it. */ +private sealed class AddMappingStep { + object None : AddMappingStep() + object ChoosingKind : AddMappingStep() + object PickingBucket : AddMappingStep() + data class PickingDestinationForBucket(val bucket: String) : AddMappingStep() + data class PickingDestinationForFolder(val treeUri: Uri) : AddMappingStep() +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun CollectionsScreen( @@ -59,11 +86,51 @@ fun CollectionsScreen( val scope = rememberCoroutineScope() var pendingImportUri by remember { mutableStateOf(null) } + var addMappingStep by remember { mutableStateOf(AddMappingStep.None) } + // Set when tapping an *existing* mapping row to change its destination, as opposed to + // adding a brand new one - both cases reuse the same RemoteFolderPickerDialog below. + var editingMapping by remember { mutableStateOf(null) } val openDocument = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri != null) pendingImportUri = uri } + val pickLocalFolder = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { treeUri -> + if (treeUri != null) { + addMappingStep = AddMappingStep.PickingDestinationForFolder(treeUri) + } else { + addMappingStep = AddMappingStep.None + } + } + + val mediaPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + // What to do once the media permission is confirmed granted - turning the feature on, or + // just opening the source-folder picker (which also needs to read MediaStore to list + // folders), depending on which control the user tapped. + var pendingPermissionAction by remember { mutableStateOf<(() -> Unit)?>(null) } + val mediaPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + pendingPermissionAction?.invoke() + } else { + Toast.makeText(context, "Photo access needed for auto-upload", Toast.LENGTH_SHORT).show() + } + pendingPermissionAction = null + } + fun withMediaPermission(action: () -> Unit) { + val alreadyGranted = androidx.core.content.ContextCompat.checkSelfPermission(context, mediaPermission) == + android.content.pm.PackageManager.PERMISSION_GRANTED + if (alreadyGranted) { + action() + } else { + pendingPermissionAction = action + mediaPermissionLauncher.launch(mediaPermission) + } + } + Scaffold( topBar = { CenterAlignedTopAppBar( @@ -96,6 +163,46 @@ fun CollectionsScreen( TextButton(onClick = viewModel::dismissImportStatus) { Text("Dismiss") } } } + ListItem( + headlineContent = { Text("Auto-upload") }, + supportingContent = { Text("Upload new photos and files to Nextcloud on Wi-Fi") }, + trailingContent = { + Switch( + checked = state.autoUploadEnabled, + onCheckedChange = { turnOn -> + if (turnOn) { + withMediaPermission { viewModel.setAutoUploadEnabled(true) } + } else { + viewModel.setAutoUploadEnabled(false) + } + } + ) + } + ) + state.autoUploadMappings.forEach { mapping -> + ListItem( + headlineContent = { Text(mapping.sourceLabel) }, + supportingContent = { Text("→ ${mapping.destination}") }, + leadingContent = { + Icon( + if (mapping.type == UploadSourceType.PHOTO_BUCKET) Icons.Filled.Photo else Icons.Filled.Folder, + null + ) + }, + trailingContent = { + IconButton(onClick = { viewModel.removeMapping(mapping.type, mapping.sourceKey) }) { + Icon(Icons.Filled.Delete, "Remove ${mapping.sourceLabel}") + } + }, + modifier = Modifier.clickable { editingMapping = mapping } + ) + } + ListItem( + headlineContent = { Text("Add folder to upload") }, + leadingContent = { Icon(Icons.Filled.Add, null) }, + modifier = Modifier.clickable { addMappingStep = AddMappingStep.ChoosingKind } + ) + HorizontalDivider() LazyColumn { items(state.collections, key = { it.url }) { col -> ListItem( @@ -145,6 +252,219 @@ fun CollectionsScreen( } ) } + + when (val step = addMappingStep) { + is AddMappingStep.None -> {} + is AddMappingStep.ChoosingKind -> { + AlertDialog( + onDismissRequest = { addMappingStep = AddMappingStep.None }, + title = { Text("What do you want to upload?") }, + text = { + Column { + ListItem( + headlineContent = { Text("Photos") }, + supportingContent = { Text("A folder of photos, e.g. Camera or Screenshots") }, + leadingContent = { Icon(Icons.Filled.Photo, null) }, + modifier = Modifier.fillMaxWidth().clickable { + withMediaPermission { addMappingStep = AddMappingStep.PickingBucket } + } + ) + ListItem( + headlineContent = { Text("Files & folders") }, + supportingContent = { Text("Any folder on this device, including subfolders") }, + leadingContent = { Icon(Icons.Filled.Folder, null) }, + modifier = Modifier.fillMaxWidth().clickable { pickLocalFolder.launch(null) } + ) + } + }, + confirmButton = { + TextButton(onClick = { addMappingStep = AddMappingStep.None }) { Text("Cancel") } + } + ) + } + is AddMappingStep.PickingBucket -> { + BucketChooserDialog( + viewModel = viewModel, + alreadyMapped = state.autoUploadMappings + .filter { it.type == UploadSourceType.PHOTO_BUCKET } + .map { it.sourceKey }.toSet(), + onDismiss = { addMappingStep = AddMappingStep.None }, + onSelect = { bucket -> addMappingStep = AddMappingStep.PickingDestinationForBucket(bucket) } + ) + } + is AddMappingStep.PickingDestinationForBucket -> { + RemoteFolderPickerDialog( + viewModel = viewModel, + initialPath = AutoUploadPrefs.DEFAULT_DESTINATION, + onDismiss = { addMappingStep = AddMappingStep.None }, + onConfirm = { path -> + viewModel.addOrReplacePhotoMapping(step.bucket, path) + addMappingStep = AddMappingStep.None + } + ) + } + is AddMappingStep.PickingDestinationForFolder -> { + RemoteFolderPickerDialog( + viewModel = viewModel, + initialPath = AutoUploadPrefs.DEFAULT_DESTINATION, + onDismiss = { addMappingStep = AddMappingStep.None }, + onConfirm = { path -> + viewModel.addOrReplaceFolderMapping(step.treeUri, path) + addMappingStep = AddMappingStep.None + } + ) + } + } + + editingMapping?.let { mapping -> + RemoteFolderPickerDialog( + viewModel = viewModel, + initialPath = mapping.destination, + onDismiss = { editingMapping = null }, + onConfirm = { path -> + if (mapping.type == UploadSourceType.PHOTO_BUCKET) { + viewModel.addOrReplacePhotoMapping(mapping.sourceKey, path) + } else { + viewModel.addOrReplaceFolderMapping(Uri.parse(mapping.sourceKey), path) + } + editingMapping = null + } + ) + } +} + +/** Single-select source-folder chooser for the "add a folder pair" flow - tapping a folder + * immediately proceeds to the destination picker, since (unlike the old flat multi-select + * bucket list this replaced) each folder pair is added one at a time. */ +@Composable +private fun BucketChooserDialog( + viewModel: CollectionsViewModel, + alreadyMapped: Set, + onDismiss: () -> Unit, + onSelect: (String) -> Unit +) { + var available by remember { mutableStateOf?>(null) } + + LaunchedEffect(Unit) { + available = viewModel.loadAvailableBuckets().sorted() + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Upload photos from") }, + text = { + val buckets = available + when { + buckets == null -> CircularProgressIndicator(Modifier.padding(16.dp)) + buckets.isEmpty() -> Text("No photo folders found on this device.") + else -> Column { + buckets.forEach { bucket -> + ListItem( + headlineContent = { Text(bucket) }, + supportingContent = { + if (bucket in alreadyMapped) Text("Already set up - pick again to change its destination") + }, + modifier = Modifier.fillMaxWidth().clickable { onSelect(bucket) } + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +/** Browses the user's real Nextcloud Files space (reusing [CollectionsViewModel.filesRepository] + * - the same WebDAV browsing already used by the Files screen) so the auto-upload destination + * can be picked from real folders, or a new one created on the spot, rather than the user + * having to type a path from memory. */ +@Composable +private fun RemoteFolderPickerDialog( + viewModel: CollectionsViewModel, + initialPath: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var currentPath by remember { mutableStateOf(initialPath.trim('/')) } + var entries by remember { mutableStateOf?>(null) } + var error by remember { mutableStateOf(null) } + var newFolderName by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + suspend fun load(path: String) { + entries = null + error = null + entries = try { + viewModel.filesRepository.listDirectory(path).filter { it.isDirectory } + } catch (e: Exception) { + error = e.message ?: "Couldn't load folders" + emptyList() + } + } + + LaunchedEffect(currentPath) { load(currentPath) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("/" + currentPath) }, + text = { + Column { + if (currentPath.isNotBlank()) { + ListItem( + headlineContent = { Text("..") }, + leadingContent = { Icon(Icons.Filled.Folder, null) }, + modifier = Modifier.fillMaxWidth().clickable { + currentPath = currentPath.substringBeforeLast('/', "") + } + ) + } + when { + error != null -> Text(error!!, color = MaterialTheme.colorScheme.error) + entries == null -> CircularProgressIndicator(Modifier.padding(16.dp)) + else -> entries!!.forEach { dir -> + ListItem( + headlineContent = { Text(dir.name) }, + leadingContent = { Icon(Icons.Filled.Folder, null) }, + modifier = Modifier.fillMaxWidth().clickable { currentPath = dir.path } + ) + } + } + Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { + OutlinedTextField( + value = newFolderName, + onValueChange = { newFolderName = it }, + label = { Text("New folder") }, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { + val name = newFolderName.trim() + if (name.isNotEmpty()) { + scope.launch { + val newPath = if (currentPath.isBlank()) name else "$currentPath/$name" + try { + viewModel.filesRepository.createFolder(newPath) + newFolderName = "" + currentPath = newPath + } catch (e: Exception) { + error = e.message ?: "Couldn't create folder" + } + } + } + }) { + Icon(Icons.Filled.CreateNewFolder, "Create folder") + } + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(currentPath) }) { Text("Select this folder") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) } @Composable diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt index 11a9cb0..b59b8c7 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt @@ -1,35 +1,114 @@ package com.homelab.ncal.ui.collections +import android.content.Intent +import android.net.Uri +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.homelab.ncal.NcalApplication import com.homelab.ncal.data.model.CalendarCollection +import com.homelab.ncal.data.prefs.AutoUploadPrefs +import com.homelab.ncal.data.prefs.UploadMapping +import com.homelab.ncal.data.prefs.UploadSourceType +import com.homelab.ncal.data.repository.FilesRepository import com.homelab.ncal.data.repository.NextcloudRepository +import com.homelab.ncal.sync.CameraBuckets +import com.homelab.ncal.sync.PhotoAutoUploadScheduler import com.homelab.ncal.util.toUserMessage +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext data class CollectionsUiState( val collections: List = emptyList(), val refreshing: Boolean = false, val error: String? = null, - val importStatus: String? = null + val importStatus: String? = null, + val autoUploadEnabled: Boolean = false, + val autoUploadMappings: List = emptyList() ) -class CollectionsViewModel(private val repo: NextcloudRepository) : ViewModel() { +class CollectionsViewModel(private val repo: NextcloudRepository, private val app: NcalApplication) : ViewModel() { + + /** Exposed directly so the destination-folder picker dialog can browse/create folders + * without this ViewModel needing to re-wrap every [FilesRepository] method. */ + val filesRepository: FilesRepository get() = app.filesRepository + + private val autoUploadPrefs = AutoUploadPrefs(app) private val refreshing = MutableStateFlow(false) private val error = MutableStateFlow(null) private val importStatus = MutableStateFlow(null) + private val autoUploadEnabled = MutableStateFlow(autoUploadPrefs.enabled) + private val autoUploadMappings = MutableStateFlow(autoUploadPrefs.mappings) val state: StateFlow = combine( - repo.observeCollections(), refreshing, error, importStatus - ) { collections, isRefreshing, err, importMsg -> - CollectionsUiState(collections, isRefreshing, err, importMsg) + repo.observeCollections(), refreshing, error, importStatus, + combine(autoUploadEnabled, autoUploadMappings, ::Pair) + ) { collections, isRefreshing, err, importMsg, autoUpload -> + val (enabled, mappings) = autoUpload + CollectionsUiState(collections, isRefreshing, err, importMsg, enabled, mappings) }.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), CollectionsUiState()) + /** Called only after the caller has confirmed the needed media-read permission is granted + * (see [CollectionsScreen]) - this layer just persists the toggle and (de)schedules the + * background worker. */ + fun setAutoUploadEnabled(enabled: Boolean) { + autoUploadPrefs.enabled = enabled + if (enabled) { + // Seed the watermark at "now" rather than 0 so turning this on doesn't suddenly + // try to upload the device's entire pre-existing camera roll. + if (autoUploadPrefs.watermarkSeconds == 0L) { + autoUploadPrefs.watermarkSeconds = System.currentTimeMillis() / 1000 + } + PhotoAutoUploadScheduler.schedule(app) + } else { + PhotoAutoUploadScheduler.cancel(app) + } + autoUploadEnabled.value = enabled + } + + /** Loads the device's available MediaStore photo folders for the source-folder picker - + * only call once the media-read permission is confirmed granted. */ + suspend fun loadAvailableBuckets(): List = withContext(Dispatchers.IO) { + CameraBuckets.listAvailable(app) + } + + /** Adds a new photo-bucket -> destination pair, or replaces the destination of an existing + * one if [bucket] is already mapped - each source has exactly one destination at a time. */ + fun addOrReplacePhotoMapping(bucket: String, destination: String) { + addOrReplaceMapping(UploadMapping(UploadSourceType.PHOTO_BUCKET, bucket, bucket, "/" + destination.trim('/'))) + } + + /** Persists a read grant on [treeUri] (survives process death/reboots, unlike a one-shot + * Intent grant) so the background worker can read this folder later without the user + * present, then adds/replaces its mapping to [destination]. */ + fun addOrReplaceFolderMapping(treeUri: Uri, destination: String) { + app.contentResolver.takePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + val label = DocumentFile.fromTreeUri(app, treeUri)?.name ?: treeUri.lastPathSegment ?: treeUri.toString() + addOrReplaceMapping(UploadMapping(UploadSourceType.LOCAL_FOLDER, treeUri.toString(), label, "/" + destination.trim('/'))) + } + + private fun addOrReplaceMapping(mapping: UploadMapping) { + val updated = autoUploadPrefs.mappings + .filterNot { it.type == mapping.type && it.sourceKey == mapping.sourceKey } + mapping + autoUploadPrefs.mappings = updated + autoUploadMappings.value = updated + } + + fun removeMapping(type: UploadSourceType, sourceKey: String) { + val updated = autoUploadPrefs.mappings.filterNot { it.type == type && it.sourceKey == sourceKey } + autoUploadPrefs.mappings = updated + autoUploadMappings.value = updated + } + fun refresh() { refreshing.value = true viewModelScope.launch { diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserScreen.kt index 8e437f5..e104bbd 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserScreen.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserScreen.kt @@ -1,6 +1,8 @@ package com.homelab.ncal.ui.files +import android.content.Context import android.content.Intent +import android.net.Uri import android.webkit.MimeTypeMap import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -19,6 +21,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.DriveFolderUpload import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material.icons.filled.UploadFile @@ -52,6 +55,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider +import androidx.documentfile.provider.DocumentFile import com.homelab.ncal.data.network.DavFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -77,21 +81,19 @@ fun FilesBrowserScreen( LaunchedEffect(Unit) { viewModel.refresh() } BackHandler(enabled = state.path.isNotBlank()) { viewModel.navigateUp() } - val uploadLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - if (uri == null) return@rememberLauncherForActivityResult + val uploadLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + if (uris.isEmpty()) return@rememberLauncherForActivityResult scope.launch { - val (name, mimeType, bytes) = withContext(Dispatchers.IO) { - val resolver = context.contentResolver - var displayName = "upload" - resolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (cursor.moveToFirst() && nameIndex >= 0) displayName = cursor.getString(nameIndex) - } - val mime = resolver.getType(uri) ?: "application/octet-stream" - val data = resolver.openInputStream(uri)?.use { it.readBytes() } ?: ByteArray(0) - Triple(displayName, mime, data) - } - viewModel.upload(name, bytes, mimeType) + val items = withContext(Dispatchers.IO) { uris.map { readUploadItem(context, it) } } + viewModel.uploadBatch(items) + } + } + + val folderUploadLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { treeUri -> + if (treeUri == null) return@rememberLauncherForActivityResult + scope.launch { + val items = withContext(Dispatchers.IO) { walkDocumentTree(context, treeUri) } + viewModel.uploadBatch(items) } } @@ -111,9 +113,14 @@ fun FilesBrowserScreen( ExtendedFloatingActionButton(onClick = { showFabMenu = true }, text = { Text("Add") }, icon = { Icon(Icons.Filled.Add, null) }) DropdownMenu(expanded = showFabMenu, onDismissRequest = { showFabMenu = false }) { DropdownMenuItem( - text = { Text("Upload file") }, + text = { Text("Upload files") }, leadingIcon = { Icon(Icons.Filled.UploadFile, null) }, - onClick = { showFabMenu = false; uploadLauncher.launch("*/*") } + onClick = { showFabMenu = false; uploadLauncher.launch(arrayOf("*/*")) } + ) + DropdownMenuItem( + text = { Text("Upload folder") }, + leadingIcon = { Icon(Icons.Filled.DriveFolderUpload, null) }, + onClick = { showFabMenu = false; folderUploadLauncher.launch(null) } ) DropdownMenuItem( text = { Text("New folder") }, @@ -232,6 +239,44 @@ private fun formatSize(bytes: Long): String = when { else -> "$bytes B" } +private fun readUploadItem(context: Context, uri: Uri): UploadItem { + val resolver = context.contentResolver + var displayName = "upload" + resolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (cursor.moveToFirst() && nameIndex >= 0) displayName = cursor.getString(nameIndex) + } + val mime = resolver.getType(uri) ?: "application/octet-stream" + val data = resolver.openInputStream(uri)?.use { it.readBytes() } ?: ByteArray(0) + return UploadItem(displayName, data, mime) +} + +/** Recursively walks a SAF-picked folder tree into a flat list of [UploadItem]s, nested under + * a top-level relative path matching the picked folder's own name (so uploading "Vacation" + * creates a real "Vacation" subfolder on the server, rather than dumping its contents loose + * into whatever folder was open in NCal). */ +private fun walkDocumentTree(context: Context, treeUri: Uri): List { + val root = DocumentFile.fromTreeUri(context, treeUri) ?: return emptyList() + val items = mutableListOf() + + fun walk(dir: DocumentFile, relativePrefix: String) { + dir.listFiles().forEach { child -> + val childName = child.name ?: return@forEach + val relativePath = if (relativePrefix.isBlank()) childName else "$relativePrefix/$childName" + if (child.isDirectory) { + walk(child, relativePath) + } else { + val mime = child.type ?: "application/octet-stream" + val bytes = context.contentResolver.openInputStream(child.uri)?.use { it.readBytes() } ?: ByteArray(0) + items.add(UploadItem(relativePath, bytes, mime)) + } + } + } + + walk(root, root.name ?: "upload") + return items +} + /** Downloads [file] to a cache file and opens it with whatever app the device has for its type. */ internal suspend fun openWithViewer(context: android.content.Context, viewModel: FilesBrowserViewModel, file: DavFile) { val dir = File(context.cacheDir, "downloads").apply { mkdirs() } diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserViewModel.kt index 03192d8..1715cb3 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserViewModel.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/files/FilesBrowserViewModel.kt @@ -2,6 +2,7 @@ package com.homelab.ncal.ui.files import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.homelab.ncal.data.network.CalDavException import com.homelab.ncal.data.network.DavFile import com.homelab.ncal.data.repository.FilesRepository import kotlinx.coroutines.flow.MutableStateFlow @@ -9,6 +10,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.io.OutputStream +/** One file to upload, [relativePath] relative to the current folder - just a filename for a + * flat multi-file pick, or "subfolder/nested/file.txt" for a recursive folder upload. */ +data class UploadItem(val relativePath: String, val bytes: ByteArray, val mimeType: String) + data class FilesBrowserUiState( val path: String = "", val entries: List = emptyList(), @@ -71,8 +76,59 @@ class FilesBrowserViewModel(private val repo: FilesRepository) : ViewModel() { } fun upload(name: String, bytes: ByteArray, mimeType: String) { - val newPath = joinPath(_state.value.path, name) - runBusy { repo.upload(newPath, bytes, mimeType) } + uploadBatch(listOf(UploadItem(name, bytes, mimeType))) + } + + /** Uploads several files at once, e.g. from a multi-select picker or a recursively-walked + * folder pick. Creates any subfolders the items' relative paths imply first (WebDAV MKCOL + * needs each level's immediate parent to already exist, so this creates shallowest-first + * and tolerates 405 "already exists" rather than treating it as a failure), then uploads + * every file. Partial failures don't abort the batch - they're collected and reported + * together so one bad file doesn't silently swallow the rest. + */ + fun uploadBatch(items: List) { + if (items.isEmpty()) return + _state.value = _state.value.copy(busy = true, error = null) + viewModelScope.launch { + val failures = mutableListOf() + + val allDirs = items.flatMap { item -> + val dirPart = item.relativePath.substringBeforeLast('/', "") + if (dirPart.isBlank()) { + emptyList() + } else { + val parts = dirPart.split('/') + parts.indices.map { i -> parts.subList(0, i + 1).joinToString("/") } + } + }.distinct().sortedBy { it.count { c -> c == '/' } } + + for (dir in allDirs) { + try { + repo.createFolder(joinPath(_state.value.path, dir)) + } catch (e: CalDavException) { + if (e.httpCode != 405) failures.add(dir) + } catch (e: Exception) { + failures.add(dir) + } + } + + for (item in items) { + try { + repo.upload(joinPath(_state.value.path, item.relativePath), item.bytes, item.mimeType) + } catch (e: Exception) { + failures.add(item.relativePath) + } + } + + _state.value = _state.value.copy( + busy = false, + error = if (failures.isEmpty()) null else { + val shown = failures.take(5).joinToString(", ") + "Couldn't upload: $shown" + if (failures.size > 5) " (+${failures.size - 5} more)" else "" + } + ) + refresh() + } } /** Downloads [file] into [output] - the caller owns opening/closing the stream (a cache diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthScreen.kt index ebc0d78..442ff7c 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthScreen.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthScreen.kt @@ -17,23 +17,17 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Event import androidx.compose.material.icons.filled.RadioButtonUnchecked -import androidx.compose.material.icons.filled.Today -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -58,77 +52,69 @@ import java.time.format.TextStyle import java.time.temporal.WeekFields import java.util.Locale +/** The "Month" tab's content inside [com.homelab.ncal.ui.calendar.CalendarHomeScreen] - no + * Scaffold/top bar of its own now that Agenda/Week/Month/Tasks share one. Its own header + * (month/year label + today shortcut + prev/next) still lives here since it's specific to + * this view, not something the shared outer top bar could show meaningfully. */ @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable -fun MonthScreen( +fun MonthContent( viewModel: MonthViewModel, onOpenItem: (CalendarItem) -> Unit, - onNewEvent: () -> Unit, - onBack: () -> Unit + modifier: Modifier = Modifier ) { val state by viewModel.state.collectAsState() - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text(state.month.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) + " " + state.month.year) }, - navigationIcon = { - IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) } - }, - actions = { - IconButton(onClick = viewModel::goToToday) { Icon(Icons.Filled.Today, "Today") } - } - ) - }, - floatingActionButton = { - FloatingActionButton(onClick = onNewEvent) { Icon(Icons.Filled.Add, "New event") } - } - ) { padding -> - PullToRefreshBox( - isRefreshing = state.refreshing, - onRefresh = viewModel::refresh, - modifier = Modifier.padding(padding).fillMaxSize() - ) { - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - state.error?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(16.dp) - ) - } - MonthHeader(onPrev = viewModel::prevMonth, onNext = viewModel::nextMonth) - WeekdayLabels() - MonthGrid( - month = state.month, - selectedDay = state.selectedDay, - itemsByDay = state.itemsByDay, - onDayClick = viewModel::selectDay, - onItemClick = onOpenItem, - onToggleTask = viewModel::toggleTask - ) - HorizontalDivider() - DayAgenda( - day = state.selectedDay, - items = state.itemsByDay[state.selectedDay].orEmpty(), - onOpenItem = onOpenItem, - onToggleTask = viewModel::toggleTask + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = viewModel::refresh, + modifier = modifier.fillMaxSize() + ) { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + state.error?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(16.dp) ) } + MonthHeader( + label = state.month.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) + " " + state.month.year, + onPrev = viewModel::prevMonth, + onNext = viewModel::nextMonth, + onToday = viewModel::goToToday + ) + WeekdayLabels() + MonthGrid( + month = state.month, + selectedDay = state.selectedDay, + itemsByDay = state.itemsByDay, + onDayClick = viewModel::selectDay, + onItemClick = onOpenItem, + onToggleTask = viewModel::toggleTask + ) + HorizontalDivider() + DayAgenda( + day = state.selectedDay, + items = state.itemsByDay[state.selectedDay].orEmpty(), + onOpenItem = onOpenItem, + onToggleTask = viewModel::toggleTask + ) } } } @Composable -private fun MonthHeader(onPrev: () -> Unit, onNext: () -> Unit) { +private fun MonthHeader(label: String, onPrev: () -> Unit, onNext: () -> Unit, onToday: () -> Unit) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = onPrev) { Icon(Icons.Filled.ChevronLeft, "Previous month") } + Text(label, style = MaterialTheme.typography.titleMedium, modifier = Modifier.clickable(onClick = onToday)) IconButton(onClick = onNext) { Icon(Icons.Filled.ChevronRight, "Next month") } } } 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 dd3bf17..f4f86e8 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 @@ -27,8 +27,8 @@ import com.homelab.ncal.data.model.CalendarItem import com.homelab.ncal.ui.FilesViewModelFactory import com.homelab.ncal.ui.NcalViewModelFactory import com.homelab.ncal.ui.TalkViewModelFactory -import com.homelab.ncal.ui.agenda.AgendaScreen import com.homelab.ncal.ui.agenda.AgendaViewModel +import com.homelab.ncal.ui.calendar.CalendarHomeScreen import com.homelab.ncal.ui.files.FilesBrowserScreen import com.homelab.ncal.ui.files.FilesBrowserViewModel import com.homelab.ncal.ui.photos.PhotosGridScreen @@ -39,7 +39,6 @@ import com.homelab.ncal.ui.detail.ItemEditScreen import com.homelab.ncal.ui.detail.ItemEditViewModel import com.homelab.ncal.ui.login.LoginScreen import com.homelab.ncal.ui.login.LoginViewModel -import com.homelab.ncal.ui.month.MonthScreen import com.homelab.ncal.ui.month.MonthViewModel import com.homelab.ncal.ui.search.SearchScreen import com.homelab.ncal.ui.search.SearchViewModel @@ -47,9 +46,7 @@ import com.homelab.ncal.ui.talk.TalkChatScreen import com.homelab.ncal.ui.talk.TalkChatViewModel import com.homelab.ncal.ui.talk.TalkRoomListScreen import com.homelab.ncal.ui.talk.TalkRoomListViewModel -import com.homelab.ncal.ui.tasks.TasksScreen import com.homelab.ncal.ui.tasks.TasksViewModel -import com.homelab.ncal.ui.week.WeekScreen import com.homelab.ncal.ui.week.WeekViewModel import java.net.URLDecoder import java.net.URLEncoder @@ -57,9 +54,6 @@ import java.net.URLEncoder private object Routes { const val LOGIN = "login" const val AGENDA = "agenda" - const val TASKS = "tasks" - const val MONTH = "month" - const val WEEK = "week" const val COLLECTIONS = "collections" const val SEARCH = "search" const val EDIT = "edit/{type}/{href}/{calendarUrl}" @@ -131,21 +125,24 @@ fun NcalNavGraph( } composable(Routes.AGENDA) { - val vm: AgendaViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> AgendaViewModel(repo) }) - AgendaScreen( - viewModel = vm, - onOpenItem = { item: CalendarItem -> - navController.navigate(editRoute(item.type.name, item.href)) - }, + val agendaVm: AgendaViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> AgendaViewModel(repo) }) + val weekVm: WeekViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> WeekViewModel(repo) }) + val monthVm: MonthViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> MonthViewModel(repo) }) + val tasksVm: TasksViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> TasksViewModel(repo) }) + CalendarHomeScreen( + agendaViewModel = agendaVm, + weekViewModel = weekVm, + monthViewModel = monthVm, + tasksViewModel = tasksVm, + onOpenItem = { item: CalendarItem -> navController.navigate(editRoute(item.type.name, item.href)) }, + onOpenTask = { item: CalendarItem -> navController.navigate(editRoute("TASK", item.href)) }, onNewEvent = { navController.navigate(editRoute("EVENT", null)) }, - onOpenTasks = { navController.navigate(Routes.TASKS) }, - onOpenCollections = { navController.navigate(Routes.COLLECTIONS) }, - onOpenMonth = { navController.navigate(Routes.MONTH) }, - onOpenWeek = { navController.navigate(Routes.WEEK) }, + onNewTask = { navController.navigate(editRoute("TASK", null)) }, onOpenSearch = { navController.navigate(Routes.SEARCH) }, onOpenTalk = { navController.navigate(Routes.TALK_ROOMS) }, onOpenFiles = { navController.navigate(Routes.FILES) }, - onOpenPhotos = { navController.navigate(Routes.PHOTOS) } + onOpenPhotos = { navController.navigate(Routes.PHOTOS) }, + onOpenCollections = { navController.navigate(Routes.COLLECTIONS) } ) } @@ -158,38 +155,8 @@ fun NcalNavGraph( ) } - composable(Routes.MONTH) { - val vm: MonthViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> MonthViewModel(repo) }) - MonthScreen( - viewModel = vm, - onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) }, - onNewEvent = { navController.navigate(editRoute("EVENT", null)) }, - onBack = { navController.popBackStack() } - ) - } - - composable(Routes.WEEK) { - val vm: WeekViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> WeekViewModel(repo) }) - WeekScreen( - viewModel = vm, - onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) }, - onNewEvent = { navController.navigate(editRoute("EVENT", null)) }, - onBack = { navController.popBackStack() } - ) - } - - composable(Routes.TASKS) { - val vm: TasksViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> TasksViewModel(repo) }) - TasksScreen( - viewModel = vm, - onOpenTask = { item -> navController.navigate(editRoute("TASK", item.href)) }, - onNewTask = { navController.navigate(editRoute("TASK", null)) }, - onBack = { navController.popBackStack() } - ) - } - composable(Routes.COLLECTIONS) { - val vm: CollectionsViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> CollectionsViewModel(repo) }) + val vm: CollectionsViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> CollectionsViewModel(repo, app) }) CollectionsScreen(viewModel = vm, onBack = { navController.popBackStack() }) } 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 index 60d3af4..810823c 100644 --- 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 @@ -1,11 +1,24 @@ package com.homelab.ncal.ui.photos +import android.Manifest +import android.content.ContentValues +import android.content.Context +import android.media.MediaScannerConnection +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts 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.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -18,11 +31,14 @@ 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.AddAPhoto import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download 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.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -32,7 +48,10 @@ 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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -43,7 +62,13 @@ import coil.ImageLoader import coil.compose.AsyncImage import coil.request.ImageRequest import coil.size.Size +import com.homelab.ncal.data.network.DavFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import java.io.File +import java.io.FileOutputStream @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -53,6 +78,7 @@ fun PhotosGridScreen( ) { val state by viewModel.state.collectAsState() val context = LocalContext.current + val scope = rememberCoroutineScope() val imageLoader = remember(viewModel) { val authHeader = viewModel.authHeader() @@ -68,8 +94,27 @@ fun PhotosGridScreen( BackHandler(enabled = state.path.isNotBlank() && state.viewerIndex == null) { viewModel.navigateUp() } BackHandler(enabled = state.viewerIndex != null) { viewModel.closeViewer() } + val uploadLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + val (name, mimeType, bytes) = withContext(Dispatchers.IO) { + val resolver = context.contentResolver + var displayName = "photo.jpg" + resolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (cursor.moveToFirst() && nameIndex >= 0) displayName = cursor.getString(nameIndex) + } + val mime = resolver.getType(uri) ?: "image/jpeg" + val data = resolver.openInputStream(uri)?.use { it.readBytes() } ?: ByteArray(0) + Triple(displayName, mime, data) + } + viewModel.upload(name, bytes, mimeType) + } + } + if (state.viewerIndex != null) { PhotoViewer( + viewModel = viewModel, images = state.images, startIndex = state.viewerIndex!!, imageLoader = imageLoader, @@ -89,10 +134,15 @@ fun PhotosGridScreen( } } ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { uploadLauncher.launch("image/*") }) { + Icon(Icons.Filled.AddAPhoto, "Upload photo") + } } ) { padding -> Box(Modifier.fillMaxSize().padding(padding)) { - if (state.loading) { + if (state.loading || state.busy) { CircularProgressIndicator(modifier = Modifier.align(Alignment.TopCenter).padding(16.dp)) } if (state.folders.isEmpty() && state.images.isEmpty() && !state.loading && state.error == null) { @@ -143,23 +193,52 @@ fun PhotosGridScreen( @OptIn(ExperimentalFoundationApi::class) @Composable private fun PhotoViewer( - images: List, + viewModel: PhotosGridViewModel, + images: List, startIndex: Int, imageLoader: ImageLoader, - urlFor: (com.homelab.ncal.data.network.DavFile) -> String, + urlFor: (DavFile) -> String, onClose: () -> Unit ) { val context = LocalContext.current + val scope = rememberCoroutineScope() 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). + // Only needed pre-Android-10 - saving via MediaStore on API 29+ needs no permission. + var pendingSaveIndex by remember { mutableStateOf(null) } + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + val index = pendingSaveIndex + pendingSaveIndex = null + if (granted && index != null) { + scope.launch { saveToGallery(context, viewModel, images[index]) } + } else if (!granted) { + Toast.makeText(context, "Storage permission needed to save photos", Toast.LENGTH_SHORT).show() + } + } + + fun requestSave(index: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + pendingSaveIndex = index + permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + scope.launch { saveToGallery(context, viewModel, images[index]) } + } + } + + // The close/save buttons live in their own row above the pager rather than overlaid on + // top of it, and need statusBarsPadding() - MainActivity calls enableEdgeToEdge(), so + // without it these buttons render right under the real status bar and never receive taps + // at all (verified live earlier: the close 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) + Row(Modifier.fillMaxWidth().padding(4.dp)) { + IconButton(onClick = onClose) { + Icon(Icons.Filled.Close, "Close", tint = Color.White) + } + IconButton(onClick = { requestSave(pagerState.currentPage) }) { + Icon(Icons.Filled.Download, "Save to device", tint = Color.White) + } } HorizontalPager(state = pagerState, modifier = Modifier.weight(1f).fillMaxWidth()) { page -> AsyncImage( @@ -172,3 +251,32 @@ private fun PhotoViewer( } } } + +private suspend fun saveToGallery(context: Context, viewModel: PhotosGridViewModel, file: DavFile) { + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.name.substringAfterLast('.', "")) ?: "image/*" + val ok = withContext(Dispatchers.IO) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, file.name) + put(MediaStore.Images.Media.MIME_TYPE, mimeType) + put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/NCal") + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return@withContext false + resolver.openOutputStream(uri)?.use { out -> viewModel.download(file, out) } + true + } else { + val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "NCal") + dir.mkdirs() + val target = File(dir, file.name) + FileOutputStream(target).use { out -> viewModel.download(file, out) } + MediaScannerConnection.scanFile(context, arrayOf(target.absolutePath), arrayOf(mimeType), null) + true + } + } catch (e: Exception) { + false + } + } + Toast.makeText(context, if (ok) "Saved to Pictures/NCal" else "Couldn't save photo", Toast.LENGTH_SHORT).show() +} 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 index 01f07b1..bf77f00 100644 --- 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 @@ -15,6 +15,7 @@ data class PhotosGridUiState( val folders: List = emptyList(), val images: List = emptyList(), val loading: Boolean = false, + val busy: Boolean = false, val error: String? = null, /** Index into [images] currently shown full-screen, or null when the grid is showing. */ val viewerIndex: Int? = null @@ -70,4 +71,25 @@ class PhotosGridViewModel(private val repo: FilesRepository) : ViewModel() { fun imageUrl(file: DavFile): String = repo.rawFileUrl(file.path) fun authHeader(): String = repo.basicAuthHeader() + + fun upload(name: String, bytes: ByteArray, mimeType: String) { + val path = _state.value.path + val newPath = if (path.isBlank()) name else "$path/$name" + _state.value = _state.value.copy(busy = true, error = null) + viewModelScope.launch { + try { + repo.upload(newPath, bytes, mimeType) + _state.value = _state.value.copy(busy = false) + refresh() + } catch (e: Exception) { + _state.value = _state.value.copy(busy = false, error = e.message ?: "Couldn't upload photo") + } + } + } + + /** Downloads [file] into [output] - used both to open a photo in the full-screen viewer's + * temp cache and to save it to the device's gallery (see [PhotosGridScreen]). */ + suspend fun download(file: DavFile, output: java.io.OutputStream) { + repo.downloadTo(file.path, output) + } } diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksScreen.kt index 8be5050..03aa63a 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksScreen.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksScreen.kt @@ -7,20 +7,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.RadioButtonUnchecked -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -35,69 +28,58 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -@OptIn(ExperimentalMaterial3Api::class) +/** The "Tasks" tab's content inside [com.homelab.ncal.ui.calendar.CalendarHomeScreen] - no + * Scaffold/top bar of its own now that Agenda/Week/Month/Tasks share one. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable -fun TasksScreen( +fun TasksContent( viewModel: TasksViewModel, onOpenTask: (CalendarItem) -> Unit, - onNewTask: () -> Unit, - onBack: () -> Unit + modifier: Modifier = Modifier ) { val state by viewModel.state.collectAsState() - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text("Tasks") }, - navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) } } + Column(modifier.fillMaxSize()) { + state.error?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(16.dp) ) - }, - floatingActionButton = { - FloatingActionButton(onClick = onNewTask) { Icon(Icons.Filled.Add, "New task") } } - ) { padding -> - Column(Modifier.padding(padding).fillMaxSize()) { - state.error?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(16.dp) - ) - } - PullToRefreshBox( - isRefreshing = state.refreshing, - onRefresh = viewModel::refresh, - modifier = Modifier.weight(1f) - ) { - LazyColumn { - items(state.tasks, key = { it.item.href.ifEmpty { it.item.uid } }) { node -> - val task = node.item - ListItem( - modifier = Modifier - .padding(start = (node.depth * 24).dp) - .clickable { onOpenTask(task) }, - headlineContent = { - Text( - task.summary, - textDecoration = if (task.completed) TextDecoration.LineThrough else null - ) - }, - supportingContent = { - task.due?.let { Text("Due " + it.toFormattedDate()) } - }, - leadingContent = { - Icon( - imageVector = if (task.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked, - contentDescription = "Toggle complete", - tint = if (task.completed) MaterialTheme.colorScheme.primary else (priorityColor(task.priority) ?: MaterialTheme.colorScheme.primary), - modifier = Modifier.clickable { viewModel.toggle(task) } - ) - } - ) - HorizontalDivider() - } + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.weight(1f) + ) { + LazyColumn { + items(state.tasks, key = { it.item.href.ifEmpty { it.item.uid } }) { node -> + val task = node.item + ListItem( + modifier = Modifier + .padding(start = (node.depth * 24).dp) + .clickable { onOpenTask(task) }, + headlineContent = { + Text( + task.summary, + textDecoration = if (task.completed) TextDecoration.LineThrough else null + ) + }, + supportingContent = { + task.due?.let { Text("Due " + it.toFormattedDate()) } + }, + leadingContent = { + Icon( + imageVector = if (task.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked, + contentDescription = "Toggle complete", + tint = if (task.completed) MaterialTheme.colorScheme.primary else (priorityColor(task.priority) ?: MaterialTheme.colorScheme.primary), + modifier = Modifier.clickable { viewModel.toggle(task) } + ) + } + ) + HorizontalDivider() } } } diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekScreen.kt index 6c27065..a9240a1 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekScreen.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekScreen.kt @@ -10,23 +10,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Event import androidx.compose.material.icons.filled.RadioButtonUnchecked -import androidx.compose.material.icons.filled.Today -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable @@ -42,13 +35,16 @@ import com.homelab.ncal.data.model.ItemType import java.time.LocalDate import java.time.format.DateTimeFormatter -@OptIn(ExperimentalMaterial3Api::class) +/** The "Week" tab's content inside [com.homelab.ncal.ui.calendar.CalendarHomeScreen] - no + * Scaffold/top bar of its own now that Agenda/Week/Month/Tasks share one. Its own header + * (week range label + today shortcut + prev/next) still lives here since it's specific to + * this view, not something the shared outer top bar could show meaningfully. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable -fun WeekScreen( +fun WeekContent( viewModel: WeekViewModel, onOpenItem: (CalendarItem) -> Unit, - onNewEvent: () -> Unit, - onBack: () -> Unit + modifier: Modifier = Modifier ) { val state by viewModel.state.collectAsState() @@ -56,64 +52,47 @@ fun WeekScreen( viewModel.refresh() } - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text(weekRangeLabel(state.weekStart)) }, - navigationIcon = { - IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) } - }, - actions = { - IconButton(onClick = viewModel::goToToday) { Icon(Icons.Filled.Today, "Today") } - } + Column(modifier.fillMaxSize()) { + state.error?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(16.dp) ) - }, - floatingActionButton = { - FloatingActionButton(onClick = onNewEvent) { Icon(Icons.Filled.Add, "New event") } } - ) { padding -> - Column(Modifier.padding(padding).fillMaxSize()) { - state.error?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(16.dp) - ) - } - WeekHeader(onPrev = viewModel::prevWeek, onNext = viewModel::nextWeek) - PullToRefreshBox( - isRefreshing = state.refreshing, - onRefresh = viewModel::refresh, - modifier = Modifier.weight(1f) - ) { - LazyColumn { - items(state.itemsByDay.entries.toList(), key = { it.key.toString() }) { (day, dayItems) -> - DaySection( - day = day, - items = dayItems, - onOpenItem = onOpenItem, - onToggleTask = viewModel::toggleTask - ) - } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconButton(onClick = viewModel::prevWeek) { Icon(Icons.Filled.ChevronLeft, "Previous week") } + Text( + weekRangeLabel(state.weekStart), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 12.dp).clickable(onClick = viewModel::goToToday) + ) + IconButton(onClick = viewModel::nextWeek) { Icon(Icons.Filled.ChevronRight, "Next week") } + } + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.weight(1f) + ) { + LazyColumn { + items(state.itemsByDay.entries.toList(), key = { it.key.toString() }) { (day, dayItems) -> + DaySection( + day = day, + items = dayItems, + onOpenItem = onOpenItem, + onToggleTask = viewModel::toggleTask + ) } } } } } -@Composable -private fun WeekHeader(onPrev: () -> Unit, onNext: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - IconButton(onClick = onPrev) { Icon(Icons.Filled.ChevronLeft, "Previous week") } - IconButton(onClick = onNext) { Icon(Icons.Filled.ChevronRight, "Next week") } - } -} - @Composable private fun DaySection( day: LocalDate,