Consolidate Month/Week/List/Tasks into one Calendar home screen; add Files/Photos upload, and configurable Wi-Fi auto-upload for photos and files

- Calendar home screen (ui/calendar/CalendarHomeScreen.kt): Month/Week/List/Tasks
  are now tabs of one screen instead of four separate destinations, with a
  trimmed top bar (Search/Talk/Files/Photos/Calendars).
- Files: multi-file selection and whole-folder upload (subfolders recreated
  server-side via MKCOL), alongside the existing browse/download/delete/rename.
- Photos: upload from device and download back to the device's gallery.
- Auto-upload (data/prefs/AutoUploadPrefs.kt, sync/PhotoAutoUploadWorker.kt,
  sync/PhotoAutoUploadScheduler.kt, sync/LocalFolderScanner.kt,
  sync/CameraBuckets.kt): opt-in, Wi-Fi-only background upload. Fully
  user-configured - any number of independent source -> destination folder
  pairs, each source either a device photo folder or an arbitrary local
  folder (files and subfolders, recursed and recreated server-side). Nothing
  is watched until at least one pair is explicitly added.
- README updated to document all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
RomanNum3ral 2026-07-20 05:48:38 -04:00
parent aaaa034d95
commit cc4303fadb
Signed by: RomanNum3ral
GPG Key ID: B94562C9B7535805
23 changed files with 1509 additions and 384 deletions

View File

@ -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/<user>/`, 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

View File

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

View File

@ -7,6 +7,13 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Only needed pre-Android-10 to save a downloaded photo into the gallery - scoped
storage on API 29+ (MediaStore insert) needs no permission at all. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<!-- Needed to query other apps' (i.e. the Camera app's) photos via MediaStore for
Wi-Fi auto-upload - READ_MEDIA_IMAGES on API 33+, READ_EXTERNAL_STORAGE below that. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<application
android:name=".NcalApplication"

View File

@ -7,8 +7,10 @@ import androidx.glance.appwidget.updateAll
import com.homelab.ncal.data.repository.FilesRepository
import com.homelab.ncal.data.repository.NextcloudRepository
import com.homelab.ncal.data.repository.TalkRepository
import com.homelab.ncal.data.prefs.AutoUploadPrefs
import com.homelab.ncal.notifications.CHANNEL_ID
import com.homelab.ncal.notifications.ReminderScheduler
import com.homelab.ncal.sync.PhotoAutoUploadScheduler
import com.homelab.ncal.widget.MonthGridWidget
import com.homelab.ncal.widget.NextTasksWidget
import kotlinx.coroutines.CoroutineScope
@ -36,6 +38,9 @@ class NcalApplication : Application() {
if (repository.isLoggedIn) {
repository.scheduleBackgroundSync()
if (AutoUploadPrefs(this).enabled) {
PhotoAutoUploadScheduler.schedule(this)
}
CoroutineScope(Dispatchers.IO).launch {
ReminderScheduler.rescheduleAll(this@NcalApplication, repository.allCachedItems())
MonthGridWidget().updateAll(this@NcalApplication)

View File

@ -106,6 +106,15 @@ class TalkClient(
isSystemMessage = o.optString("systemMessage").isNotBlank()
)
/** Raw signaling-settings JSON (signaling mode, STUN/TURN server config) - the same call
* a real call feature will need to configure its ICE servers, kept here since it was
* already used once to check this server's signaling/TURN setup before building calls. */
fun fetchSignalingSettingsRaw(token: String): String {
val url = "${serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v3/signaling/settings?format=json&token=$token"
val request = ocsRequest(url).get().build()
return execute(request)
}
private fun ocsRequest(url: String) = Request.Builder()
.url(url)
.header("Authorization", authHeader)

View File

@ -0,0 +1,104 @@
package com.homelab.ncal.data.prefs
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
enum class UploadSourceType { PHOTO_BUCKET, LOCAL_FOLDER }
/** One local source -> 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<String, Long>
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<UploadMapping>
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"
}
}

View File

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

View File

@ -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<String> {
val names = sortedSetOf<String>()
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()
}
}

View File

@ -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<LocalFile> {
val root = DocumentFile.fromTreeUri(context, treeUri) ?: return emptyList()
val results = mutableListOf<LocalFile>()
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 }
}
}

View File

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

View File

@ -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<com.homelab.ncal.data.prefs.UploadMapping>
) {
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<String>()
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<com.homelab.ncal.data.prefs.UploadMapping>
) {
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<String>()
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<String>
): 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<CameraPhoto> {
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<CameraPhoto>()
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
}
}

View File

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

View File

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

View File

@ -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<Uri?>(null) }
var addMappingStep by remember { mutableStateOf<AddMappingStep>(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<UploadMapping?>(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<String>,
onDismiss: () -> Unit,
onSelect: (String) -> Unit
) {
var available by remember { mutableStateOf<List<String>?>(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<List<DavFile>?>(null) }
var error by remember { mutableStateOf<String?>(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

View File

@ -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<CalendarCollection> = emptyList(),
val refreshing: Boolean = false,
val error: String? = null,
val importStatus: String? = null
val importStatus: String? = null,
val autoUploadEnabled: Boolean = false,
val autoUploadMappings: List<UploadMapping> = 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<String?>(null)
private val importStatus = MutableStateFlow<String?>(null)
private val autoUploadEnabled = MutableStateFlow(autoUploadPrefs.enabled)
private val autoUploadMappings = MutableStateFlow(autoUploadPrefs.mappings)
val state: StateFlow<CollectionsUiState> = 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<String> = 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 {

View File

@ -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<UploadItem> {
val root = DocumentFile.fromTreeUri(context, treeUri) ?: return emptyList()
val items = mutableListOf<UploadItem>()
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() }

View File

@ -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<DavFile> = 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<UploadItem>) {
if (items.isEmpty()) return
_state.value = _state.value.copy(busy = true, error = null)
viewModelScope.launch {
val failures = mutableListOf<String>()
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

View File

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

View File

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

View File

@ -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<com.homelab.ncal.data.network.DavFile>,
viewModel: PhotosGridViewModel,
images: List<DavFile>,
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<Int?>(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()
}

View File

@ -15,6 +15,7 @@ data class PhotosGridUiState(
val folders: List<DavFile> = emptyList(),
val images: List<DavFile> = 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)
}
}

View File

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

View File

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