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:
parent
aaaa034d95
commit
cc4303fadb
99
README.md
99
README.md
|
|
@ -25,7 +25,14 @@ is the fourth option: one app, one account, one sync engine.
|
|||
parsing). Nothing phones home to a vendor.
|
||||
- **No third-party server.** The only network calls in the app go to whatever
|
||||
Nextcloud server URL *you* enter at login (`data/network/CalDavClient.kt`). There is
|
||||
no hardcoded third-party endpoint anywhere in the source.
|
||||
no hardcoded third-party endpoint anywhere in the source — this holds for Files,
|
||||
Photos, Talk, and Auto-upload too, not just calendar sync.
|
||||
- **Auto-upload is opt-in and scoped to exactly what you configure.** It uploads
|
||||
nothing until you explicitly add at least one source → destination folder pair, and
|
||||
only ever reads the specific photo folder(s)/local folder(s) you picked — a photo
|
||||
folder needs the OS photo-library permission (asked for the first time you add one),
|
||||
an arbitrary folder needs no broader permission at all, since the Storage Access
|
||||
Framework grant is scoped to just the folder you picked via the system picker.
|
||||
- **Everything is encrypted at rest.** Server URL, username, and app password live in
|
||||
`EncryptedSharedPreferences` (`data/prefs/SecurePrefs.kt`, AES-256-GCM/SIV via an
|
||||
Android Keystore-backed `MasterKey`). The local Room cache itself is an
|
||||
|
|
@ -41,13 +48,17 @@ is the fourth option: one app, one account, one sync engine.
|
|||
|
||||
## Features
|
||||
|
||||
- **Agenda** — unified, day-grouped list of events *and* tasks. Syncs automatically
|
||||
every time the app is opened, plus pull to refresh any time.
|
||||
- **Month view** — a full calendar grid with events rendered as spanning colored
|
||||
pills and tasks as dot + text, exactly mirroring what's on the home screen widget
|
||||
(they share the same layout algorithm). Tap a day to see its agenda below the grid.
|
||||
- **Tasks** — dedicated checklist view. Tasks render as an indented tree under their
|
||||
parent, not a flat list.
|
||||
- **Calendar home screen** — Month, Week, List, and Tasks are tabs of one screen
|
||||
(`ui/calendar/CalendarHomeScreen.kt`), not four separate destinations, so switching
|
||||
how you're looking at the same data is a tap, not a navigation.
|
||||
- **List** — unified, day-grouped list of events *and* tasks. Syncs automatically
|
||||
every time the app is opened, plus pull to refresh any time.
|
||||
- **Month** — a full calendar grid with events rendered as spanning colored pills
|
||||
and tasks as dot + text, exactly mirroring what's on the home screen widget (they
|
||||
share the same layout algorithm). Tap a day to see its agenda below the grid.
|
||||
- **Week** — a scrollable week grid.
|
||||
- **Tasks** — dedicated checklist view. Tasks render as an indented tree under
|
||||
their parent, not a flat list.
|
||||
- **Subtasks** — assign any task a parent task (stored as the standard iCalendar
|
||||
`RELATED-TO` property, so it round-trips with Nextcloud's own Tasks app and other
|
||||
CalDAV clients). The parent picker excludes the task itself and its own descendants
|
||||
|
|
@ -75,8 +86,36 @@ is the fourth option: one app, one account, one sync engine.
|
|||
- **Calendars** — pick which calendars/task-lists sync, mirroring what you'd see in
|
||||
Nextcloud's own sidebar.
|
||||
- **Recurring events and tasks** — `RRULE` (`FREQ=DAILY/WEEKLY/MONTHLY/YEARLY` with
|
||||
`INTERVAL`/`COUNT`/`UNTIL`) is evaluated client-side to re-date each item to its
|
||||
next upcoming occurrence. See "Editing recurring items" below for what this doesn't cover.
|
||||
`INTERVAL`/`COUNT`/`UNTIL`, including ordinaled monthly rules like "the 3rd Thursday"
|
||||
or "the last Friday") is evaluated client-side to re-date each item to its next
|
||||
upcoming occurrence. See "Editing recurring items" below for what this doesn't cover.
|
||||
- **Edit/delete a single occurrence** of a recurring event or task without touching the
|
||||
rest of the series, using standard RFC 5545 mechanics (`RECURRENCE-ID` overrides and
|
||||
`EXDATE` exclusions) rather than app-specific state, so it round-trips with any other
|
||||
CalDAV client.
|
||||
- **Search** — find any event or task across every enabled calendar/task-list by title.
|
||||
- **Undo-after-delete** — deleting an event or task shows a snackbar to undo it; the
|
||||
item is recreated server-side with a fresh UID (Nextcloud's calendar trashbin keeps
|
||||
the original UID reserved for 30 days).
|
||||
- **Import/export `.ics`** — export any calendar/task-list to a real `.ics` file via the
|
||||
system share sheet, or import one into any calendar.
|
||||
- **Files** — full WebDAV browsing of your Nextcloud Files space: browse folders,
|
||||
download and open a file in whatever app handles its type, upload one or many files
|
||||
at once, upload an entire local folder (subfolders included, recreated on the
|
||||
server), delete, rename, and create folders.
|
||||
- **Photos** — a dedicated photo grid + full-screen swipeable viewer over the same
|
||||
Files space (filtered to images), with upload from the device and download back to
|
||||
the device's gallery.
|
||||
- **Talk** — read and send text messages in your Nextcloud Talk rooms (no voice/video
|
||||
calls — see "What's deliberately not built" below).
|
||||
- **Auto-upload** — a Wi-Fi-only background job that uploads new content to Nextcloud
|
||||
automatically, fully opt-in and user-configured: any number of independent source →
|
||||
destination folder pairs, each source either a device photo folder (Camera,
|
||||
Screenshots, etc., picked from what's actually on the device) or an arbitrary local
|
||||
folder of any file type (subfolders included, recreated on the server the same way
|
||||
Files' manual folder upload does). Nothing is watched or uploaded until you
|
||||
explicitly add at least one pair; see `data/prefs/AutoUploadPrefs.kt` and
|
||||
`sync/PhotoAutoUploadWorker.kt`.
|
||||
|
||||
## Home screen widgets
|
||||
|
||||
|
|
@ -123,6 +162,26 @@ nothing.
|
|||
pending alarm per item to track.
|
||||
- **`MonthGridWidget` / `NextTasksWidget`** (`widget/`) — Glance widgets that read
|
||||
straight from the same repository the app uses.
|
||||
- **`FilesClient` / `FilesRepository`** (`data/network/`, `data/repository/`) — plain
|
||||
WebDAV browsing/CRUD (`PROPFIND`/`GET`/`PUT`/`DELETE`/`MKCOL`/`MOVE`) against
|
||||
`/remote.php/dav/files/<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue