diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d3d1d72f..0cac59b5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,12 +26,18 @@ assignees: '' ## Environment -- **Platform**: Web / Android +- **Platform**: Web / Android / iOS / Backend +- **Workspace mode**: Local Mode / Server Mode / Not sure - **Browser** (if web): - **Device/Emulator** (if Android): +- **Simulator/Device** (if iOS): - **T'Day version**: - **OS**: +## Data / Sync Context + + + ## Screenshots / Logs diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 0605c33d..139e544a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -14,10 +14,18 @@ assignees: '' +## Product Surface + + + ## Alternatives Considered +## Parity / Data Notes + + + ## Additional Context diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1205a69e..03954982 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,6 +8,10 @@ - +## Product / Data Impact + + + ## Type of Change - [ ] Feature (new functionality) @@ -15,6 +19,7 @@ - [ ] Refactor (no behavior change) - [ ] Chore (deps, CI, docs, tooling) - [ ] Breaking change (existing behavior altered) +- [ ] Documentation-only ## Pre-Merge Checklist @@ -24,9 +29,14 @@ - [ ] No AI tool attribution in commits or PR description — no `Co-authored-by`, `Made-with`, or any trailer/text referencing Cursor, Codex, Copilot, ChatGPT, Claude, etc. - [ ] Backward compatibility maintained (or migration provided) - [ ] Flyway migration reviewed (if schema changed) +- [ ] Shared DTOs / Android Room / iOS SwiftData / sync mappers reviewed (if data shape changed) +- [ ] Local Mode behavior reviewed (if mobile behavior changed) +- [ ] Android/iOS parity checked (if mobile UI changed) +- [ ] Relevant docs updated (`README`, product/data/API/architecture/testing/platform docs) - [ ] Error handling and logging added where needed -- [ ] API changes follow [API Guidelines](docs/API_GUIDELINES.md) +- [ ] API changes follow [API Guidelines](../docs/API_GUIDELINES.md) - [ ] Android changes tested on emulator or device (if applicable) +- [ ] iOS changes tested on simulator or device (if applicable) ## Testing diff --git a/AGENTS.md b/AGENTS.md index 1d17721b..d65c4a52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # T'Day Agent Guide -This file is the working agreement for AI agents contributing to T'Day. Read it with `README.md`, `docs/ARCHITECTURE.md`, `docs/CODING_STANDARDS.md`, and `docs/TESTING.md`. +This file is the working agreement for AI agents contributing to T'Day. Read it with `README.md`, `docs/PRODUCT_DIRECTION.md`, `docs/DATA_MODEL.md`, `docs/ARCHITECTURE.md`, `docs/CODING_STANDARDS.md`, `docs/TESTING.md`, and `docs/REPO_HOUSEKEEPING.md`. ## Project Shape @@ -8,12 +8,20 @@ T'Day is a private, self-hosted personal task planner with: - `tday-web/`: Vite, React, TypeScript, Tailwind, i18next. - `tday-backend/`: Ktor, Exposed, Flyway, PostgreSQL, JWE sessions. -- `shared/`: Kotlin Multiplatform DTOs, enums, and validators consumed by backend, Android, and iOS. -- `android-compose/`: Native Android app using Kotlin, Jetpack Compose, Hilt, Retrofit, offline cache and sync. -- `ios-swiftUI/`: Native iOS app using SwiftUI, SwiftData, Observation, URLSession, Keychain/cookie handling. +- `shared/`: Kotlin Multiplatform DTOs, enums, validators, and route constants consumed by backend/Android and mirrored by iOS contract models. +- `android-compose/`: Native Android app using Kotlin, Jetpack Compose, Hilt, Retrofit, Room offline cache, reminders, widgets, and sync. +- `ios-swiftUI/`: Native iOS app using SwiftUI, SwiftData, Observation, URLSession, Keychain/cookie handling, reminders, and widget snapshots. The native mobile apps should feel like one product expressed through two platform-native implementations. +Current product direction: + +- Scheduled `Todo` items have due-date, recurrence, reminder, calendar, and scheduled-list semantics. +- `Floater` items are unscheduled Anytime tasks with separate floater lists and completed history. +- Local Mode is a first-class mobile workspace and must not silently upload local-only data to a server workspace. +- Server Mode uses local optimistic writes plus pending mutation replay. +- Documentation is part of the deliverable when behavior, structure, API, data shape, or verification changes. + ## How To Work In This Repo - Start by checking `git status --short --branch`. The worktree may already contain user changes. @@ -23,6 +31,7 @@ The native mobile apps should feel like one product expressed through two platfo - When the user asks for implementation, implement it, verify it, then report clearly. - When the user asks for a PR, push the active branch and open/update the PR they requested. - When resolving merge conflicts into an outdated base, prefer the active/latest branch behavior unless the user explicitly says otherwise. +- When the user asks for documentation, inspect the real project state and recent commits before writing broad claims. ## Git And Attribution @@ -44,6 +53,7 @@ Before finishing a mobile UI task, ask: - Do labels, task counts, date rules, empty states, and disabled states match? - Do navigation rules match, including lower bounds and edge cases? - Does the interaction feel platform-native on each OS? +- Does Local Mode hide/disable server-only affordances consistently? - Is one platform now clearly nicer? If yes, bring the other platform up to the same product quality. Do not blindly copy implementation details across platforms. Copy behavior, interaction rules, information architecture, and visual intent while using native APIs and established local patterns. @@ -63,6 +73,7 @@ Core rules: - Task counts come from pending scheduled items grouped by local start-of-day. - Day and week task counts cap display at `9+`. - The task section title stays in the form `Tasks due EEE, MMM d`. +- Floaters do not appear on the calendar unless a future product decision gives them scheduled semantics. Interaction rules: @@ -90,6 +101,7 @@ T'Day is a task app, not a marketing site. Mobile screens should feel quiet, use - Text must fit in compact mobile layouts without overlap or truncation that hides meaning. - Empty states should be calm and short. - Preserve dark mode. +- Root feed behavior should stay aligned: Home and Floater/Anytime are sibling root feeds controlled by `RootFeedDock`, with the create action available from the root controls. ## Design Tokens And Strings @@ -102,30 +114,46 @@ T'Day is a task app, not a marketing site. Mobile screens should feel quiet, use ## Architecture Expectations +Across the repo, keep changes shaped around readable boundaries: a file or type should have a clear reason to exist, dependencies should flow from UI to state to services/repositories to storage/network, and helpers should start local before being promoted to shared. + Backend: - Keep request/response contracts aligned with `shared/` models when possible. - Services return typed errors and avoid leaking internals. - Preserve tenant isolation in all data access. +- Keep scheduled-task routes, floater routes, scheduled-list routes, and floater-list routes distinct. +- Routes translate HTTP and validation; services own business decisions; Exposed table/query code stays out of UI-facing layers. Android: - Use MVVM with `@HiltViewModel`, `StateFlow`, repositories, and app services. - Keep mutable state private and expose read-only state. -- Respect offline-first cache/sync behavior. +- Respect Room-backed offline-first cache/sync behavior and Local Mode. - Use Compose idioms and Material 3. +- ViewModels depend on injected repositories/services, not Retrofit, Room DAOs, or storage details directly. iOS: - Use SwiftUI, Observation, SwiftData, and URLSession patterns already present in the app. - Keep feature code inside `Feature//` unless it is truly shared. - Prefer small local helpers before creating broad abstractions. +- Keep `AppContainer` wiring explicit and update SwiftData/cache mappers with data model changes. +- Views render state and invoke actions; repositories/cache managers own persistence and sync details. Web: - Use React Query for server state. - Use the shared API client, not raw backend `fetch` calls from components. - Use Tailwind semantic tokens and locale keys. +- Keep feature modules cohesive and move repeated logic into `src/lib/`, `src/hooks/`, or feature-scoped helpers only when that reduces real duplication. + +Docs: + +- Update `README.md` for project shape and documentation-map changes. +- Update `docs/DATA_MODEL.md` for table, DTO, local cache, and pending mutation changes. +- Update `docs/API_GUIDELINES.md` for route changes. +- Update `docs/ARCHITECTURE.md` for data flow, module, and platform architecture changes. +- Update platform READMEs for Android/iOS setup, storage, sync, or feature-surface changes. ## Verification Commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 490a360d..66653515 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,9 +5,11 @@ This document covers everything a developer needs to know before writing code, o ## Table of Contents - [Development Setup](#development-setup) +- [Product Direction](#product-direction) - [Branch Strategy](#branch-strategy) - [Commit Messages](#commit-messages) - [Pull Request Process](#pull-request-process) +- [Documentation Expectations](#documentation-expectations) - [Coding Conventions](#coding-conventions) - [Linting and Formatting](#linting-and-formatting) - [Testing](#testing) @@ -39,9 +41,16 @@ bash scripts/install-hooks.sh # install git hooks (required, one-time) 1. Open `android-compose/` in Android Studio. 2. Ensure Android SDK 35 is installed. -3. Set the server URL at first launch to point to your local or remote T'Day instance. +3. Choose Local Mode for offline-only testing, or set the server URL at first launch to point to your local or remote T'Day instance. 4. Run on emulator or physical device. +### iOS (SwiftUI) + +1. Open `ios-swiftUI/TdayApp.xcodeproj` in Xcode. +2. Select the `Tday` scheme. +3. Run on an iOS 17+ simulator or physical device. +4. Choose Local Mode for offline-only testing, or connect to a self-hosted server. + ### Database PostgreSQL 15 is required. Use Docker or a local installation: @@ -59,6 +68,17 @@ docker compose up -d --build docker exec -it tday_ollama ollama pull qwen2.5:0.5b ``` +## Product Direction + +Read [`docs/PRODUCT_DIRECTION.md`](docs/PRODUCT_DIRECTION.md) before changing product behavior. The short version: + +- T'Day is a quiet private planner with web, backend, Android, and iOS surfaces. +- Mobile is local-first and cross-platform parity matters. +- Scheduled `Todo` items and unscheduled `Floater` items are separate domain concepts. +- Local Mode is a first-class Android/iOS workspace that does not require a server. +- Server Mode writes optimistically to local cache and replays pending mutations to the backend. +- Documentation should move with behavior, data shape, architecture, and verification changes. + ## Branch Strategy | Branch | Purpose | Deploys to | @@ -119,10 +139,12 @@ docs: add architecture decision record for offline sync 2. Make your changes following [coding standards](docs/CODING_STANDARDS.md). 3. Ensure lint passes: `cd tday-web && npm run lint`. 4. Ensure tests pass: `cd tday-web && npm run test` and `./gradlew :tday-backend:test`. -5. Open a PR against `develop` using the [PR template](.github/PULL_REQUEST_TEMPLATE.md). -6. Request review from at least one maintainer. -7. Address all review comments. -8. Squash-merge when approved. +5. For Android changes, run `cd android-compose && ./gradlew :app:compileDebugKotlin` and targeted tests when practical. +6. For iOS changes, run the `Tday` scheme build/test in Xcode or the `xcodebuild` command from [`docs/TESTING.md`](docs/TESTING.md). +7. Open a PR against `develop` using the [PR template](.github/PULL_REQUEST_TEMPLATE.md). +8. Request review from at least one maintainer. +9. Address all review comments. +10. Squash-merge when approved. **CI enforcement:** PRs to `master` run lint and the full test suite automatically. The Docker image will **not** be built or released unless all tests pass. See [Deployment > Test-Before-Build Policy](docs/DEPLOYMENT.md#test-before-build-policy). @@ -131,6 +153,7 @@ docs: add architecture decision record for offline sync - Aim for < 400 lines changed per PR. - Split large features into incremental PRs. - Refactoring PRs should not include behavior changes. +- Cross-platform mobile changes should stay behaviorally paired even when implementation differs. ### Review Checklist (for reviewers) @@ -139,13 +162,31 @@ docs: add architecture decision record for offline sync - [ ] Error handling covers failure paths. - [ ] New API endpoints follow [API guidelines](docs/API_GUIDELINES.md). - [ ] Database changes include a Flyway migration and are backward-compatible. +- [ ] Shared DTO, Android Room, iOS SwiftData, and sync mappers are updated if the data shape changed. - [ ] Tests cover the happy path and at least one error path. - [ ] No console.log / Log.d left from debugging. +## Documentation Expectations + +Documentation is part of the change when a future contributor would otherwise have to rediscover the rule. + +| Change | Docs to update | +|--------|----------------| +| Product surface, navigation, Local Mode, or cross-platform UX rule | `README.md`, `docs/PRODUCT_DIRECTION.md`, platform READMEs | +| Backend table, shared DTO, local cache record, mutation kind | `docs/DATA_MODEL.md`, `docs/ARCHITECTURE.md`, `docs/API_GUIDELINES.md` | +| Route or response contract | `docs/API_GUIDELINES.md`, shared models, tests | +| Coding pattern or guardrail | `docs/CODING_STANDARDS.md`, guardrail tests | +| Verification workflow or CI expectation | `docs/TESTING.md`, `.github/PULL_REQUEST_TEMPLATE.md` | +| Deployment, versioning, signing, ingress, telemetry, security | The matching doc under `docs/`, `SECURITY.md`, or platform README | + +Use [`docs/REPO_HOUSEKEEPING.md`](docs/REPO_HOUSEKEEPING.md) for generated-file rules, markdown maintenance, and cleanup expectations. + ## Coding Conventions See [`docs/CODING_STANDARDS.md`](docs/CODING_STANDARDS.md) for the full rules. Key highlights: +Across all codebases, prefer readable, narrow units with explicit names and clear dependency direction. UI renders state, ViewModels/controllers coordinate, repositories/services own data work, and transport/storage details stay behind injected collaborators. + ### TypeScript (Frontend) - Strict mode is enabled — no `any` unless absolutely unavoidable. @@ -161,6 +202,13 @@ See [`docs/CODING_STANDARDS.md`](docs/CODING_STANDARDS.md) for the full rules. K - Use `runCatching` for operations that can fail. - Constants in `companion object` with `UPPER_SNAKE_CASE`. +### Swift (iOS) + +- Use SwiftUI, Observation, SwiftData, URLSession, Keychain/cookie handling, and the existing `AppContainer` dependency graph. +- Keep feature code in `ios-swiftUI/Tday/Feature//` and shared app logic in `Core/`. +- Mirror product behavior with Android while keeping platform-native UI, gestures, and system integrations. +- Update SwiftData entities and cache mappers whenever offline state changes. + ## Linting and Formatting ### Frontend @@ -212,6 +260,16 @@ npm run test # all Vitest suites - Tests go in `app/src/test/` (unit) and `app/src/androidTest/` (instrumented). - Test naming: `should when `. +### iOS + +```bash +xcodebuild test -project ios-swiftUI/TdayApp.xcodeproj -scheme Tday -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6' +``` + +- Tests live in `ios-swiftUI/Tests/`. +- Prefer focused tests for repository/cache mapping, sync behavior, and core helpers. +- For UI polish without tests, build the app and do a simulator/device spot check when practical. + ## Before Merging Checklist Every PR must satisfy these before merge: @@ -219,13 +277,16 @@ Every PR must satisfy these before merge: - [ ] `cd tday-web && npm run lint` passes with no warnings. - [ ] `cd tday-web && npm run test` passes with no failures (including guardrail tests). - [ ] `./gradlew :tday-backend:test` passes with no failures. +- [ ] Android build/tests run for Android changes, or the skip reason is documented. +- [ ] iOS build/tests run for iOS changes, or the skip reason is documented. - [ ] CI pipeline passes (lint + tests are enforced automatically on PRs to `master`). - [ ] No secrets or credentials in the diff. - [ ] No AI tool attribution in commits or PR description — no `Co-authored-by`, `Made-with`, or any trailer/text referencing Cursor, Codex, Copilot, ChatGPT, Claude, etc. - [ ] Backward compatibility maintained (or migration provided). - [ ] Flyway migration SQL and corresponding Exposed table changes reviewed if schema changed. +- [ ] Shared DTOs, Android Room, iOS SwiftData, and sync mappers reviewed if data shape changed. - [ ] Error handling and logging added where appropriate. - [ ] API changes documented in the PR description. -- [ ] Android changes tested on emulator or device. +- [ ] Android/iOS parity checked for mobile user-facing changes. **Note:** The release pipeline will not build or push a Docker image unless all tests pass. Broken tests block the entire release. diff --git a/README.md b/README.md index bd8a5028..6e5a17ae 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ # T'Day -Personal task planner — self-hosted, private, multilingual. +Private, self-hosted personal task planning with native mobile apps and local-first behavior. -- Tasks with priorities, pinning, drag-and-drop reordering, and RFC 5545 recurrence -- Calendar with month, week, and day views -- Lists (projects) for organization with colors and icons -- Completion history and AI-powered task summaries (Ollama) -- 11 languages via i18next -- Native Android client (Kotlin + Jetpack Compose) -- Native iOS client (SwiftUI + SwiftData) +T'Day is designed to be a quiet daily planner, not a generic productivity platform. The end goal is a single product expressed across web, Android, and iOS: + +- Scheduled tasks with priorities, pinning, drag-and-drop reordering, reminders, and RFC 5545 recurrence. +- Floater/Anytime tasks for unscheduled work, with their own lists and completed history. +- Calendar with month, week, and day views, anchored headers, bounded navigation, overdue visibility, and cross-platform paging rules. +- Local Mode on Android and iOS for offline-only use without server setup or login. +- Server Mode for self-hosted sync, realtime updates, encrypted sessions, and private PostgreSQL storage. +- Local-first mobile data backed by Room on Android and SwiftData on iOS. +- Completion history, list metadata preservation, task search, widgets, in-app update/version compatibility, and AI-powered summaries via Ollama. +- 11 web locales via i18next, with mobile strings handled through platform-local patterns. ## Tech Stack @@ -18,11 +21,18 @@ Personal task planner — self-hosted, private, multilingual. | Backend | Ktor (Kotlin), Exposed ORM, Flyway migrations | | Database | PostgreSQL 15 | | Auth | Rolling JWE cookie sessions, PBKDF2 credentials, credential envelope encryption | +| Shared Contracts | Kotlin Multiplatform DTOs, enums, validators, and route constants | | AI | Ollama (local, e.g. qwen2.5:0.5b) | -| Android | Kotlin, Jetpack Compose, Hilt, Retrofit, Material 3 | -| iOS | SwiftUI, SwiftData, URLSession, Observation | +| Android | Kotlin, Jetpack Compose, Hilt, Retrofit, Room, WorkManager, Glance widgets, Material 3 | +| iOS | SwiftUI, SwiftData, URLSession, Observation, Keychain/cookie handling, WidgetKit-ready snapshots | | Infra | Docker Compose, GitHub Actions CI/CD, GHCR | +## Documentation Currency + +Markdown files were audited on **2026-05-29** using git history. The docs were mostly last touched between March and May 2026, while recent commits added Local Mode, Floater/Anytime tasks, RootFeedDock, Room/SwiftData cache parity, mobile sheet/swipe/calendar polish, and offline sync refinements. This refresh updates the project docs around that current direction. + +See [`docs/REPO_HOUSEKEEPING.md`](docs/REPO_HOUSEKEEPING.md) for the markdown audit summary and maintenance expectations. + ## Quick Start ### Docker (recommended) @@ -68,11 +78,11 @@ Requires a running PostgreSQL instance. The Ktor backend applies Flyway migratio ### Android -Open `android-compose/` in Android Studio (SDK 35 required) and run on device or emulator. See [`android-compose/README.md`](android-compose/README.md) for first-launch behavior and persistence details. +Open `android-compose/` in Android Studio (SDK 35 required) and run on a device or emulator. The app can start in Local Mode or connect to a self-hosted server. See [`android-compose/README.md`](android-compose/README.md) for structure, first-launch behavior, persistence, sync, and release notes. ### iOS -Open `ios-swiftUI/` in Xcode on macOS and use the `Tday/` source tree for the native SwiftUI app. See [`ios-swiftUI/README.md`](ios-swiftUI/README.md) for the current structure and environment notes. +Open `ios-swiftUI/TdayApp.xcodeproj` in Xcode on macOS and run the `Tday` scheme. The app supports Local Mode, server workspaces, SwiftData cache, reminders, and the shared mobile feature surface. See [`ios-swiftUI/README.md`](ios-swiftUI/README.md) for structure and environment notes. ## Project Structure @@ -101,15 +111,42 @@ Tday/ │ ├── routes/ # API route handlers │ ├── security/ # Auth, encryption, throttling │ └── services/ # Business logic -├── shared/ # Shared Kotlin Multiplatform code -├── android-compose/ # Native Android client (Kotlin + Compose) -├── ios-swiftUI/ # Native iOS client (SwiftUI) -├── scripts/ # Git hooks -├── docs/ # Architecture, coding standards, guides +├── shared/ # KMP DTOs, enums, validators, and route constants +├── android-compose/ # Native Android client (Compose, Room, Hilt) +├── ios-swiftUI/ # Native iOS client (SwiftUI, SwiftData, Observation) +├── scripts/ # Git hooks, version sync, operational helpers +├── docs/ # Product, architecture, data, coding, testing, deployment ├── Dockerfile.backend # Multi-stage Docker build (Vite + Ktor) └── docker-compose.yaml # Full stack orchestration ``` +## Product Model + +| Concept | Purpose | +|---------|---------| +| Scheduled task | Due-date task used by Today, Scheduled, Calendar, reminders, recurrence, and scheduled-task lists | +| Floater | Unscheduled Anytime task with priority, pinning, ordering, list membership, and completion | +| List | Project/group for scheduled tasks | +| Floater list | Project/group for floaters | +| Completed history | Completed todo and completed floater records, preserving list metadata where possible | +| Local Mode | Mobile-only workspace that never requires server setup or login | +| Server Mode | Authenticated self-hosted workspace with local optimistic writes and sync replay | + +The detailed data contract lives in [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md). + +## Product Direction + +Future coding should preserve these expectations: + +- Mobile UX parity matters. Android and iOS should expose the same feature surface while using native implementation patterns. +- Local cache is the mobile screen source of truth. Network sync updates the cache; screens observe cache changes. +- Scheduled tasks and floaters are separate domain concepts. Do not represent unscheduled work by making `Todo.due` nullable. +- Server APIs must remain stable for mobile clients, with explicit compatibility behavior before breaking changes. +- Code should stay easy to reason about: narrow responsibilities, clear dependency direction, named concepts over cleverness, and no shared abstraction until it has a real second use. +- Documentation is updated with the behavior it describes. + +The fuller north star is in [`docs/PRODUCT_DIRECTION.md`](docs/PRODUCT_DIRECTION.md). + ## Documentation | Document | Purpose | @@ -117,6 +154,9 @@ Tday/ | [`AGENTS.md`](AGENTS.md) | AI agent workflow, git expectations, and cross-platform UX parity rules | | [`CONTRIBUTING.md`](CONTRIBUTING.md) | Developer setup, conventions, PR process | | [`SECURITY.md`](SECURITY.md) | Security practices and responsible disclosure | +| [`docs/PRODUCT_DIRECTION.md`](docs/PRODUCT_DIRECTION.md) | Product goal, mobile rules, Local Mode, Floater/Anytime direction | +| [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md) | Backend tables, shared DTOs, mobile cache records, mutation queue, data-change checklist | +| [`docs/REPO_HOUSEKEEPING.md`](docs/REPO_HOUSEKEEPING.md) | Markdown audit, generated-file rules, docs maintenance, repo hygiene | | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | System design, domain boundaries, data flow | | [`docs/CODING_STANDARDS.md`](docs/CODING_STANDARDS.md) | Code quality rules, naming, patterns | | [`docs/API_GUIDELINES.md`](docs/API_GUIDELINES.md) | REST API contracts and conventions | @@ -126,6 +166,28 @@ Tday/ | [`docs/TELEMETRY.md`](docs/TELEMETRY.md) | What crash reporting collects (and doesn't) — no PII, no analytics | | [`docs/adr/`](docs/adr/) | Architecture Decision Records | +## Verification + +Run the smallest meaningful checks for your change, then broaden when a change crosses boundaries. + +```bash +# Web +cd tday-web && npm run lint +cd tday-web && npm run test + +# Backend +./gradlew :tday-backend:test + +# Android +cd android-compose && ./gradlew :app:compileDebugKotlin +cd android-compose && ./gradlew :app:testDebugUnitTest + +# iOS +xcodebuild test -project ios-swiftUI/TdayApp.xcodeproj -scheme Tday -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6' +``` + +For user-facing mobile changes, do a parity pass on both platforms even when only one platform was edited. + ## License Private repository. All rights reserved. diff --git a/SECURITY.md b/SECURITY.md index 75d096ac..a517f1cd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,7 @@ # Security Policy +For product/data context, see [`docs/PRODUCT_DIRECTION.md`](docs/PRODUCT_DIRECTION.md) and [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md). Security rules apply to Server Mode; Local Mode stays on-device unless a future explicit migration/import flow is designed. + ## Reporting Vulnerabilities If you discover a security vulnerability, please report it privately. Do **not** open a public issue. @@ -91,8 +93,11 @@ Every response includes (via `SecurityHeaders.kt`): ### Mobile Client Security -- Server URL and credentials stored in Android `EncryptedSharedPreferences`. -- Session cookies persist in an encrypted cookie store. +- Android server URL and credentials are stored in `EncryptedSharedPreferences`. +- iOS server config, credentials, cookies, and mode state are stored through Keychain-backed `SecureStore`. +- Session cookies persist in encrypted platform stores. +- Android task data is cached in Room; iOS task data is cached in SwiftData. Both caches are local app data and are cleared through logout/session invalidation flows where appropriate. +- Local Mode does not send task/list/floater data to a server and should not silently migrate local-only data into Server Mode. - Optional public-key fingerprint pinning for self-hosted servers. - All local user data (credentials, cache, cookies) is wiped on logout or session invalidation. - Custom client headers (`X-Tday-Client`, `X-Tday-App-Version`, `X-Tday-Device-Id`) for audit trails. diff --git a/android-compose/README.md b/android-compose/README.md index 6ba1eccd..8f2f3c86 100644 --- a/android-compose/README.md +++ b/android-compose/README.md @@ -2,30 +2,100 @@ Native Kotlin + Jetpack Compose Android client for T'Day. -## Goals -- Replace old web-wrapper APK approach with a real native app. -- Keep feature parity with backend domains: auth, todos, calendar feed, completed, notes, projects, preferences/profile. -- iOS Reminders-inspired UI direction for mobile UX. +## Product Role + +The Android app is a primary T'Day client, not a web wrapper. It should stay behaviorally aligned +with the iOS SwiftUI app while using Android-native implementation patterns. + +Current feature surface: + +- Local Mode for offline-only planning without server setup. +- Server Mode with JWE cookie auth, optimistic local writes, realtime refresh, and pending mutation + replay. +- Home and Floater/Anytime root feeds controlled by `RootFeedDock`. +- Scheduled tasks, floaters, scheduled-task lists, floater lists, completed history, calendar, + search, settings, reminders, widgets, and in-app APK updates. +- Room-backed local cache with a one-time migration from the older encrypted JSON cache. ## Module + - `app`: Android application module. +## Structure + +```text +android-compose/app/src/main/java/com/ohmz/tday/compose/ +├── core/ +│ ├── data/ # Repositories, Room cache, sync, auth/server stores +│ ├── model/ # API/domain models and UI-facing data +│ ├── navigation/ # AppRoute +│ ├── network/ # Retrofit, cookies, realtime, connectivity +│ ├── notification/ # Reminders, boot receiver, workers +│ ├── security/ # Probe/decryption helpers +│ └── ui/ # Shared app UI helpers +├── feature/ +│ ├── app/ # Bootstrap, Local/Server Mode, sync, version state +│ ├── auth/ # Login/register and credential handoff +│ ├── home/ # Home root feed +│ ├── todos/ # Todo/Floater/List screens +│ ├── calendar/ # Month/week/day calendar +│ ├── completed/ # Completed todo/floater history +│ ├── settings/ # Settings and admin toggles +│ ├── release/ # Latest release and APK installer +│ └── widget/ # Today tasks widget +└── ui/ + ├── component/ # RootFeedDock, sheets, pull refresh, controls + └── theme/ # Colors, typography, dimensions +``` + ## Run -1. Open `/home/ohmz/StudioProjects/Tday/android-compose` in Android Studio. + +1. Open `android-compose/` in Android Studio. 2. Ensure Android SDK 35 is installed. 3. Run on emulator/device. -First launch behavior: -- The app shows a server URL dialog before login. -- Enter your host (app.example.com). -- URL is normalized and kept in memory for the current auth flow. +Useful command-line checks: + +```bash +cd android-compose +./gradlew :app:compileDebugKotlin +./gradlew :app:testDebugUnitTest +``` + +## First Launch + +The onboarding overlay offers two workspace paths: + +- **Local Mode**: start immediately with local data only. Pull-to-refresh, server sync, realtime + updates, and admin server settings are not active. +- **Server Mode**: configure a self-hosted T'Day URL, verify the mobile probe/version compatibility, + then login/register. + +Server URLs are normalized and persisted only after successful authenticated setup. Server URL +credentials use Android Credential Manager where available, while real login credentials and cookies +are stored through encrypted local stores. + +## Persistence and Sync + +- Room stores todos, floaters, lists, floater lists, completed records, pending mutations, and sync + metadata. +- `OfflineCacheManager` exposes `cacheDataVersion`; ViewModels reload from cache when it changes. +- Repositories write optimistically to Room first. +- In Server Mode, `SyncManager` replays pending mutations and refreshes snapshots. +- In Local Mode, pending mutations are cleared/ignored because there is no remote target. +- Logout or invalid session clears session/user data according to the auth flow. + +See [`../docs/DATA_MODEL.md`](../docs/DATA_MODEL.md) for the shared cache model. + +## Mobile Parity + +For user-facing Android changes, compare the iOS implementation in `ios-swiftUI/Tday/Feature/` and +`ios-swiftUI/Tday/Core/`. Match behavior, counts, empty states, Local Mode affordances, and +navigation rules while keeping Android Compose idioms. -Persistence: -- Server URL is persisted only after a successful authenticated login. -- Login credentials are stored encrypted only after successful login. -- If no valid authenticated session exists, local user data is wiped (server URL, credentials, offline cache, cookies, and encrypted local prefs). -- After logout or expired/invalid session, the app requires server setup and login again. +## Theme -Theme: - Theme mode can be changed in Settings: `System`, `Light`, or `Dark`. - Selection is applied immediately and is cleared when unauthenticated data wipe runs. +- New shared colors/dimensions belong in `ui/theme/Color.kt` or `ui/theme/Dimens.kt` before screen + code uses them. diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt index b434cb73..bd4ab807 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -55,6 +56,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -92,6 +94,9 @@ import com.ohmz.tday.compose.feature.release.LatestReleaseViewModel import com.ohmz.tday.compose.feature.settings.SettingsScreen import com.ohmz.tday.compose.feature.todos.TodoListScreen import com.ohmz.tday.compose.feature.todos.TodoListViewModel +import com.ohmz.tday.compose.ui.component.RootCreateTaskButton +import com.ohmz.tday.compose.ui.component.RootFeedDock +import com.ohmz.tday.compose.ui.component.RootFeedTab import com.ohmz.tday.compose.ui.theme.TdayTheme import io.sentry.android.navigation.SentryNavigationListener import kotlin.math.roundToInt @@ -150,6 +155,12 @@ fun TdayApp( var activeToast by remember { mutableStateOf(null) } var hasShownLaunchUpdateToast by rememberSaveable { mutableStateOf(false) } var isStartupSplashHeld by remember { mutableStateOf(false) } + var rootFeedTab by rememberSaveable { mutableStateOf(RootFeedTab.HOME) } + var rootCreateTaskRequestKey by rememberSaveable { mutableStateOf(0) } + var rootHomeScrollToTopRequestKey by remember { mutableStateOf(0) } + var rootFloaterScrollToTopRequestKey by remember { mutableStateOf(0) } + var rootDockCollapsed by rememberSaveable { mutableStateOf(false) } + var rootControlsVisible by rememberSaveable { mutableStateOf(true) } val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current @@ -174,6 +185,17 @@ fun TdayApp( showSystemToast(context, taskDeletedToastMessage) } + fun handleRootFeedTabSelection(tab: RootFeedTab) { + if (rootFeedTab == tab) { + when (tab) { + RootFeedTab.HOME -> rootHomeScrollToTopRequestKey += 1 + RootFeedTab.FLOATER -> rootFloaterScrollToTopRequestKey += 1 + } + } else { + rootFeedTab = tab + } + } + HandleStartupNavigation( appUiState = appUiState, currentRoute = currentRoute, @@ -292,7 +314,7 @@ fun TdayApp( ) { val authViewModel: AuthViewModel = hiltViewModel() val authUiState by authViewModel.uiState.collectAsStateWithLifecycle() - val showOnboardingWizard = !appUiState.authenticated + val showOnboardingWizard = !appUiState.isWorkspaceAvailable Box(modifier = Modifier.fillMaxSize()) { Box( @@ -306,53 +328,140 @@ fun TdayApp( }, ), ) { - if (appUiState.authenticated) { - val homeViewModel: HomeViewModel = hiltViewModel() - val homeUiState by homeViewModel.uiState.collectAsStateWithLifecycle() - OnRouteResume { - homeViewModel.refreshFromCache() - appViewModel.refreshVersionInfo() - } - HomeScreen( - uiState = homeUiState, - onRefresh = homeViewModel::refresh, - onOpenToday = { navController.navigate(AppRoute.TodayTodos.route) }, - onOpenOverdue = { navController.navigate(AppRoute.OverdueTodos.route) }, - onOpenScheduled = { navController.navigate(AppRoute.ScheduledTodos.route) }, - onOpenAll = { navController.navigate(AppRoute.AllTodos.create()) }, - onOpenPriority = { navController.navigate(AppRoute.PriorityTodos.route) }, - onOpenCompleted = { navController.navigate(AppRoute.Completed.route) }, - onOpenCalendar = { navController.navigate(AppRoute.Calendar.route) }, - onOpenSettings = { navController.navigate(AppRoute.Settings.route) }, - onOpenTaskFromSearch = { todoId -> - navController.currentBackStackEntry - ?.savedStateHandle - ?.set(PENDING_SEARCH_HIGHLIGHT_TODO_ID, todoId) - navController.navigate(AppRoute.AllTodos.create()) - }, - onOpenList = { id, name -> - navController.navigate(AppRoute.ListTodos.create(id, name)) - }, - onCreateTask = { payload -> - homeViewModel.createTask(payload) - }, - onParseTaskTitleNlp = homeViewModel::parseTaskTitleNlp, - onCreateList = { name, color, iconKey -> - homeViewModel.createList( - name = name, - color = color, - iconKey = iconKey, + if (appUiState.isWorkspaceAvailable) { + Box(modifier = Modifier.fillMaxSize()) { + when (rootFeedTab) { + RootFeedTab.HOME -> { + val homeViewModel: HomeViewModel = hiltViewModel() + val homeUiState by homeViewModel.uiState.collectAsStateWithLifecycle() + OnRouteResume { + homeViewModel.refreshFromCache() + appViewModel.refreshVersionInfo() + } + HomeScreen( + uiState = homeUiState, + onRefresh = homeViewModel::refresh, + pullRefreshEnabled = !appUiState.isLocalMode, + onOpenToday = { navController.navigate(AppRoute.TodayTodos.route) }, + onOpenOverdue = { navController.navigate(AppRoute.OverdueTodos.route) }, + onOpenScheduled = { navController.navigate(AppRoute.ScheduledTodos.route) }, + onOpenAll = { navController.navigate(AppRoute.AllTodos.create()) }, + onOpenPriority = { navController.navigate(AppRoute.PriorityTodos.route) }, + onOpenCompleted = { navController.navigate(AppRoute.Completed.route) }, + onOpenCalendar = { navController.navigate(AppRoute.Calendar.route) }, + onOpenFloater = { + rootFeedTab = RootFeedTab.FLOATER + }, + onOpenSettings = { navController.navigate(AppRoute.Settings.route) }, + onOpenTaskFromSearch = { todoId -> + navController.currentBackStackEntry + ?.savedStateHandle + ?.set( + PENDING_SEARCH_HIGHLIGHT_TODO_ID, + todoId + ) + navController.navigate(AppRoute.AllTodos.create()) + }, + onOpenList = { id, name -> + navController.navigate( + AppRoute.ListTodos.create( + id, + name + ) + ) + }, + onCreateTask = { payload -> + homeViewModel.createTask(payload) + }, + onParseTaskTitleNlp = homeViewModel::parseTaskTitleNlp, + onCreateList = { name, color, iconKey -> + homeViewModel.createList( + name = name, + color = color, + iconKey = iconKey, + ) + }, + onCompleteTask = { todo -> + homeViewModel.completeTodo( + todo + ) + }, + onDeleteTask = { todo -> + homeViewModel.deleteTodo( + todo + ) + }, + onUpdateTask = { todo, payload -> + homeViewModel.updateTask( + todo, + payload + ) + }, + showRootFeedDock = false, + showCreateTaskButton = false, + createTaskRequestKey = rootCreateTaskRequestKey, + scrollToTopRequestKey = rootHomeScrollToTopRequestKey, + onRootDockCollapsedChange = { + rootDockCollapsed = it + }, + onRootControlsVisibleChange = { + rootControlsVisible = it + }, + ) + } + + RootFeedTab.FLOATER -> { + TodosRoute( + mode = TodoListMode.FLOATER, + onBack = { rootFeedTab = RootFeedTab.HOME }, + onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, + onOpenFloaterList = { id, name -> + navController.navigate( + AppRoute.FloaterListTodos.create( + id, + name + ) + ) + }, + onOpenSettings = { + navController.navigate(AppRoute.Settings.route) + }, + showRootFeedDock = false, + showCreateTaskButton = false, + usesRootFeedHeader = true, + createTaskRequestKey = rootCreateTaskRequestKey, + scrollToTopRequestKey = rootFloaterScrollToTopRequestKey, + onRootDockCollapsedChange = { + rootDockCollapsed = it + }, + onRootControlsVisibleChange = { + rootControlsVisible = it + }, + ) + } + } + + if (rootControlsVisible) { + RootFeedDock( + activeTab = rootFeedTab, + collapsed = rootDockCollapsed, + onTabSelected = ::handleRootFeedTabSelection, + modifier = Modifier + .align(Alignment.BottomStart) + .zIndex(8f), ) - }, - onCompleteTask = { todo -> homeViewModel.completeTodo(todo) }, - onDeleteTask = { todo -> homeViewModel.deleteTodo(todo) }, - onUpdateTask = { todo, payload -> - homeViewModel.updateTask( - todo, - payload + RootCreateTaskButton( + backgroundColor = Color(0xFF6EA8E1), + onClick = { rootCreateTaskRequestKey += 1 }, + modifier = Modifier + .align(Alignment.BottomEnd) + .navigationBarsPadding() + .padding(end = 18.dp, bottom = 18.dp) + .zIndex(8f), ) - }, - ) + } + } } else { HomeScreen( uiState = UnauthenticatedHomeUiState, @@ -364,6 +473,7 @@ fun TdayApp( onOpenPriority = {}, onOpenCompleted = {}, onOpenCalendar = {}, + onOpenFloater = {}, onOpenSettings = {}, onOpenTaskFromSearch = {}, onOpenList = { _, _ -> }, @@ -395,6 +505,11 @@ fun TdayApp( serverCanResetTrust = appUiState.canResetServerTrust, pendingApprovalMessage = appUiState.pendingApprovalMessage, authUiState = authUiState, + onUseLocalMode = { + authViewModel.clearStatus() + appViewModel.clearPendingApprovalNotice() + appViewModel.useLocalMode() + }, onConnectServer = { rawUrl, onResult -> appViewModel.saveServerUrl( rawUrl = rawUrl, @@ -451,6 +566,7 @@ fun TdayApp( val authenticatedVersionCheck = appUiState.versionCheckResult if (appUiState.authenticated && + !appUiState.isLocalMode && (authenticatedVersionCheck is com.ohmz.tday.compose.core.data.server.VersionCheckResult.AppUpdateRequired || authenticatedVersionCheck is com.ohmz.tday.compose.core.data.server.VersionCheckResult.ServerUpdateRequired) ) { @@ -464,6 +580,20 @@ fun TdayApp( } } + composable( + route = AppRoute.FloaterTodos.route, + deepLinks = listOf(navDeepLink { uriPattern = "tday://floater" }), + ) { + LaunchedEffect(Unit) { + rootFeedTab = RootFeedTab.FLOATER + navController.navigate(AppRoute.Home.route) { + popUpTo(AppRoute.Home.route) { inclusive = false } + launchSingleTop = true + } + } + Box(modifier = Modifier.fillMaxSize()) + } + composable( route = AppRoute.TodayTodos.route, deepLinks = listOf(navDeepLink { uriPattern = "tday://todos/today" }), @@ -472,6 +602,7 @@ fun TdayApp( mode = TodoListMode.TODAY, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, ) } @@ -483,6 +614,7 @@ fun TdayApp( mode = TodoListMode.OVERDUE, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, ) } @@ -494,6 +626,7 @@ fun TdayApp( mode = TodoListMode.SCHEDULED, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, ) } @@ -524,6 +657,7 @@ fun TdayApp( highlightTodoId = highlightTodoId, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, ) } @@ -535,6 +669,7 @@ fun TdayApp( mode = TodoListMode.PRIORITY, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, ) } @@ -556,7 +691,37 @@ fun TdayApp( listName = listName, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, + onListDeleted = { + navController.navigate(AppRoute.Home.route) { + popUpTo(AppRoute.Home.route) { inclusive = false } + launchSingleTop = true + } + }, + ) + } + + composable( + route = AppRoute.FloaterListTodos.route, + arguments = listOf( + navArgument("listId") { type = NavType.StringType }, + navArgument("listName") { type = NavType.StringType }, + ), + deepLinks = listOf( + navDeepLink { uriPattern = "tday://floater/list/{listId}/{listName}" }, + ), + ) { entry -> + val listId = entry.arguments?.getString("listId").orEmpty() + val listName = Uri.decode(entry.arguments?.getString("listName").orEmpty()) + TodosRoute( + mode = TodoListMode.FLOATER, + listId = listId, + listName = listName, + onBack = { navController.popBackStack() }, + onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, onListDeleted = { + rootFeedTab = RootFeedTab.FLOATER navController.navigate(AppRoute.Home.route) { popUpTo(AppRoute.Home.route) { inclusive = false } launchSingleTop = true @@ -632,6 +797,7 @@ fun TdayApp( } SettingsScreen( user = appUiState.user, + isLocalMode = appUiState.isLocalMode, selectedThemeMode = appUiState.themeMode, selectedReminder = appUiState.selectedReminder, adminAiSummaryEnabled = appUiState.adminAiSummaryEnabled, @@ -672,7 +838,7 @@ fun TdayApp( } OfflineBanner( - visible = appUiState.isOffline && appUiState.authenticated, + visible = appUiState.isOffline && appUiState.authenticated && !appUiState.isLocalMode, pendingMutationCount = appUiState.pendingMutationCount, noticeKey = appUiState.offlineNoticeId, modifier = Modifier.align(Alignment.TopCenter), @@ -729,14 +895,14 @@ private fun HandleStartupNavigation( ) { LaunchedEffect( appUiState.loading, - appUiState.authenticated, + appUiState.isWorkspaceAvailable, currentRoute, isStartupSplashHeld, ) { if (appUiState.loading) return@LaunchedEffect if (isStartupSplashHeld) return@LaunchedEffect - if (appUiState.authenticated) { + if (appUiState.isWorkspaceAvailable) { val unauthenticatedRoutes = setOf( AppRoute.Splash.route, AppRoute.Login.route, @@ -823,9 +989,21 @@ private fun TodosRoute( onBack: () -> Unit, onTaskDeleted: () -> Unit, onListDeleted: () -> Unit = {}, + onOpenFloaterList: (String, String) -> Unit = { _, _ -> }, + onOpenSettings: () -> Unit = {}, highlightTodoId: String? = null, listId: String? = null, listName: String? = null, + rootFeedTab: RootFeedTab? = null, + onRootFeedTabSelected: ((RootFeedTab) -> Unit)? = null, + showRootFeedDock: Boolean = true, + showCreateTaskButton: Boolean = true, + usesRootFeedHeader: Boolean = false, + createTaskRequestKey: Int = 0, + scrollToTopRequestKey: Int = 0, + onRootDockCollapsedChange: (Boolean) -> Unit = {}, + onRootControlsVisibleChange: (Boolean) -> Unit = {}, + pullRefreshEnabled: Boolean = true, ) { val viewModel: TodoListViewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -868,6 +1046,19 @@ private fun TodosRoute( onOptimisticDelete = onListDeleted, ) }, + onOpenFloaterList = onOpenFloaterList, + onOpenSettings = onOpenSettings, + onCreateList = viewModel::createList, + rootFeedTab = rootFeedTab, + onRootFeedTabSelected = onRootFeedTabSelected, + showRootFeedDock = showRootFeedDock, + showCreateTaskButton = showCreateTaskButton, + pullRefreshEnabled = pullRefreshEnabled, + usesRootFeedHeader = usesRootFeedHeader, + createTaskRequestKey = createTaskRequestKey, + scrollToTopRequestKey = scrollToTopRequestKey, + onRootDockCollapsedChange = onRootDockCollapsedChange, + onRootControlsVisibleChange = onRootControlsVisibleChange, ) } @@ -1072,6 +1263,7 @@ private val UnauthenticatedHomeUiState = HomeUiState( scheduledCount = 0, allCount = 0, priorityCount = 0, + floaterCount = 0, completedCount = 0, lists = listOf( ListSummary( diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/AppDataMode.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/AppDataMode.kt new file mode 100644 index 00000000..d3d39787 --- /dev/null +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/AppDataMode.kt @@ -0,0 +1,7 @@ +package com.ohmz.tday.compose.core.data + +enum class AppDataMode { + UNSET, + SERVER, + LOCAL, +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt index 2b5f5e11..7462108a 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt @@ -7,8 +7,11 @@ data class OfflineSyncState( val lastSuccessfulSyncEpochMs: Long = 0L, val lastSyncAttemptEpochMs: Long = 0L, val todos: List = emptyList(), + val floaters: List = emptyList(), val completedItems: List = emptyList(), + val completedFloaters: List = emptyList(), val lists: List = emptyList(), + val floaterLists: List = emptyList(), val pendingMutations: List = emptyList(), val aiSummaryEnabled: Boolean = true, ) @@ -20,7 +23,7 @@ data class CachedTodoRecord( val title: String, val description: String? = null, val priority: String = "Low", - val dueEpochMs: Long, + val dueEpochMs: Long? = null, val rrule: String? = null, val instanceDateEpochMs: Long? = null, val pinned: Boolean = false, @@ -29,6 +32,19 @@ data class CachedTodoRecord( val updatedAtEpochMs: Long = 0L, ) +@Serializable +data class CachedFloaterRecord( + val id: String, + val canonicalId: String, + val title: String, + val description: String? = null, + val priority: String = "Low", + val pinned: Boolean = false, + val completed: Boolean = false, + val listId: String? = null, + val updatedAtEpochMs: Long = 0L, +) + @Serializable data class CachedListRecord( val id: String, @@ -40,6 +56,17 @@ data class CachedListRecord( val createdAtEpochMs: Long = 0L, ) +@Serializable +data class CachedFloaterListRecord( + val id: String, + val name: String, + val color: String? = null, + val iconKey: String? = null, + val todoCount: Int = 0, + val updatedAtEpochMs: Long = 0L, + val createdAtEpochMs: Long = 0L, +) + @Serializable data class CachedCompletedRecord( val id: String, @@ -47,7 +74,7 @@ data class CachedCompletedRecord( val title: String, val description: String? = null, val priority: String, - val dueEpochMs: Long, + val dueEpochMs: Long? = null, val completedAtEpochMs: Long = 0L, val rrule: String? = null, val instanceDateEpochMs: Long? = null, @@ -56,6 +83,19 @@ data class CachedCompletedRecord( val listColor: String? = null, ) +@Serializable +data class CachedCompletedFloaterRecord( + val id: String, + val originalFloaterId: String? = null, + val title: String, + val description: String? = null, + val priority: String, + val completedAtEpochMs: Long = 0L, + val listId: String? = null, + val listName: String? = null, + val listColor: String? = null, +) + @Serializable data class PendingMutationRecord( val mutationId: String, @@ -81,12 +121,20 @@ enum class MutationKind { CREATE_LIST, UPDATE_LIST, DELETE_LIST, + CREATE_FLOATER_LIST, + UPDATE_FLOATER_LIST, + DELETE_FLOATER_LIST, CREATE_TODO, UPDATE_TODO, DELETE_TODO, + CREATE_FLOATER, + UPDATE_FLOATER, + DELETE_FLOATER, SET_PINNED, SET_PRIORITY, COMPLETE_TODO, COMPLETE_TODO_INSTANCE, UNCOMPLETE_TODO, + COMPLETE_FLOATER, + UNCOMPLETE_FLOATER, } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt index 03bfa1d5..20160fde 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt @@ -32,6 +32,25 @@ class SecureConfigStore @Inject constructor( fun hasServerUrl(): Boolean = !getServerUrl().isNullOrBlank() + fun getAppDataMode(): AppDataMode { + val persisted = prefs.getString(KEY_APP_DATA_MODE, null) + ?.let { raw -> + runCatching { AppDataMode.valueOf(raw) }.getOrNull() + } + if (persisted != null) return persisted + return if (!getServerUrl().isNullOrBlank()) AppDataMode.SERVER else AppDataMode.UNSET + } + + fun isLocalMode(): Boolean = getAppDataMode() == AppDataMode.LOCAL + + fun setAppDataMode(mode: AppDataMode) { + prefs.edit().putString(KEY_APP_DATA_MODE, mode.name).apply() + } + + fun clearAppDataMode() { + prefs.edit().remove(KEY_APP_DATA_MODE).apply() + } + fun getServerUrl(): String? { val inMemory = runtimeServerUrl?.takeIf { it.isNotBlank() } if (inMemory != null) return inMemory @@ -53,7 +72,10 @@ class SecureConfigStore @Inject constructor( runtimeServerUrl = normalized if (persist) { - prefs.edit().putString(KEY_SERVER_URL, normalized).apply() + prefs.edit() + .putString(KEY_SERVER_URL, normalized) + .putString(KEY_APP_DATA_MODE, AppDataMode.SERVER.name) + .apply() } else { prefs.edit().remove(KEY_SERVER_URL).apply() } @@ -65,7 +87,10 @@ class SecureConfigStore @Inject constructor( ?: return Result.failure(IllegalStateException("Server URL is not configured")) runtimeServerUrl = current - prefs.edit().putString(KEY_SERVER_URL, current).apply() + prefs.edit() + .putString(KEY_SERVER_URL, current) + .putString(KEY_APP_DATA_MODE, AppDataMode.SERVER.name) + .apply() return Result.success(current) } @@ -254,5 +279,6 @@ class SecureConfigStore @Inject constructor( const val KEY_LIST_ICON_MAP = "list_icon_map" const val KEY_OFFLINE_SYNC_STATE = "offline_sync_state_v1" const val KEY_CACHED_SESSION_USER = "cached_session_user_v1" + const val KEY_APP_DATA_MODE = "app_data_mode_v1" } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt index 306a3d0e..48ee907e 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt @@ -1,10 +1,16 @@ package com.ohmz.tday.compose.core.data.cache +import com.ohmz.tday.compose.core.data.CachedCompletedFloaterRecord import com.ohmz.tday.compose.core.data.CachedCompletedRecord +import com.ohmz.tday.compose.core.data.CachedFloaterListRecord +import com.ohmz.tday.compose.core.data.CachedFloaterRecord import com.ohmz.tday.compose.core.data.CachedListRecord import com.ohmz.tday.compose.core.data.CachedTodoRecord +import com.ohmz.tday.compose.core.model.CompletedFloaterDto import com.ohmz.tday.compose.core.model.CompletedItem import com.ohmz.tday.compose.core.model.CompletedTodoDto +import com.ohmz.tday.compose.core.model.FloaterDto +import com.ohmz.tday.compose.core.model.FloaterListDto import com.ohmz.tday.compose.core.model.ListDto import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.TodoDto @@ -15,8 +21,11 @@ import java.time.OffsetDateTime import java.time.ZoneOffset internal const val LOCAL_TODO_PREFIX = "local-todo-" +internal const val LOCAL_FLOATER_PREFIX = "local-floater-" internal const val LOCAL_LIST_PREFIX = "local-list-" +internal const val LOCAL_FLOATER_LIST_PREFIX = "local-floater-list-" internal const val LOCAL_COMPLETED_PREFIX = "local-completed-" +internal const val LOCAL_COMPLETED_FLOATER_PREFIX = "local-completed-floater-" internal fun todoToCache(todo: TodoItem): CachedTodoRecord { return CachedTodoRecord( @@ -25,7 +34,7 @@ internal fun todoToCache(todo: TodoItem): CachedTodoRecord { title = todo.title, description = todo.description, priority = todo.priority, - dueEpochMs = todo.due.toEpochMilli(), + dueEpochMs = todo.due?.toEpochMilli(), rrule = todo.rrule, instanceDateEpochMs = todo.instanceDateEpochMillis, pinned = todo.pinned, @@ -42,7 +51,7 @@ internal fun todoFromCache(cache: CachedTodoRecord): TodoItem { title = cache.title, description = cache.description, priority = cache.priority, - due = Instant.ofEpochMilli(cache.dueEpochMs), + due = cache.dueEpochMs?.let(Instant::ofEpochMilli), rrule = cache.rrule, instanceDate = cache.instanceDateEpochMs?.let(Instant::ofEpochMilli), pinned = cache.pinned, @@ -56,6 +65,41 @@ internal fun todoFromCache(cache: CachedTodoRecord): TodoItem { ) } +internal fun floaterToCache(floater: TodoItem): CachedFloaterRecord { + return CachedFloaterRecord( + id = floater.id, + canonicalId = floater.canonicalId, + title = floater.title, + description = floater.description, + priority = floater.priority, + pinned = floater.pinned, + completed = floater.completed, + listId = floater.listId, + updatedAtEpochMs = floater.updatedAt?.toEpochMilli() ?: 0L, + ) +} + +internal fun floaterFromCache(cache: CachedFloaterRecord): TodoItem { + return TodoItem( + id = cache.id, + canonicalId = cache.canonicalId, + title = cache.title, + description = cache.description, + priority = cache.priority, + due = null, + rrule = null, + instanceDate = null, + pinned = cache.pinned, + completed = cache.completed, + listId = cache.listId, + updatedAt = if (cache.updatedAtEpochMs > 0L) { + Instant.ofEpochMilli(cache.updatedAtEpochMs) + } else { + null + }, + ) +} + internal fun listToCache(list: ListSummary): CachedListRecord { return CachedListRecord( id = list.id, @@ -78,6 +122,16 @@ internal fun orderListsLikeWeb(lists: List): List): List { + if (lists.none { it.createdAtEpochMs > 0L }) return lists + return lists.withIndex() + .sortedWith( + compareByDescending> { it.value.createdAtEpochMs } + .thenBy { it.index }, + ) + .map { it.value } +} + internal fun listFromCache( cache: CachedListRecord, todoCountOverride: Int, @@ -101,6 +155,33 @@ internal fun listFromCache( ) } +internal fun floaterListToCache(list: ListSummary): CachedFloaterListRecord { + return CachedFloaterListRecord( + id = list.id, + name = list.name, + color = list.color, + iconKey = list.iconKey, + todoCount = list.todoCount, + updatedAtEpochMs = list.updatedAt?.toEpochMilli() ?: 0L, + createdAtEpochMs = list.createdAt?.toEpochMilli() ?: 0L, + ) +} + +internal fun floaterListFromCache( + cache: CachedFloaterListRecord, + todoCountOverride: Int, +): ListSummary { + return ListSummary( + id = cache.id, + name = cache.name, + color = cache.color, + iconKey = cache.iconKey, + todoCount = todoCountOverride, + updatedAt = if (cache.updatedAtEpochMs > 0L) Instant.ofEpochMilli(cache.updatedAtEpochMs) else null, + createdAt = if (cache.createdAtEpochMs > 0L) Instant.ofEpochMilli(cache.createdAtEpochMs) else null, + ) +} + internal fun completedToCache(item: CompletedItem): CachedCompletedRecord { return CachedCompletedRecord( id = item.id, @@ -108,7 +189,7 @@ internal fun completedToCache(item: CompletedItem): CachedCompletedRecord { title = item.title, description = item.description, priority = item.priority, - dueEpochMs = item.due.toEpochMilli(), + dueEpochMs = item.due?.toEpochMilli(), completedAtEpochMs = item.completedAt?.toEpochMilli() ?: 0L, rrule = item.rrule, instanceDateEpochMs = item.instanceDate?.toEpochMilli(), @@ -125,7 +206,7 @@ internal fun completedFromCache(cache: CachedCompletedRecord): CompletedItem { title = cache.title, description = cache.description, priority = cache.priority, - due = Instant.ofEpochMilli(cache.dueEpochMs), + due = cache.dueEpochMs?.let(Instant::ofEpochMilli), completedAt = if (cache.completedAtEpochMs > 0L) { Instant.ofEpochMilli(cache.completedAtEpochMs) } else { @@ -139,6 +220,41 @@ internal fun completedFromCache(cache: CachedCompletedRecord): CompletedItem { ) } +internal fun completedFloaterToCache(item: CompletedItem): CachedCompletedFloaterRecord { + return CachedCompletedFloaterRecord( + id = item.id, + originalFloaterId = item.originalTodoId, + title = item.title, + description = item.description, + priority = item.priority, + completedAtEpochMs = item.completedAt?.toEpochMilli() ?: 0L, + listId = item.listId, + listName = item.listName, + listColor = item.listColor, + ) +} + +internal fun completedFloaterFromCache(cache: CachedCompletedFloaterRecord): CompletedItem { + return CompletedItem( + id = cache.id, + originalTodoId = cache.originalFloaterId, + title = cache.title, + description = cache.description, + priority = cache.priority, + due = null, + completedAt = if (cache.completedAtEpochMs > 0L) { + Instant.ofEpochMilli(cache.completedAtEpochMs) + } else { + null + }, + rrule = null, + instanceDate = null, + listId = cache.listId, + listName = cache.listName, + listColor = cache.listColor, + ) +} + internal fun mapTodoDto(dto: TodoDto): TodoItem { val canonicalId = dto.id.substringBefore(':') val suffixInstance = dto.id.substringAfter(':', "") @@ -152,7 +268,7 @@ internal fun mapTodoDto(dto: TodoDto): TodoItem { title = dto.title, description = dto.description, priority = dto.priority, - due = parseInstant(dto.due), + due = parseOptionalInstant(dto.due), rrule = dto.rrule, instanceDate = explicitInstance ?: suffixInstance, pinned = dto.pinned, @@ -162,6 +278,23 @@ internal fun mapTodoDto(dto: TodoDto): TodoItem { ) } +internal fun mapFloaterDto(dto: FloaterDto): TodoItem { + return TodoItem( + id = dto.id, + canonicalId = dto.id, + title = dto.title, + description = dto.description, + priority = dto.priority, + due = null, + rrule = null, + instanceDate = null, + pinned = dto.pinned, + completed = dto.completed, + listId = dto.listID, + updatedAt = parseOptionalInstant(dto.updatedAt), + ) +} + internal fun mapCompletedDto(dto: CompletedTodoDto): CompletedItem { return CompletedItem( id = dto.id, @@ -169,7 +302,7 @@ internal fun mapCompletedDto(dto: CompletedTodoDto): CompletedItem { title = dto.title, description = dto.description, priority = dto.priority, - due = parseInstant(dto.due), + due = parseOptionalInstant(dto.due), completedAt = parseOptionalInstant(dto.completedAt), rrule = dto.rrule, instanceDate = parseOptionalInstant(dto.instanceDate), @@ -179,6 +312,23 @@ internal fun mapCompletedDto(dto: CompletedTodoDto): CompletedItem { ) } +internal fun mapCompletedFloaterDto(dto: CompletedFloaterDto): CompletedItem { + return CompletedItem( + id = dto.id, + originalTodoId = dto.originalFloaterID, + title = dto.title, + description = dto.description, + priority = dto.priority, + due = null, + completedAt = parseOptionalInstant(dto.completedAt), + rrule = null, + instanceDate = null, + listId = dto.listID, + listName = dto.listName, + listColor = dto.listColor, + ) +} + internal fun mapListDto(dto: ListDto, iconFallback: String? = null): ListSummary { return ListSummary( id = dto.id, @@ -191,6 +341,18 @@ internal fun mapListDto(dto: ListDto, iconFallback: String? = null): ListSummary ) } +internal fun mapFloaterListDto(dto: FloaterListDto, iconFallback: String? = null): ListSummary { + return ListSummary( + id = dto.id, + name = dto.name, + color = dto.color, + iconKey = dto.iconKey ?: iconFallback, + todoCount = dto.todoCount, + updatedAt = parseOptionalInstant(dto.updatedAt), + createdAt = parseOptionalInstant(dto.createdAt), + ) +} + internal fun matchesCompletedRecord( record: CachedCompletedRecord, itemId: String, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt index df291e25..78d4eef2 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerializationException -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import java.net.CookieManager import javax.inject.Inject @@ -29,8 +28,11 @@ class OfflineCacheManager @Inject constructor( private val cookieManager: CookieManager, ) { private val todoDao = database.todoDao() + private val floaterDao = database.floaterDao() private val listDao = database.listDao() + private val floaterListDao = database.floaterListDao() private val completedDao = database.completedDao() + private val completedFloaterDao = database.completedFloaterDao() private val mutationDao = database.mutationDao() private val syncMetadataDao = database.syncMetadataDao() @@ -78,8 +80,11 @@ class OfflineCacheManager @Inject constructor( fun loadOfflineState(): OfflineSyncState { ensureMigrated() val todos = todoDao.getAll().map { it.toRecord() } + val floaters = floaterDao.getAll().map { it.toRecord() } val lists = listDao.getAll().map { it.toRecord() } + val floaterLists = floaterListDao.getAll().map { it.toRecord() } val completed = completedDao.getAll().map { it.toRecord() } + val completedFloaters = completedFloaterDao.getAll().map { it.toRecord() } val mutations = mutationDao.getAll().map { it.toRecord() } val metadata = syncMetadataDao.get() @@ -87,8 +92,11 @@ class OfflineCacheManager @Inject constructor( lastSuccessfulSyncEpochMs = metadata?.lastSuccessfulSyncEpochMs ?: 0L, lastSyncAttemptEpochMs = metadata?.lastSyncAttemptEpochMs ?: 0L, todos = todos, + floaters = floaters, completedItems = completed, + completedFloaters = completedFloaters, lists = lists, + floaterLists = floaterLists, pendingMutations = mutations, aiSummaryEnabled = metadata?.aiSummaryEnabled ?: true, ) @@ -99,11 +107,20 @@ class OfflineCacheManager @Inject constructor( fun saveOfflineState(state: OfflineSyncState) { ensureMigrated() val previous = lastPersistedState ?: loadOfflineState() - if (previous == state) return + val normalizedState = if (secureConfigStore.isLocalMode()) { + state.copy( + lastSuccessfulSyncEpochMs = 0L, + lastSyncAttemptEpochMs = 0L, + pendingMutations = emptyList(), + ) + } else { + state + } + if (previous == normalizedState) return - persistStateToDaos(state) - lastPersistedState = state - if (hasUiDataChanges(previous, state)) { + persistStateToDaos(normalizedState) + lastPersistedState = normalizedState + if (hasUiDataChanges(previous, normalizedState)) { cacheDataVersionMutable.value = cacheDataVersionMutable.value + 1L } } @@ -117,8 +134,11 @@ class OfflineCacheManager @Inject constructor( fun hasCachedData(): Boolean { ensureMigrated() if (todoDao.count() > 0) return true + if (floaterDao.count() > 0) return true if (listDao.count() > 0) return true + if (floaterListDao.count() > 0) return true if (completedDao.count() > 0) return true + if (completedFloaterDao.count() > 0) return true return mutationDao.count() > 0 } @@ -159,10 +179,16 @@ class OfflineCacheManager @Inject constructor( database.runInTransaction { todoDao.deleteAll() todoDao.insertAll(state.todos.map { it.toEntity() }) + floaterDao.deleteAll() + floaterDao.insertAll(state.floaters.map { it.toEntity() }) listDao.deleteAll() listDao.insertAll(state.lists.map { it.toEntity() }) + floaterListDao.deleteAll() + floaterListDao.insertAll(state.floaterLists.map { it.toEntity() }) completedDao.deleteAll() completedDao.insertAll(state.completedItems.map { it.toEntity() }) + completedFloaterDao.deleteAll() + completedFloaterDao.insertAll(state.completedFloaters.map { it.toEntity() }) mutationDao.deleteAll() mutationDao.insertAll(state.pendingMutations.map { it.toEntity() }) syncMetadataDao.upsert( @@ -180,8 +206,11 @@ class OfflineCacheManager @Inject constructor( next: OfflineSyncState, ): Boolean { return previous.todos != next.todos || + previous.floaters != next.floaters || previous.completedItems != next.completedItems || + previous.completedFloaters != next.completedFloaters || previous.lists != next.lists || + previous.floaterLists != next.floaterLists || previous.aiSummaryEnabled != next.aiSummaryEnabled } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt index a86658e3..36a5c8ac 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt @@ -69,6 +69,8 @@ class CompletedRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (originalTodoId.startsWith(LOCAL_TODO_PREFIX)) return runCatching { @@ -119,8 +121,8 @@ class CompletedRepository @Inject constructor( title = normalizedTitle, description = payload.description, priority = normalizedPriority, - dueEpochMs = payload.due.toEpochMilli(), - rrule = payload.rrule, + dueEpochMs = payload.due?.toEpochMilli(), + rrule = payload.rrule?.takeIf { payload.due != null }, listId = normalizedListId, updatedAtEpochMs = timestampMs, ) @@ -143,10 +145,10 @@ class CompletedRepository @Inject constructor( title = normalizedTitle, description = payload.description, priority = normalizedPriority, - dueEpochMs = payload.due.toEpochMilli(), + dueEpochMs = payload.due?.toEpochMilli(), completedAtEpochMs = completed.completedAtEpochMs.takeIf { it > 0L } ?: timestampMs, - rrule = payload.rrule, + rrule = payload.rrule?.takeIf { payload.due != null }, listId = normalizedListId, listName = listMeta?.name, listColor = listMeta?.color, @@ -158,6 +160,8 @@ class CompletedRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + requireApiBody( api.patchCompletedTodoByBody( UpdateCompletedTodoRequest( @@ -165,8 +169,8 @@ class CompletedRepository @Inject constructor( title = normalizedTitle, description = payload.description, priority = normalizedPriority, - due = payload.due.toString(), - rrule = payload.rrule, + due = payload.due?.toString(), + rrule = payload.rrule?.takeIf { payload.due != null }, listID = normalizedListId, ), ), @@ -207,6 +211,8 @@ class CompletedRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (resolvedCompletedId.startsWith(LOCAL_COMPLETED_PREFIX)) return requireApiBody( @@ -222,6 +228,10 @@ class CompletedRepository @Inject constructor( canonicalTodoId: String?, instanceDateEpochMs: Long?, ): String { + if (syncManager.isLocalMode()) { + return currentCompletedId + } + if (!currentCompletedId.startsWith(LOCAL_COMPLETED_PREFIX)) { return currentCompletedId } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Daos.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Daos.kt index 999de023..d685329f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Daos.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Daos.kt @@ -24,6 +24,21 @@ interface TodoDao { fun deleteAll() } +@Dao +interface FloaterDao { + @Query("SELECT * FROM cached_floaters") + fun getAll(): List + + @Query("SELECT COUNT(*) FROM cached_floaters") + fun count(): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(floaters: List) + + @Query("DELETE FROM cached_floaters") + fun deleteAll() +} + @Dao interface ListDao { @Query("SELECT * FROM cached_lists") @@ -42,6 +57,21 @@ interface ListDao { fun deleteAll() } +@Dao +interface FloaterListDao { + @Query("SELECT * FROM cached_floater_lists") + fun getAll(): List + + @Query("SELECT COUNT(*) FROM cached_floater_lists") + fun count(): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(lists: List) + + @Query("DELETE FROM cached_floater_lists") + fun deleteAll() +} + @Dao interface CompletedDao { @Query("SELECT * FROM cached_completed") @@ -60,6 +90,21 @@ interface CompletedDao { fun deleteAll() } +@Dao +interface CompletedFloaterDao { + @Query("SELECT * FROM cached_completed_floaters") + fun getAll(): List + + @Query("SELECT COUNT(*) FROM cached_completed_floaters") + fun count(): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(items: List) + + @Query("DELETE FROM cached_completed_floaters") + fun deleteAll() +} + @Dao interface MutationDao { @Query("SELECT * FROM pending_mutations ORDER BY timestampEpochMs ASC") diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt deleted file mode 100644 index 2a3d8a0b..00000000 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.ohmz.tday.compose.core.data.db - -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -/** - * Drops `dtstartEpochMs` from cached todos, completed rows, and pending mutations. - */ -val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS `cached_todos_new` ( - `id` TEXT NOT NULL PRIMARY KEY, - `canonicalId` TEXT NOT NULL, - `title` TEXT NOT NULL, - `description` TEXT, - `priority` TEXT NOT NULL, - `dueEpochMs` INTEGER NOT NULL, - `rrule` TEXT, - `instanceDateEpochMs` INTEGER, - `pinned` INTEGER NOT NULL, - `completed` INTEGER NOT NULL, - `listId` TEXT, - `updatedAtEpochMs` INTEGER NOT NULL - ) - """.trimIndent(), - ) - db.execSQL( - """ - INSERT INTO `cached_todos_new` (`id`,`canonicalId`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`instanceDateEpochMs`,`pinned`,`completed`,`listId`,`updatedAtEpochMs`) - SELECT `id`,`canonicalId`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`instanceDateEpochMs`,`pinned`,`completed`,`listId`,`updatedAtEpochMs` FROM `cached_todos` - """.trimIndent(), - ) - db.execSQL("DROP TABLE `cached_todos`") - db.execSQL("ALTER TABLE `cached_todos_new` RENAME TO `cached_todos`") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_listId` ON `cached_todos` (`listId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_dueEpochMs` ON `cached_todos` (`dueEpochMs`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_completed` ON `cached_todos` (`completed`)") - - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS `cached_completed_new` ( - `id` TEXT NOT NULL PRIMARY KEY, - `originalTodoId` TEXT, - `title` TEXT NOT NULL, - `description` TEXT, - `priority` TEXT NOT NULL, - `dueEpochMs` INTEGER NOT NULL, - `completedAtEpochMs` INTEGER NOT NULL, - `rrule` TEXT, - `instanceDateEpochMs` INTEGER, - `listName` TEXT, - `listColor` TEXT - ) - """.trimIndent(), - ) - db.execSQL( - """ - INSERT INTO `cached_completed_new` (`id`,`originalTodoId`,`title`,`description`,`priority`,`dueEpochMs`,`completedAtEpochMs`,`rrule`,`instanceDateEpochMs`,`listName`,`listColor`) - SELECT `id`,`originalTodoId`,`title`,`description`,`priority`,`dueEpochMs`,`completedAtEpochMs`,`rrule`,`instanceDateEpochMs`,`listName`,`listColor` FROM `cached_completed` - """.trimIndent(), - ) - db.execSQL("DROP TABLE `cached_completed`") - db.execSQL("ALTER TABLE `cached_completed_new` RENAME TO `cached_completed`") - db.execSQL( - "CREATE INDEX IF NOT EXISTS `index_cached_completed_completedAtEpochMs` ON `cached_completed` (`completedAtEpochMs`)", - ) - - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS `pending_mutations_new` ( - `mutationId` TEXT NOT NULL PRIMARY KEY, - `kind` TEXT NOT NULL, - `targetId` TEXT, - `timestampEpochMs` INTEGER NOT NULL, - `title` TEXT, - `description` TEXT, - `priority` TEXT, - `dueEpochMs` INTEGER, - `rrule` TEXT, - `listId` TEXT, - `pinned` INTEGER, - `completed` INTEGER, - `instanceDateEpochMs` INTEGER, - `name` TEXT, - `color` TEXT, - `iconKey` TEXT - ) - """.trimIndent(), - ) - db.execSQL( - """ - INSERT INTO `pending_mutations_new` (`mutationId`,`kind`,`targetId`,`timestampEpochMs`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`listId`,`pinned`,`completed`,`instanceDateEpochMs`,`name`,`color`,`iconKey`) - SELECT `mutationId`,`kind`,`targetId`,`timestampEpochMs`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`listId`,`pinned`,`completed`,`instanceDateEpochMs`,`name`,`color`,`iconKey` FROM `pending_mutations` - """.trimIndent(), - ) - db.execSQL("DROP TABLE `pending_mutations`") - db.execSQL("ALTER TABLE `pending_mutations_new` RENAME TO `pending_mutations`") - db.execSQL( - "CREATE INDEX IF NOT EXISTS `index_pending_mutations_timestampEpochMs` ON `pending_mutations` (`timestampEpochMs`)", - ) - } -} - -val MIGRATION_2_3 = object : Migration(2, 3) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - "ALTER TABLE `cached_lists` ADD COLUMN `createdAtEpochMs` INTEGER NOT NULL DEFAULT 0", - ) - } -} - -val MIGRATION_3_4 = object : Migration(3, 4) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - "ALTER TABLE `cached_completed` ADD COLUMN `listId` TEXT", - ) - } -} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt index d016df3e..1a4bcb5d 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt @@ -21,7 +21,7 @@ object DatabaseModule { TdayDatabase::class.java, "tday_offline_cache.db", ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) + .fallbackToDestructiveMigration() .allowMainThreadQueries() .build() } @@ -29,12 +29,21 @@ object DatabaseModule { @Provides fun provideTodoDao(db: TdayDatabase): TodoDao = db.todoDao() + @Provides + fun provideFloaterDao(db: TdayDatabase): FloaterDao = db.floaterDao() + @Provides fun provideListDao(db: TdayDatabase): ListDao = db.listDao() + @Provides + fun provideFloaterListDao(db: TdayDatabase): FloaterListDao = db.floaterListDao() + @Provides fun provideCompletedDao(db: TdayDatabase): CompletedDao = db.completedDao() + @Provides + fun provideCompletedFloaterDao(db: TdayDatabase): CompletedFloaterDao = db.completedFloaterDao() + @Provides fun provideMutationDao(db: TdayDatabase): MutationDao = db.mutationDao() diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt index 4d740605..01824215 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt @@ -18,7 +18,7 @@ data class CachedTodoEntity( val title: String, val description: String?, val priority: String, - val dueEpochMs: Long, + val dueEpochMs: Long?, val rrule: String?, val instanceDateEpochMs: Long?, val pinned: Boolean, @@ -27,6 +27,25 @@ data class CachedTodoEntity( val updatedAtEpochMs: Long, ) +@Entity( + tableName = "cached_floaters", + indices = [ + Index("listId"), + Index("completed"), + ], +) +data class CachedFloaterEntity( + @PrimaryKey val id: String, + val canonicalId: String, + val title: String, + val description: String?, + val priority: String, + val pinned: Boolean, + val completed: Boolean, + val listId: String?, + val updatedAtEpochMs: Long, +) + @Entity(tableName = "cached_lists") data class CachedListEntity( @PrimaryKey val id: String, @@ -38,6 +57,17 @@ data class CachedListEntity( val createdAtEpochMs: Long, ) +@Entity(tableName = "cached_floater_lists") +data class CachedFloaterListEntity( + @PrimaryKey val id: String, + val name: String, + val color: String?, + val iconKey: String?, + val todoCount: Int, + val updatedAtEpochMs: Long, + val createdAtEpochMs: Long, +) + @Entity( tableName = "cached_completed", indices = [Index("completedAtEpochMs")], @@ -48,7 +78,7 @@ data class CachedCompletedEntity( val title: String, val description: String?, val priority: String, - val dueEpochMs: Long, + val dueEpochMs: Long?, val completedAtEpochMs: Long, val rrule: String?, val instanceDateEpochMs: Long?, @@ -57,6 +87,22 @@ data class CachedCompletedEntity( val listColor: String?, ) +@Entity( + tableName = "cached_completed_floaters", + indices = [Index("completedAtEpochMs")], +) +data class CachedCompletedFloaterEntity( + @PrimaryKey val id: String, + val originalFloaterId: String?, + val title: String, + val description: String?, + val priority: String, + val completedAtEpochMs: Long, + val listId: String?, + val listName: String?, + val listColor: String?, +) + @Entity( tableName = "pending_mutations", indices = [Index("timestampEpochMs")], diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt index f05916d3..cb351e53 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt @@ -1,6 +1,9 @@ package com.ohmz.tday.compose.core.data.db +import com.ohmz.tday.compose.core.data.CachedCompletedFloaterRecord import com.ohmz.tday.compose.core.data.CachedCompletedRecord +import com.ohmz.tday.compose.core.data.CachedFloaterListRecord +import com.ohmz.tday.compose.core.data.CachedFloaterRecord import com.ohmz.tday.compose.core.data.CachedListRecord import com.ohmz.tday.compose.core.data.CachedTodoRecord import com.ohmz.tday.compose.core.data.MutationKind @@ -36,6 +39,30 @@ fun CachedTodoEntity.toRecord() = CachedTodoRecord( updatedAtEpochMs = updatedAtEpochMs, ) +fun CachedFloaterRecord.toEntity() = CachedFloaterEntity( + id = id, + canonicalId = canonicalId, + title = title, + description = description, + priority = priority, + pinned = pinned, + completed = completed, + listId = listId, + updatedAtEpochMs = updatedAtEpochMs, +) + +fun CachedFloaterEntity.toRecord() = CachedFloaterRecord( + id = id, + canonicalId = canonicalId, + title = title, + description = description, + priority = priority, + pinned = pinned, + completed = completed, + listId = listId, + updatedAtEpochMs = updatedAtEpochMs, +) + fun CachedListRecord.toEntity() = CachedListEntity( id = id, name = name, @@ -56,6 +83,26 @@ fun CachedListEntity.toRecord() = CachedListRecord( createdAtEpochMs = createdAtEpochMs, ) +fun CachedFloaterListRecord.toEntity() = CachedFloaterListEntity( + id = id, + name = name, + color = color, + iconKey = iconKey, + todoCount = todoCount, + updatedAtEpochMs = updatedAtEpochMs, + createdAtEpochMs = createdAtEpochMs, +) + +fun CachedFloaterListEntity.toRecord() = CachedFloaterListRecord( + id = id, + name = name, + color = color, + iconKey = iconKey, + todoCount = todoCount, + updatedAtEpochMs = updatedAtEpochMs, + createdAtEpochMs = createdAtEpochMs, +) + fun CachedCompletedRecord.toEntity() = CachedCompletedEntity( id = id, originalTodoId = originalTodoId, @@ -86,6 +133,30 @@ fun CachedCompletedEntity.toRecord() = CachedCompletedRecord( listColor = listColor, ) +fun CachedCompletedFloaterRecord.toEntity() = CachedCompletedFloaterEntity( + id = id, + originalFloaterId = originalFloaterId, + title = title, + description = description, + priority = priority, + completedAtEpochMs = completedAtEpochMs, + listId = listId, + listName = listName, + listColor = listColor, +) + +fun CachedCompletedFloaterEntity.toRecord() = CachedCompletedFloaterRecord( + id = id, + originalFloaterId = originalFloaterId, + title = title, + description = description, + priority = priority, + completedAtEpochMs = completedAtEpochMs, + listId = listId, + listName = listName, + listColor = listColor, +) + fun PendingMutationRecord.toEntity() = PendingMutationEntity( mutationId = mutationId, kind = kind.name, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt index fdd4ddd4..3c2356a0 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt @@ -6,18 +6,24 @@ import androidx.room.RoomDatabase @Database( entities = [ CachedTodoEntity::class, + CachedFloaterEntity::class, CachedListEntity::class, + CachedFloaterListEntity::class, CachedCompletedEntity::class, + CachedCompletedFloaterEntity::class, PendingMutationEntity::class, SyncMetadataEntity::class, ], - version = 4, + version = 7, exportSchema = false, ) abstract class TdayDatabase : RoomDatabase() { abstract fun todoDao(): TodoDao + abstract fun floaterDao(): FloaterDao abstract fun listDao(): ListDao + abstract fun floaterListDao(): FloaterListDao abstract fun completedDao(): CompletedDao + abstract fun completedFloaterDao(): CompletedFloaterDao abstract fun mutationDao(): MutationDao abstract fun syncMetadataDao(): SyncMetadataDao } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/FloaterListRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/FloaterListRepository.kt new file mode 100644 index 00000000..910521e9 --- /dev/null +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/FloaterListRepository.kt @@ -0,0 +1,354 @@ +package com.ohmz.tday.compose.core.data.list + +import android.util.Log +import com.ohmz.tday.compose.core.data.CachedFloaterListRecord +import com.ohmz.tday.compose.core.data.MutationKind +import com.ohmz.tday.compose.core.data.OfflineSyncState +import com.ohmz.tday.compose.core.data.PendingMutationRecord +import com.ohmz.tday.compose.core.data.SecureConfigStore +import com.ohmz.tday.compose.core.data.cache.LOCAL_FLOATER_LIST_PREFIX +import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager +import com.ohmz.tday.compose.core.data.cache.floaterListFromCache +import com.ohmz.tday.compose.core.data.cache.orderFloaterListsLikeWeb +import com.ohmz.tday.compose.core.data.cache.parseOptionalInstant +import com.ohmz.tday.compose.core.data.isLikelyUnrecoverableMutationError +import com.ohmz.tday.compose.core.data.requireApiBody +import com.ohmz.tday.compose.core.data.sync.SyncManager +import com.ohmz.tday.compose.core.model.CreateFloaterListRequest +import com.ohmz.tday.compose.core.model.DeleteFloaterListRequest +import com.ohmz.tday.compose.core.model.ListSummary +import com.ohmz.tday.compose.core.model.UpdateFloaterListRequest +import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter +import com.ohmz.tday.compose.core.network.TdayApiService +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FloaterListRepository @Inject constructor( + private val api: TdayApiService, + private val cacheManager: OfflineCacheManager, + private val secureConfigStore: SecureConfigStore, + private val syncManager: SyncManager, +) { + suspend fun fetchLists(): List = + buildListsForState(cacheManager.loadOfflineState()) + + fun fetchListsSnapshot(): List = + buildListsForState(cacheManager.loadOfflineState()) + + suspend fun createList(name: String, color: String? = null, iconKey: String? = null) { + val normalizedName = capitalizeFirstListLetter(name).trim() + if (normalizedName.isBlank()) return + + val localListId = "$LOCAL_FLOATER_LIST_PREFIX${UUID.randomUUID()}" + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + cacheManager.updateOfflineState { state -> + val newList = CachedFloaterListRecord( + id = localListId, + name = normalizedName, + color = color, + iconKey = iconKey, + todoCount = 0, + createdAtEpochMs = timestampMs, + updatedAtEpochMs = timestampMs, + ) + state.copy( + floaterLists = state.floaterLists + newList, + pendingMutations = state.pendingMutations + PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.CREATE_FLOATER_LIST, + targetId = localListId, + timestampEpochMs = timestampMs, + name = normalizedName, + color = color, + iconKey = iconKey, + ), + ) + } + + if (syncManager.isLocalMode()) return + + runCatching { + requireApiBody( + api.createFloaterList( + CreateFloaterListRequest( + name = normalizedName, + color = color, + iconKey = iconKey, + ), + ), + "Could not create floater list", + ).list + }.onSuccess { createdList -> + if (createdList == null) return@onSuccess + val createdAt = + parseOptionalInstant(createdList.createdAt)?.toEpochMilli() ?: timestampMs + val updatedAt = + parseOptionalInstant(createdList.updatedAt)?.toEpochMilli() ?: timestampMs + cacheManager.updateOfflineState { state -> + val remapped = replaceLocalFloaterListId( + state = state, + localListId = localListId, + serverListId = createdList.id, + ) + val todoCount = + remapped.floaters.count { !it.completed && it.listId == createdList.id } + remapped.copy( + floaterLists = remapped.floaterLists.map { list -> + if (list.id == createdList.id) { + list.copy( + name = createdList.name, + color = createdList.color, + iconKey = createdList.iconKey ?: list.iconKey, + todoCount = todoCount, + updatedAtEpochMs = updatedAt, + createdAtEpochMs = createdAt, + ) + } else { + list + } + }, + pendingMutations = remapped.pendingMutations.filterNot { it.mutationId == mutationId }, + ) + } + } + } + + suspend fun updateList( + listId: String, + name: String, + color: String? = null, + iconKey: String? = null + ) { + val trimmedName = capitalizeFirstListLetter(name).trim() + if (listId.isBlank()) return + require(trimmedName.isNotBlank()) { "List name is required" } + + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + if (listId.startsWith(LOCAL_FLOATER_LIST_PREFIX)) { + cacheManager.updateOfflineState { state -> + state.copy( + floaterLists = state.floaterLists.map { list -> + if (list.id == listId) { + list.copy( + name = trimmedName, + color = color ?: list.color, + iconKey = iconKey ?: list.iconKey, + updatedAtEpochMs = timestampMs, + ) + } else { + list + } + }, + pendingMutations = state.pendingMutations.map { mutation -> + if (mutation.kind == MutationKind.CREATE_FLOATER_LIST && mutation.targetId == listId) { + mutation.copy( + name = trimmedName, + color = color ?: mutation.color, + iconKey = iconKey ?: mutation.iconKey, + timestampEpochMs = timestampMs, + ) + } else { + mutation + } + }, + ) + } + iconKey?.takeIf { it.isNotBlank() }?.let { + secureConfigStore.saveListIcon(listId, it) + } + if (syncManager.isLocalMode()) return + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + val pendingMutation = PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.UPDATE_FLOATER_LIST, + targetId = listId, + timestampEpochMs = timestampMs, + name = trimmedName, + color = color, + iconKey = iconKey, + ) + cacheManager.updateOfflineState { state -> + state.copy( + floaterLists = state.floaterLists.map { list -> + if (list.id == listId) { + list.copy( + name = trimmedName, + color = color ?: list.color, + iconKey = iconKey ?: list.iconKey, + updatedAtEpochMs = timestampMs, + ) + } else { + list + } + }, + pendingMutations = state.pendingMutations + .filterNot { it.kind == MutationKind.UPDATE_FLOATER_LIST && it.targetId == listId } + pendingMutation, + ) + } + + if (syncManager.isLocalMode()) { + iconKey?.takeIf { it.isNotBlank() }?.let { + secureConfigStore.saveListIcon(listId, it) + } + return + } + + val immediateError = runCatching { + requireApiBody( + api.patchFloaterListByBody( + UpdateFloaterListRequest( + id = listId, + name = trimmedName, + color = color, + iconKey = iconKey, + ), + ), + "Could not update floater list", + ) + }.exceptionOrNull() + + if (immediateError != null && isLikelyUnrecoverableMutationError( + immediateError, + pendingMutation + ) + ) { + throw immediateError + } + + iconKey?.takeIf { it.isNotBlank() }?.let { + secureConfigStore.saveListIcon(listId, it) + } + + if (immediateError == null) { + cacheManager.updateOfflineState { state -> + state.copy(pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }) + } + } else { + Log.w( + LOG_TAG, + "updateFloaterList deferred listId=$listId reason=${immediateError.message}" + ) + } + } + + suspend fun deleteList( + listId: String, + onOptimisticDelete: () -> Unit = {}, + ) { + val normalizedListId = listId.trim() + if (normalizedListId.isBlank()) return + + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + val pendingMutation = PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.DELETE_FLOATER_LIST, + targetId = normalizedListId, + timestampEpochMs = timestampMs, + ) + val isLocalOnly = normalizedListId.startsWith(LOCAL_FLOATER_LIST_PREFIX) + + cacheManager.updateOfflineState { state -> + val deletedFloaterIds = state.floaters + .filter { it.listId == normalizedListId } + .map { it.canonicalId } + .toSet() + val prunedMutations = state.pendingMutations.filterNot { mutation -> + mutation.targetId == normalizedListId || + mutation.listId == normalizedListId || + deletedFloaterIds.contains(mutation.targetId) + } + + state.copy( + floaterLists = state.floaterLists.filterNot { it.id == normalizedListId }, + floaters = state.floaters.filterNot { it.listId == normalizedListId }, + completedFloaters = state.completedFloaters.filterNot { completed -> + completed.listId == normalizedListId || + completed.originalFloaterId?.let(deletedFloaterIds::contains) == true + }, + pendingMutations = if (isLocalOnly) prunedMutations else prunedMutations + pendingMutation, + ) + } + + onOptimisticDelete() + + if (syncManager.isLocalMode()) return + + if (isLocalOnly) { + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + val immediateError = runCatching { + requireApiBody( + api.deleteFloaterListByBody(DeleteFloaterListRequest(id = normalizedListId)), + "Could not delete floater list", + ) + }.exceptionOrNull() + + if (immediateError != null && isLikelyUnrecoverableMutationError( + immediateError, + pendingMutation + ) + ) { + throw immediateError + } + + if (immediateError == null) { + cacheManager.updateOfflineState { state -> + state.copy(pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }) + } + } else { + Log.w( + LOG_TAG, + "deleteFloaterList deferred listId=$normalizedListId reason=${immediateError.message}" + ) + } + } + + private fun buildListsForState(state: OfflineSyncState): List { + val todoCountsByList = state.floaters + .asSequence() + .filterNot { it.completed } + .groupingBy { it.listId } + .eachCount() + return orderFloaterListsLikeWeb(state.floaterLists).map { + floaterListFromCache(cache = it, todoCountOverride = todoCountsByList[it.id] ?: 0) + } + } + + private fun replaceLocalFloaterListId( + state: OfflineSyncState, + localListId: String, + serverListId: String, + ): OfflineSyncState { + return state.copy( + floaterLists = state.floaterLists.map { + if (it.id == localListId) it.copy(id = serverListId) else it + }, + floaters = state.floaters.map { + if (it.listId == localListId) it.copy(listId = serverListId) else it + }, + completedFloaters = state.completedFloaters.map { + if (it.listId == localListId) it.copy(listId = serverListId) else it + }, + pendingMutations = state.pendingMutations.map { + it.copy( + targetId = if (it.targetId == localListId) serverListId else it.targetId, + listId = if (it.listId == localListId) serverListId else it.listId, + ) + }, + ) + } + + private companion object { + const val LOG_TAG = "FloaterListRepository" + } +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt index f8d4c20c..77860885 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt @@ -74,6 +74,8 @@ class ListRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + runCatching { requireApiBody( api.createList( @@ -164,6 +166,7 @@ class ListRepository @Inject constructor( iconKey?.takeIf { it.isNotBlank() }?.let { secureConfigStore.saveListIcon(listId, it) } + if (syncManager.isLocalMode()) return syncManager.syncCachedData(force = true, replayPendingMutations = true) Log.d(LOG_TAG, "updateList local-list path finished listId=$listId") return @@ -197,6 +200,13 @@ class ListRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) { + iconKey?.takeIf { it.isNotBlank() }?.let { + secureConfigStore.saveListIcon(listId, it) + } + return + } + Log.d(LOG_TAG, "updateList patch /api/list listId=$listId") val pendingMutation = PendingMutationRecord( mutationId = mutationId, @@ -263,7 +273,6 @@ class ListRepository @Inject constructor( .filter { it.listId == normalizedListId } .map { it.canonicalId } .toSet() - val prunedMutations = state.pendingMutations.filterNot { mutation -> mutation.targetId == normalizedListId || mutation.listId == normalizedListId || @@ -287,6 +296,8 @@ class ListRepository @Inject constructor( onOptimisticDelete() + if (syncManager.isLocalMode()) return + if (isLocalOnly) { syncManager.syncCachedData(force = true, replayPendingMutations = true) return diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/server/ServerConfigRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/server/ServerConfigRepository.kt index 9662a5ba..21ffa2ae 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/server/ServerConfigRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/server/ServerConfigRepository.kt @@ -2,10 +2,10 @@ package com.ohmz.tday.compose.core.data.server import android.util.Log import com.ohmz.tday.compose.BuildConfig +import com.ohmz.tday.compose.core.data.AppDataMode import com.ohmz.tday.compose.core.data.SecureConfigStore import com.ohmz.tday.compose.core.data.ServerProbeException import com.ohmz.tday.compose.core.data.extractApiErrorMessage -import com.ohmz.tday.compose.core.data.requireApiBody import com.ohmz.tday.compose.core.network.TdayApiService import com.ohmz.tday.compose.core.security.ProbeDecryptor import kotlinx.coroutines.withTimeout @@ -22,10 +22,21 @@ class ServerConfigRepository @Inject constructor( private val api: TdayApiService, private val secureConfigStore: SecureConfigStore, ) { + fun getAppDataMode(): AppDataMode = secureConfigStore.getAppDataMode() + + fun isLocalMode(): Boolean = secureConfigStore.isLocalMode() + fun hasServerConfigured(): Boolean = secureConfigStore.hasServerUrl() fun getServerUrl(): String? = secureConfigStore.getServerUrl() + fun enableLocalMode() { + secureConfigStore.clearServerUrl() + secureConfigStore.clearCachedSessionUser() + secureConfigStore.clearLastEmail() + secureConfigStore.setAppDataMode(AppDataMode.LOCAL) + } + data class ProbeResult( val serverUrl: String, val versionCheck: VersionCheckResult, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/settings/SettingsRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/settings/SettingsRepository.kt index 7ff6fa03..9d158a35 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/settings/SettingsRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/settings/SettingsRepository.kt @@ -1,5 +1,6 @@ package com.ohmz.tday.compose.core.data.settings +import com.ohmz.tday.compose.core.data.SecureConfigStore import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager import com.ohmz.tday.compose.core.data.requireApiBody import com.ohmz.tday.compose.core.model.AdminSettingsResponse @@ -12,12 +13,16 @@ import javax.inject.Singleton class SettingsRepository @Inject constructor( private val api: TdayApiService, private val cacheManager: OfflineCacheManager, + private val secureConfigStore: SecureConfigStore, ) { fun isAiSummaryEnabledSnapshot(): Boolean { + if (secureConfigStore.isLocalMode()) return false return cacheManager.loadOfflineState().aiSummaryEnabled } suspend fun refreshAiSummaryEnabled(): Boolean { + if (secureConfigStore.isLocalMode()) return false + return runCatching { val enabled = requireApiBody( api.getAppSettings(), @@ -33,6 +38,8 @@ class SettingsRepository @Inject constructor( } suspend fun fetchAdminAiSummaryEnabled(): Boolean { + if (secureConfigStore.isLocalMode()) return false + val enabled = requireApiBody( api.getAdminSettings(), "Could not load admin settings", @@ -44,6 +51,10 @@ class SettingsRepository @Inject constructor( } suspend fun updateAdminAiSummaryEnabled(enabled: Boolean): AdminSettingsResponse { + if (secureConfigStore.isLocalMode()) { + throw IllegalStateException("Admin settings are unavailable in local mode") + } + val response = requireApiBody( api.patchAdminSettings( UpdateAdminSettingsRequest(aiSummaryEnabled = enabled), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt index 26bde6ab..a775360c 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt @@ -3,20 +3,31 @@ package com.ohmz.tday.compose.core.data.sync import android.content.Context import android.util.Log import androidx.glance.appwidget.updateAll +import com.ohmz.tday.compose.core.data.CachedFloaterListRecord +import com.ohmz.tday.compose.core.data.CachedFloaterRecord import com.ohmz.tday.compose.core.data.CachedListRecord import com.ohmz.tday.compose.core.data.CachedTodoRecord import com.ohmz.tday.compose.core.data.MutationKind import com.ohmz.tday.compose.core.data.OfflineSyncState import com.ohmz.tday.compose.core.data.PendingMutationRecord import com.ohmz.tday.compose.core.data.SecureConfigStore +import com.ohmz.tday.compose.core.data.cache.LOCAL_FLOATER_LIST_PREFIX +import com.ohmz.tday.compose.core.data.cache.LOCAL_FLOATER_PREFIX import com.ohmz.tday.compose.core.data.cache.LOCAL_LIST_PREFIX import com.ohmz.tday.compose.core.data.cache.LOCAL_TODO_PREFIX import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager +import com.ohmz.tday.compose.core.data.cache.completedFloaterToCache import com.ohmz.tday.compose.core.data.cache.completedToCache +import com.ohmz.tday.compose.core.data.cache.floaterListToCache +import com.ohmz.tday.compose.core.data.cache.floaterToCache import com.ohmz.tday.compose.core.data.cache.listToCache import com.ohmz.tday.compose.core.data.cache.mapCompletedDto +import com.ohmz.tday.compose.core.data.cache.mapCompletedFloaterDto +import com.ohmz.tday.compose.core.data.cache.mapFloaterDto +import com.ohmz.tday.compose.core.data.cache.mapFloaterListDto import com.ohmz.tday.compose.core.data.cache.mapListDto import com.ohmz.tday.compose.core.data.cache.mapTodoDto +import com.ohmz.tday.compose.core.data.cache.orderFloaterListsLikeWeb import com.ohmz.tday.compose.core.data.cache.orderListsLikeWeb import com.ohmz.tday.compose.core.data.cache.todoMergeKey import com.ohmz.tday.compose.core.data.cache.todoToCache @@ -24,16 +35,24 @@ import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue import com.ohmz.tday.compose.core.data.isLikelyUnrecoverableMutationError import com.ohmz.tday.compose.core.data.requireApiBody import com.ohmz.tday.compose.core.model.CompletedItem +import com.ohmz.tday.compose.core.model.CreateFloaterListRequest +import com.ohmz.tday.compose.core.model.CreateFloaterRequest import com.ohmz.tday.compose.core.model.CreateListRequest import com.ohmz.tday.compose.core.model.CreateTodoRequest +import com.ohmz.tday.compose.core.model.DeleteFloaterListRequest +import com.ohmz.tday.compose.core.model.DeleteFloaterRequest import com.ohmz.tday.compose.core.model.DeleteListRequest import com.ohmz.tday.compose.core.model.DeleteTodoRequest +import com.ohmz.tday.compose.core.model.FloaterCompleteRequest +import com.ohmz.tday.compose.core.model.FloaterUncompleteRequest import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.TodoCompleteRequest import com.ohmz.tday.compose.core.model.TodoInstanceUpdateRequest import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoPrioritizeRequest import com.ohmz.tday.compose.core.model.TodoUncompleteRequest +import com.ohmz.tday.compose.core.model.UpdateFloaterListRequest +import com.ohmz.tday.compose.core.model.UpdateFloaterRequest import com.ohmz.tday.compose.core.model.UpdateListRequest import com.ohmz.tday.compose.core.model.UpdateTodoRequest import com.ohmz.tday.compose.core.network.TdayApiService @@ -63,7 +82,9 @@ class SyncManager @Inject constructor( val offlineSyncSuccesses: SharedFlow = offlineSyncSuccessMutable.asSharedFlow() fun hasPendingMutations(): Boolean = - cacheManager.loadOfflineState().pendingMutations.isNotEmpty() + !isLocalMode() && cacheManager.loadOfflineState().pendingMutations.isNotEmpty() + + fun isLocalMode(): Boolean = secureConfigStore.isLocalMode() suspend fun syncCachedData( force: Boolean = false, @@ -71,6 +92,25 @@ class SyncManager @Inject constructor( notifyOfflineFailure: Boolean = true, connectionProbeTimeoutMs: Long? = null, ): Result { + if (isLocalMode()) { + cacheManager.updateOfflineState { state -> + if (state.pendingMutations.isEmpty() && + state.lastSuccessfulSyncEpochMs == 0L && + state.lastSyncAttemptEpochMs == 0L + ) { + state + } else { + state.copy( + lastSuccessfulSyncEpochMs = 0L, + lastSyncAttemptEpochMs = 0L, + pendingMutations = emptyList(), + ) + } + } + runCatching { TodayTasksWidget().updateAll(context) } + return Result.success(Unit) + } + val result = runCatching { var contactedServer = false if (connectionProbeTimeoutMs != null) { @@ -188,6 +228,20 @@ class SyncManager @Inject constructor( ).completedTodos.map(::mapCompletedDto) } + val floaters = async { + requireApiBody( + api.getFloaters(), + "Could not load floaters", + ).floaters.map(::mapFloaterDto) + } + + val completedFloaters = async { + requireApiBody( + api.getCompletedFloaters(), + "Could not load completed floaters", + ).completedFloaters.map(::mapCompletedFloaterDto) + } + val lists = async { requireApiBody( api.getLists(), @@ -195,6 +249,18 @@ class SyncManager @Inject constructor( ).lists.map { mapListDto(it, iconFallback = secureConfigStore.getListIcon(it.id)) } } + val floaterLists = async { + requireApiBody( + api.getFloaterLists(), + "Could not load floater lists", + ).lists.map { + mapFloaterListDto( + it, + iconFallback = secureConfigStore.getListIcon(it.id) + ) + } + } + val aiSummaryEnabled = async { runCatching { requireApiBody( @@ -208,8 +274,11 @@ class SyncManager @Inject constructor( RemoteSnapshot( todos = todos.await(), + floaters = floaters.await(), completedItems = completed.await(), + completedFloaters = completedFloaters.await(), lists = lists.await(), + floaterLists = floaterLists.await(), aiSummaryEnabled = aiSummaryEnabled.await(), ) } @@ -224,13 +293,14 @@ class SyncManager @Inject constructor( val pending = initialState.pendingMutations.sortedBy { it.timestampEpochMs }.toMutableList() val resolvedTodoIds = mutableMapOf() val resolvedListIds = mutableMapOf() + val resolvedFloaterListIds = mutableMapOf() val remaining = mutableListOf() for (mutation in pending) { val resolvedTargetId = resolveTargetId( targetId = mutation.targetId, todoIdMap = resolvedTodoIds, - listIdMap = resolvedListIds, + listIdMap = resolvedListIds + resolvedFloaterListIds, ) val success = runCatching { @@ -285,6 +355,57 @@ class SyncManager @Inject constructor( true } + MutationKind.CREATE_FLOATER_LIST -> { + val localListId = mutation.targetId ?: return@runCatching false + if (!localListId.startsWith(LOCAL_FLOATER_LIST_PREFIX)) return@runCatching true + val localListExists = state.floaterLists.any { it.id == localListId } + if (!localListExists) return@runCatching true + val response = requireApiBody( + api.createFloaterList( + CreateFloaterListRequest( + name = mutation.name?.trim().orEmpty(), + color = mutation.color, + iconKey = mutation.iconKey, + ), + ), + "Could not create floater list", + ) + val serverListId = response.list?.id ?: return@runCatching false + resolvedFloaterListIds[localListId] = serverListId + state = replaceLocalFloaterListId(state, localListId, serverListId) + true + } + + MutationKind.UPDATE_FLOATER_LIST -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_LIST_PREFIX)) return@runCatching false + val remoteUpdatedAt = + remoteSnapshot.floaterListUpdatedAtById[targetId] ?: 0L + if (remoteUpdatedAt > mutation.timestampEpochMs) return@runCatching true + requireApiBody( + api.patchFloaterListByBody( + UpdateFloaterListRequest( + id = targetId, + name = mutation.name, + color = mutation.color, + iconKey = mutation.iconKey, + ), + ), + "Could not update floater list", + ) + true + } + + MutationKind.DELETE_FLOATER_LIST -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_LIST_PREFIX)) return@runCatching true + requireApiBody( + api.deleteFloaterListByBody(DeleteFloaterListRequest(id = targetId)), + "Could not delete floater list", + ) + true + } + MutationKind.CREATE_TODO -> { val localTodoId = mutation.targetId ?: return@runCatching false if (!localTodoId.startsWith(LOCAL_TODO_PREFIX)) return@runCatching true @@ -302,10 +423,10 @@ class SyncManager @Inject constructor( title = mutation.title?.trim().orEmpty(), description = mutation.description, priority = mutation.priority ?: "Low", - due = Instant.ofEpochMilli( - mutation.dueEpochMs ?: System.currentTimeMillis(), - ).toString(), - rrule = mutation.rrule, + due = mutation.dueEpochMs?.let { + Instant.ofEpochMilli(it).toString() + } ?: return@runCatching false, + rrule = mutation.rrule?.takeIf { mutation.dueEpochMs != null }, listID = resolvedListId, ), ), @@ -422,6 +543,88 @@ class SyncManager @Inject constructor( true } + MutationKind.CREATE_FLOATER -> { + val localFloaterId = mutation.targetId ?: return@runCatching false + if (!localFloaterId.startsWith(LOCAL_FLOATER_PREFIX)) return@runCatching true + val localFloaterExists = + state.floaters.any { it.canonicalId == localFloaterId } + if (!localFloaterExists) return@runCatching true + val resolvedListId = mutation.listId?.let { + resolvedFloaterListIds[it] ?: it + } + if (resolvedListId != null && resolvedListId.startsWith( + LOCAL_FLOATER_LIST_PREFIX + ) + ) { + return@runCatching false + } + val created = requireApiBody( + api.createFloater( + CreateFloaterRequest( + title = mutation.title?.trim().orEmpty(), + description = mutation.description, + priority = mutation.priority ?: "Low", + listID = resolvedListId, + ), + ), + "Could not create floater", + ).floater ?: return@runCatching false + val createdFloater = mapFloaterDto(created) + resolvedTodoIds[localFloaterId] = createdFloater.canonicalId + state = + replaceLocalFloaterId(state, localFloaterId, createdFloater.canonicalId) + true + } + + MutationKind.UPDATE_FLOATER -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_PREFIX)) return@runCatching false + val remoteUpdatedAt = + remoteSnapshot.floaterUpdatedAtByCanonical[targetId] ?: 0L + if (remoteUpdatedAt > mutation.timestampEpochMs) return@runCatching true + val resolvedListId = + mutation.listId?.let { resolvedFloaterListIds[it] ?: it } + if (!resolvedListId.isNullOrBlank() && resolvedListId.startsWith( + LOCAL_FLOATER_LIST_PREFIX + ) + ) { + return@runCatching false + } + val remoteFloater = + remoteSnapshot.floaters.firstOrNull { it.canonicalId == targetId } + val listIdForApi = resolvedListId + ?: if (!remoteFloater?.listId.isNullOrBlank()) "" else null + requireApiBody( + api.patchFloaterByBody( + UpdateFloaterRequest( + id = targetId, + title = mutation.title, + description = mutation.description + ?: if (remoteFloater?.description != null) "" else null, + pinned = mutation.pinned, + priority = mutation.priority, + completed = mutation.completed, + listID = listIdForApi, + ), + ), + "Could not update floater", + ) + true + } + + MutationKind.DELETE_FLOATER -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_PREFIX)) return@runCatching true + val remoteUpdatedAt = + remoteSnapshot.floaterUpdatedAtByCanonical[targetId] ?: 0L + if (remoteUpdatedAt > mutation.timestampEpochMs) return@runCatching true + requireApiBody( + api.deleteFloaterByBody(DeleteFloaterRequest(id = targetId)), + "Could not delete floater", + ) + true + } + MutationKind.SET_PINNED -> { val targetId = resolvedTargetId ?: return@runCatching false if (targetId.startsWith(LOCAL_TODO_PREFIX)) return@runCatching false @@ -511,6 +714,29 @@ class SyncManager @Inject constructor( ) true } + + MutationKind.COMPLETE_FLOATER -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_PREFIX)) return@runCatching false + val remoteUpdatedAt = + remoteSnapshot.floaterUpdatedAtByCanonical[targetId] ?: 0L + if (remoteUpdatedAt > mutation.timestampEpochMs) return@runCatching true + requireApiBody( + api.completeFloaterByBody(FloaterCompleteRequest(id = targetId)), + "Could not complete floater", + ) + true + } + + MutationKind.UNCOMPLETE_FLOATER -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_PREFIX)) return@runCatching false + requireApiBody( + api.uncompleteFloaterByBody(FloaterUncompleteRequest(id = targetId)), + "Could not restore floater", + ) + true + } } }.getOrElse { error -> if (isLikelyConnectivityIssue(error)) { @@ -551,19 +777,35 @@ class SyncManager @Inject constructor( .filter { it.kind == MutationKind.DELETE_LIST } .mapNotNull { it.targetId } .toSet() + val pendingDeletedFloaterListIds = localState.pendingMutations + .filter { it.kind == MutationKind.DELETE_FLOATER_LIST } + .mapNotNull { it.targetId } + .toSet() val remoteTodos = remote.todos .filterNot { it.listId != null && pendingDeletedListIds.contains(it.listId) } .map(::todoToCache) val remoteLists = remote.lists.map(::listToCache) + val remoteFloaterLists = remote.floaterLists.map(::floaterListToCache) val remoteCompleted = remote.completedItems .filterNot { it.listId != null && pendingDeletedListIds.contains(it.listId) } .map(::completedToCache) .toMutableList() + val remoteFloaters = remote.floaters + .filterNot { it.listId != null && pendingDeletedFloaterListIds.contains(it.listId) } + .map(::floaterToCache) + val remoteCompletedFloaters = remote.completedFloaters + .filterNot { it.listId != null && pendingDeletedFloaterListIds.contains(it.listId) } + .map(::completedFloaterToCache) + .toMutableList() val pendingTodoCanonicalIds = localState.pendingMutations .filter { it.kind.affectsTodo() } .mapNotNull { it.targetId } .toSet() + val pendingFloaterCanonicalIds = localState.pendingMutations + .filter { it.kind.affectsFloater() } + .mapNotNull { it.targetId } + .toSet() val pendingListIds = localState.pendingMutations .filter { it.kind == MutationKind.CREATE_LIST || @@ -572,6 +814,14 @@ class SyncManager @Inject constructor( } .mapNotNull { it.targetId } .toSet() + val pendingFloaterListIds = localState.pendingMutations + .filter { + it.kind == MutationKind.CREATE_FLOATER_LIST || + it.kind == MutationKind.UPDATE_FLOATER_LIST || + it.kind == MutationKind.DELETE_FLOATER_LIST + } + .mapNotNull { it.targetId } + .toSet() val pendingDeleteAllCanonicals = localState.pendingMutations .filter { it.kind == MutationKind.DELETE_TODO && it.instanceDateEpochMs == null } .mapNotNull { it.targetId } @@ -584,6 +834,10 @@ class SyncManager @Inject constructor( } } .toSet() + val pendingDeletedFloaterIds = localState.pendingMutations + .filter { it.kind == MutationKind.DELETE_FLOATER } + .mapNotNull { it.targetId } + .toSet() val localTodoByKey = localState.todos.associateBy(::todoMergeKey) val remoteTodoByKey = remoteTodos.associateBy(::todoMergeKey) @@ -633,6 +887,56 @@ class SyncManager @Inject constructor( } } + val localFloaterById = localState.floaters.associateBy { it.canonicalId } + val remoteFloaterById = remoteFloaters.associateBy { it.canonicalId } + val mergedFloaters = mutableListOf() + val allFloaterIds = LinkedHashSet().apply { + addAll(remoteFloaterById.keys) + addAll(localFloaterById.keys) + } + + allFloaterIds.forEach { canonicalId -> + val localFloater = localFloaterById[canonicalId] + val remoteFloater = remoteFloaterById[canonicalId] + + if (remoteFloater != null && pendingDeletedFloaterIds.contains(remoteFloater.canonicalId)) { + return@forEach + } + if (remoteFloater == null && localFloater != null) { + val hasPendingLocalMutation = + pendingFloaterCanonicalIds.contains(localFloater.canonicalId) + val isUnsyncedLocalFloater = + localFloater.canonicalId.startsWith(LOCAL_FLOATER_PREFIX) + if (!hasPendingLocalMutation && !isUnsyncedLocalFloater) return@forEach + } + + val merged = when { + localFloater != null && remoteFloater != null -> { + if (pendingFloaterCanonicalIds.contains(localFloater.canonicalId) || + localFloater.updatedAtEpochMs > remoteFloater.updatedAtEpochMs + ) { + localFloater + } else { + remoteFloater + } + } + + localFloater != null -> localFloater + remoteFloater != null -> remoteFloater + else -> null + } + if (merged != null) mergedFloaters.add(merged) + } + + pendingFloaterCanonicalIds.forEach { canonicalId -> + val localCompletedForFloater = + localState.completedFloaters.filter { it.originalFloaterId == canonicalId } + if (localCompletedForFloater.isNotEmpty()) { + remoteCompletedFloaters.removeAll { it.originalFloaterId == canonicalId } + remoteCompletedFloaters.addAll(localCompletedForFloater) + } + } + val localListById = localState.lists.associateBy { it.id } val remoteListById = remoteLists.associateBy { it.id } val mergedLists = mutableListOf() @@ -673,6 +977,47 @@ class SyncManager @Inject constructor( if (merged != null) mergedLists.add(merged) } + val localFloaterListById = localState.floaterLists.associateBy { it.id } + val remoteFloaterListById = remoteFloaterLists.associateBy { it.id } + val mergedFloaterLists = mutableListOf() + val allFloaterListIds = LinkedHashSet().apply { + addAll(remoteFloaterListById.keys) + addAll(localFloaterListById.keys) + } + + allFloaterListIds.forEach { listId -> + val localList = localFloaterListById[listId] + val remoteList = remoteFloaterListById[listId] + + if (remoteList != null && pendingDeletedFloaterListIds.contains(remoteList.id)) { + return@forEach + } + + if (remoteList == null && localList != null) { + val hasPendingLocalMutation = pendingFloaterListIds.contains(localList.id) + val isUnsyncedLocalList = localList.id.startsWith(LOCAL_FLOATER_LIST_PREFIX) + if (!hasPendingLocalMutation && !isUnsyncedLocalList) return@forEach + } + + val merged = when { + localList != null && remoteList != null -> { + if ( + pendingFloaterListIds.contains(listId) || + localList.updatedAtEpochMs > remoteList.updatedAtEpochMs + ) { + localList + } else { + remoteList + } + } + + localList != null -> localList + remoteList != null -> remoteList + else -> null + } + if (merged != null) mergedFloaterLists.add(merged) + } + val todoCountByList = mergedTodos .asSequence() .filterNot { it.completed } @@ -683,11 +1028,24 @@ class SyncManager @Inject constructor( it.copy(todoCount = todoCountByList[it.id] ?: 0) }, ) + val floaterCountByList = mergedFloaters + .asSequence() + .filterNot { it.completed } + .groupingBy { it.listId } + .eachCount() + val normalizedFloaterLists = orderFloaterListsLikeWeb( + mergedFloaterLists.map { + it.copy(todoCount = floaterCountByList[it.id] ?: 0) + }, + ) val dataMergedState = localState.copy( todos = mergedTodos, + floaters = mergedFloaters, completedItems = remoteCompleted, + completedFloaters = remoteCompletedFloaters, lists = normalizedLists, + floaterLists = normalizedFloaterLists, aiSummaryEnabled = remote.aiSummaryEnabled, ) val localWinsMutations = buildLocalWinsMutations( @@ -713,6 +1071,10 @@ class SyncManager @Inject constructor( .filter { it.kind.affectsTodo() } .mapNotNull { it.targetId } .toSet() + val pendingFloaterCanonicalIds = existingPending + .filter { it.kind.affectsFloater() } + .mapNotNull { it.targetId } + .toSet() val pendingListIds = existingPending .filter { it.kind == MutationKind.CREATE_LIST || @@ -721,17 +1083,35 @@ class SyncManager @Inject constructor( } .mapNotNull { it.targetId } .toSet() + val pendingFloaterListIds = existingPending + .filter { + it.kind == MutationKind.CREATE_FLOATER_LIST || + it.kind == MutationKind.UPDATE_FLOATER_LIST || + it.kind == MutationKind.DELETE_FLOATER_LIST + } + .mapNotNull { it.targetId } + .toSet() val pendingLocalListCreates = existingPending .filter { it.kind == MutationKind.CREATE_LIST } .mapNotNull { it.targetId } .toSet() + val pendingLocalFloaterListCreates = existingPending + .filter { it.kind == MutationKind.CREATE_FLOATER_LIST } + .mapNotNull { it.targetId } + .toSet() val remoteTodoByKey = remote.todos .map(::todoToCache) .associateBy(::todoMergeKey) + val remoteFloaterById = remote.floaters + .map(::floaterToCache) + .associateBy { it.canonicalId } val remoteListById = remote.lists .map(::listToCache) .associateBy { it.id } + val remoteFloaterListById = remote.floaterLists + .map(::floaterListToCache) + .associateBy { it.id } val generated = mutableListOf() @@ -793,6 +1173,51 @@ class SyncManager @Inject constructor( generated.add(mutation) } + mergedState.floaters.forEach { localFloater -> + if (localFloater.canonicalId.startsWith(LOCAL_FLOATER_PREFIX)) return@forEach + if (pendingFloaterCanonicalIds.contains(localFloater.canonicalId)) return@forEach + + val remoteFloater = remoteFloaterById[localFloater.canonicalId] ?: return@forEach + if (!hasFloaterMeaningfulDifferences( + local = localFloater, + remote = remoteFloater + ) + ) return@forEach + val localUpdatedAt = localFloater.updatedAtEpochMs + val remoteUpdatedAt = remoteFloater.updatedAtEpochMs + if (localUpdatedAt <= 0L || localUpdatedAt <= remoteUpdatedAt) return@forEach + + val mutation = if (localFloater.completed != remoteFloater.completed) { + PendingMutationRecord( + mutationId = UUID.randomUUID().toString(), + kind = if (localFloater.completed) MutationKind.COMPLETE_FLOATER else MutationKind.UNCOMPLETE_FLOATER, + targetId = localFloater.canonicalId, + timestampEpochMs = localUpdatedAt, + ) + } else { + val localListId = localFloater.listId + if (!localListId.isNullOrBlank() && + localListId.startsWith(LOCAL_FLOATER_LIST_PREFIX) && + !pendingLocalFloaterListCreates.contains(localListId) + ) { + return@forEach + } + PendingMutationRecord( + mutationId = UUID.randomUUID().toString(), + kind = MutationKind.UPDATE_FLOATER, + targetId = localFloater.canonicalId, + timestampEpochMs = localUpdatedAt, + title = localFloater.title, + description = localFloater.description, + priority = localFloater.priority, + pinned = localFloater.pinned, + completed = localFloater.completed, + listId = localFloater.listId, + ) + } + generated.add(mutation) + } + mergedState.lists.forEach { localList -> if (localList.id.startsWith(LOCAL_LIST_PREFIX)) return@forEach if (pendingListIds.contains(localList.id)) return@forEach @@ -816,6 +1241,33 @@ class SyncManager @Inject constructor( ) } + mergedState.floaterLists.forEach { localList -> + if (localList.id.startsWith(LOCAL_FLOATER_LIST_PREFIX)) return@forEach + if (pendingFloaterListIds.contains(localList.id)) return@forEach + + val remoteList = remoteFloaterListById[localList.id] ?: return@forEach + if (!hasFloaterListMeaningfulDifferences( + local = localList, + remote = remoteList + ) + ) return@forEach + val localUpdatedAt = localList.updatedAtEpochMs + val remoteUpdatedAt = remoteList.updatedAtEpochMs + if (localUpdatedAt <= 0L || localUpdatedAt <= remoteUpdatedAt) return@forEach + + generated.add( + PendingMutationRecord( + mutationId = UUID.randomUUID().toString(), + kind = MutationKind.UPDATE_FLOATER_LIST, + targetId = localList.id, + timestampEpochMs = localUpdatedAt, + name = localList.name, + color = localList.color, + iconKey = localList.iconKey, + ), + ) + } + return generated } @@ -843,6 +1295,18 @@ class SyncManager @Inject constructor( local.listId != remote.listId } + private fun hasFloaterMeaningfulDifferences( + local: CachedFloaterRecord, + remote: CachedFloaterRecord, + ): Boolean { + return local.title != remote.title || + local.description != remote.description || + local.priority != remote.priority || + local.pinned != remote.pinned || + local.completed != remote.completed || + local.listId != remote.listId + } + private fun hasListMeaningfulDifferences( local: CachedListRecord, remote: CachedListRecord, @@ -852,6 +1316,15 @@ class SyncManager @Inject constructor( local.iconKey != remote.iconKey } + private fun hasFloaterListMeaningfulDifferences( + local: CachedFloaterListRecord, + remote: CachedFloaterListRecord, + ): Boolean { + return local.name != remote.name || + local.color != remote.color || + local.iconKey != remote.iconKey + } + private fun mergePendingMutations( existing: List, generated: List, @@ -891,6 +1364,14 @@ class SyncManager @Inject constructor( this == MutationKind.UNCOMPLETE_TODO } + private fun MutationKind.affectsFloater(): Boolean { + return this == MutationKind.CREATE_FLOATER || + this == MutationKind.UPDATE_FLOATER || + this == MutationKind.DELETE_FLOATER || + this == MutationKind.COMPLETE_FLOATER || + this == MutationKind.UNCOMPLETE_FLOATER + } + private fun replaceLocalListId( state: OfflineSyncState, localListId: String, @@ -915,6 +1396,30 @@ class SyncManager @Inject constructor( ) } + private fun replaceLocalFloaterListId( + state: OfflineSyncState, + localListId: String, + serverListId: String, + ): OfflineSyncState { + return state.copy( + floaterLists = state.floaterLists.map { + if (it.id == localListId) it.copy(id = serverListId) else it + }, + floaters = state.floaters.map { + if (it.listId == localListId) it.copy(listId = serverListId) else it + }, + completedFloaters = state.completedFloaters.map { + if (it.listId == localListId) it.copy(listId = serverListId) else it + }, + pendingMutations = state.pendingMutations.map { + it.copy( + targetId = if (it.targetId == localListId) serverListId else it.targetId, + listId = if (it.listId == localListId) serverListId else it.listId, + ) + }, + ) + } + private fun replaceLocalTodoId( state: OfflineSyncState, localTodoId: String, @@ -937,6 +1442,28 @@ class SyncManager @Inject constructor( ) } + private fun replaceLocalFloaterId( + state: OfflineSyncState, + localFloaterId: String, + serverFloaterId: String, + ): OfflineSyncState { + return state.copy( + floaters = state.floaters.map { + if (it.canonicalId == localFloaterId) { + it.copy( + id = if (it.id == localFloaterId) serverFloaterId else it.id, + canonicalId = serverFloaterId, + ) + } else { + it + } + }, + pendingMutations = state.pendingMutations.map { + if (it.targetId == localFloaterId) it.copy(targetId = serverFloaterId) else it + }, + ) + } + private fun resolveTargetId( targetId: String?, todoIdMap: Map, @@ -955,8 +1482,11 @@ class SyncManager @Inject constructor( private data class RemoteSnapshot( val todos: List, + val floaters: List, val completedItems: List, + val completedFloaters: List, val lists: List, + val floaterLists: List, val aiSummaryEnabled: Boolean, ) { val todoUpdatedAtByCanonical: Map = todos @@ -970,6 +1500,18 @@ class SyncManager @Inject constructor( .mapValues { (_, entries) -> entries.maxOfOrNull { it.updatedAt?.toEpochMilli() ?: 0L } ?: 0L } + + val floaterListUpdatedAtById: Map = floaterLists + .groupBy { it.id } + .mapValues { (_, entries) -> + entries.maxOfOrNull { it.updatedAt?.toEpochMilli() ?: 0L } ?: 0L + } + + val floaterUpdatedAtByCanonical: Map = floaters + .groupBy { it.canonicalId } + .mapValues { (_, entries) -> + entries.maxOfOrNull { it.updatedAt?.toEpochMilli() ?: 0L } ?: 0L + } } companion object { diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt index 0a9cec6b..313f7e20 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt @@ -1,25 +1,34 @@ package com.ohmz.tday.compose.core.data.todo import android.util.Log +import com.ohmz.tday.compose.core.data.CachedFloaterRecord import com.ohmz.tday.compose.core.data.CachedTodoRecord import com.ohmz.tday.compose.core.data.MutationKind import com.ohmz.tday.compose.core.data.OfflineSyncState import com.ohmz.tday.compose.core.data.PendingMutationRecord +import com.ohmz.tday.compose.core.data.cache.LOCAL_COMPLETED_FLOATER_PREFIX +import com.ohmz.tday.compose.core.data.cache.LOCAL_FLOATER_LIST_PREFIX +import com.ohmz.tday.compose.core.data.cache.LOCAL_FLOATER_PREFIX import com.ohmz.tday.compose.core.data.cache.LOCAL_LIST_PREFIX import com.ohmz.tday.compose.core.data.cache.LOCAL_TODO_PREFIX import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager -import com.ohmz.tday.compose.core.data.cache.completedFromCache +import com.ohmz.tday.compose.core.data.cache.floaterFromCache +import com.ohmz.tday.compose.core.data.cache.floaterToCache import com.ohmz.tday.compose.core.data.cache.listFromCache +import com.ohmz.tday.compose.core.data.cache.mapFloaterDto import com.ohmz.tday.compose.core.data.cache.mapTodoDto import com.ohmz.tday.compose.core.data.cache.orderListsLikeWeb import com.ohmz.tday.compose.core.data.cache.todoFromCache import com.ohmz.tday.compose.core.data.isLikelyUnrecoverableMutationError import com.ohmz.tday.compose.core.data.requireApiBody import com.ohmz.tday.compose.core.data.sync.SyncManager +import com.ohmz.tday.compose.core.model.CreateFloaterRequest import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.CreateTodoRequest import com.ohmz.tday.compose.core.model.DashboardSummary +import com.ohmz.tday.compose.core.model.DeleteFloaterRequest import com.ohmz.tday.compose.core.model.DeleteTodoRequest +import com.ohmz.tday.compose.core.model.FloaterCompleteRequest import com.ohmz.tday.compose.core.model.TodoCompleteRequest import com.ohmz.tday.compose.core.model.TodoInstanceDeleteRequest import com.ohmz.tday.compose.core.model.TodoInstanceUpdateRequest @@ -29,6 +38,7 @@ import com.ohmz.tday.compose.core.model.TodoSummaryRequest import com.ohmz.tday.compose.core.model.TodoSummaryResponse import com.ohmz.tday.compose.core.model.TodoTitleNlpRequest import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse +import com.ohmz.tday.compose.core.model.UpdateFloaterRequest import com.ohmz.tday.compose.core.model.UpdateTodoRequest import com.ohmz.tday.compose.core.network.TdayApiService import java.time.Instant @@ -93,7 +103,8 @@ class TodoRepository @Inject constructor( "High" -> "High" else -> "Low" } - val normalizedDue = payload.due + val normalizedDue = payload.due ?: ZonedDateTime.now(zoneId).plusHours(1).toInstant() + val normalizedRrule = payload.rrule?.takeIf { it.isNotBlank() } val normalizedDescription = payload.description?.trim()?.ifBlank { null } val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } @@ -109,7 +120,7 @@ class TodoRepository @Inject constructor( description = normalizedDescription, priority = normalizedPriority, dueEpochMs = normalizedDue.toEpochMilli(), - rrule = payload.rrule, + rrule = normalizedRrule, instanceDateEpochMs = null, pinned = false, completed = false, @@ -127,13 +138,18 @@ class TodoRepository @Inject constructor( description = normalizedDescription, priority = normalizedPriority, dueEpochMs = normalizedDue.toEpochMilli(), - rrule = payload.rrule, + rrule = normalizedRrule, listId = normalizedListId, ), ) } - if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith(LOCAL_LIST_PREFIX)) { + if (syncManager.isLocalMode()) return + + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith( + LOCAL_FLOATER_LIST_PREFIX + ) + ) { syncManager.syncCachedData(force = true, replayPendingMutations = true) return } @@ -146,7 +162,7 @@ class TodoRepository @Inject constructor( description = normalizedDescription, priority = normalizedPriority, due = normalizedDue.toString(), - rrule = payload.rrule, + rrule = normalizedRrule, listID = normalizedListId, ), ), @@ -175,6 +191,93 @@ class TodoRepository @Inject constructor( }.onFailure { /* pending mutation will be retried by background sync */ } } + suspend fun createFloater(payload: CreateTaskPayload) { + val trimmedTitle = payload.title.trim() + if (trimmedTitle.isBlank()) return + + val normalizedPriority = when (payload.priority.trim()) { + "Medium" -> "Medium" + "High" -> "High" + else -> "Low" + } + val normalizedDescription = payload.description?.trim()?.ifBlank { null } + val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } + val localFloaterId = "$LOCAL_FLOATER_PREFIX${UUID.randomUUID()}" + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + + cacheManager.updateOfflineState { state -> + val newFloater = CachedFloaterRecord( + id = localFloaterId, + canonicalId = localFloaterId, + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + pinned = false, + completed = false, + listId = normalizedListId, + updatedAtEpochMs = timestampMs, + ) + state.copy( + floaters = state.floaters + newFloater, + pendingMutations = state.pendingMutations + PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.CREATE_FLOATER, + targetId = localFloaterId, + timestampEpochMs = timestampMs, + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listId = normalizedListId, + ), + ) + } + + if (syncManager.isLocalMode()) return + + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith( + LOCAL_FLOATER_LIST_PREFIX + ) + ) { + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + runCatching { + requireApiBody( + api.createFloater( + CreateFloaterRequest( + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listID = normalizedListId, + ), + ), + "Could not create floater", + ).floater + }.onSuccess { createdDto -> + if (createdDto == null) return@onSuccess + val createdFloater = mapFloaterDto(createdDto) + cacheManager.updateOfflineState { state -> + val remapped = replaceLocalFloaterId( + state = state, + localFloaterId = localFloaterId, + serverFloaterId = createdFloater.canonicalId, + ) + remapped.copy( + floaters = remapped.floaters.map { + if (it.canonicalId == createdFloater.canonicalId) { + floaterToCache(createdFloater) + } else { + it + } + }, + pendingMutations = remapped.pendingMutations.filterNot { it.mutationId == mutationId }, + ) + } + }.onFailure { /* pending mutation will be retried by background sync */ } + } + suspend fun updateTodo(todo: TodoItem, payload: CreateTaskPayload) { val canonicalId = todo.canonicalId if (canonicalId.isBlank()) return @@ -187,7 +290,8 @@ class TodoRepository @Inject constructor( "High" -> "High" else -> "Low" } - val normalizedDue = payload.due + val normalizedDue = + payload.due ?: todo.due ?: ZonedDateTime.now(zoneId).plusHours(1).toInstant() val normalizedDescription = payload.description?.trim()?.ifBlank { null } val normalizedRrule = payload.rrule?.takeIf { it.isNotBlank() } val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } @@ -245,6 +349,7 @@ class TodoRepository @Inject constructor( }, ) } + if (syncManager.isLocalMode()) return syncManager.syncCachedData(force = true, replayPendingMutations = true) return } @@ -277,7 +382,12 @@ class TodoRepository @Inject constructor( ) } - if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith(LOCAL_LIST_PREFIX)) { + if (syncManager.isLocalMode()) return + + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith( + LOCAL_FLOATER_LIST_PREFIX + ) + ) { syncManager.syncCachedData(force = true, replayPendingMutations = true) return } @@ -337,6 +447,133 @@ class TodoRepository @Inject constructor( } } + suspend fun updateFloater(floater: TodoItem, payload: CreateTaskPayload) { + val canonicalId = floater.canonicalId + if (canonicalId.isBlank()) return + val trimmedTitle = payload.title.trim() + if (trimmedTitle.isBlank()) return + + val normalizedPriority = when (payload.priority.trim()) { + "Medium" -> "Medium" + "High" -> "High" + else -> "Low" + } + val normalizedDescription = payload.description?.trim()?.ifBlank { null } + val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + val pendingMutation = PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.UPDATE_FLOATER, + targetId = canonicalId, + timestampEpochMs = timestampMs, + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listId = normalizedListId, + ) + + if (canonicalId.startsWith(LOCAL_FLOATER_PREFIX)) { + cacheManager.updateOfflineState { state -> + state.copy( + floaters = state.floaters.map { cached -> + if (cached.canonicalId == canonicalId) { + cached.copy( + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listId = normalizedListId, + updatedAtEpochMs = timestampMs, + ) + } else { + cached + } + }, + pendingMutations = state.pendingMutations.map { mutation -> + if (mutation.kind == MutationKind.CREATE_FLOATER && mutation.targetId == canonicalId) { + mutation.copy( + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listId = normalizedListId, + timestampEpochMs = timestampMs, + ) + } else { + mutation + } + }, + ) + } + if (syncManager.isLocalMode()) return + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + cacheManager.updateOfflineState { state -> + state.copy( + floaters = state.floaters.map { cached -> + if (cached.canonicalId == canonicalId) { + cached.copy( + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listId = normalizedListId, + updatedAtEpochMs = timestampMs, + ) + } else { + cached + } + }, + pendingMutations = state.pendingMutations + .filterNot { it.kind == MutationKind.UPDATE_FLOATER && it.targetId == canonicalId } + pendingMutation, + ) + } + + if (syncManager.isLocalMode()) return + + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith(LOCAL_LIST_PREFIX)) { + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + val descriptionForApi = + normalizedDescription ?: if (floater.description != null) "" else null + val listIdForApi = normalizedListId ?: if (!floater.listId.isNullOrBlank()) "" else null + val immediateError = runCatching { + requireApiBody( + api.patchFloaterByBody( + UpdateFloaterRequest( + id = canonicalId, + title = trimmedTitle, + description = descriptionForApi, + priority = normalizedPriority, + listID = listIdForApi, + ), + ), + "Could not update floater", + ) + }.exceptionOrNull() + + if (immediateError != null && isLikelyUnrecoverableMutationError( + immediateError, + pendingMutation + ) + ) { + throw immediateError + } + + if (immediateError == null) { + cacheManager.updateOfflineState { state -> + state.copy(pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }) + } + } else { + Log.w( + LOG_TAG, + "updateFloater deferred floater=$canonicalId reason=${immediateError.message}" + ) + } + } + suspend fun moveTodo(todo: TodoItem, due: Instant) { val canonicalId = todo.canonicalId if (canonicalId.isBlank()) return @@ -403,6 +640,8 @@ class TodoRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (isLocalOnly) { syncManager.syncCachedData(force = true, replayPendingMutations = true) return @@ -492,6 +731,8 @@ class TodoRepository @Inject constructor( } } + if (syncManager.isLocalMode()) return + if (canonicalId.startsWith(LOCAL_TODO_PREFIX)) return runCatching { @@ -522,6 +763,55 @@ class TodoRepository @Inject constructor( } } + suspend fun deleteFloater(floater: TodoItem) { + val timestampMs = System.currentTimeMillis() + val canonicalId = floater.canonicalId + val mutationId = UUID.randomUUID().toString() + + cacheManager.updateOfflineState { state -> + val isLocalOnly = canonicalId.startsWith(LOCAL_FLOATER_PREFIX) + val prunedFloaters = state.floaters.filterNot { it.canonicalId == canonicalId } + val prunedCompleted = + state.completedFloaters.filterNot { it.originalFloaterId == canonicalId } + + if (isLocalOnly) { + state.copy( + floaters = prunedFloaters, + completedFloaters = prunedCompleted, + pendingMutations = state.pendingMutations.filterNot { it.targetId == canonicalId }, + ) + } else { + state.copy( + floaters = prunedFloaters, + completedFloaters = prunedCompleted, + pendingMutations = state.pendingMutations + .filterNot { it.kind == MutationKind.DELETE_FLOATER && it.targetId == canonicalId } + + PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.DELETE_FLOATER, + targetId = canonicalId, + timestampEpochMs = timestampMs, + ), + ) + } + } + + if (syncManager.isLocalMode()) return + + if (canonicalId.startsWith(LOCAL_FLOATER_PREFIX)) return + + runCatching { + requireApiBody( + api.deleteFloaterByBody(DeleteFloaterRequest(id = canonicalId)), + "Could not delete floater", + ) + }.onSuccess { + cacheManager.updateOfflineState { state -> + state.copy(pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }) + } + } + } + suspend fun completeTodo(todo: TodoItem) { val timestampMs = System.currentTimeMillis() val mutationId = UUID.randomUUID().toString() @@ -549,7 +839,7 @@ class TodoRepository @Inject constructor( title = todo.title, description = todo.description, priority = todo.priority, - dueEpochMs = todo.due.toEpochMilli(), + dueEpochMs = todo.due?.toEpochMilli(), completedAtEpochMs = timestampMs, rrule = todo.rrule, instanceDateEpochMs = todo.instanceDateEpochMillis, @@ -577,6 +867,8 @@ class TodoRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (todo.canonicalId.startsWith(LOCAL_TODO_PREFIX)) return runCatching { @@ -607,10 +899,68 @@ class TodoRepository @Inject constructor( } } + suspend fun completeFloater(floater: TodoItem) { + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + cacheManager.updateOfflineState { state -> + val updatedFloaters = state.floaters.map { + if (it.canonicalId == floater.canonicalId) { + it.copy(completed = true, updatedAtEpochMs = timestampMs) + } else { + it + } + } + val completedId = "$LOCAL_COMPLETED_FLOATER_PREFIX${UUID.randomUUID()}" + val listMeta = + floater.listId?.let { listId -> state.floaterLists.firstOrNull { it.id == listId } } + val completedItem = com.ohmz.tday.compose.core.data.CachedCompletedFloaterRecord( + id = completedId, + originalFloaterId = floater.canonicalId, + title = floater.title, + description = floater.description, + priority = floater.priority, + completedAtEpochMs = timestampMs, + listId = floater.listId, + listName = listMeta?.name, + listColor = listMeta?.color, + ) + + state.copy( + floaters = updatedFloaters, + completedFloaters = state.completedFloaters + completedItem, + pendingMutations = state.pendingMutations + PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.COMPLETE_FLOATER, + targetId = floater.canonicalId, + timestampEpochMs = timestampMs, + ), + ) + } + + if (syncManager.isLocalMode()) return + + if (floater.canonicalId.startsWith(LOCAL_FLOATER_PREFIX)) return + + runCatching { + requireApiBody( + api.completeFloaterByBody(FloaterCompleteRequest(id = floater.canonicalId)), + "Could not complete floater", + ) + }.onSuccess { + cacheManager.updateOfflineState { state -> + state.copy(pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }) + } + } + } + suspend fun summarizeTodos( mode: TodoListMode, listId: String? = null, ): TodoSummaryResponse { + if (syncManager.isLocalMode()) { + throw IllegalStateException("AI summary is unavailable in local mode") + } + val modeValue = when (mode) { TodoListMode.TODAY -> "today" TodoListMode.OVERDUE -> throw IllegalStateException( @@ -619,6 +969,9 @@ class TodoRepository @Inject constructor( TodoListMode.SCHEDULED -> "scheduled" TodoListMode.ALL -> "all" TodoListMode.PRIORITY -> "priority" + TodoListMode.FLOATER -> throw IllegalStateException( + "Summary is available only for Today, Scheduled, All, and Priority screens", + ) TodoListMode.LIST -> throw IllegalStateException( "Summary is available only for Today, Scheduled, All, and Priority screens", ) @@ -635,6 +988,7 @@ class TodoRepository @Inject constructor( ): TodoTitleNlpResponse? { val trimmedText = text.trim() if (trimmedText.isBlank()) return null + if (syncManager.isLocalMode()) return null val timezoneOffsetMinutes = ZoneId.systemDefault() .rules @@ -663,10 +1017,14 @@ class TodoRepository @Inject constructor( .map(::todoFromCache) .filterNot { it.completed } .toList() + val activeFloaters = state.floaters + .asSequence() + .map(::floaterFromCache) + .filterNot { it.completed } + .toList() val todayTodos = timelineTodos.filter(::isTodayTodo) val now = Instant.now() val scheduledTodos = timelineTodos.filter { isScheduledTodo(it, now) } - val completedTodos = state.completedItems.map(::completedFromCache) val todoCountsByList = timelineTodos .groupingBy { it.listId } .eachCount() @@ -680,7 +1038,8 @@ class TodoRepository @Inject constructor( scheduledCount = scheduledTodos.size, allCount = timelineTodos.size, priorityCount = timelineTodos.count { isPriorityTodo(it.priority) }, - completedCount = completedTodos.size, + floaterCount = activeFloaters.size, + completedCount = state.completedItems.size, lists = lists, ) } @@ -695,6 +1054,11 @@ class TodoRepository @Inject constructor( .map(::todoFromCache) .toList() val activeTodos = allTodos.filterNot { it.completed } + val activeFloaters = state.floaters + .asSequence() + .map(::floaterFromCache) + .filterNot { it.completed } + .toList() val now = Instant.now() return when (mode) { @@ -703,6 +1067,10 @@ class TodoRepository @Inject constructor( TodoListMode.ALL -> activeTodos TodoListMode.SCHEDULED -> activeTodos.filter { isScheduledTodo(it, now) } TodoListMode.PRIORITY -> activeTodos.filter { isPriorityTodo(it.priority) } + TodoListMode.FLOATER -> { + if (listId.isNullOrBlank()) activeFloaters + else activeFloaters.filter { it.listId == listId } + } TodoListMode.LIST -> { if (listId.isNullOrBlank()) emptyList() else activeTodos.filter { it.listId == listId } @@ -713,15 +1081,16 @@ class TodoRepository @Inject constructor( private fun isTodayTodo(todo: TodoItem): Boolean { val start = Instant.ofEpochMilli(startOfTodayMillis()) val end = Instant.ofEpochMilli(endOfTodayMillis()) - return todo.due >= start && todo.due <= end + val due = todo.due ?: return false + return due >= start && due <= end } private fun isScheduledTodo(todo: TodoItem, now: Instant = Instant.now()): Boolean { - return !todo.due.isBefore(now) + return todo.due?.isBefore(now) == false } private fun isOverdueTodo(todo: TodoItem, now: Instant = Instant.now()): Boolean { - return todo.due.isBefore(now) + return todo.due?.isBefore(now) == true } private fun isPriorityTodo(priority: String?): Boolean { @@ -776,6 +1145,32 @@ class TodoRepository @Inject constructor( ) } + private fun replaceLocalFloaterId( + state: OfflineSyncState, + localFloaterId: String, + serverFloaterId: String, + ): OfflineSyncState { + return state.copy( + floaters = state.floaters.map { + if (it.canonicalId == localFloaterId) { + it.copy( + id = if (it.id == localFloaterId) serverFloaterId else it.id, + canonicalId = serverFloaterId, + ) + } else { + it + } + }, + pendingMutations = state.pendingMutations.map { + if (it.targetId == localFloaterId) { + it.copy(targetId = serverFloaterId) + } else { + it + } + }, + ) + } + private companion object { const val LOG_TAG = "TodoRepository" } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/ApiModels.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/ApiModels.kt index 05602434..44cb4046 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/ApiModels.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/ApiModels.kt @@ -17,6 +17,16 @@ typealias TodoDto = com.ohmz.tday.shared.model.TodoDto typealias CreateTodoResponse = com.ohmz.tday.shared.model.CreateTodoResponse typealias UpdateTodoRequest = com.ohmz.tday.shared.model.UpdateTodoRequest typealias DeleteTodoRequest = com.ohmz.tday.shared.model.DeleteTodoRequest +typealias FloatersResponse = com.ohmz.tday.shared.model.FloatersResponse +typealias FloaterDto = com.ohmz.tday.shared.model.FloaterDto +typealias CreateFloaterRequest = com.ohmz.tday.shared.model.CreateFloaterRequest +typealias CreateFloaterResponse = com.ohmz.tday.shared.model.CreateFloaterResponse +typealias UpdateFloaterRequest = com.ohmz.tday.shared.model.UpdateFloaterRequest +typealias DeleteFloaterRequest = com.ohmz.tday.shared.model.DeleteFloaterRequest +typealias FloaterCompleteRequest = com.ohmz.tday.shared.model.FloaterCompleteRequest +typealias FloaterUncompleteRequest = com.ohmz.tday.shared.model.FloaterUncompleteRequest +typealias FloaterPrioritizeRequest = com.ohmz.tday.shared.model.FloaterPrioritizeRequest +typealias FloaterReorderRequest = com.ohmz.tday.shared.model.FloaterReorderRequest typealias TodoInstanceUpdateRequest = com.ohmz.tday.shared.model.TodoInstancePatchRequest typealias TodoInstanceDeleteRequest = com.ohmz.tday.shared.model.TodoInstanceDeleteRequest typealias TodoCompleteRequest = com.ohmz.tday.shared.model.TodoCompleteRequest @@ -30,10 +40,22 @@ typealias ListDetailResponse = com.ohmz.tday.shared.model.ListDetailResponse typealias UpdateListRequest = com.ohmz.tday.shared.model.UpdateListRequest typealias DeleteListRequest = com.ohmz.tday.shared.model.DeleteListRequest typealias DeleteListResponse = com.ohmz.tday.shared.model.DeleteListResponse +typealias FloaterListsResponse = com.ohmz.tday.shared.model.FloaterListsResponse +typealias CreateFloaterListRequest = com.ohmz.tday.shared.model.CreateFloaterListRequest +typealias FloaterListDto = com.ohmz.tday.shared.model.FloaterListDto +typealias CreateFloaterListResponse = com.ohmz.tday.shared.model.CreateFloaterListResponse +typealias FloaterListDetailResponse = com.ohmz.tday.shared.model.FloaterListDetailResponse +typealias UpdateFloaterListRequest = com.ohmz.tday.shared.model.UpdateFloaterListRequest +typealias DeleteFloaterListRequest = com.ohmz.tday.shared.model.DeleteFloaterListRequest +typealias DeleteFloaterListResponse = com.ohmz.tday.shared.model.DeleteFloaterListResponse typealias CompletedTodosResponse = com.ohmz.tday.shared.model.CompletedTodosResponse typealias CompletedTodoDto = com.ohmz.tday.shared.model.CompletedTodoDto typealias UpdateCompletedTodoRequest = com.ohmz.tday.shared.model.UpdateCompletedTodoRequest typealias DeleteCompletedTodoRequest = com.ohmz.tday.shared.model.DeleteCompletedTodoRequest +typealias CompletedFloatersResponse = com.ohmz.tday.shared.model.CompletedFloatersResponse +typealias CompletedFloaterDto = com.ohmz.tday.shared.model.CompletedFloaterDto +typealias UpdateCompletedFloaterRequest = com.ohmz.tday.shared.model.UpdateCompletedFloaterRequest +typealias DeleteCompletedFloaterRequest = com.ohmz.tday.shared.model.DeleteCompletedFloaterRequest typealias PreferencesResponse = com.ohmz.tday.shared.model.PreferencesResponse typealias PreferencesDto = com.ohmz.tday.shared.model.PreferencesDto diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt index 43bc4741..0fe24dbc 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt @@ -12,6 +12,7 @@ enum class TodoListMode { SCHEDULED, ALL, PRIORITY, + FLOATER, LIST, } @@ -24,7 +25,7 @@ data class CreateTaskPayload( val title: String, val description: String? = null, val priority: String = "Low", - val due: Instant, + val due: Instant?, val rrule: String? = null, val listId: String? = null, ) @@ -35,7 +36,7 @@ data class TodoItem( val title: String, val description: String?, val priority: String, - val due: Instant, + val due: Instant?, val rrule: String?, val instanceDate: Instant?, val pinned: Boolean, @@ -58,6 +59,7 @@ fun TodoListMode.supportsTaskReschedule(): Boolean { TodoListMode.LIST, -> true + TodoListMode.FLOATER, TodoListMode.TODAY, TodoListMode.OVERDUE, -> false @@ -85,11 +87,12 @@ fun createMovedTaskPayload( targetDate: LocalDate, zoneId: ZoneId = ZoneId.systemDefault(), ): CreateTaskPayload { + val due = todo.due ?: ZonedDateTime.now(zoneId).toInstant() return CreateTaskPayload( title = todo.title, description = todo.description, priority = todo.priority, - due = movedDuePreservingTime(todo.due, targetDate, zoneId), + due = movedDuePreservingTime(due, targetDate, zoneId), rrule = todo.rrule, listId = todo.listId, ) @@ -143,6 +146,7 @@ data class DashboardSummary( val scheduledCount: Int, val allCount: Int, val priorityCount: Int, + val floaterCount: Int, val completedCount: Int, val lists: List, ) @@ -153,7 +157,7 @@ data class CompletedItem( val title: String, val description: String? = null, val priority: String, - val due: Instant, + val due: Instant?, val completedAt: Instant? = null, val rrule: String?, val instanceDate: Instant?, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt index 83ad436e..3b46648f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt @@ -7,6 +7,7 @@ sealed class AppRoute(val route: String) { data object ServerSetup : AppRoute("server-setup") data object Login : AppRoute("login") data object Home : AppRoute("home") + data object FloaterTodos : AppRoute("floater") data object TodayTodos : AppRoute("todos/today") data object OverdueTodos : AppRoute("todos/overdue") data object ScheduledTodos : AppRoute("todos/scheduled") @@ -25,6 +26,11 @@ sealed class AppRoute(val route: String) { return "todos/list/$listId/${Uri.encode(listName)}" } } + data object FloaterListTodos : AppRoute("floater/list/{listId}/{listName}") { + fun create(listId: String, listName: String): String { + return "floater/list/$listId/${Uri.encode(listName)}" + } + } data object Completed : AppRoute("completed") data object Calendar : AppRoute("calendar") diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt index 504a54ce..02b8b963 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt @@ -3,7 +3,12 @@ package com.ohmz.tday.compose.core.network import com.ohmz.tday.compose.core.model.AdminSettingsResponse import com.ohmz.tday.compose.core.model.AppSettingsResponse import com.ohmz.tday.compose.core.model.ChangePasswordRequest +import com.ohmz.tday.compose.core.model.CompletedFloatersResponse import com.ohmz.tday.compose.core.model.CompletedTodosResponse +import com.ohmz.tday.compose.core.model.CreateFloaterListRequest +import com.ohmz.tday.compose.core.model.CreateFloaterListResponse +import com.ohmz.tday.compose.core.model.CreateFloaterRequest +import com.ohmz.tday.compose.core.model.CreateFloaterResponse import com.ohmz.tday.compose.core.model.CreateListRequest import com.ohmz.tday.compose.core.model.CreateListResponse import com.ohmz.tday.compose.core.model.CreateTodoRequest @@ -11,10 +16,21 @@ import com.ohmz.tday.compose.core.model.CreateTodoResponse import com.ohmz.tday.compose.core.model.CredentialKeyResponse import com.ohmz.tday.compose.core.model.CredentialsCallbackRequest import com.ohmz.tday.compose.core.model.CsrfResponse +import com.ohmz.tday.compose.core.model.DeleteCompletedFloaterRequest import com.ohmz.tday.compose.core.model.DeleteCompletedTodoRequest -import com.ohmz.tday.compose.core.model.DeleteListResponse +import com.ohmz.tday.compose.core.model.DeleteFloaterListRequest +import com.ohmz.tday.compose.core.model.DeleteFloaterListResponse +import com.ohmz.tday.compose.core.model.DeleteFloaterRequest import com.ohmz.tday.compose.core.model.DeleteListRequest +import com.ohmz.tday.compose.core.model.DeleteListResponse import com.ohmz.tday.compose.core.model.DeleteTodoRequest +import com.ohmz.tday.compose.core.model.FloaterCompleteRequest +import com.ohmz.tday.compose.core.model.FloaterListDetailResponse +import com.ohmz.tday.compose.core.model.FloaterListsResponse +import com.ohmz.tday.compose.core.model.FloaterPrioritizeRequest +import com.ohmz.tday.compose.core.model.FloaterReorderRequest +import com.ohmz.tday.compose.core.model.FloaterUncompleteRequest +import com.ohmz.tday.compose.core.model.FloatersResponse import com.ohmz.tday.compose.core.model.ListDetailResponse import com.ohmz.tday.compose.core.model.ListsResponse import com.ohmz.tday.compose.core.model.MessageResponse @@ -35,7 +51,10 @@ import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.model.TodoUncompleteRequest import com.ohmz.tday.compose.core.model.TodosResponse import com.ohmz.tday.compose.core.model.UpdateAdminSettingsRequest +import com.ohmz.tday.compose.core.model.UpdateCompletedFloaterRequest import com.ohmz.tday.compose.core.model.UpdateCompletedTodoRequest +import com.ohmz.tday.compose.core.model.UpdateFloaterListRequest +import com.ohmz.tday.compose.core.model.UpdateFloaterRequest import com.ohmz.tday.compose.core.model.UpdateListRequest import com.ohmz.tday.compose.core.model.UpdateProfileRequest import com.ohmz.tday.compose.core.model.UpdateTodoRequest @@ -119,6 +138,44 @@ interface TdayApiService { @Body payload: CreateTodoRequest, ): Response + @GET("/api/floater") + suspend fun getFloaters(): Response + + @POST("/api/floater") + suspend fun createFloater( + @Body payload: CreateFloaterRequest, + ): Response + + @PATCH("/api/floater") + suspend fun patchFloaterByBody( + @Body payload: UpdateFloaterRequest, + ): Response + + @HTTP(method = "DELETE", path = "/api/floater", hasBody = true) + suspend fun deleteFloaterByBody( + @Body payload: DeleteFloaterRequest, + ): Response + + @PATCH("/api/floater/complete") + suspend fun completeFloaterByBody( + @Body payload: FloaterCompleteRequest, + ): Response + + @PATCH("/api/floater/uncomplete") + suspend fun uncompleteFloaterByBody( + @Body payload: FloaterUncompleteRequest, + ): Response + + @PATCH("/api/floater/prioritize") + suspend fun prioritizeFloaterByBody( + @Body payload: FloaterPrioritizeRequest, + ): Response + + @PATCH("/api/floater/reorder") + suspend fun reorderFloater( + @Body payload: FloaterReorderRequest, + ): Response + @PATCH("/api/todo") suspend fun patchTodoByBody( @Body payload: UpdateTodoRequest, @@ -168,6 +225,9 @@ interface TdayApiService { @GET("/api/completedTodo") suspend fun getCompletedTodos(): Response + @GET("/api/completedFloater") + suspend fun getCompletedFloaters(): Response + @PATCH("/api/completedTodo") suspend fun patchCompletedTodoByBody( @Body payload: UpdateCompletedTodoRequest, @@ -178,6 +238,16 @@ interface TdayApiService { @Body payload: DeleteCompletedTodoRequest, ): Response + @PATCH("/api/completedFloater") + suspend fun patchCompletedFloaterByBody( + @Body payload: UpdateCompletedFloaterRequest, + ): Response + + @HTTP(method = "DELETE", path = "/api/completedFloater", hasBody = true) + suspend fun deleteCompletedFloaterByBody( + @Body payload: DeleteCompletedFloaterRequest, + ): Response + @GET("/api/list") suspend fun getLists(): Response @@ -203,6 +273,29 @@ interface TdayApiService { @Body payload: DeleteListRequest, ): Response + @GET("/api/floaterList") + suspend fun getFloaterLists(): Response + + @GET("/api/floaterList/{id}") + suspend fun getFloaterListTodos( + @Path("id") listId: String, + ): Response + + @POST("/api/floaterList") + suspend fun createFloaterList( + @Body payload: CreateFloaterListRequest, + ): Response + + @PATCH("/api/floaterList") + suspend fun patchFloaterListByBody( + @Body payload: UpdateFloaterListRequest, + ): Response + + @HTTP(method = "DELETE", path = "/api/floaterList", hasBody = true) + suspend fun deleteFloaterListByBody( + @Body payload: DeleteFloaterListRequest, + ): Response + @GET("/api/preferences") suspend fun getPreferences(): Response diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/notification/TaskReminderScheduler.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/notification/TaskReminderScheduler.kt index 8ce76e0c..b16765db 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/notification/TaskReminderScheduler.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/notification/TaskReminderScheduler.kt @@ -75,7 +75,7 @@ class TaskReminderScheduler @Inject constructor( val intent = Intent(context, TaskReminderReceiver::class.java).apply { putExtra(TaskReminderReceiver.EXTRA_TASK_ID, task.id) putExtra(TaskReminderReceiver.EXTRA_TASK_TITLE, task.title) - putExtra(TaskReminderReceiver.EXTRA_TASK_DUE_MILLIS, task.due.toEpochMilli()) + putExtra(TaskReminderReceiver.EXTRA_TASK_DUE_MILLIS, task.due?.toEpochMilli() ?: -1L) putExtra(TaskReminderReceiver.EXTRA_TASK_PRIORITY, task.priority) putExtra( TaskReminderReceiver.EXTRA_INSTANCE_DATE_MILLIS, @@ -114,7 +114,7 @@ class TaskReminderScheduler @Inject constructor( } private fun computeAlarmTime(task: TodoItem, reminder: ReminderOption): Long? { - val dueMillis = task.due.toEpochMilli() + val dueMillis = task.due?.toEpochMilli() ?: return null if (dueMillis <= 0) return null return dueMillis - reminder.offsetMillis } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt index 73b39f7e..1e8d273b 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt @@ -3,6 +3,7 @@ package com.ohmz.tday.compose.feature.app import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ohmz.tday.compose.core.data.ApiCallException +import com.ohmz.tday.compose.core.data.AppDataMode import com.ohmz.tday.compose.core.data.ServerProbeException import com.ohmz.tday.compose.core.data.ThemePreferenceStore import com.ohmz.tday.compose.core.data.auth.AuthRepository @@ -49,6 +50,7 @@ data class AppUiState( val requiresServerSetup: Boolean = false, val requiresLogin: Boolean = false, val serverUrl: String? = null, + val dataMode: AppDataMode = AppDataMode.UNSET, val themeMode: AppThemeMode = AppThemeMode.SYSTEM, val user: SessionUser? = null, val error: String? = null, @@ -68,7 +70,13 @@ data class AppUiState( val backendVersion: String? = null, val requiredUpdateRelease: GitHubRelease? = null, val isCheckingUpdateRelease: Boolean = false, -) +) { + val isLocalMode: Boolean + get() = dataMode == AppDataMode.LOCAL + + val isWorkspaceAvailable: Boolean + get() = authenticated || isLocalMode +} internal const val OFFLINE_NOTICE_COOLDOWN_MS = 10 * 60 * 1000L @@ -141,6 +149,12 @@ class AppViewModel @Inject constructor( fun bootstrap() { viewModelScope.launch { _uiState.update { it.copy(loading = true, error = null, isManualSyncing = false) } + val dataMode = serverConfigRepository.getAppDataMode() + if (dataMode == AppDataMode.LOCAL) { + enterLocalWorkspace() + return@launch + } + if (!serverConfigRepository.hasServerConfigured()) { authRepository.clearAllLocalUserDataForUnauthenticatedState() _uiState.update { @@ -150,6 +164,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = true, requiresLogin = false, serverUrl = null, + dataMode = AppDataMode.UNSET, user = null, error = null, canResetServerTrust = false, @@ -159,6 +174,12 @@ class AppViewModel @Inject constructor( isAdminAiSummaryLoading = false, isAdminAiSummarySaving = false, adminAiSummaryError = null, + isOffline = false, + pendingMutationCount = 0, + versionCheckResult = VersionCheckResult.Compatible, + backendVersion = null, + requiredUpdateRelease = null, + isCheckingUpdateRelease = false, ) } ensureResyncLoop(authenticated = false) @@ -187,6 +208,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = false, requiresLogin = false, serverUrl = serverConfigRepository.getServerUrl(), + dataMode = AppDataMode.SERVER, user = sessionUser, error = null, canResetServerTrust = false, @@ -228,6 +250,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = false, requiresLogin = false, serverUrl = serverConfigRepository.getServerUrl(), + dataMode = AppDataMode.SERVER, user = null, error = null, canResetServerTrust = false, @@ -255,6 +278,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = false, requiresLogin = true, serverUrl = serverConfigRepository.getServerUrl(), + dataMode = AppDataMode.SERVER, user = null, error = null, canResetServerTrust = false, @@ -270,6 +294,58 @@ class AppViewModel @Inject constructor( } } + fun useLocalMode() { + viewModelScope.launch { + runCatching { authRepository.clearAllLocalUserDataForUnauthenticatedState() } + runCatching { systemCredentialService.clearCredentialState() } + runCatching { reminderScheduler.cancelAll() } + serverConfigRepository.enableLocalMode() + enterLocalWorkspace() + } + } + + private fun enterLocalWorkspace() { + ensureResyncLoop(authenticated = false) + runCatching { + cacheManager.updateOfflineState { state -> + state.copy( + lastSuccessfulSyncEpochMs = 0L, + lastSyncAttemptEpochMs = 0L, + pendingMutations = emptyList(), + ) + } + } + _uiState.update { + it.copy( + loading = false, + authenticated = false, + requiresServerSetup = false, + requiresLogin = false, + serverUrl = null, + dataMode = AppDataMode.LOCAL, + user = null, + error = null, + canResetServerTrust = false, + pendingApprovalMessage = null, + isManualSyncing = false, + adminAiSummaryEnabled = null, + isAdminAiSummaryLoading = false, + isAdminAiSummarySaving = false, + adminAiSummaryError = null, + aiSummaryValidationError = null, + isOffline = false, + pendingMutationCount = 0, + versionCheckResult = VersionCheckResult.Compatible, + backendVersion = null, + requiredUpdateRelease = null, + isCheckingUpdateRelease = false, + ) + } + viewModelScope.launch(Dispatchers.Default) { + runCatching { reminderScheduler.rescheduleAll() } + } + } + private suspend fun restoreSessionAndPrimeData(): SessionBootstrapResult? { val restored = authRepository.restoreSessionForBootstrap() ?: return null val user = restored.user @@ -304,7 +380,7 @@ class AppViewModel @Inject constructor( fun refreshAdminAiSummarySetting() { val current = _uiState.value - if (!isAdmin(current.user)) { + if (current.isLocalMode || !isAdmin(current.user)) { _uiState.update { it.copy( adminAiSummaryEnabled = null, @@ -348,7 +424,7 @@ class AppViewModel @Inject constructor( fun setAdminAiSummaryEnabled(enabled: Boolean) { val current = _uiState.value - if (!isAdmin(current.user) || current.isAdminAiSummarySaving) return + if (current.isLocalMode || !isAdmin(current.user) || current.isAdminAiSummarySaving) return viewModelScope.launch { _uiState.update { @@ -410,6 +486,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = false, requiresLogin = !isBlocking, serverUrl = probeResult.serverUrl, + dataMode = AppDataMode.SERVER, error = null, canResetServerTrust = false, pendingApprovalMessage = null, @@ -438,6 +515,7 @@ class AppViewModel @Inject constructor( } fun recheckVersion() { + if (_uiState.value.isLocalMode) return viewModelScope.launch { appVersionManager.refreshServerCompatibility() if (appVersionManager.state.value.versionCheckResult is VersionCheckResult.Compatible && @@ -450,7 +528,11 @@ class AppViewModel @Inject constructor( fun refreshVersionInfo() { viewModelScope.launch { - appVersionManager.refreshAll() + if (_uiState.value.isLocalMode) { + appVersionManager.refreshGitHubReleases() + } else { + appVersionManager.refreshAll() + } } } @@ -496,6 +578,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = true, requiresLogin = false, serverUrl = null, + dataMode = AppDataMode.UNSET, user = null, error = null, loading = false, @@ -506,13 +589,20 @@ class AppViewModel @Inject constructor( isAdminAiSummaryLoading = false, isAdminAiSummarySaving = false, adminAiSummaryError = null, + aiSummaryValidationError = null, + isOffline = false, + pendingMutationCount = 0, + versionCheckResult = VersionCheckResult.Compatible, + backendVersion = null, + requiredUpdateRelease = null, + isCheckingUpdateRelease = false, ) } } } fun syncNow() { - if (!_uiState.value.authenticated) return + if (!_uiState.value.authenticated || _uiState.value.isLocalMode) return if (_uiState.value.isManualSyncing) return viewModelScope.launch { @@ -561,7 +651,7 @@ class AppViewModel @Inject constructor( } fun reconnectAfterForeground() { - if (!_uiState.value.authenticated) return + if (!_uiState.value.authenticated || _uiState.value.isLocalMode) return if (foregroundReconnectJob?.isActive == true) return foregroundReconnectJob = viewModelScope.launch { @@ -720,6 +810,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = false, requiresLogin = false, serverUrl = serverConfigRepository.getServerUrl(), + dataMode = AppDataMode.SERVER, user = restoredSession.user, error = null, pendingApprovalMessage = null, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index f84c8029..856d8d29 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -142,6 +142,7 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -254,7 +255,9 @@ private fun shouldShowDateDivider( ): Boolean { val currentTodo = items.getOrNull(afterItemIndex) ?: return false val nextTodo = items.getOrNull(afterItemIndex + 1) ?: return false - return LocalDate.ofInstant(currentTodo.due, zoneId) != LocalDate.ofInstant(nextTodo.due, zoneId) + val currentDue = currentTodo.due ?: return false + val nextDue = nextTodo.due ?: return false + return LocalDate.ofInstant(currentDue, zoneId) != LocalDate.ofInstant(nextDue, zoneId) } private data class CalendarTaskRescheduleDrop( @@ -276,7 +279,7 @@ private fun calendarTaskAlreadyDueOnDate( todo: TodoItem, date: LocalDate, zoneId: ZoneId = ZoneId.systemDefault(), -): Boolean = LocalDate.ofInstant(todo.due, zoneId) == date +): Boolean = todo.due?.let { LocalDate.ofInstant(it, zoneId) == date } == true @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -385,8 +388,9 @@ fun CalendarScreen( val calendarTaskRescheduleEnabled = selectedViewMode != CalendarViewMode.DAY val tasksByDate = remember(uiState.items, zoneId) { uiState.items - .groupBy { LocalDate.ofInstant(it.due, zoneId) } - .mapValues { (_, tasks) -> tasks.sortedBy { it.due } } + .mapNotNull { todo -> todo.due?.let { due -> due to todo } } + .groupBy({ (due, _) -> LocalDate.ofInstant(due, zoneId) }, { (_, todo) -> todo }) + .mapValues { (_, tasks) -> tasks.sortedBy { it.due ?: java.time.Instant.MAX } } } val selectedDatePendingTasks = tasksByDate[selectedDate].orEmpty() fun canNavigateTo(date: LocalDate): Boolean = YearMonth.from(date) >= minNavigableMonth @@ -411,6 +415,7 @@ fun CalendarScreen( remember { mutableStateMapOf() } var activeDropDateIso by remember { mutableStateOf(null) } var pendingRescheduleDrop by remember { mutableStateOf(null) } + var openSwipeTaskId by rememberSaveable { mutableStateOf(null) } LaunchedEffect(selectedViewMode) { if (selectedViewMode == CalendarViewMode.DAY) { draggedCalendarTodoId = null @@ -419,6 +424,12 @@ fun CalendarScreen( calendarDropTargetBounds.clear() } } + LaunchedEffect(uiState.items, openSwipeTaskId) { + val openId = openSwipeTaskId ?: return@LaunchedEffect + if (uiState.items.none { it.id == openId }) { + openSwipeTaskId = null + } + } val editTarget = remember(editTargetId, uiState.items) { editTargetId?.let { targetId -> uiState.items.firstOrNull { it.id == targetId } @@ -693,6 +704,8 @@ fun CalendarScreen( onInfo = { editTargetId = todo.id }, onDelete = { onDelete(todo) }, dragging = calendarTaskRescheduleEnabled && draggedCalendarTodo?.id == todo.id, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = { openSwipeTaskId = it }, onDragStart = { position -> activeDropDateIso = null draggedCalendarTodoId = todo.id @@ -2058,13 +2071,15 @@ private fun CalendarTaskDragPreview( color = colorScheme.onSurface, maxLines = 1, ) - Text( - text = CalendarTaskDragDueTimeFormatter.format(todo.due), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = colorScheme.onSurfaceVariant, - maxLines = 1, - ) + todo.due?.let(CalendarTaskDragDueTimeFormatter::format)?.let { dueText -> + Text( + text = dueText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } } if (listMeta != null) { Icon( @@ -2098,6 +2113,8 @@ private fun CalendarTodoRow( onInfo: () -> Unit, onDelete: () -> Unit, dragging: Boolean, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, onDragStart: (Offset) -> Unit, onDragMove: (Offset) -> Unit, onDragEnd: (Offset?) -> Unit, @@ -2119,6 +2136,19 @@ private fun CalendarTodoRow( var titleLayoutResult by remember(todo.id) { mutableStateOf(null) } var rowOriginInRoot by remember(todo.id) { mutableStateOf(Offset.Zero) } var dragPointerPosition by remember(todo.id) { mutableStateOf(null) } + val latestOpenSwipeTaskId = rememberUpdatedState(openSwipeTaskId) + fun claimSwipeSlot() { + if (latestOpenSwipeTaskId.value != todo.id) { + onOpenSwipeTaskIdChange(todo.id) + } + } + + fun closeSwipeSlot() { + targetOffsetX = 0f + if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } + } val animatedOffsetX by animateFloatAsState( targetValue = targetOffsetX, animationSpec = spring(stiffness = Spring.StiffnessLow), @@ -2145,9 +2175,8 @@ private fun CalendarTodoRow( animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), label = "calendarTaskTitleStrikeProgress", ) - val dueText = DateTimeFormatter.ofPattern("h:mm a") - .withZone(ZoneId.systemDefault()) - .format(todo.due) + val dueText = todo.due + ?.let { DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(it) } val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val showListIndicator = listMeta != null val priorityIcon = priorityIconFor(todo.priority) @@ -2156,6 +2185,12 @@ private fun CalendarTodoRow( val rowShape = RoundedCornerShape(16.dp) val foregroundColor = colorScheme.background val actionRevealProgress = (-animatedOffsetX / actionRevealPx).coerceIn(0f, 1f) + LaunchedEffect(openSwipeTaskId, todo.id) { + if (openSwipeTaskId != null && openSwipeTaskId != todo.id && targetOffsetX != 0f) { + targetOffsetX = 0f + swipeHinting = false + } + } Column( modifier = modifier @@ -2189,8 +2224,8 @@ private fun CalendarTodoRow( revealDelay = 0.62f, onClick = { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + closeSwipeSlot() onInfo() - targetOffsetX = 0f }, ) CalendarSwipeActionButton( @@ -2203,7 +2238,7 @@ private fun CalendarTodoRow( revealDelay = 0.04f, onClick = { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) - targetOffsetX = 0f + closeSwipeSlot() onDelete() }, ) @@ -2221,7 +2256,7 @@ private fun CalendarTodoRow( Modifier.pointerInput(todo.id) { detectDragGesturesAfterLongPress( onDragStart = { localOffset -> - targetOffsetX = 0f + closeSwipeSlot() val startPosition = rowOriginInRoot + localOffset dragPointerPosition = startPosition onDragStart(startPosition) @@ -2255,10 +2290,16 @@ private fun CalendarTodoRow( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> + if (delta < 0f || targetOffsetX != 0f) { + claimSwipeSlot() + } targetOffsetX = (targetOffsetX + delta).coerceIn( -maxElasticDragPx, 0f, ) + if (targetOffsetX == 0f && latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, onDragStopped = { velocity -> val flingOpen = velocity < -1450f @@ -2268,6 +2309,11 @@ private fun CalendarTodoRow( } else { 0f } + if (targetOffsetX != 0f) { + claimSwipeSlot() + } else if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, ) .clickable( @@ -2275,15 +2321,19 @@ private fun CalendarTodoRow( indication = null, ) { if (targetOffsetX != 0f) { - targetOffsetX = 0f + closeSwipeSlot() } else if (!swipeHinting && !pendingCompletion) { swipeHinting = true + claimSwipeSlot() coroutineScope.launch { targetOffsetX = -swipeHintOffsetPx delay(150) targetOffsetX = 0f delay(360) swipeHinting = false + if (latestOpenSwipeTaskId.value == todo.id && targetOffsetX == 0f) { + onOpenSwipeTaskIdChange(null) + } } } }, @@ -2316,7 +2366,7 @@ private fun CalendarTodoRow( enabled = !pendingCompletion, onClick = { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) - targetOffsetX = 0f + closeSwipeSlot() localChecked = true pendingCompletion = true coroutineScope.launch { @@ -2364,11 +2414,13 @@ private fun CalendarTodoRow( maxLines = 2, onTextLayout = { titleLayoutResult = it }, ) - Text( - text = dueText, - color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - style = MaterialTheme.typography.bodySmall, - ) + dueText?.let { text -> + Text( + text = text, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall, + ) + } } if (showListIndicator || showPriorityIcon) { Row( @@ -2438,9 +2490,8 @@ private fun CalendarCompletedTodoRow( ), label = "calendarCompletedRestoreOffsetY", ) - val dueText = DateTimeFormatter.ofPattern("h:mm a") - .withZone(ZoneId.systemDefault()) - .format(item.due) + val dueText = item.due + ?.let { DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(it) } val listMeta = item.resolveListSummary(lists) val listIndicatorColor = listMeta?.color?.let(::listAccentColor) ?: item.listColor?.let(::listAccentColor) @@ -2522,11 +2573,13 @@ private fun CalendarCompletedTodoRow( }, maxLines = 2, ) - Text( - text = dueText, - color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - style = MaterialTheme.typography.bodySmall, - ) + dueText?.let { text -> + Text( + text = text, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall, + ) + } } if (showPriorityIcon) { Row( @@ -2713,16 +2766,16 @@ private fun buildMonthCells(month: YearMonth): List { private fun priorityColor(priority: String): Color { return when (priority.lowercase(Locale.getDefault())) { - "high", "urgent", "important" -> Color(0xFFE56A6A) - "medium" -> Color(0xFFE3B368) - else -> Color(0xFF6FBF86) + "high", "urgent", "important" -> Color(0xFFFF3B30) + "medium" -> Color(0xFFFF9500) + else -> Color(0xFF007AFF) } } private fun priorityIconFor(priority: String): ImageVector? { return when (priority.trim().lowercase(Locale.getDefault())) { "medium" -> Icons.Rounded.Flag - "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + "high", "urgent", "important" -> Icons.Rounded.Flag else -> null } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt index c228b049..67e1a939 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt @@ -50,7 +50,8 @@ class CalendarViewModel @Inject constructor( runCatching { CalendarUiState( isLoading = false, - items = todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL), + items = todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL) + .filter { it.due != null }, completedItems = completedRepository.fetchCompletedItemsSnapshot(), lists = listRepository.fetchListsSnapshot(), errorMessage = null, @@ -96,7 +97,8 @@ class CalendarViewModel @Inject constructor( private fun hydrateFromCache() { runCatching { - val todos = todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL) + val todos = + todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL).filter { it.due != null } val completedItems = completedRepository.fetchCompletedItemsSnapshot() val lists = listRepository.fetchListsSnapshot() Triple(todos, completedItems, lists) @@ -139,7 +141,8 @@ class CalendarViewModel @Inject constructor( ) .onFailure { /* fall back to cache */ } } - val todos = todoRepository.fetchTodos(mode = TodoListMode.ALL) + val todos = + todoRepository.fetchTodos(mode = TodoListMode.ALL).filter { it.due != null } val completedItems = completedRepository.fetchCompletedItems() val lists = listRepository.fetchLists() Triple(todos, completedItems, lists) @@ -243,7 +246,8 @@ class CalendarViewModel @Inject constructor( } fun moveTask(todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) { - val movedDue = movedDuePreservingTime(todo.due, targetDate) + val due = todo.due ?: return + val movedDue = movedDuePreservingTime(due, targetDate) val previousState = _uiState.value val updatedTodo = todo.copy(due = movedDue) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt index 113af4af..a387b26d 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt @@ -49,7 +49,6 @@ import androidx.compose.material.icons.rounded.Inbox import androidx.compose.material.icons.rounded.LocalBar import androidx.compose.material.icons.rounded.LocalHospital import androidx.compose.material.icons.rounded.MusicNote -import androidx.compose.material.icons.rounded.PriorityHigh import androidx.compose.material.icons.rounded.RadioButtonUnchecked import androidx.compose.material.icons.rounded.Restaurant import androidx.compose.material.icons.rounded.Schedule @@ -64,10 +63,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -160,9 +161,16 @@ fun CompletedScreen( mutableStateOf(emptySet()) } var editTargetId by rememberSaveable { mutableStateOf(null) } + var openSwipeTaskId by rememberSaveable { mutableStateOf(null) } val editTarget = remember(editTargetId, uiState.items) { editTargetId?.let { targetId -> uiState.items.firstOrNull { it.id == targetId } } } + LaunchedEffect(uiState.items, openSwipeTaskId) { + val openId = openSwipeTaskId ?: return@LaunchedEffect + if (uiState.items.none { it.id == openId }) { + openSwipeTaskId = null + } + } Scaffold( containerColor = colorScheme.background, @@ -260,6 +268,8 @@ fun CompletedScreen( onInfo = { editTargetId = completed.id }, onDelete = { onDelete(completed) }, onUncomplete = { onUncomplete(completed) }, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = { openSwipeTaskId = it }, ) } } @@ -515,6 +525,8 @@ private fun CompletedSwipeRow( onInfo: () -> Unit, onDelete: () -> Unit, onUncomplete: () -> Unit, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -522,6 +534,19 @@ private fun CompletedSwipeRow( val swipeRevealState = rememberTaskSwipeRevealState(item.id) var restorePhase by remember(item.id) { mutableStateOf(CompletedRestorePhase.Completed) } var titleLayoutResult by remember(item.id) { mutableStateOf(null) } + val latestOpenSwipeTaskId = rememberUpdatedState(openSwipeTaskId) + fun claimSwipeSlot() { + if (latestOpenSwipeTaskId.value != item.id) { + onOpenSwipeTaskIdChange(item.id) + } + } + + fun closeSwipeSlot() { + swipeRevealState.close() + if (latestOpenSwipeTaskId.value == item.id) { + onOpenSwipeTaskIdChange(null) + } + } val animatedOffsetX by animateTaskSwipeOffsetAsState( state = swipeRevealState, label = "completedSwipeOffset", @@ -572,7 +597,7 @@ private fun CompletedSwipeRow( ) val completedAtText = COMPLETED_ROW_TIME_FORMATTER .withZone(ZoneId.systemDefault()) - .format(item.completedAt ?: item.due) + .format(item.completedAt ?: item.due ?: Instant.EPOCH) val listMeta = item.resolveListSummary(lists) val listIndicatorColor = listMeta?.color?.let(::listAccentColor) ?: item.listColor?.let(::listAccentColor) @@ -582,6 +607,11 @@ private fun CompletedSwipeRow( val showPriorityIcon = priorityIcon != null val rowShape = RoundedCornerShape(16.dp) val foregroundColor = colorScheme.background + LaunchedEffect(openSwipeTaskId, item.id) { + if (openSwipeTaskId != null && openSwipeTaskId != item.id && swipeRevealState.isOpenOrDragging) { + swipeRevealState.close() + } + } Column( modifier = modifier @@ -594,11 +624,11 @@ private fun CompletedSwipeRow( }, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(CompletedSwipeRowHeight), - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(CompletedSwipeRowHeight), + ) { Row( modifier = Modifier .align(Alignment.CenterEnd) @@ -619,8 +649,8 @@ private fun CompletedSwipeRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) + closeSwipeSlot() onInfo() - swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -636,8 +666,8 @@ private fun CompletedSwipeRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) + closeSwipeSlot() onDelete() - swipeRevealState.close() }, ) } @@ -649,10 +679,21 @@ private fun CompletedSwipeRow( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> + if (delta < 0f || swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } swipeRevealState.dragBy(delta) + if (!swipeRevealState.isOpenOrDragging && latestOpenSwipeTaskId.value == item.id) { + onOpenSwipeTaskIdChange(null) + } }, onDragStopped = { velocity -> swipeRevealState.settle(velocity) + if (swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } else if (latestOpenSwipeTaskId.value == item.id) { + onOpenSwipeTaskIdChange(null) + } }, ) .clickable( @@ -660,10 +701,14 @@ private fun CompletedSwipeRow( indication = null, ) { if (swipeRevealState.isOpenOrDragging) { - swipeRevealState.close() + closeSwipeSlot() } else if (!swipeRevealState.isHinting && !isRestoring) { + claimSwipeSlot() coroutineScope.launch { swipeRevealState.playHint() + if (latestOpenSwipeTaskId.value == item.id && !swipeRevealState.isOpenOrDragging) { + onOpenSwipeTaskIdChange(null) + } } } }, @@ -695,7 +740,7 @@ private fun CompletedSwipeRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) - swipeRevealState.close() + closeSwipeSlot() coroutineScope.launch { restorePhase = CompletedRestorePhase.Unchecked delay(COMPLETED_RESTORE_STEP_MS) @@ -857,16 +902,16 @@ private fun EmptyCompletedState( @Composable private fun priorityColor(priority: String): Color { return when (priority.lowercase()) { - "high", "urgent", "important" -> Color(0xFFE56A6A) - "medium" -> Color(0xFFE3B368) - else -> Color(0xFF6FBF86) + "high", "urgent", "important" -> Color(0xFFFF3B30) + "medium" -> Color(0xFFFF9500) + else -> Color(0xFF007AFF) } } private fun priorityIconFor(priority: String): ImageVector? { return when (priority.trim().lowercase(Locale.getDefault())) { "medium" -> Icons.Rounded.Flag - "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + "high", "urgent", "important" -> Icons.Rounded.Flag else -> null } } @@ -975,7 +1020,7 @@ private fun shouldShowDateDivider( return !currentItem.completedDate().isSameLocalDayAs(nextVisibleItem.completedDate(), zoneId) } -private fun CompletedItem.completedDate() = completedAt ?: due +private fun CompletedItem.completedDate() = completedAt ?: due ?: Instant.EPOCH private fun Instant.isSameLocalDayAs(other: Instant, zoneId: ZoneId): Boolean = LocalDate.ofInstant(this, zoneId) == LocalDate.ofInstant(other, zoneId) @@ -985,14 +1030,14 @@ private fun buildCompletedTimelineSections( zoneId: ZoneId = ZoneId.systemDefault(), ): List { val groupedByDate = items.groupBy { item -> - LocalDate.ofInstant(item.completedAt ?: item.due, zoneId) + LocalDate.ofInstant(item.completedAt ?: item.due ?: Instant.EPOCH, zoneId) } return groupedByDate.keys .sortedDescending() .map { date -> val sectionItems = groupedByDate[date].orEmpty().sortedWith( - compareByDescending { it.completedAt ?: it.due } + compareByDescending { it.completedAt ?: it.due ?: Instant.EPOCH } .thenBy { it.title.lowercase(Locale.getDefault()) } .thenBy { it.id }, ) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index 677d90b6..d263081e 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -150,12 +149,14 @@ import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -175,7 +176,6 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput @@ -209,7 +209,14 @@ import com.ohmz.tday.compose.core.ui.TaskSwipeActionButton import com.ohmz.tday.compose.core.ui.animateTaskSwipeOffsetAsState import com.ohmz.tday.compose.core.ui.rememberTaskSwipeRevealState import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet +import com.ohmz.tday.compose.ui.component.RootFeedDock +import com.ohmz.tday.compose.ui.component.RootFeedTab +import com.ohmz.tday.compose.ui.component.TdayCenteredSheetContent import com.ohmz.tday.compose.ui.component.TdayPullToRefreshBox +import com.ohmz.tday.compose.ui.component.TdaySheetCard +import com.ohmz.tday.compose.ui.component.TdaySheetDefaults +import com.ohmz.tday.compose.ui.component.TdaySheetHeader +import com.ohmz.tday.compose.ui.component.TdaySheetSectionTitle import com.ohmz.tday.compose.ui.theme.TdayDimens import com.ohmz.tday.compose.ui.theme.TdayFontFamily import kotlinx.coroutines.delay @@ -233,6 +240,7 @@ fun HomeScreen( onOpenPriority: () -> Unit, onOpenCompleted: () -> Unit, onOpenCalendar: () -> Unit, + onOpenFloater: () -> Unit, onOpenSettings: () -> Unit, onOpenTaskFromSearch: (todoId: String) -> Unit, onOpenList: (listId: String, listName: String) -> Unit, @@ -242,6 +250,13 @@ fun HomeScreen( onCompleteTask: (todo: com.ohmz.tday.compose.core.model.TodoItem) -> Unit, onDeleteTask: (todo: com.ohmz.tday.compose.core.model.TodoItem) -> Unit, onUpdateTask: (todo: com.ohmz.tday.compose.core.model.TodoItem, payload: CreateTaskPayload) -> Unit, + showRootFeedDock: Boolean = true, + showCreateTaskButton: Boolean = true, + pullRefreshEnabled: Boolean = true, + createTaskRequestKey: Int = 0, + scrollToTopRequestKey: Int = 0, + onRootDockCollapsedChange: (Boolean) -> Unit = {}, + onRootControlsVisibleChange: (Boolean) -> Unit = {}, ) { val colorScheme = MaterialTheme.colorScheme val focusManager = LocalFocusManager.current @@ -264,6 +279,10 @@ fun HomeScreen( var searchResultsBounds by remember { mutableStateOf(null) } var rootInRoot by remember { mutableStateOf(Offset.Zero) } var showCreateTask by rememberSaveable { mutableStateOf(false) } + var openSwipeTaskId by rememberSaveable { mutableStateOf(null) } + var lastHandledCreateTaskRequestKey by rememberSaveable { + mutableIntStateOf(createTaskRequestKey) + } var editTargetTodoId by rememberSaveable { mutableStateOf(null) } val editTargetTodo = remember(editTargetTodoId, uiState.todayTodos) { editTargetTodoId?.let { id -> uiState.todayTodos.firstOrNull { it.id == id } } @@ -272,12 +291,6 @@ fun HomeScreen( var listColor by rememberSaveable { mutableStateOf(DEFAULT_LIST_COLOR) } var listIconKey by rememberSaveable { mutableStateOf(DEFAULT_LIST_ICON_KEY) } var showCreateList by rememberSaveable { mutableStateOf(false) } - var hasCapturedInitialListSnapshot by rememberSaveable { mutableStateOf(false) } - var hasShownListDataOnce by rememberSaveable { mutableStateOf(false) } - var lastListStructureSignature by rememberSaveable { mutableStateOf("") } - var lastListIdsSignature by rememberSaveable { mutableStateOf("") } - var visibleListStage by rememberSaveable { mutableIntStateOf(0) } - var animateListCascade by rememberSaveable { mutableStateOf(false) } var searchResultOpening by rememberSaveable { mutableStateOf(false) } val searchResultScope = rememberCoroutineScope() val closeSearch = { @@ -305,6 +318,19 @@ fun HomeScreen( BackHandler(enabled = searchExpanded) { closeSearch() } + LaunchedEffect(createTaskRequestKey) { + if (createTaskRequestKey > lastHandledCreateTaskRequestKey) { + lastHandledCreateTaskRequestKey = createTaskRequestKey + closeSearch() + showCreateTask = true + } + } + LaunchedEffect(searchExpanded) { + onRootControlsVisibleChange(!searchExpanded) + } + DisposableEffect(Unit) { + onDispose { onRootControlsVisibleChange(true) } + } LaunchedEffect(searchExpanded, imeVisible) { if (!searchExpanded) { searchImeWasVisible = false @@ -319,27 +345,11 @@ fun HomeScreen( closeSearch() } } - val listStructureSignature = remember(uiState.summary.lists) { - uiState.summary.lists.joinToString(separator = "|") { list -> - buildString { - append(list.id) - append(':') - append(list.name) - append(':') - append(list.color.orEmpty()) - append(':') - append(list.iconKey.orEmpty()) - } - } - } - val listIdsSignature = remember(uiState.summary.lists) { - uiState.summary.lists.joinToString(separator = "|") { it.id } - } val listById = remember(uiState.summary.lists) { uiState.summary.lists.associateBy { it.id } } val normalizedSearchQuery = remember(searchQuery) { searchQuery.trim().lowercase(Locale.getDefault()) } val overdueCount = remember(uiState.searchableTodos) { val now = Instant.now() - uiState.searchableTodos.count { todo -> todo.due.isBefore(now) } + uiState.searchableTodos.count { todo -> todo.due?.isBefore(now) == true } } val dueFormatter = remember { java.time.format.DateTimeFormatter.ofPattern("EEE h:mm a") @@ -357,7 +367,7 @@ fun HomeScreen( (todo.listId?.let { listById[it]?.name }?.lowercase(Locale.getDefault()) ?.contains(normalizedSearchQuery) == true) } - .sortedBy { it.due } + .sortedBy { it.due ?: Instant.MAX } .take(20) .toList() } @@ -365,6 +375,30 @@ fun HomeScreen( val showSearchResultsOverlay = searchExpanded && searchQuery.isNotBlank() val density = LocalDensity.current val listState = rememberLazyListState() + val hasScrollableContent = + listState.canScrollForward || listState.canScrollBackward + val dockCollapseThresholdPx = with(density) { RootFeedDockCollapseThreshold.roundToPx() } + val hasScrolledPastDockCollapseThreshold = + listState.firstVisibleItemIndex > 0 || + listState.firstVisibleItemScrollOffset > dockCollapseThresholdPx + val dockCollapsed = + hasScrollableContent && hasScrolledPastDockCollapseThreshold + LaunchedEffect(dockCollapsed) { + onRootDockCollapsedChange(dockCollapsed) + } + LaunchedEffect(scrollToTopRequestKey) { + if (scrollToTopRequestKey <= 0) return@LaunchedEffect + closeSearch() + if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { + listState.animateScrollToItem(index = 0, scrollOffset = 0) + } + } + LaunchedEffect(uiState.todayTodos, openSwipeTaskId) { + val openId = openSwipeTaskId ?: return@LaunchedEffect + if (uiState.todayTodos.none { it.id == openId }) { + openSwipeTaskId = null + } + } LaunchedEffect(listState.isScrollInProgress, searchExpanded) { if (searchExpanded || listState.isScrollInProgress) return@LaunchedEffect // Snap only when top header row is partially visible. @@ -380,69 +414,6 @@ fun HomeScreen( } } - LaunchedEffect(listStructureSignature) { - val lists = uiState.summary.lists - val targetFinalStage = if (lists.isEmpty()) 0 else lists.size + 1 - if (!hasCapturedInitialListSnapshot) { - visibleListStage = targetFinalStage - animateListCascade = false - hasCapturedInitialListSnapshot = true - hasShownListDataOnce = lists.isNotEmpty() - lastListStructureSignature = listStructureSignature - lastListIdsSignature = listIdsSignature - return@LaunchedEffect - } - - if (listStructureSignature == lastListStructureSignature) { - visibleListStage = targetFinalStage - animateListCascade = false - return@LaunchedEffect - } - - val previousListIds = lastListIdsSignature - .split('|') - .filter { it.isNotBlank() } - val currentListIds = lists.map { it.id } - val isDeletionOnly = previousListIds.isNotEmpty() && - currentListIds.size < previousListIds.size && - currentListIds.all { it in previousListIds } - val isMetadataOnlyChange = previousListIds == currentListIds - - lastListStructureSignature = listStructureSignature - lastListIdsSignature = listIdsSignature - if (lists.isEmpty()) { - visibleListStage = 0 - animateListCascade = false - return@LaunchedEffect - } - - if (!hasShownListDataOnce) { - visibleListStage = targetFinalStage - animateListCascade = false - hasShownListDataOnce = true - return@LaunchedEffect - } - - if (isDeletionOnly || isMetadataOnlyChange) { - visibleListStage = targetFinalStage - animateListCascade = false - return@LaunchedEffect - } - - animateListCascade = true - visibleListStage = 0 - delay(70) - visibleListStage = 1 - delay(75) - lists.forEachIndexed { index, _ -> - visibleListStage = index + 2 - delay(60) - } - // Stop wrapping rows with entry animation once the cascade has completed. - // This prevents rows from re-animating when they are recomposed during scroll. - visibleListStage = targetFinalStage - animateListCascade = false - } LaunchedEffect(showSearchResultsOverlay) { if (!showSearchResultsOverlay) { searchResultsBounds = null @@ -452,63 +423,68 @@ fun HomeScreen( Scaffold( containerColor = colorScheme.background, floatingActionButton = { - CreateTaskButton( - modifier = Modifier - .offset(y = fabOffsetY) - .graphicsLayer { - scaleX = fabScale - scaleY = fabScale + if (showCreateTaskButton) { + CreateTaskButton( + modifier = Modifier + .offset(y = fabOffsetY) + .graphicsLayer { + scaleX = fabScale + scaleY = fabScale + }, + interactionSource = fabInteractionSource, + onClick = { + showCreateTask = true }, - interactionSource = fabInteractionSource, - onClick = { - showCreateTask = true - }, - ) + ) + } }, ) { padding -> - CompositionLocalProvider(LocalOverscrollConfiguration provides null) { - TdayPullToRefreshBox( - isRefreshing = uiState.isLoading, - onRefresh = onRefresh, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - Box( + Box(modifier = Modifier.fillMaxSize()) { + CompositionLocalProvider(LocalOverscrollConfiguration provides null) { + TdayPullToRefreshBox( + isRefreshing = uiState.isLoading, + onRefresh = onRefresh, + enabled = pullRefreshEnabled, modifier = Modifier .fillMaxSize() - .then( - if (searchExpanded) { - Modifier - .onGloballyPositioned { coordinates -> - val topLeft = coordinates.boundsInRoot().topLeft - if (rootInRoot != topLeft) { - rootInRoot = topLeft + .padding(padding), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .then( + if (searchExpanded) { + Modifier + .onGloballyPositioned { coordinates -> + val topLeft = coordinates.boundsInRoot().topLeft + if (rootInRoot != topLeft) { + rootInRoot = topLeft + } } - } - .pointerInput( - searchBarBounds, - searchResultsBounds, - rootInRoot - ) { - awaitEachGesture { - val down = awaitFirstDown(pass = PointerEventPass.Final) - val tapInRoot = down.position + rootInRoot - val tappedSearchBar = - searchBarBounds?.contains(tapInRoot) == true - val tappedSearchResults = - searchResultsBounds?.contains(tapInRoot) == true - val up = - waitForUpOrCancellation(pass = PointerEventPass.Final) - if (up != null && !tappedSearchBar && !tappedSearchResults) { - closeSearch() + .pointerInput( + searchBarBounds, + searchResultsBounds, + rootInRoot + ) { + awaitEachGesture { + val down = + awaitFirstDown(pass = PointerEventPass.Final) + val tapInRoot = down.position + rootInRoot + val tappedSearchBar = + searchBarBounds?.contains(tapInRoot) == true + val tappedSearchResults = + searchResultsBounds?.contains(tapInRoot) == true + val up = + waitForUpOrCancellation(pass = PointerEventPass.Final) + if (up != null && !tappedSearchBar && !tappedSearchResults) { + closeSearch() + } } } - } - } else { - Modifier - } - ), + } else { + Modifier + } + ), ) { LazyColumn( state = listState, @@ -573,6 +549,8 @@ fun HomeScreen( onComplete = { onCompleteTask(todo) }, onDelete = { onDeleteTask(todo) }, onEdit = { editTargetTodoId = todo.id }, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = { openSwipeTaskId = it }, ) } @@ -615,57 +593,23 @@ fun HomeScreen( if (uiState.summary.lists.isNotEmpty()) { item { - if (visibleListStage >= 1) { - if (animateListCascade) { - TopDownCascadeReveal { - MyListsHeader() - } - } else { - MyListsHeader() - } - } + MyListsHeader() } itemsIndexed( items = uiState.summary.lists, key = { _, list -> list.id }, contentType = { _, _ -> "list_row" }, - ) { index, list -> - if (visibleListStage >= index + 2) { - val listRowPlacementModifier = Modifier.animateItem( - fadeInSpec = tween(durationMillis = 180), - placementSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - fadeOutSpec = tween(durationMillis = 130), - ) - if (animateListCascade) { - TopDownCascadeReveal(modifier = listRowPlacementModifier) { - ListRow( - name = list.name, - colorKey = list.color, - iconKey = list.iconKey, - count = list.todoCount, - onClick = { - closeSearch() - onOpenList(list.id, capitalizeFirstListLetter(list.name)) - }, - ) - } - } else { - ListRow( - modifier = listRowPlacementModifier, - name = list.name, - colorKey = list.color, - iconKey = list.iconKey, - count = list.todoCount, - onClick = { - closeSearch() - onOpenList(list.id, capitalizeFirstListLetter(list.name)) - }, - ) - } - } + ) { _, list -> + ListRow( + name = list.name, + colorKey = list.color, + iconKey = list.iconKey, + count = list.todoCount, + onClick = { + closeSearch() + onOpenList(list.id, capitalizeFirstListLetter(list.name)) + }, + ) } } @@ -748,7 +692,8 @@ fun HomeScreen( fontWeight = FontWeight.ExtraBold, ) Text( - text = dueFormatter.format(todo.due), + text = todo.due?.let(dueFormatter::format) + .orEmpty(), style = MaterialTheme.typography.bodySmall, color = colorScheme.onSurfaceVariant, maxLines = 1, @@ -777,8 +722,34 @@ fun HomeScreen( } } } + + } } } + if (showRootFeedDock && !searchExpanded) { + RootFeedDock( + activeTab = RootFeedTab.HOME, + collapsed = dockCollapsed, + onTabSelected = { tab -> + when (tab) { + RootFeedTab.HOME -> { + searchResultScope.launch { + closeSearch() + listState.animateScrollToItem(index = 0, scrollOffset = 0) + } + } + + RootFeedTab.FLOATER -> { + closeSearch() + onOpenFloater() + } + } + }, + modifier = Modifier + .align(Alignment.BottomStart) + .zIndex(8f), + ) + } } } @@ -839,7 +810,9 @@ private fun shouldShowDateDivider( ): Boolean { val currentTodo = items.getOrNull(afterItemIndex) ?: return false val nextTodo = items.getOrNull(afterItemIndex + 1) ?: return false - return LocalDate.ofInstant(currentTodo.due, zoneId) != LocalDate.ofInstant(nextTodo.due, zoneId) + val currentDue = currentTodo.due ?: return false + val nextDue = nextTodo.due ?: return false + return LocalDate.ofInstant(currentDue, zoneId) != LocalDate.ofInstant(nextDue, zoneId) } @Composable @@ -883,17 +856,9 @@ private fun CreateListBottomSheet( ), label = "createListSheetHeight", ) - val isDarkTheme = colorScheme.background.luminance() < 0.5f - val sheetContainerColor = if (isDarkTheme) { - lerp(colorScheme.background, colorScheme.surfaceVariant, 0.34f) - } else { - colorScheme.background - } - val sheetScrimColor = if (isDarkTheme) { - Color.Black.copy(alpha = 0.68f) - } else { - Color.Black.copy(alpha = 0.40f) - } + val sheetContainerColor = TdaySheetDefaults.containerColor() + val sheetScrimColor = TdaySheetDefaults.scrimColor() + val sheetTonalElevation = TdaySheetDefaults.tonalElevation() LaunchedEffect(Unit) { sheetVisible = true @@ -954,22 +919,27 @@ private fun CreateListBottomSheet( interactionSource = remember { MutableInteractionSource() }, indication = null, ) {}, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), + shape = TdaySheetDefaults.TopShape, color = sheetContainerColor, - tonalElevation = if (isDarkTheme) 10.dp else 0.dp, + tonalElevation = sheetTonalElevation, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = 18.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - ListSheetHeader( - onClose = { + TdayCenteredSheetContent { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 18.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TdaySheetHeader( + title = stringResource(R.string.home_new_list), + leftIcon = Icons.Rounded.Close, + leftContentDescription = stringResource(R.string.action_close), + onLeftClick = { dismissKeyboard() onDismiss() }, + confirmContentDescription = stringResource(R.string.action_create_list), onConfirm = { dismissKeyboard() if (canCreate) onCreate() @@ -977,7 +947,7 @@ private fun CreateListBottomSheet( confirmEnabled = canCreate, ) - ListSheetCard { + TdaySheetCard { Column( modifier = Modifier .fillMaxWidth() @@ -1018,7 +988,7 @@ private fun CreateListBottomSheet( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) - .background(colorScheme.surfaceVariant) + .background(TdaySheetDefaults.controlSurfaceColor()) .padding(horizontal = 14.dp, vertical = 12.dp), contentAlignment = Alignment.Center, ) { @@ -1037,8 +1007,8 @@ private fun CreateListBottomSheet( } } - ListSheetSectionTitle(stringResource(R.string.home_section_color)) - ListSheetCard { + TdaySheetSectionTitle(stringResource(R.string.home_section_color)) + TdaySheetCard { Row( modifier = Modifier .fillMaxWidth() @@ -1073,8 +1043,8 @@ private fun CreateListBottomSheet( } } - ListSheetSectionTitle(stringResource(R.string.home_section_icon)) - ListSheetCard { + TdaySheetSectionTitle(stringResource(R.string.home_section_icon)) + TdaySheetCard { Row( modifier = Modifier .fillMaxWidth() @@ -1095,7 +1065,7 @@ private fun CreateListBottomSheet( if (selected) { selectedAccent.copy(alpha = 0.2f) } else { - colorScheme.surfaceVariant + TdaySheetDefaults.controlSurfaceColor() }, ) .border( @@ -1123,6 +1093,7 @@ private fun CreateListBottomSheet( } Spacer(Modifier.height(4.dp)) + } } } } @@ -1130,133 +1101,6 @@ private fun CreateListBottomSheet( } } -@Composable -private fun ListSheetHeader( - onClose: () -> Unit, - onConfirm: () -> Unit, - confirmEnabled: Boolean, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - ListSheetActionButton( - icon = Icons.Rounded.Close, - contentDescription = stringResource(R.string.action_close), - enabled = true, - accentColor = Color(0xFFE35A5A), - onClick = onClose, - ) - - Text( - text = stringResource(R.string.home_new_list), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.ExtraBold, - ) - - ListSheetActionButton( - icon = Icons.Rounded.Check, - contentDescription = stringResource(R.string.action_create_list), - enabled = confirmEnabled, - accentColor = Color(0xFF2FA35B), - onClick = onConfirm, - ) - } -} - -@Composable -private fun ListSheetActionButton( - icon: ImageVector, - contentDescription: String, - enabled: Boolean, - accentColor: Color, - onClick: () -> Unit, -) { - val view = LocalView.current - val colorScheme = MaterialTheme.colorScheme - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (pressed && enabled) 0.93f else 1f, - label = "listSheetHeaderButtonScale", - ) - val offsetY by animateDpAsState( - targetValue = if (pressed && enabled) 2.dp else 0.dp, - label = "listSheetHeaderButtonOffsetY", - ) - val containerColor = colorScheme.surfaceVariant - val iconTint = colorScheme.onBackground.copy(alpha = if (enabled) 1f else 0.55f) - val borderColor = if (enabled) { - accentColor.copy(alpha = 0.55f) - } else { - accentColor.copy(alpha = 0.3f) - } - - Card( - modifier = Modifier - .size(TdayDimens.FabSize) - .offset(y = offsetY) - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .border( - width = 1.5.dp, - color = borderColor, - shape = RoundedCornerShape(999.dp), - ), - onClick = { - if (enabled) performGentleHaptic(view) - onClick() - }, - enabled = enabled, - interactionSource = interactionSource, - shape = RoundedCornerShape(999.dp), - colors = CardDefaults.cardColors(containerColor = containerColor), - elevation = CardDefaults.cardElevation( - defaultElevation = if (enabled) TdayDimens.FabElevation else 0.dp, - pressedElevation = if (enabled) TdayDimens.FabPressedElevation else 0.dp, - ), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = iconTint, - modifier = Modifier.size(22.dp), - ) - } - } -} - -@Composable -private fun ListSheetSectionTitle(text: String) { - Text( - text = text, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), - ) -} - -@Composable -private fun ListSheetCard(content: @Composable ColumnScope.() -> Unit) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Column(modifier = Modifier.fillMaxWidth(), content = content) - } -} - @Composable private fun CreateTaskButton( modifier: Modifier, @@ -1654,17 +1498,6 @@ private fun HomeTodayCard( onDrawWithContent { drawRect(glow); drawRect(pearl); drawContent() } }, ) { - Box(modifier = Modifier.matchParentSize()) { - Icon( - modifier = Modifier - .align(Alignment.CenterEnd) - .offset(x = 22.dp, y = 12.dp) - .size(124.dp), - imageVector = Icons.Rounded.WbSunny, - contentDescription = null, - tint = lerp(color, Color.White, 0.28f).copy(alpha = 0.4f), - ) - } Row( modifier = Modifier .fillMaxWidth() @@ -1672,26 +1505,18 @@ private fun HomeTodayCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Icon( - Icons.Rounded.WbSunny, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(26.dp) - ) - Text( - text = dateLabel, - style = MaterialTheme.typography.titleLarge, - color = Color.White, - fontFamily = TdayFontFamily, - fontSize = 22.sp, - fontWeight = FontWeight.ExtraBold, - lineHeight = 28.sp, - ) - } + Text( + text = dateLabel, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + color = Color.White, + fontFamily = TdayFontFamily, + fontSize = 22.sp, + fontWeight = FontWeight.ExtraBold, + lineHeight = 28.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) Text( text = count.toString(), style = MaterialTheme.typography.headlineLarge, @@ -1715,6 +1540,8 @@ private fun HomeTodayTaskRow( onComplete: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -1725,6 +1552,19 @@ private fun HomeTodayTaskRow( var pendingCompletion by remember(todo.id) { mutableStateOf(false) } var completionFading by remember(todo.id) { mutableStateOf(false) } var titleLayoutResult by remember(todo.id) { mutableStateOf(null) } + val latestOpenSwipeTaskId = rememberUpdatedState(openSwipeTaskId) + fun claimSwipeSlot() { + if (latestOpenSwipeTaskId.value != todo.id) { + onOpenSwipeTaskIdChange(todo.id) + } + } + + fun closeSwipeSlot() { + swipeRevealState.close() + if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } + } val animatedOffsetX by animateTaskSwipeOffsetAsState( state = swipeRevealState, label = "homeTodaySwipeOffset", @@ -1745,20 +1585,27 @@ private fun HomeTodayTaskRow( label = "homeTodayTitleStrikeProgress", ) val actionRevealProgress = swipeRevealState.revealProgress(animatedOffsetX) - val dueText = HOME_TODAY_DUE_FORMATTER.format(todo.due) + val dueText = todo.due?.let(HOME_TODAY_DUE_FORMATTER::format) val rowShape = RoundedCornerShape(16.dp) val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val listIndicatorColor = listColorAccent(listMeta?.color) val priorityIcon = priorityIconFor(todo.priority) - val isOverdue = !todo.completed && todo.due.isBefore(Instant.now()) + val isOverdue = !todo.completed && todo.due?.isBefore(Instant.now()) == true val subtitleColor = if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant.copy( alpha = 0.8f ) - val subtitleText = if (isOverdue) { - stringResource(R.string.todos_due_overdue_text, dueText) - } else { - stringResource(R.string.todos_due_text, dueText) + val subtitleText = dueText?.let { text -> + if (isOverdue) { + stringResource(R.string.todos_due_overdue_text, text) + } else { + stringResource(R.string.todos_due_text, text) + } + } + LaunchedEffect(openSwipeTaskId, todo.id) { + if (openSwipeTaskId != null && openSwipeTaskId != todo.id && swipeRevealState.isOpenOrDragging) { + swipeRevealState.close() + } } Column( @@ -1794,8 +1641,8 @@ private fun HomeTodayTaskRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK ) + closeSwipeSlot() onEdit() - swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -1811,8 +1658,8 @@ private fun HomeTodayTaskRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK ) + closeSwipeSlot() onDelete() - swipeRevealState.close() }, ) } @@ -1824,10 +1671,21 @@ private fun HomeTodayTaskRow( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> + if (delta < 0f || swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } swipeRevealState.dragBy(delta) + if (!swipeRevealState.isOpenOrDragging && latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, onDragStopped = { velocity -> swipeRevealState.settle(velocity) + if (swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } else if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, ) .clickable( @@ -1835,10 +1693,14 @@ private fun HomeTodayTaskRow( indication = null, ) { if (swipeRevealState.isOpenOrDragging) { - swipeRevealState.close() + closeSwipeSlot() } else if (!swipeRevealState.isHinting && !pendingCompletion) { + claimSwipeSlot() coroutineScope.launch { swipeRevealState.playHint() + if (latestOpenSwipeTaskId.value == todo.id && !swipeRevealState.isOpenOrDragging) { + onOpenSwipeTaskIdChange(null) + } } } }, @@ -1864,7 +1726,7 @@ private fun HomeTodayTaskRow( enabled = !pendingCompletion, ) { if (!pendingCompletion) { - swipeRevealState.close() + closeSwipeSlot() localChecked = true pendingCompletion = true coroutineScope.launch { @@ -1932,15 +1794,17 @@ private fun HomeTodayTaskRow( overflow = TextOverflow.Ellipsis, onTextLayout = { titleLayoutResult = it }, ) - Text( - text = subtitleText, - style = MaterialTheme.typography.bodySmall, - fontFamily = TdayFontFamily, - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - lineHeight = 18.sp, - color = subtitleColor, - ) + subtitleText?.let { text -> + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + fontFamily = TdayFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + lineHeight = 18.sp, + color = subtitleColor, + ) + } } if (listMeta != null || priorityIcon != null) { @@ -2063,39 +1927,6 @@ private fun calendarTileColor(colorScheme: ColorScheme): Color { return Color(0xFF9A89D2) } -@Composable -private fun TopDownCascadeReveal( - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - var revealed by remember { mutableStateOf(false) } - val alpha by animateFloatAsState( - targetValue = if (revealed) 1f else 0f, - animationSpec = tween(durationMillis = 320), - label = "listCascadeAlpha", - ) - val offsetY by animateDpAsState( - targetValue = if (revealed) 0.dp else (-14).dp, - animationSpec = tween(durationMillis = 320), - label = "listCascadeOffsetY", - ) - - LaunchedEffect(Unit) { - revealed = true - } - - Box( - modifier = modifier - .fillMaxWidth() - .graphicsLayer { - this.alpha = alpha - translationY = offsetY.toPx() - }, - ) { - content() - } -} - @Composable private fun CategoryCard( modifier: Modifier, @@ -2442,6 +2273,7 @@ private const val CREATE_LIST_SHEET_NORMAL_HEIGHT_FRACTION = 0.70f private const val CREATE_LIST_SHEET_KEYBOARD_HEIGHT_FRACTION = 0.80f private const val CREATE_LIST_SHEET_MOTION_MS = 320 private const val SEARCH_RESULT_SEARCH_CLOSE_DELAY_MS = 260L +private val RootFeedDockCollapseThreshold = 44.dp private fun performGentleHaptic(view: android.view.View) { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) @@ -2450,16 +2282,16 @@ private fun performGentleHaptic(view: android.view.View) { @Composable private fun priorityColor(priority: String): Color { return when (priority.lowercase(Locale.getDefault())) { - "high", "urgent", "important" -> Color(0xFFE56A6A) - "medium" -> Color(0xFFE3B368) - else -> Color(0xFF6FBF86) + "high", "urgent", "important" -> Color(0xFFFF3B30) + "medium" -> Color(0xFFFF9500) + else -> Color(0xFF007AFF) } } private fun priorityIconFor(priority: String): ImageVector? { return when (priority.trim().lowercase(Locale.getDefault())) { "medium" -> Icons.Rounded.Flag - "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + "high", "urgent", "important" -> Icons.Rounded.Flag else -> null } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt index 4085ebac..ae746492 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt @@ -31,6 +31,7 @@ data class HomeUiState( scheduledCount = 0, allCount = 0, priorityCount = 0, + floaterCount = 0, completedCount = 0, lists = emptyList(), ), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt index 8e73ff42..4bd55c92 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt @@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -28,8 +30,9 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material.icons.rounded.WbSunny import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -60,10 +63,12 @@ import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalAutofill @@ -85,11 +90,13 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch private enum class WizardStep { + MODE, SERVER, LOGIN, } private enum class WizardViewState { + MODE, SERVER, CONNECTING, LOGIN, @@ -121,6 +128,7 @@ fun OnboardingWizardOverlay( onRequestSavedCredential: suspend (Context, String?) -> SystemCredential?, onRequestSavedServerUrl: suspend (Context) -> String?, onSaveServerUrlCredential: suspend (Context, String) -> Unit, + onUseLocalMode: () -> Unit, onClearAuthStatus: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -132,7 +140,7 @@ fun OnboardingWizardOverlay( val credentialCoordinator = remember { LoginCredentialCoordinator() } var step by rememberSaveable(initialServerUrl) { - mutableStateOf(if (initialServerUrl.isNullOrBlank()) WizardStep.SERVER else WizardStep.LOGIN) + mutableStateOf(if (initialServerUrl.isNullOrBlank()) WizardStep.MODE else WizardStep.LOGIN) } var serverUrl by rememberSaveable { mutableStateOf(initialServerUrl.orEmpty()) } var email by rememberSaveable { mutableStateOf("") } @@ -345,7 +353,8 @@ fun OnboardingWizardOverlay( isConnecting -> WizardViewState.CONNECTING authUiState.isLoading -> WizardViewState.AUTHENTICATING step == WizardStep.LOGIN -> WizardViewState.LOGIN - else -> WizardViewState.SERVER + step == WizardStep.SERVER -> WizardViewState.SERVER + else -> WizardViewState.MODE } val fieldColors = OutlinedTextFieldDefaults.colors( @@ -358,6 +367,9 @@ fun OnboardingWizardOverlay( cursorColor = colorScheme.onSurface, focusedPlaceholderColor = colorScheme.onSurface.copy(alpha = 0.4f), unfocusedPlaceholderColor = colorScheme.onSurface.copy(alpha = 0.4f), + focusedContainerColor = colorScheme.surface, + unfocusedContainerColor = colorScheme.surface, + errorContainerColor = colorScheme.surface, ) BoxWithConstraints( @@ -384,9 +396,10 @@ fun OnboardingWizardOverlay( Card( modifier = Modifier .width(cardWidth), - shape = RoundedCornerShape(32.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + shape = RoundedCornerShape(34.dp), + colors = CardDefaults.cardColors(containerColor = colorScheme.background), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), + border = BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.08f)), ) { Box( modifier = Modifier @@ -395,8 +408,8 @@ fun OnboardingWizardOverlay( val tint = colorScheme.onSurface val wash = Brush.linearGradient( colors = listOf( - tint.copy(alpha = 0.06f), - tint.copy(alpha = 0.02f), + Color.White.copy(alpha = 0.18f), + tint.copy(alpha = 0.015f), Color.Transparent, ), ) @@ -407,52 +420,113 @@ fun OnboardingWizardOverlay( } .padding(WIZARD_CARD_CONTENT_PADDING), ) { - Icon( - imageVector = if (step == WizardStep.SERVER) Icons.Rounded.Language else Icons.Rounded.Lock, - contentDescription = null, - tint = lerp(colorScheme.surface, colorScheme.primary, 0.3f).copy(alpha = 0.25f), - modifier = Modifier - .align(Alignment.BottomEnd) - .size(WIZARD_WATERMARK_SIZE), - ) - Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Text( - text = stringResource(R.string.onboarding_title), - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onSurface, - fontWeight = FontWeight.ExtraBold, - ) - Text( - text = stringResource(R.string.onboarding_subtitle), - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.WbSunny, + contentDescription = null, + tint = Color(0xFFF4C542), + modifier = Modifier.size(27.dp), + ) + Text( + text = "T'Day", + style = MaterialTheme.typography.headlineMedium, + color = colorScheme.onSurface, + fontWeight = FontWeight.Black, + ) + } Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + WizardStepChip( + modifier = Modifier.weight(1f), + title = stringResource(R.string.onboarding_step_mode), + imageVector = Icons.Rounded.PhoneAndroid, + color = Color(0xFF7FB78A), + active = step == WizardStep.MODE, + ) WizardStepChip( modifier = Modifier.weight(1f), title = stringResource(R.string.onboarding_step_server), - isServerStep = true, + imageVector = Icons.Rounded.Language, color = Color(0xFF6EA8E1), active = step == WizardStep.SERVER, ) WizardStepChip( modifier = Modifier.weight(1f), title = stringResource(R.string.onboarding_step_login), - isServerStep = false, + imageVector = Icons.Rounded.Person, color = Color(0xFFD48A8C), active = step == WizardStep.LOGIN, ) } + Text( + text = stringResource(R.string.onboarding_subtitle), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.62f), + fontWeight = FontWeight.Bold, + ) + AnimatedContent(targetState = viewState, label = "wizardState") { state -> when (state) { + WizardViewState.MODE -> { + Column(verticalArrangement = Arrangement.spacedBy(11.dp)) { + WizardHeroTile( + title = stringResource(R.string.onboarding_mode_title), + subtitle = stringResource(R.string.onboarding_mode_subtitle), + imageVector = Icons.Rounded.PhoneAndroid, + color = Color(0xFF6EA8E1), + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + WizardModeChoiceButton( + modifier = Modifier.weight(1f), + title = stringResource(R.string.onboarding_mode_server_short_title), + subtitle = stringResource(R.string.onboarding_mode_server_short_subtitle), + imageVector = Icons.Rounded.Language, + color = Color(0xFF6EA8E1), + enabled = !isResettingTrust, + onClick = { + step = WizardStep.SERVER + serverError = null + localAuthError = null + onClearAuthStatus() + }, + ) + WizardModeChoiceButton( + modifier = Modifier.weight(1f), + title = stringResource(R.string.onboarding_mode_local_short_title), + subtitle = stringResource(R.string.onboarding_mode_local_short_subtitle), + imageVector = Icons.Rounded.PhoneAndroid, + color = Color(0xFF719F84), + enabled = !isResettingTrust, + onClick = { + keyboardController?.hide() + focusManager.clearFocus(force = true) + serverError = null + localAuthError = null + onClearAuthStatus() + onUseLocalMode() + }, + ) + } + } + } + WizardViewState.SERVER -> { - Column { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WizardHeroTile( + title = stringResource(R.string.onboarding_mode_server_title), + subtitle = stringResource(R.string.onboarding_server_hero_subtitle), + imageVector = Icons.Rounded.Language, + color = Color(0xFF6EA8E1), + ) OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = serverUrl, @@ -469,6 +543,7 @@ fun OnboardingWizardOverlay( onGo = { connectToServer() }, onDone = { connectToServer() }, ), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) @@ -529,7 +604,7 @@ fun OnboardingWizardOverlay( Button( modifier = Modifier .fillMaxWidth() - .padding(top = 14.dp), + .height(48.dp), enabled = serverUrl.isNotBlank() && !isResettingTrust, onClick = connectToServer, colors = ButtonDefaults.buttonColors( @@ -539,6 +614,23 @@ fun OnboardingWizardOverlay( ) { Text(stringResource(R.string.onboarding_connect)) } + + TextButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + enabled = !isResettingTrust, + onClick = { + keyboardController?.hide() + focusManager.clearFocus(force = true) + serverError = null + localAuthError = null + onClearAuthStatus() + step = WizardStep.MODE + }, + ) { + Text(stringResource(R.string.onboarding_change_setup)) + } } } @@ -553,9 +645,16 @@ fun OnboardingWizardOverlay( Column { when (authMode) { AuthPanelMode.SIGN_IN -> { + WizardHeroTile( + title = stringResource(R.string.onboarding_sign_in), + subtitle = stringResource(R.string.onboarding_login_hero_subtitle), + imageVector = Icons.Rounded.Person, + color = Color(0xFFC97880), + ) OutlinedTextField( modifier = Modifier .fillMaxWidth() + .padding(top = 10.dp) .tdayAutofill( autofillTypes = listOf( AutofillType.Username, @@ -578,6 +677,7 @@ fun OnboardingWizardOverlay( keyboardActions = KeyboardActions( onNext = { passwordFocusRequester.requestFocus() }, ), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) OutlinedTextField( @@ -605,6 +705,7 @@ fun OnboardingWizardOverlay( onDone = { signIn() }, ), visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) @@ -646,7 +747,8 @@ fun OnboardingWizardOverlay( Button( modifier = Modifier .fillMaxWidth() - .padding(top = 14.dp), + .padding(top = 4.dp) + .height(48.dp), enabled = email.isNotBlank() && password.isNotBlank() && !authUiState.isLoading, onClick = signIn, colors = ButtonDefaults.buttonColors( @@ -673,20 +775,28 @@ fun OnboardingWizardOverlay( } TextButton( onClick = { - step = WizardStep.SERVER + step = WizardStep.MODE canRequestSavedLoginCredential = false localAuthError = null onClearAuthStatus() }, ) { - Text(stringResource(R.string.onboarding_change_server)) + Text(stringResource(R.string.onboarding_change_setup)) } } } AuthPanelMode.CREATE_ACCOUNT -> { + WizardHeroTile( + title = stringResource(R.string.onboarding_create_account), + subtitle = stringResource(R.string.onboarding_register_hero_subtitle), + imageVector = Icons.Rounded.Person, + color = Color(0xFFC97880), + ) OutlinedTextField( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), value = firstName, onValueChange = { firstName = it @@ -699,6 +809,7 @@ fun OnboardingWizardOverlay( keyboardActions = KeyboardActions( onNext = { passwordFocusRequester.requestFocus() }, ), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) OutlinedTextField( @@ -728,6 +839,7 @@ fun OnboardingWizardOverlay( keyboardActions = KeyboardActions( onNext = { registerPasswordFocusRequester.requestFocus() }, ), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) OutlinedTextField( @@ -755,6 +867,7 @@ fun OnboardingWizardOverlay( onNext = { registerConfirmFocusRequester.requestFocus() }, ), visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) OutlinedTextField( @@ -782,6 +895,7 @@ fun OnboardingWizardOverlay( onDone = { createAccount() }, ), visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) @@ -813,7 +927,8 @@ fun OnboardingWizardOverlay( Button( modifier = Modifier .fillMaxWidth() - .padding(top = 14.dp), + .padding(top = 4.dp) + .height(48.dp), enabled = firstName.isNotBlank() && email.isNotBlank() && registerPassword.isNotBlank() && @@ -851,14 +966,14 @@ fun OnboardingWizardOverlay( } TextButton( onClick = { - step = WizardStep.SERVER + step = WizardStep.MODE authMode = AuthPanelMode.SIGN_IN canRequestSavedLoginCredential = false localAuthError = null onClearAuthStatus() }, ) { - Text(stringResource(R.string.onboarding_change_server)) + Text(stringResource(R.string.onboarding_change_setup)) } } } @@ -939,6 +1054,7 @@ private fun WizardLoading( title: String, subtitle: String, ) { + val colorScheme = MaterialTheme.colorScheme val transition = rememberInfiniteTransition(label = "wizardLoading") val rotation by transition.animateFloat( initialValue = 0f, @@ -947,36 +1063,216 @@ private fun WizardLoading( label = "wizardRotation", ) - Column( + Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding(vertical = 4.dp), + shape = RoundedCornerShape(26.dp), + colors = CardDefaults.cardColors(containerColor = colorScheme.surface), + border = BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.08f)), ) { - Icon( - imageVector = Icons.Rounded.Language, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + Column( modifier = Modifier - .size(34.dp) - .graphicsLayer(rotationZ = rotation), - ) - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.5.dp, - ) - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - ) + .fillMaxWidth() + .padding(vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Rounded.Language, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .size(34.dp) + .graphicsLayer(rotationZ = rotation), + ) + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.5.dp, + ) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold, + color = colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + } +} + +@Composable +private fun WizardHeroTile( + title: String, + subtitle: String, + imageVector: ImageVector, + color: Color, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .height(WIZARD_HERO_TILE_HEIGHT), + shape = RoundedCornerShape(26.dp), + colors = CardDefaults.cardColors(containerColor = color), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val glow = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.24f), + Color.White.copy(alpha = 0.08f), + Color.Transparent, + ), + center = Offset(size.width * 0.18f, size.height * 0.18f), + radius = size.width * 0.72f, + ) + onDrawWithContent { + drawRect(glow) + drawContent() + } + }, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = Color.White.copy(alpha = 0.2f), + modifier = Modifier + .align(Alignment.CenterEnd) + .size(86.dp) + .offset(x = 22.dp, y = 12.dp), + ) + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(42.dp) + .background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(23.dp), + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold, + color = Color.White, + maxLines = 1, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = Color.White.copy(alpha = 0.82f), + maxLines = 2, + ) + } + } + } + } +} + +@Composable +private fun WizardModeChoiceButton( + modifier: Modifier = Modifier, + title: String, + subtitle: String, + imageVector: ImageVector, + color: Color, + enabled: Boolean, + onClick: () -> Unit, +) { + Card( + modifier = modifier + .height(WIZARD_MODE_TILE_HEIGHT) + .clickable(enabled = enabled, onClick = onClick), + shape = RoundedCornerShape(26.dp), + colors = CardDefaults.cardColors( + containerColor = color.copy(alpha = if (enabled) 1f else 0.55f), + ), + elevation = CardDefaults.cardElevation(defaultElevation = if (enabled) 8.dp else 0.dp), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val glow = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.22f), + Color.White.copy(alpha = 0.08f), + Color.Transparent, + ), + center = Offset(size.width * 0.22f, size.height * 0.18f), + radius = size.maxDimension * 0.9f, + ) + val wash = Brush.linearGradient( + colors = listOf(Color.White.copy(alpha = 0.12f), Color.Transparent), + ) + onDrawWithContent { + drawRect(glow) + drawRect(wash) + drawContent() + } + }, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = Color.White.copy(alpha = 0.22f), + modifier = Modifier + .align(Alignment.BottomEnd) + .size(76.dp) + .offset(x = 18.dp, y = 16.dp), + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(13.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.ExtraBold, + color = Color.White, + maxLines = 2, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = Color.White.copy(alpha = 0.82f), + maxLines = 2, + ) + } + } } } @@ -984,20 +1280,21 @@ private fun WizardLoading( private fun WizardStepChip( modifier: Modifier, title: String, - isServerStep: Boolean, + imageVector: ImageVector, color: Color, active: Boolean, ) { val scale by animateFloatAsState( - targetValue = if (active) 1.04f else 1f, + targetValue = if (active) 1.02f else 1f, animationSpec = tween(durationMillis = 180), label = "wizardStepChipScale", ) val borderWidth by animateDpAsState( - targetValue = if (active) 2.dp else 0.dp, + targetValue = 1.dp, animationSpec = tween(durationMillis = 180), label = "wizardStepChipBorderWidth", ) + val colorScheme = MaterialTheme.colorScheme val ringColor = lerp(color, MaterialTheme.colorScheme.onSurface, 0.35f) Card( @@ -1005,24 +1302,27 @@ private fun WizardStepChip( scaleX = scale, scaleY = scale, ), - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors(containerColor = color), - elevation = CardDefaults.cardElevation(defaultElevation = if (active) 12.dp else 8.dp), - border = if (active) BorderStroke(borderWidth, ringColor) else null, + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = if (active) color else colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = if (active) 8.dp else 0.dp), + border = BorderStroke( + borderWidth, + if (active) ringColor.copy(alpha = 0.62f) else colorScheme.onSurface.copy(alpha = 0.08f), + ), ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - imageVector = if (isServerStep) Icons.Rounded.Language else Icons.Rounded.Person, + imageVector = imageVector, contentDescription = null, - tint = Color.White, - modifier = Modifier.size(14.dp), + tint = if (active) Color.White else colorScheme.onSurface.copy(alpha = 0.68f), + modifier = Modifier.size(13.dp), ) Text( text = title, - color = Color.White, + color = if (active) Color.White else colorScheme.onSurface.copy(alpha = 0.68f), style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.ExtraBold, + fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 6.dp), ) } @@ -1037,4 +1337,5 @@ private val WIZARD_CARD_CONTENT_PADDING = 18.dp private val WIZARD_SCREEN_EDGE_PADDING = 20.dp private val WIZARD_WIDE_LAYOUT_BREAKPOINT = 600.dp private val WIZARD_WIDE_CARD_WIDTH = 360.dp -private val WIZARD_WATERMARK_SIZE = 130.dp +private val WIZARD_HERO_TILE_HEIGHT = 78.dp +private val WIZARD_MODE_TILE_HEIGHT = 116.dp diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt index c543a796..b0ac848c 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt @@ -25,15 +25,12 @@ 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.rounded.Check import androidx.compose.material.icons.rounded.ChevronLeft import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -68,6 +65,7 @@ import com.ohmz.tday.compose.core.data.server.VersionCheckResult import com.ohmz.tday.compose.core.model.SessionUser import com.ohmz.tday.compose.core.notification.ReminderOption import com.ohmz.tday.compose.core.ui.rememberScrollCollapsingTitleScrollBehavior +import com.ohmz.tday.compose.ui.component.TdayCenteredSelectorDialog import com.ohmz.tday.compose.ui.component.TdaySegmentedSlider import com.ohmz.tday.compose.ui.theme.AppThemeMode import com.ohmz.tday.compose.ui.theme.TdayDimens @@ -75,6 +73,7 @@ import com.ohmz.tday.compose.ui.theme.TdayDimens @Composable fun SettingsScreen( user: SessionUser?, + isLocalMode: Boolean = false, selectedThemeMode: AppThemeMode, selectedReminder: ReminderOption, adminAiSummaryEnabled: Boolean?, @@ -122,9 +121,11 @@ fun SettingsScreen( .padding(horizontal = 18.dp, vertical = 2.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - SettingsProfileCard( - user = user, - ) + if (!isLocalMode) { + SettingsProfileCard( + user = user, + ) + } SettingsSectionCard { SettingsSectionTitle(title = stringResource(R.string.settings_appearance)) @@ -140,7 +141,7 @@ fun SettingsScreen( ) } - if (isAdminUser) { + if (!isLocalMode && isAdminUser) { SettingsSectionCard { SettingsSectionTitle(title = stringResource(R.string.settings_feature_toggle)) Row( @@ -208,7 +209,7 @@ fun SettingsScreen( fontWeight = FontWeight.ExtraBold, ) } - if (backendVersion != null) { + if (!isLocalMode && backendVersion != null) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -244,15 +245,17 @@ fun SettingsScreen( } } } - SettingsDivider() - SettingsListRow( - title = stringResource(R.string.action_sign_out), - value = null, - onClick = onLogout, - titleColor = colorScheme.error, - trailingTint = colorScheme.error.copy(alpha = 0.72f), - showChevron = false, - ) + if (!isLocalMode) { + SettingsDivider() + SettingsListRow( + title = stringResource(R.string.action_sign_out), + value = null, + onClick = onLogout, + titleColor = colorScheme.error, + trailingTint = colorScheme.error.copy(alpha = 0.72f), + showChevron = false, + ) + } } Spacer(modifier = Modifier.height(24.dp)) @@ -602,41 +605,38 @@ private fun ReminderSelector( } } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - ReminderOption.entries.forEach { option -> - val isSelected = option == selectedReminder - DropdownMenuItem( - text = { - Text( - text = option.label, - fontWeight = FontWeight.ExtraBold, - ) - }, - onClick = { - ViewCompat.performHapticFeedback( - view, - HapticFeedbackConstantsCompat.CLOCK_TICK, - ) - onReminderSelected(option) - expanded = false - }, - trailingIcon = if (isSelected) { - { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.secondary, - modifier = Modifier.size(18.dp), - ) - } - } else { - null - }, - ) - } + if (expanded) { + TdayCenteredSelectorDialog( + title = "Default reminder", + options = ReminderOption.entries, + optionLabel = { option -> option.label }, + optionSwatchColor = { option -> reminderSwatchColor(option) }, + isSelected = { option -> option == selectedReminder }, + onDismiss = { expanded = false }, + onOptionSelected = { option -> + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + onReminderSelected(option) + expanded = false + }, + ) } } } + +private fun reminderSwatchColor(option: ReminderOption): Color { + return when (option) { + ReminderOption.NONE -> Color(0xFFB7BCC8) + ReminderOption.AT_TIME -> Color(0xFF6EA8E1) + ReminderOption.MINUTES_5 -> Color(0xFF7088C8) + ReminderOption.MINUTES_10 -> Color(0xFF7D67B6) + ReminderOption.MINUTES_15 -> Color(0xFFC7AA63) + ReminderOption.MINUTES_30 -> Color(0xFFD39A82) + ReminderOption.HOURS_1 -> Color(0xFF8DBB73) + ReminderOption.HOURS_2 -> Color(0xFF67AAA7) + ReminderOption.DAYS_1 -> Color(0xFF9A86CF) + ReminderOption.DAYS_2 -> Color(0xFFC98299) + } +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index c7353226..af776511 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -1,5 +1,6 @@ package com.ohmz.tday.compose.feature.todos +import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing @@ -21,6 +22,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -36,6 +38,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -51,6 +54,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.DirectionsRun import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.automirrored.rounded.MenuBook +import androidx.compose.material.icons.automirrored.rounded.PlaylistAdd import androidx.compose.material.icons.rounded.AcUnit import androidx.compose.material.icons.rounded.AccountBalance import androidx.compose.material.icons.rounded.AccountBalanceWallet @@ -105,6 +109,7 @@ import androidx.compose.material.icons.rounded.Medication import androidx.compose.material.icons.rounded.Mood import androidx.compose.material.icons.rounded.MoreHoriz import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.NightsStay import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.Payments import androidx.compose.material.icons.rounded.Pets @@ -113,6 +118,7 @@ import androidx.compose.material.icons.rounded.RadioButtonUnchecked import androidx.compose.material.icons.rounded.Restaurant import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.School +import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.ShoppingBasket import androidx.compose.material.icons.rounded.ShoppingCart import androidx.compose.material.icons.rounded.SportsBaseball @@ -136,7 +142,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -151,6 +156,7 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos @@ -159,10 +165,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector @@ -176,6 +185,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource @@ -185,6 +195,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -211,6 +222,14 @@ import com.ohmz.tday.compose.core.ui.animateTaskSwipeOffsetAsState import com.ohmz.tday.compose.core.ui.rememberLazyListCollapsingTitleScrollBehavior import com.ohmz.tday.compose.core.ui.rememberTaskSwipeRevealState import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet +import com.ohmz.tday.compose.ui.component.RootFeedDock +import com.ohmz.tday.compose.ui.component.RootFeedTab +import com.ohmz.tday.compose.ui.component.TdayModalBottomSheet +import com.ohmz.tday.compose.ui.component.TdayPullToRefreshBox +import com.ohmz.tday.compose.ui.component.TdaySheetCard +import com.ohmz.tday.compose.ui.component.TdaySheetDefaults +import com.ohmz.tday.compose.ui.component.TdaySheetHeader +import com.ohmz.tday.compose.ui.component.TdaySheetSectionTitle import com.ohmz.tday.compose.ui.theme.TdayDimens import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -225,12 +244,14 @@ import java.time.format.TextStyle import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt +import androidx.compose.ui.graphics.lerp as lerpColor private val TimelineSameDateTaskSpacing = 2.dp private val TimelineDateGroupSpacing = 6.dp private val TimelineSectionTopSpacing = 6.dp private val TimelineHeaderBodySpacing = 2.dp private val TimelineCollapsedSectionSpacing = 4.dp +private val RootFeedDockCollapseThreshold = 44.dp private fun timelineTaskBottomSpacing( itemIndex: Int, @@ -261,6 +282,19 @@ fun TodoListScreen( onDelete: (todo: TodoItem) -> Unit, onUpdateListSettings: (listId: String, name: String, color: String?, iconKey: String?) -> Unit, onDeleteList: (listId: String) -> Unit, + onOpenFloaterList: (listId: String, listName: String) -> Unit = { _, _ -> }, + onOpenSettings: () -> Unit = {}, + onCreateList: (name: String, color: String?, iconKey: String?) -> Unit = { _, _, _ -> }, + rootFeedTab: RootFeedTab? = null, + onRootFeedTabSelected: ((RootFeedTab) -> Unit)? = null, + showRootFeedDock: Boolean = true, + showCreateTaskButton: Boolean = true, + pullRefreshEnabled: Boolean = true, + usesRootFeedHeader: Boolean = false, + createTaskRequestKey: Int = 0, + scrollToTopRequestKey: Int = 0, + onRootDockCollapsedChange: (Boolean) -> Unit = {}, + onRootControlsVisibleChange: (Boolean) -> Unit = {}, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -268,7 +302,14 @@ fun TodoListScreen( val selectedList = uiState.lists.firstOrNull { it.id == uiState.listId } val selectedListColorKey = selectedList?.color val usesTodayStyle = - uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST + uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.FLOATER || uiState.mode == TodoListMode.LIST + val isRootFloaterScreen = + uiState.mode == TodoListMode.FLOATER && uiState.listId.isNullOrBlank() + val isListDetailScreen = + uiState.mode == TodoListMode.LIST || + (uiState.mode == TodoListMode.FLOATER && !uiState.listId.isNullOrBlank()) + val usesRootFeedChrome = + usesRootFeedHeader || isRootFloaterScreen val titleColor = modeAccentColor( mode = uiState.mode, listColorKey = selectedListColorKey, @@ -282,7 +323,7 @@ fun TodoListScreen( listIconKey = selectedList?.iconKey, ) val showSectionedTimeline = - uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST + uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.FLOATER || uiState.mode == TodoListMode.LIST val suppressInitialTodayTimeline = uiState.mode == TodoListMode.TODAY && !uiState.hasHydratedSnapshot && @@ -295,6 +336,22 @@ fun TodoListScreen( items = uiState.items, ) } + val floaterListRows = remember(uiState.mode, uiState.listId, uiState.items, uiState.lists) { + if (uiState.mode == TodoListMode.FLOATER && uiState.listId.isNullOrBlank()) { + val floaterCountsByList = uiState.items + .asSequence() + .mapNotNull { it.listId } + .groupingBy { it } + .eachCount() + uiState.lists.mapNotNull { list -> + val count = floaterCountsByList[list.id] ?: return@mapNotNull null + list to count + } + } else { + emptyList() + } + } + val floaterListById = remember(uiState.lists) { uiState.lists.associateBy { it.id } } var timelineAnimationsReady by remember(uiState.mode, uiState.listId) { mutableStateOf(uiState.mode != TodoListMode.TODAY) } @@ -315,6 +372,26 @@ fun TodoListScreen( val timelineAnimationsEnabled = uiState.mode != TodoListMode.TODAY || timelineAnimationsReady val listState = rememberLazyListState() + val screenScope = rememberCoroutineScope() + val hasScrollableContent = + listState.canScrollForward || listState.canScrollBackward + val dockCollapseThresholdPx = with(LocalDensity.current) { + RootFeedDockCollapseThreshold.roundToPx() + } + val hasScrolledPastDockCollapseThreshold = + listState.firstVisibleItemIndex > 0 || + listState.firstVisibleItemScrollOffset > dockCollapseThresholdPx + val dockCollapsed = + hasScrollableContent && hasScrolledPastDockCollapseThreshold + LaunchedEffect(dockCollapsed) { + onRootDockCollapsedChange(dockCollapsed) + } + LaunchedEffect(Unit) { + onRootControlsVisibleChange(true) + } + DisposableEffect(Unit) { + onDispose { onRootControlsVisibleChange(true) } + } val density = LocalDensity.current val todayTitleScrollBehavior = rememberLazyListCollapsingTitleScrollBehavior( listState = listState, @@ -327,6 +404,65 @@ fun TodoListScreen( uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST var showCreateTaskSheet by rememberSaveable { mutableStateOf(false) } + var openSwipeTaskId by rememberSaveable(uiState.mode, uiState.listId) { + mutableStateOf(null) + } + var rootFloaterSearchExpanded by rememberSaveable { mutableStateOf(false) } + var rootFloaterSearchQuery by rememberSaveable { mutableStateOf("") } + val normalizedRootFloaterSearchQuery = remember(rootFloaterSearchQuery) { + rootFloaterSearchQuery.trim().lowercase(Locale.getDefault()) + } + val rootFloaterSearchResults = remember( + isRootFloaterScreen, + normalizedRootFloaterSearchQuery, + uiState.items, + floaterListById, + ) { + if (!isRootFloaterScreen || normalizedRootFloaterSearchQuery.isBlank()) { + emptyList() + } else { + uiState.items + .asSequence() + .filter { todo -> + todo.title.lowercase(Locale.getDefault()) + .contains(normalizedRootFloaterSearchQuery) || + (todo.description?.lowercase(Locale.getDefault()) + ?.contains(normalizedRootFloaterSearchQuery) == true) || + (todo.listId?.let { floaterListById[it]?.name } + ?.lowercase(Locale.getDefault()) + ?.contains(normalizedRootFloaterSearchQuery) == true) + } + .sortedWith( + compareByDescending { it.pinned } + .thenBy { floaterPriorityRank(it.priority) } + .thenBy { it.title.lowercase(Locale.getDefault()) }, + ) + .take(20) + .toList() + } + } + val showRootFloaterSearchResults = + isRootFloaterScreen && rootFloaterSearchExpanded && rootFloaterSearchQuery.isNotBlank() + val closeRootFloaterSearch = { + rootFloaterSearchExpanded = false + rootFloaterSearchQuery = "" + } + LaunchedEffect(scrollToTopRequestKey) { + if (scrollToTopRequestKey <= 0 || !isRootFloaterScreen) return@LaunchedEffect + closeRootFloaterSearch() + if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { + listState.animateScrollToItem(index = 0, scrollOffset = 0) + } + } + LaunchedEffect(uiState.items, openSwipeTaskId) { + val openId = openSwipeTaskId ?: return@LaunchedEffect + if (uiState.items.none { it.id == openId }) { + openSwipeTaskId = null + } + } + var lastHandledCreateTaskRequestKey by rememberSaveable { + mutableStateOf(createTaskRequestKey) + } var collapsedSectionKeys by rememberSaveable(uiState.mode, uiState.listId, highlightedTodoId) { mutableStateOf( if (isCollapsibleTimelineMode && highlightedTodoId.isNullOrBlank()) { @@ -345,7 +481,27 @@ fun TodoListScreen( val timelineDropTargetBounds = remember(uiState.mode) { mutableStateMapOf() } var pendingRescheduleDrop by remember(uiState.mode) { mutableStateOf(null) } + LaunchedEffect(createTaskRequestKey) { + if (createTaskRequestKey > lastHandledCreateTaskRequestKey) { + lastHandledCreateTaskRequestKey = createTaskRequestKey + closeRootFloaterSearch() + quickAddDueEpochMs = null + showCreateTaskSheet = true + } + } + BackHandler(enabled = rootFloaterSearchExpanded) { + closeRootFloaterSearch() + } + LaunchedEffect(isRootFloaterScreen, rootFloaterSearchExpanded) { + if (!isRootFloaterScreen) { + closeRootFloaterSearch() + onRootControlsVisibleChange(true) + } else { + onRootControlsVisibleChange(!rootFloaterSearchExpanded) + } + } var showListSettingsSheet by rememberSaveable { mutableStateOf(false) } + var showCreateListSheet by rememberSaveable { mutableStateOf(false) } var showDeleteListConfirmation by rememberSaveable { mutableStateOf(false) } var showSummarySheet by rememberSaveable(uiState.mode) { mutableStateOf(false) } var listSettingsTargetId by rememberSaveable { mutableStateOf(null) } @@ -354,6 +510,9 @@ fun TodoListScreen( var listSettingsIconKey by rememberSaveable { mutableStateOf(DEFAULT_LIST_ICON_KEY) } var listSettingsColorTouched by rememberSaveable { mutableStateOf(false) } var listSettingsIconTouched by rememberSaveable { mutableStateOf(false) } + var createListName by rememberSaveable { mutableStateOf("") } + var createListColor by rememberSaveable { mutableStateOf(DEFAULT_LIST_COLOR_KEY) } + var createListIconKey by rememberSaveable { mutableStateOf(DEFAULT_LIST_ICON_KEY) } val fabInteractionSource = remember { MutableInteractionSource() } val editTargetTodo = remember(editTargetTodoId, uiState.items) { editTargetTodoId?.let { targetId -> uiState.items.firstOrNull { it.id == targetId } } @@ -363,12 +522,14 @@ fun TodoListScreen( uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } } } - val requestTaskReschedule: (TodoItem, LocalDate) -> Unit = { todo, targetDate -> + val requestTaskReschedule: (TodoItem, LocalDate) -> Unit = + requestTaskReschedule@{ todo, targetDate -> draggedScheduledTodoId = null activeDropSectionKey = null activeTimelineDrag = null timelineDropTargetBounds.clear() - val currentDate = LocalDate.ofInstant(todo.due, zoneId) + val currentDue = todo.due ?: return@requestTaskReschedule + val currentDate = LocalDate.ofInstant(currentDue, zoneId) if (currentDate != targetDate) { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) if (todo.isRecurring) { @@ -381,8 +542,9 @@ fun TodoListScreen( val canSummarizeCurrentMode = uiState.mode != TodoListMode.LIST && uiState.mode != TodoListMode.OVERDUE && + uiState.mode != TodoListMode.FLOATER && uiState.aiSummaryEnabled - val showTopBarActionButton = canSummarizeCurrentMode || uiState.mode == TodoListMode.LIST + val showTopBarActionButton = canSummarizeCurrentMode || isListDetailScreen val fabPressed by fabInteractionSource.collectIsPressedAsState() val fabScale by animateFloatAsState( targetValue = if (fabPressed) 0.93f else 1f, @@ -409,6 +571,20 @@ fun TodoListScreen( } return null } + fun rootFloaterTodoListTarget(todoId: String): Pair? { + var itemIndex = 1 // Root Floater header row. + timelineSections.forEach { section -> + val todoIndex = section.items.indexOfFirst { item -> + item.id == todoId || item.canonicalId == todoId + } + if (todoIndex >= 0) { + val todo = section.items[todoIndex] + return itemIndex + todoIndex to "timeline-todo-${section.key}-${todo.id}" + } + itemIndex += section.items.size + } + return null + } LaunchedEffect(showSummarySheet, canSummarizeCurrentMode) { if (showSummarySheet && canSummarizeCurrentMode) { onSummarize() @@ -439,6 +615,30 @@ fun TodoListScreen( } } } + fun openRootFloaterSearchResult(todo: TodoItem) { + closeRootFloaterSearch() + val target = rootFloaterTodoListTarget(todo.id) ?: return + screenScope.launch { + delay(SEARCH_RESULT_NAV_SETTLE_DELAY_MS) + val viewportHeight = + listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset + val estimatedRowHeight = + with(density) { SEARCH_RESULT_ESTIMATED_ROW_HEIGHT_DP.dp.toPx().toInt() } + val centeredScrollOffset = + -((viewportHeight - estimatedRowHeight).coerceAtLeast(0) / 2) + listState.animateSearchResultScrollToItem( + targetIndex = target.first, + targetKey = target.second, + centeredScrollOffset = centeredScrollOffset, + estimatedItemSizePx = estimatedRowHeight, + ) + flashTodoId = todo.id + delay(2300) + if (flashTodoId == todo.id || flashTodoId == todo.canonicalId) { + flashTodoId = null + } + } + } LaunchedEffect(uiState.mode) { if (uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST) { collapsedSectionKeys = collapsedSectionKeys + "earlier" @@ -467,7 +667,8 @@ fun TodoListScreen( fun canDropTodoInTimelineSection(todo: TodoItem, section: TodoSection): Boolean { val targetDate = section.targetDate ?: return false if (originSectionKeyFor(todo) == section.key) return false - return LocalDate.ofInstant(todo.due, zoneId) != targetDate + val due = todo.due ?: return false + return LocalDate.ofInstant(due, zoneId) != targetDate } fun timelineDropSectionKeyAt(position: Offset, todo: TodoItem): String? { @@ -511,75 +712,82 @@ fun TodoListScreen( Scaffold( containerColor = colorScheme.background, topBar = { - if (usesTodayStyle) { - TodayTopBar( - onBack = onBack, - collapseProgress = todayTitleScrollBehavior.collapseProgress, - title = uiState.title, - titleColor = titleColor, - showActionButton = showTopBarActionButton, - actionIcon = if (canSummarizeCurrentMode) { - Icons.Rounded.AutoAwesome - } else { - Icons.Rounded.MoreHoriz - }, - actionContentDescription = if (canSummarizeCurrentMode) { - stringResource(R.string.todos_summarize) - } else { - stringResource(R.string.action_more_options) - }, - onAction = { - if (canSummarizeCurrentMode) { - showSummarySheet = true - } else if (selectedList != null) { - listSettingsTargetId = selectedList.id - listSettingsName = selectedList.name - listSettingsColor = normalizedListColorKey(selectedList.color) - listSettingsIconKey = selectedList.iconKey - ?.takeIf { isSupportedListIconKey(it) } - ?: DEFAULT_LIST_ICON_KEY - listSettingsColorTouched = false - listSettingsIconTouched = false - showListSettingsSheet = true - } - }, - ) - } else { - TopAppBar( - title = { - Text( - text = uiState.title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.ExtraBold, - color = titleColor, - ) - }, - navigationIcon = { - TodayHeaderButton( - onClick = onBack, - icon = Icons.Rounded.ChevronLeft, - contentDescription = stringResource(R.string.action_back), - isBackButton = true, - ) - }, - ) + when { + usesRootFeedChrome -> Unit + usesTodayStyle -> { + TodayTopBar( + onBack = onBack, + collapseProgress = todayTitleScrollBehavior.collapseProgress, + title = uiState.title, + titleColor = titleColor, + showActionButton = showTopBarActionButton, + actionIcon = if (canSummarizeCurrentMode) { + Icons.Rounded.AutoAwesome + } else { + Icons.Rounded.MoreHoriz + }, + actionContentDescription = if (canSummarizeCurrentMode) { + stringResource(R.string.todos_summarize) + } else { + stringResource(R.string.action_more_options) + }, + onAction = { + if (canSummarizeCurrentMode) { + showSummarySheet = true + } else if (selectedList != null) { + listSettingsTargetId = selectedList.id + listSettingsName = selectedList.name + listSettingsColor = normalizedListColorKey(selectedList.color) + listSettingsIconKey = selectedList.iconKey + ?.takeIf { isSupportedListIconKey(it) } + ?: DEFAULT_LIST_ICON_KEY + listSettingsColorTouched = false + listSettingsIconTouched = false + showListSettingsSheet = true + } + }, + ) + } + + else -> { + TopAppBar( + title = { + Text( + text = uiState.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold, + color = titleColor, + ) + }, + navigationIcon = { + TodayHeaderButton( + onClick = onBack, + icon = Icons.Rounded.ChevronLeft, + contentDescription = stringResource(R.string.action_back), + isBackButton = true, + ) + }, + ) + } } }, floatingActionButton = { - CreateTaskButton( - modifier = Modifier - .offset(y = fabOffsetY) - .graphicsLayer { - scaleX = fabScale - scaleY = fabScale + if (showCreateTaskButton) { + CreateTaskButton( + modifier = Modifier + .offset(y = fabOffsetY) + .graphicsLayer { + scaleX = fabScale + scaleY = fabScale + }, + interactionSource = fabInteractionSource, + backgroundColor = fabColor, + onClick = { + quickAddDueEpochMs = null + showCreateTaskSheet = true }, - interactionSource = fabInteractionSource, - backgroundColor = fabColor, - onClick = { - quickAddDueEpochMs = null - showCreateTaskSheet = true - }, - ) + ) + } }, ) { padding -> Box( @@ -589,7 +797,10 @@ fun TodoListScreen( timelineDragContainerOrigin = coordinates.positionInRoot() }, ) { - Box( + TdayPullToRefreshBox( + isRefreshing = uiState.isLoading, + onRefresh = onRefresh, + enabled = pullRefreshEnabled, modifier = Modifier .fillMaxSize() .padding(padding), @@ -605,15 +816,57 @@ fun TodoListScreen( }, ), state = listState, - contentPadding = if (usesTodayStyle) { - PaddingValues(horizontal = 18.dp, vertical = 2.dp) - } else { - PaddingValues(horizontal = 16.dp, vertical = 12.dp) + contentPadding = when { + usesRootFeedChrome -> PaddingValues(18.dp) + usesTodayStyle -> PaddingValues(horizontal = 18.dp, vertical = 2.dp) + else -> PaddingValues(horizontal = 16.dp, vertical = 12.dp) }, verticalArrangement = Arrangement.spacedBy( if (showSectionedTimeline) 0.dp else timelineItemSpacing, ), ) { + if (usesRootFeedChrome) { + item( + key = "root-feed-title", + contentType = "root-feed-title", + ) { + if (isRootFloaterScreen) { + RootFeedSearchHeaderRow( + title = uiState.title, + searchExpanded = rootFloaterSearchExpanded, + searchQuery = rootFloaterSearchQuery, + onSearchQueryChange = { rootFloaterSearchQuery = it }, + onSearchExpandedChange = { rootFloaterSearchExpanded = it }, + onSearchClose = closeRootFloaterSearch, + onCreateList = { + closeRootFloaterSearch() + showCreateListSheet = true + }, + onOpenSettings = { + closeRootFloaterSearch() + onOpenSettings() + }, + ) + } else { + RootFeedTitleRow(title = uiState.title) + } + } + } + + if (showRootFloaterSearchResults) { + item( + key = "root-floater-search-results", + contentType = "root-floater-search-results", + ) { + RootFloaterSearchResultsCard( + results = rootFloaterSearchResults, + listsById = floaterListById, + onOpenTodo = ::openRootFloaterSearchResult, + modifier = Modifier.padding(bottom = 10.dp), + ) + } + } + if (!showSectionedTimeline && uiState.items.isEmpty() && uiState.isLoading) { item { Card( @@ -653,62 +906,64 @@ fun TodoListScreen( canDropTodoInTimelineSection(todo, section) } == true - item( - key = "timeline-header-${section.key}", - contentType = "timeline-header", - ) { - var headerModifier: Modifier = Modifier - if (timelineAnimationsEnabled) { - headerModifier = headerModifier.animateItem( - fadeInSpec = null, - placementSpec = tween( - durationMillis = 320, - easing = FastOutSlowInEasing, - ), - fadeOutSpec = null, - ) - } - TimelineSectionHeader( - modifier = headerModifier - .fillMaxWidth() - .heightIn(min = 1.dp) - .timelineInAppDropTarget( - targetId = "header-${section.key}", - section = section, - enabled = isDropEligibleSection, - dropTargets = timelineDropTargetBounds, + if (!usesRootFeedChrome) { + item( + key = "timeline-header-${section.key}", + contentType = "timeline-header", + ) { + var headerModifier: Modifier = Modifier + if (timelineAnimationsEnabled) { + headerModifier = headerModifier.animateItem( + fadeInSpec = null, + placementSpec = tween( + durationMillis = 320, + easing = FastOutSlowInEasing, + ), + fadeOutSpec = null, ) - .padding(top = if (sectionIndex == 0) 0.dp else TimelineSectionTopSpacing), - section = section, - useMinimalStyle = usesTodayStyle, - isCollapsed = isCollapsed, - isDropTarget = isActiveDropSection && isDropEligibleSection, - bottomSpacing = if (isCollapsed) { - TimelineCollapsedSectionSpacing - } else { - timelineHeaderBodySpacing - }, - onHeaderClick = if (sectionCanCollapse) { - { - collapsedSectionKeys = - if (isCollapsed) { - collapsedSectionKeys - section.key - } else { - collapsedSectionKeys + section.key - } - } - } else { - null - }, - onTapForQuickAdd = section.quickAddDefaults - ?.takeUnless { sectionModeCanCollapse } - ?.let { dueEpochMs -> + } + TimelineSectionHeader( + modifier = headerModifier + .fillMaxWidth() + .heightIn(min = 1.dp) + .timelineInAppDropTarget( + targetId = "header-${section.key}", + section = section, + enabled = isDropEligibleSection, + dropTargets = timelineDropTargetBounds, + ) + .padding(top = if (sectionIndex == 0) 0.dp else TimelineSectionTopSpacing), + section = section, + useMinimalStyle = usesTodayStyle, + isCollapsed = isCollapsed, + isDropTarget = isActiveDropSection && isDropEligibleSection, + bottomSpacing = if (isCollapsed) { + TimelineCollapsedSectionSpacing + } else { + timelineHeaderBodySpacing + }, + onHeaderClick = if (sectionCanCollapse) { { - quickAddDueEpochMs = dueEpochMs - showCreateTaskSheet = true + collapsedSectionKeys = + if (isCollapsed) { + collapsedSectionKeys - section.key + } else { + collapsedSectionKeys + section.key + } } + } else { + null }, - ) + onTapForQuickAdd = section.quickAddDefaults + ?.takeUnless { sectionModeCanCollapse } + ?.let { dueEpochMs -> + { + quickAddDueEpochMs = dueEpochMs + showCreateTaskSheet = true + } + }, + ) + } } if (canRescheduleTasks && isActiveDropSection && isDropEligibleSection && section.targetDate != null) { @@ -814,6 +1069,8 @@ fun TodoListScreen( editTargetTodoId = todo.id }, draggedTodo = sectionDraggedTodo, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = { openSwipeTaskId = it }, onDragTodoStart = if (canRescheduleTasks) { { position -> activeDropSectionKey = null @@ -867,6 +1124,36 @@ fun TodoListScreen( } } + if (floaterListRows.isNotEmpty()) { + item( + key = "floater-my-lists-header", + contentType = "floater-list-header", + ) { + FloaterMyListsHeader( + modifier = Modifier.padding(top = 4.dp, bottom = 10.dp), + ) + } + items( + items = floaterListRows, + key = { (list, _) -> "floater-list-${list.id}" }, + contentType = { "floater-list-row" }, + ) { (list, count) -> + FloaterListRow( + modifier = Modifier.padding(bottom = 10.dp), + name = list.name, + colorKey = list.color, + iconKey = list.iconKey, + count = count, + onClick = { + onOpenFloaterList( + list.id, + capitalizeFirstListLetter(list.name), + ) + }, + ) + } + } + uiState.errorMessage?.let { message -> item { com.ohmz.tday.compose.core.ui.ErrorRetryCard( @@ -890,6 +1177,26 @@ fun TodoListScreen( ) } + if (showRootFeedDock && rootFeedTab != null && onRootFeedTabSelected != null) { + RootFeedDock( + activeTab = rootFeedTab, + collapsed = dockCollapsed, + onTabSelected = { tab -> + if (tab == rootFeedTab && isRootFloaterScreen) { + screenScope.launch { + closeRootFloaterSearch() + listState.animateScrollToItem(index = 0, scrollOffset = 0) + } + } else { + onRootFeedTabSelected(tab) + } + }, + modifier = Modifier + .align(Alignment.BottomStart) + .zIndex(8f), + ) + } + activeTimelineDrag?.let { drag -> TimelineTaskDragPreview( modifier = Modifier @@ -912,10 +1219,12 @@ fun TodoListScreen( if (showCreateTaskSheet) { CreateTaskBottomSheet( lists = uiState.lists, - defaultListId = if (uiState.mode == TodoListMode.LIST) uiState.listId else null, + defaultListId = if (uiState.mode == TodoListMode.LIST || uiState.mode == TodoListMode.FLOATER) uiState.listId else null, defaultPriority = if (uiState.mode == TodoListMode.PRIORITY) "Medium" else null, + defaultScheduled = uiState.mode != TodoListMode.FLOATER, + showScheduleControls = uiState.mode != TodoListMode.FLOATER, initialDueEpochMs = quickAddDueEpochMs, - onParseTaskTitleNlp = onParseTaskTitleNlp, + onParseTaskTitleNlp = if (uiState.mode == TodoListMode.FLOATER) null else onParseTaskTitleNlp, onDismiss = { showCreateTaskSheet = false quickAddDueEpochMs = null @@ -1009,7 +1318,8 @@ fun TodoListScreen( CreateTaskBottomSheet( lists = uiState.lists, editingTask = todo, - onParseTaskTitleNlp = onParseTaskTitleNlp, + showScheduleControls = uiState.mode != TodoListMode.FLOATER, + onParseTaskTitleNlp = if (uiState.mode == TodoListMode.FLOATER) null else onParseTaskTitleNlp, onDismiss = { editTargetTodoId = null }, onCreateTask = { _ -> }, onUpdateTask = { target, payload -> @@ -1019,13 +1329,39 @@ fun TodoListScreen( ) } + if (showCreateListSheet && isRootFloaterScreen) { + ListSettingsBottomSheet( + title = stringResource(R.string.home_new_list), + listName = createListName, + onListNameChange = { createListName = capitalizeFirstListLetter(it) }, + listColor = createListColor, + onListColorChange = { createListColor = it }, + listIconKey = createListIconKey, + onListIconChange = { createListIconKey = it }, + showDelete = false, + onDismiss = { showCreateListSheet = false }, + onSave = { + val normalizedName = capitalizeFirstListLetter(createListName).trim() + if (normalizedName.isNotBlank()) { + onCreateList(normalizedName, createListColor, createListIconKey) + createListName = "" + createListColor = DEFAULT_LIST_COLOR_KEY + createListIconKey = DEFAULT_LIST_ICON_KEY + showCreateListSheet = false + } + }, + onDelete = {}, + ) + } + val selectedListId = listSettingsTargetId ?: uiState.listId if ( showListSettingsSheet && - uiState.mode == TodoListMode.LIST && + isListDetailScreen && !selectedListId.isNullOrBlank() ) { ListSettingsBottomSheet( + title = stringResource(R.string.todos_list_settings), listName = listSettingsName, onListNameChange = { listSettingsName = capitalizeFirstListLetter(it) }, listColor = listSettingsColor, @@ -1062,7 +1398,7 @@ fun TodoListScreen( val deleteConfirmationListId = selectedListId if ( showDeleteListConfirmation && - uiState.mode == TodoListMode.LIST && + isListDetailScreen && !deleteConfirmationListId.isNullOrBlank() ) { ListDeleteConfirmationDialog( @@ -1083,17 +1419,8 @@ private fun ListDeleteConfirmationDialog( ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current - val isDarkTheme = colorScheme.background.luminance() < 0.5f - val dialogContainerColor = if (isDarkTheme) { - colorScheme.surface.copy(alpha = 0.98f) - } else { - colorScheme.surface - } - val scrimColor = if (isDarkTheme) { - Color.Black.copy(alpha = 0.68f) - } else { - Color.Black.copy(alpha = 0.36f) - } + val dialogContainerColor = TdaySheetDefaults.surfaceColor() + val scrimColor = TdaySheetDefaults.scrimColor() Dialog( onDismissRequest = onDismissRequest, @@ -1120,7 +1447,8 @@ private fun ListDeleteConfirmationDialog( indication = null, onClick = {}, ), - shape = RoundedCornerShape(30.dp), + shape = TdaySheetDefaults.OverlayShape, + border = BorderStroke(1.dp, TdaySheetDefaults.cardStrokeColor()), colors = CardDefaults.cardColors(containerColor = dialogContainerColor), elevation = CardDefaults.cardElevation(defaultElevation = 18.dp), ) { @@ -1153,25 +1481,322 @@ private fun ListDeleteConfirmationDialog( ) { TextButton(onClick = onDismissRequest) { Text( - text = stringResource(R.string.action_cancel), - color = colorScheme.primary, + text = stringResource(R.string.action_cancel), + color = colorScheme.primary, + fontWeight = FontWeight.ExtraBold, + ) + } + Spacer(Modifier.size(10.dp)) + TextButton( + onClick = { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + onConfirm() + }, + ) { + Text( + text = stringResource(R.string.action_delete), + color = colorScheme.error, + fontWeight = FontWeight.ExtraBold, + ) + } + } + } + } + } + } +} + +@Composable +private fun RootFeedTitleRow( + title: String, +) { + val colorScheme = MaterialTheme.colorScheme + val isDaytime = rememberTodoRootIsDaytime() + val titleIcon = if (isDaytime) Icons.Rounded.WbSunny else Icons.Rounded.NightsStay + val titleIconTint = if (isDaytime) Color(0xFFF4C542) else Color(0xFFA8B8E8) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(start = 2.dp), + contentAlignment = Alignment.CenterStart, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = titleIcon, + contentDescription = null, + tint = titleIconTint, + modifier = Modifier.size(26.dp), + ) + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.ExtraBold, + color = colorScheme.onBackground, + maxLines = 1, + ) + } + } +} + +@Composable +private fun RootFeedSearchHeaderRow( + title: String, + searchExpanded: Boolean, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onSearchExpandedChange: (Boolean) -> Unit, + onSearchClose: () -> Unit, + onCreateList: () -> Unit, + onOpenSettings: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + val isDaytime = rememberTodoRootIsDaytime() + val titleIcon = if (isDaytime) Icons.Rounded.WbSunny else Icons.Rounded.NightsStay + val titleIconTint = if (isDaytime) Color(0xFFF4C542) else Color(0xFFA8B8E8) + + LaunchedEffect(searchExpanded) { + if (searchExpanded) { + delay(300) + focusRequester.requestFocus() + keyboardController?.show() + } else { + focusManager.clearFocus(force = true) + keyboardController?.hide() + } + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + ) { + val buttonSize = 56.dp + val buttonGap = 8.dp + val expandedSearchWidth = maxWidth.coerceAtLeast(buttonSize) + val collapsedSearchOffset = -((buttonSize * 2) + (buttonGap * 2)) + val animatedSearchWidth by animateDpAsState( + targetValue = if (searchExpanded) expandedSearchWidth else buttonSize, + label = "rootFeedSearchHeaderWidth", + ) + val animatedSearchOffset by animateDpAsState( + targetValue = if (searchExpanded) 0.dp else collapsedSearchOffset, + label = "rootFeedSearchHeaderOffset", + ) + val actionsAlpha by animateFloatAsState( + targetValue = if (searchExpanded) 0f else 1f, + label = "rootFeedSearchHeaderActionsAlpha", + ) + val searchContentAlpha by animateFloatAsState( + targetValue = if (searchExpanded) 1f else 0f, + label = "rootFeedSearchHeaderContentAlpha", + ) + + Row( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 2.dp) + .graphicsLayer { alpha = actionsAlpha }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = titleIcon, + contentDescription = null, + tint = titleIconTint, + modifier = Modifier.size(26.dp), + ) + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.ExtraBold, + color = colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .graphicsLayer { alpha = actionsAlpha }, + horizontalArrangement = Arrangement.spacedBy(buttonGap), + verticalAlignment = Alignment.CenterVertically, + ) { + TodayHeaderButton( + onClick = onCreateList, + icon = Icons.AutoMirrored.Rounded.PlaylistAdd, + contentDescription = stringResource(R.string.action_create_list), + ) + TodayHeaderButton( + onClick = onOpenSettings, + icon = Icons.Rounded.MoreHoriz, + contentDescription = stringResource(R.string.action_more), + ) + } + + Card( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = animatedSearchOffset) + .width(animatedSearchWidth) + .height(buttonSize) + .zIndex(2f), + shape = CircleShape, + border = BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.38f)), + colors = CardDefaults.cardColors(containerColor = colorScheme.background), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = true), + ) { + if (!searchExpanded) onSearchExpandedChange(true) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = stringResource(R.string.action_search), + tint = colorScheme.onSurface, + modifier = Modifier + .size(30.dp) + .graphicsLayer { alpha = if (searchExpanded) 0f else 1f }, + ) + + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 14.dp) + .graphicsLayer { alpha = searchContentAlpha }, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + BasicTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + enabled = searchExpanded, + singleLine = true, + textStyle = MaterialTheme.typography.titleMedium.copy( + color = colorScheme.onSurface, + fontWeight = FontWeight.ExtraBold, + ), + cursorBrush = SolidColor(colorScheme.primary), + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (searchQuery.isBlank()) { + Text( + text = stringResource(R.string.home_search_placeholder), + style = MaterialTheme.typography.titleMedium, + color = colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } + innerTextField() + } + }, + ) + IconButton(onClick = onSearchClose) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.action_close), + tint = colorScheme.onSurfaceVariant.copy(alpha = 0.78f), + ) + } + } + } + } + } +} + +@Composable +private fun RootFloaterSearchResultsCard( + results: List, + listsById: Map, + onOpenTodo: (TodoItem) -> Unit, + modifier: Modifier = Modifier, +) { + val colorScheme = MaterialTheme.colorScheme + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + border = BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.2f)), + colors = CardDefaults.cardColors(containerColor = colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + if (results.isEmpty()) { + Text( + text = stringResource(R.string.home_search_no_results), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + items( + items = results, + key = { todo -> todo.id }, + ) { todo -> + val listMeta = todo.listId?.let { listsById[it] } + Row( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {} + .heightIn(min = 48.dp) + .clickable { onOpenTodo(todo) } + .padding(horizontal = 12.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = listIconForKey(listMeta?.iconKey), + contentDescription = null, + tint = listAccentColor(listMeta?.color).copy(alpha = 0.92f), + modifier = Modifier.size(17.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = todo.title, + style = MaterialTheme.typography.titleSmall, + color = colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.ExtraBold, ) - } - Spacer(Modifier.size(10.dp)) - TextButton( - onClick = { - ViewCompat.performHapticFeedback( - view, - HapticFeedbackConstantsCompat.CLOCK_TICK, - ) - onConfirm() - }, - ) { Text( - text = stringResource(R.string.action_delete), - color = colorScheme.error, - fontWeight = FontWeight.ExtraBold, + text = listMeta?.name ?: todo.priority, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -1181,6 +1806,137 @@ private fun ListDeleteConfirmationDialog( } } +@Composable +private fun FloaterMyListsHeader( + modifier: Modifier = Modifier, +) { + Text( + text = "My Lists", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.ExtraBold, + modifier = modifier, + ) +} + +@Composable +private fun FloaterListRow( + modifier: Modifier = Modifier, + name: String, + colorKey: String?, + iconKey: String?, + count: Int, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val view = LocalView.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val animatedScale by animateFloatAsState( + targetValue = if (isPressed) 0.98f else 1f, + label = "floaterListRowScale", + ) + val animatedOffsetY by animateDpAsState( + targetValue = if (isPressed) 2.dp else 0.dp, + label = "floaterListRowOffsetY", + ) + val animatedElevation by animateDpAsState( + targetValue = if (isPressed) 2.dp else 8.dp, + label = "floaterListRowElevation", + ) + val accent = listAccentColor(colorKey) + val icon = listIconForKey(iconKey) + val containerColor = + lerpColor(colorScheme.surfaceVariant, accent, FLOATER_LIST_CONTAINER_COLOR_WEIGHT) + val displayName = capitalizeFirstListLetter(name) + + Card( + modifier = modifier + .fillMaxWidth() + .height(70.dp) + .semantics(mergeDescendants = true) {} + .offset(y = animatedOffsetY) + .graphicsLayer { + scaleX = animatedScale + scaleY = animatedScale + }, + onClick = { + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + onClick() + }, + interactionSource = interactionSource, + shape = RoundedCornerShape(26.dp), + colors = CardDefaults.cardColors(containerColor = containerColor), + elevation = CardDefaults.cardElevation( + defaultElevation = animatedElevation, + pressedElevation = animatedElevation, + ), + ) { + Box(modifier = Modifier.fillMaxSize()) { + Icon( + imageVector = icon, + contentDescription = null, + tint = lerpColor(containerColor, Color.White, 0.34f).copy(alpha = 0.42f), + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = 14.dp, y = 8.dp) + .size(82.dp), + ) + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + Text( + text = displayName, + style = MaterialTheme.typography.titleLarge, + color = Color.White, + fontWeight = FontWeight.ExtraBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp), + ) + } + Text( + text = count.toString(), + color = Color.White, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + } +} + +@Composable +private fun rememberTodoRootIsDaytime(): Boolean { + var hour by remember { mutableStateOf(LocalTime.now().hour) } + + LaunchedEffect(Unit) { + while (true) { + val now = LocalTime.now() + val millisToNextMinute = ((60 - now.second) * 1000L) - (now.nano / 1_000_000L) + delay(millisToNextMinute.coerceAtLeast(500L)) + hour = LocalTime.now().hour + } + } + + return hour in 6 until 18 +} + @Composable private fun TodayTopBar( onBack: () -> Unit, @@ -1339,22 +2095,10 @@ private fun SummaryBottomSheet( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val colorScheme = MaterialTheme.colorScheme - val isDarkTheme = colorScheme.background.luminance() < 0.5f - val sheetContainerColor = if (isDarkTheme) colorScheme.surface else colorScheme.background - val sheetScrimColor = if (isDarkTheme) { - Color.Black.copy(alpha = 0.68f) - } else { - Color.Black.copy(alpha = 0.40f) - } - ModalBottomSheet( + TdayModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - dragHandle = null, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), - containerColor = sheetContainerColor, - tonalElevation = if (isDarkTheme) 10.dp else 0.dp, - scrimColor = sheetScrimColor, ) { Column( modifier = Modifier @@ -1363,25 +2107,13 @@ private fun SummaryBottomSheet( .padding(horizontal = 18.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.todos_summary_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.ExtraBold, - color = colorScheme.onBackground, - ) - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource(R.string.todos_summary_close), - tint = colorScheme.onBackground, - ) - } - } + TdaySheetHeader( + title = stringResource(R.string.todos_summary_title), + leftIcon = Icons.Rounded.Close, + leftContentDescription = stringResource(R.string.todos_summary_close), + onLeftClick = onDismiss, + showConfirmAction = false, + ) if (isLoading) { Row( @@ -1402,11 +2134,8 @@ private fun SummaryBottomSheet( } if (!summaryText.isNullOrBlank()) { - Card( + TdaySheetCard( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), ) { Text( modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp), @@ -1469,12 +2198,14 @@ private fun CreateTaskButton( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ListSettingsBottomSheet( + title: String, listName: String, onListNameChange: (String) -> Unit, listColor: String, onListColorChange: (String) -> Unit, listIconKey: String, onListIconChange: (String) -> Unit, + showDelete: Boolean = true, onDismiss: () -> Unit, onSave: () -> Unit, onDelete: () -> Unit, @@ -1486,22 +2217,10 @@ private fun ListSettingsBottomSheet( val selectedAccent = listAccentColor(listColor) val selectedIcon = listIconForKey(listIconKey) val canSave = listName.isNotBlank() - val isDarkTheme = colorScheme.background.luminance() < 0.5f - val sheetContainerColor = if (isDarkTheme) colorScheme.surface else colorScheme.background - val sheetScrimColor = if (isDarkTheme) { - Color.Black.copy(alpha = 0.68f) - } else { - Color.Black.copy(alpha = 0.40f) - } - ModalBottomSheet( + TdayModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - dragHandle = null, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), - containerColor = sheetContainerColor, - tonalElevation = if (isDarkTheme) 10.dp else 0.dp, - scrimColor = sheetScrimColor, ) { Box( modifier = Modifier @@ -1515,54 +2234,26 @@ private fun ListSettingsBottomSheet( .padding(horizontal = 18.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - ListSettingsActionButton( - icon = Icons.Rounded.Close, - contentDescription = stringResource(R.string.action_close), - enabled = true, - accentColor = Color(0xFFE35A5A), - onClick = { - focusManager.clearFocus(force = true) - onDismiss() - }, - ) - - Text( - text = stringResource(R.string.todos_list_settings), - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onBackground, - fontWeight = FontWeight.ExtraBold, - ) - - ListSettingsActionButton( - icon = Icons.Rounded.Check, - contentDescription = stringResource(R.string.todos_save_list_settings), - enabled = canSave, - accentColor = Color(0xFF2FA35B), - onClick = { - focusManager.clearFocus(force = true) - if (canSave) onSave() - }, - ) - } + TdaySheetHeader( + title = title, + leftIcon = Icons.Rounded.Close, + leftContentDescription = stringResource(R.string.action_close), + onLeftClick = { + focusManager.clearFocus(force = true) + onDismiss() + }, + confirmContentDescription = stringResource(R.string.todos_save_list_settings), + onConfirm = { + focusManager.clearFocus(force = true) + if (canSave) onSave() + }, + confirmEnabled = canSave, + ) - Text( + TdaySheetSectionTitle( text = stringResource(R.string.home_section_list), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.ExtraBold, - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { + TdaySheetCard { Column( modifier = Modifier .fillMaxWidth() @@ -1623,7 +2314,8 @@ private fun ListSettingsBottomSheet( modifier = Modifier .fillMaxWidth() .background( - colorScheme.surfaceVariant, RoundedCornerShape(16.dp) + TdaySheetDefaults.controlSurfaceColor(), + RoundedCornerShape(16.dp) ) .padding(horizontal = 14.dp, vertical = 12.dp), contentAlignment = Alignment.Center, @@ -1649,19 +2341,10 @@ private fun ListSettingsBottomSheet( } } - Text( + TdaySheetSectionTitle( text = stringResource(R.string.home_section_color), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.ExtraBold, - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { + TdaySheetCard { Row( modifier = Modifier .fillMaxWidth() @@ -1703,19 +2386,10 @@ private fun ListSettingsBottomSheet( } } - Text( + TdaySheetSectionTitle( text = stringResource(R.string.home_section_icon), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.ExtraBold, - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { + TdaySheetCard { Row( modifier = Modifier .fillMaxWidth() @@ -1734,7 +2408,7 @@ private fun ListSettingsBottomSheet( color = if (selected) { selectedAccent.copy(alpha = 0.2f) } else { - colorScheme.surfaceVariant + TdaySheetDefaults.controlSurfaceColor() }, shape = CircleShape, ) @@ -1769,7 +2443,9 @@ private fun ListSettingsBottomSheet( } Spacer(Modifier.height(2.dp)) - ListSettingsDeleteButton(onClick = onDelete) + if (showDelete) { + ListSettingsDeleteButton(onClick = onDelete) + } } } } @@ -1802,7 +2478,11 @@ private fun ListSettingsDeleteButton( interactionSource = interactionSource, shape = RoundedCornerShape(24.dp), border = BorderStroke(1.5.dp, colorScheme.error.copy(alpha = 0.45f)), - colors = CardDefaults.cardColors(containerColor = colorScheme.errorContainer.copy(alpha = 0.22f)), + colors = CardDefaults.cardColors( + containerColor = colorScheme.error.copy( + alpha = if (TdaySheetDefaults.isDarkTheme()) 0.14f else 0.04f, + ), + ), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp, pressedElevation = 0.dp), ) { Row( @@ -1827,78 +2507,6 @@ private fun ListSettingsDeleteButton( } } -@Composable -private fun ListSettingsActionButton( - icon: ImageVector, - contentDescription: String, - enabled: Boolean, - accentColor: Color, - onClick: () -> Unit, -) { - val view = LocalView.current - val colorScheme = MaterialTheme.colorScheme - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (pressed && enabled) 0.93f else 1f, - label = "listSettingsHeaderButtonScale", - ) - val elevation by animateDpAsState( - targetValue = when { - pressed && enabled -> 2.dp - enabled -> 8.dp - else -> 5.dp - }, - label = "listSettingsHeaderButtonElevation", - ) - val offsetY by animateDpAsState( - targetValue = if (pressed && enabled) 1.dp else 0.dp, - label = "listSettingsHeaderButtonOffsetY", - ) - val iconTint = colorScheme.onBackground.copy(alpha = if (enabled) 1f else 0.55f) - - Card( - modifier = Modifier - .size(54.dp) - .offset(y = offsetY) - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .border( - width = 1.5.dp, - color = accentColor.copy(alpha = if (enabled) 0.55f else 0.3f), - shape = CircleShape, - ), - onClick = { - if (enabled) { - ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) - } - onClick() - }, - enabled = enabled, - interactionSource = interactionSource, - shape = CircleShape, - colors = CardDefaults.cardColors(containerColor = colorScheme.surfaceVariant), - elevation = CardDefaults.cardElevation( - defaultElevation = elevation, - pressedElevation = elevation, - ), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = iconTint, - modifier = Modifier.size(22.dp), - ) - } - } -} - @Composable private fun TimelineSectionHeader( modifier: Modifier = Modifier, @@ -2088,13 +2696,15 @@ private fun TimelineTaskDragPreview( color = colorScheme.onSurface, maxLines = 1, ) - Text( - text = TODO_DUE_TIME_FORMATTER.format(todo.due), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = colorScheme.onSurfaceVariant, - maxLines = 1, - ) + todo.due?.let(TODO_DUE_TIME_FORMATTER::format)?.let { dueText -> + Text( + text = dueText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } } if (showListIndicator) { Icon( @@ -2130,6 +2740,8 @@ private fun TimelineTaskRow( onDelete: () -> Unit, onInfo: () -> Unit, draggedTodo: TodoItem? = null, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, onDragTodoStart: ((Offset) -> Unit)? = null, onDragTodoMove: (Offset) -> Unit = {}, onDragTodoEnd: (Offset?) -> Unit = {}, @@ -2155,6 +2767,8 @@ private fun TimelineTaskRow( onDragMove = onDragTodoMove, onDragEnd = onDragTodoEnd, onDragCancel = onDragTodoCancel, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = onOpenSwipeTaskIdChange, ) } else if ( useMinimalStyle && @@ -2163,6 +2777,7 @@ private fun TimelineTaskRow( mode == TodoListMode.OVERDUE || mode == TodoListMode.SCHEDULED || mode == TodoListMode.PRIORITY || + mode == TodoListMode.FLOATER || mode == TodoListMode.LIST ) ) { @@ -2183,6 +2798,8 @@ private fun TimelineTaskRow( onDragMove = onDragTodoMove, onDragEnd = onDragTodoEnd, onDragCancel = onDragTodoCancel, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = onOpenSwipeTaskIdChange, ) } else if (useMinimalStyle) { TodayTodoRow( @@ -2266,7 +2883,9 @@ private fun shouldShowDateDivider( val currentTodo = section.items.getOrNull(afterItemIndex) ?: return false val nextTodoInSection = section.items.getOrNull(afterItemIndex + 1) if (nextTodoInSection != null) { - return !currentTodo.due.isSameLocalDayAs(nextTodoInSection.due, zoneId) + val currentDue = currentTodo.due ?: return false + val nextDue = nextTodoInSection.due ?: return false + return !currentDue.isSameLocalDayAs(nextDue, zoneId) } val nextVisibleTodo = sections @@ -2277,7 +2896,9 @@ private fun shouldShowDateDivider( .firstOrNull() ?: return false - return !currentTodo.due.isSameLocalDayAs(nextVisibleTodo.due, zoneId) + val currentDue = currentTodo.due ?: return false + val nextDue = nextVisibleTodo.due ?: return false + return !currentDue.isSameLocalDayAs(nextDue, zoneId) } private fun Instant.isSameLocalDayAs(other: Instant, zoneId: ZoneId): Boolean = @@ -2318,6 +2939,8 @@ private fun buildTimelineSections( includeEmptyEarlierTarget = includeEmptyEarlierTarget, ) + TodoListMode.FLOATER -> buildFloaterSections(items) + TodoListMode.LIST -> buildScheduledSections( items = items, zoneId = zoneId, @@ -2335,13 +2958,14 @@ private fun buildOverdueSections( val now = Instant.now() val today = LocalDate.now(zoneId) val overdueByDate = items.asSequence() - .filter { todo -> todo.due.isBefore(now) } - .groupBy { todo -> LocalDate.ofInstant(todo.due, zoneId) } + .mapNotNull { todo -> todo.due?.let { due -> due to todo } } + .filter { (due, _) -> due.isBefore(now) } + .groupBy({ (due, _) -> LocalDate.ofInstant(due, zoneId) }, { (_, todo) -> todo }) val sections = mutableListOf() overdueByDate[today] - ?.sortedBy { it.due } + ?.sortedBy { it.due ?: Instant.MAX } ?.takeIf { it.isNotEmpty() } ?.let { todaysItems -> sections += TodoSection( @@ -2363,7 +2987,7 @@ private fun buildOverdueSections( sections += TodoSection( key = "day-$date", title = date.format(SCHEDULED_DAY_FORMATTER), - items = overdueByDate[date].orEmpty().sortedBy { it.due }, + items = overdueByDate[date].orEmpty().sortedBy { it.due ?: Instant.MAX }, quickAddDefaults = null, ) } @@ -2375,12 +2999,12 @@ private fun buildTodaySections( items: List, zoneId: ZoneId, ): List { - val sorted = items.sortedBy { it.due } + val sorted = items.filter { it.due != null }.sortedBy { it.due ?: Instant.MAX } val noon = LocalTime.NOON val eveningStartBoundary = LocalTime.of(18, 0) fun sectionOf(todo: TodoItem): TodaySectionSlot { - val dueTime = todo.due.atZone(zoneId).toLocalTime() + val dueTime = todo.due?.atZone(zoneId)?.toLocalTime() ?: LocalTime.NOON return when { // Requested boundaries: // Morning: 12:01 AM -> 12:00 PM (inclusive of 12:00 PM) @@ -2423,6 +3047,53 @@ private fun buildTodaySections( ) } +private fun buildFloaterSections(items: List): List { + val floaterItems = items + .sortedWith( + compareByDescending { it.pinned } + .thenBy { it.title.lowercase(Locale.getDefault()) }, + ) + + return listOfNotNull( + floaterItems.filter { it.priority.equals("High", ignoreCase = true) } + .takeIf { it.isNotEmpty() } + ?.let { + TodoSection( + key = "floater-high", + title = "High", + items = it, + ) + }, + floaterItems.filter { it.priority.equals("Medium", ignoreCase = true) } + .takeIf { it.isNotEmpty() } + ?.let { + TodoSection( + key = "floater-medium", + title = "Medium", + items = it, + ) + }, + floaterItems.filterNot { + it.priority.equals("High", ignoreCase = true) || + it.priority.equals("Medium", ignoreCase = true) + }.takeIf { it.isNotEmpty() }?.let { + TodoSection( + key = "floater-low", + title = "Low", + items = it, + ) + }, + ) +} + +private fun floaterPriorityRank(priority: String): Int { + return when { + priority.equals("High", ignoreCase = true) -> 0 + priority.equals("Medium", ignoreCase = true) -> 1 + else -> 2 + } +} + private fun buildScheduledSections( items: List, zoneId: ZoneId, @@ -2431,11 +3102,13 @@ private fun buildScheduledSections( includeEmptyEarlierTarget: Boolean = false, ): List { val now = Instant.now() - val sorted = items.asSequence().filter { todo -> - if (futureOnly) !todo.due.isBefore(now) else true - }.sortedBy { it.due }.toList() + val sorted = items.asSequence().mapNotNull { todo -> + todo.due?.let { due -> due to todo } + }.filter { (due, _) -> + if (futureOnly) !due.isBefore(now) else true + }.sortedBy { (due, _) -> due }.map { (_, todo) -> todo }.toList() val groupedByDate = sorted.groupBy { todo -> - LocalDate.ofInstant(todo.due, zoneId) + LocalDate.ofInstant(todo.due ?: Instant.MAX, zoneId) } val today = LocalDate.now(zoneId) val horizonStart = today.plusDays(7) @@ -2471,7 +3144,8 @@ private fun buildScheduledSections( val earlierSection = if (!futureOnly) { val earlierItems = groupedByDate.asSequence().filter { (date, _) -> date < today } - .flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due }.toList() + .flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due ?: Instant.MAX } + .toList() if (earlierItems.isNotEmpty() || includeEmptyEarlierTarget) { TodoSection( key = "earlier", @@ -2509,7 +3183,7 @@ private fun buildScheduledSections( val restOfCurrentMonthItems = groupedByDate.asSequence().filter { (date, _) -> date >= horizonStart && YearMonth.from(date) == currentMonth - }.flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due }.toList() + }.flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due ?: Instant.MAX }.toList() val monthName = currentMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) sections += TodoSection( key = "rest-$currentMonth", @@ -2535,7 +3209,8 @@ private fun buildScheduledSections( while (targetMonth <= finalMonth) { val monthItems = groupedByDate.asSequence().filter { (date, _) -> date >= horizonStart && YearMonth.from(date) == targetMonth - }.flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due }.toList() + }.flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due ?: Instant.MAX } + .toList() sections += TodoSection( key = "month-$targetMonth", title = monthTitle(targetMonth, currentMonth.year), @@ -2599,6 +3274,7 @@ private fun emptyStateMessageForMode(mode: TodoListMode): String { TodoListMode.TODAY -> stringResource(R.string.todos_empty_today) TodoListMode.OVERDUE -> stringResource(R.string.todos_empty_overdue) TodoListMode.PRIORITY -> stringResource(R.string.todos_empty_priority) + TodoListMode.FLOATER -> "No floater tasks" TodoListMode.SCHEDULED -> stringResource(R.string.todos_empty_scheduled) TodoListMode.ALL -> stringResource(R.string.todos_empty_all) TodoListMode.LIST -> stringResource(R.string.todos_empty_list) @@ -2613,6 +3289,7 @@ private fun emptyStateIconForMode( TodoListMode.TODAY -> Icons.Rounded.WbSunny TodoListMode.OVERDUE -> Icons.Rounded.ErrorOutline TodoListMode.PRIORITY -> Icons.Rounded.Flag + TodoListMode.FLOATER -> Icons.Rounded.Inventory TodoListMode.SCHEDULED -> Icons.Rounded.Schedule TodoListMode.ALL -> Icons.Rounded.Inbox TodoListMode.LIST -> listIconForKey(listIconKey) @@ -2755,6 +3432,8 @@ private fun AllTaskSwipeRow( onDragMove: (Offset) -> Unit = {}, onDragEnd: (Offset?) -> Unit = {}, onDragCancel: () -> Unit = {}, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, ) { SwipeTaskRow( todo = todo, @@ -2776,6 +3455,8 @@ private fun AllTaskSwipeRow( onDragMove = onDragMove, onDragEnd = onDragEnd, onDragCancel = onDragCancel, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = onOpenSwipeTaskIdChange, ) } @@ -2797,6 +3478,8 @@ private fun TodayTaskSwipeRow( onDragMove: (Offset) -> Unit = {}, onDragEnd: (Offset?) -> Unit = {}, onDragCancel: () -> Unit = {}, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, ) { SwipeTaskRow( todo = todo, @@ -2818,6 +3501,8 @@ private fun TodayTaskSwipeRow( onDragMove = onDragMove, onDragEnd = onDragEnd, onDragCancel = onDragCancel, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = onOpenSwipeTaskIdChange, ) } @@ -2844,6 +3529,8 @@ private fun SwipeTaskRow( onDragMove: (Offset) -> Unit = {}, onDragEnd: (Offset?) -> Unit = {}, onDragCancel: () -> Unit = {}, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -2856,6 +3543,19 @@ private fun SwipeTaskRow( var titleLayoutResult by remember(todo.id) { mutableStateOf(null) } var rowOriginInRoot by remember(todo.id) { mutableStateOf(Offset.Zero) } var dragPointerPosition by remember(todo.id) { mutableStateOf(null) } + val latestOpenSwipeTaskId = rememberUpdatedState(openSwipeTaskId) + fun claimSwipeSlot() { + if (latestOpenSwipeTaskId.value != todo.id) { + onOpenSwipeTaskIdChange(todo.id) + } + } + + fun closeSwipeSlot() { + swipeRevealState.close() + if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } + } val highlightAnim = remember(todo.id) { Animatable(0f) } val visuallyChecked = localChecked || (keepCompletedInline && todo.completed) val visuallyStruck = localStruck || (keepCompletedInline && todo.completed) @@ -2885,16 +3585,22 @@ private fun SwipeTaskRow( animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), label = "swipeTaskTitleStrikeProgress", ) - val dueTimeText = TODO_DUE_TIME_FORMATTER.format(todo.due) - val dueDateTimeText = TODO_DUE_DATE_TIME_FORMATTER.format(todo.due) - val isOverdue = !todo.completed && todo.due.isBefore(Instant.now()) - val dueBodyText = if (showDueDateInSubtitle) dueDateTimeText else dueTimeText - val dueSubtitleText = if (isOverdue) { - stringResource(R.string.todos_due_overdue_text, dueBodyText) - } else if (showDuePrefix) { - stringResource(R.string.todos_due_text, dueBodyText) - } else { - dueBodyText + val isOverdue = !todo.completed && todo.due?.isBefore(Instant.now()) == true + val dueBodyText = todo.due?.let { + if (showDueDateInSubtitle) { + TODO_DUE_DATE_TIME_FORMATTER.format(it) + } else { + TODO_DUE_TIME_FORMATTER.format(it) + } + } + val dueSubtitleText = dueBodyText?.let { text -> + if (isOverdue) { + stringResource(R.string.todos_due_overdue_text, text) + } else if (showDuePrefix) { + stringResource(R.string.todos_due_text, text) + } else { + text + } } val rowShape = RoundedCornerShape(16.dp) val foregroundColor = colorScheme.background @@ -2924,6 +3630,7 @@ private fun SwipeTaskRow( TodoListMode.OVERDUE, TodoListMode.SCHEDULED, TodoListMode.PRIORITY, + TodoListMode.FLOATER, TodoListMode.ALL, -> listMeta != null @@ -2933,9 +3640,14 @@ private fun SwipeTaskRow( val priorityIcon = priorityIconFor(todo.priority) val showPriorityIcon = priorityIcon != null val listIndicatorColor = listAccentColor(listMeta?.color) + LaunchedEffect(openSwipeTaskId, todo.id) { + if (openSwipeTaskId != null && openSwipeTaskId != todo.id && swipeRevealState.isOpenOrDragging) { + swipeRevealState.close() + } + } LaunchedEffect(flashHighlight) { if (!flashHighlight) return@LaunchedEffect - swipeRevealState.close() + closeSwipeSlot() highlightAnim.stop() highlightAnim.snapTo(0f) repeat(2) { pulseIndex -> @@ -2987,8 +3699,8 @@ private fun SwipeTaskRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) + closeSwipeSlot() onInfo() - swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -3004,8 +3716,8 @@ private fun SwipeTaskRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) + closeSwipeSlot() onDelete() - swipeRevealState.close() }, ) } @@ -3022,7 +3734,7 @@ private fun SwipeTaskRow( Modifier.pointerInput(todo.id, dragEnabled) { detectDragGesturesAfterLongPress( onDragStart = { localOffset -> - swipeRevealState.close() + closeSwipeSlot() val startPosition = rowOriginInRoot + localOffset dragPointerPosition = startPosition onDragStart?.invoke(startPosition) @@ -3056,10 +3768,21 @@ private fun SwipeTaskRow( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> + if (delta < 0f || swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } swipeRevealState.dragBy(delta) + if (!swipeRevealState.isOpenOrDragging && latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, onDragStopped = { velocity -> swipeRevealState.settle(velocity) + if (swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } else if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, ) .clickable( @@ -3067,10 +3790,14 @@ private fun SwipeTaskRow( indication = null, ) { if (swipeRevealState.isOpenOrDragging) { - swipeRevealState.close() + closeSwipeSlot() } else if (!swipeRevealState.isHinting && !pendingCompletion && !dragging) { + claimSwipeSlot() coroutineScope.launch { swipeRevealState.playHint() + if (latestOpenSwipeTaskId.value == todo.id && !swipeRevealState.isOpenOrDragging) { + onOpenSwipeTaskIdChange(null) + } } } }, @@ -3120,7 +3847,7 @@ private fun SwipeTaskRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) - swipeRevealState.close() + closeSwipeSlot() localChecked = true pendingCompletion = true coroutineScope.launch { @@ -3169,7 +3896,7 @@ private fun SwipeTaskRow( maxLines = 2, onTextLayout = { titleLayoutResult = it }, ) - if (showDueText) { + if (showDueText && dueSubtitleText != null) { Text( text = dueSubtitleText, color = if (isOverdue) colorScheme.error else colorScheme.onSurfaceVariant.copy(alpha = 0.8f), @@ -3223,12 +3950,14 @@ private fun TodayTodoRow( onDelete: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val dueText = TODO_DUE_TIME_FORMATTER.format(todo.due) - val isDetailOverdue = !todo.completed && todo.due.isBefore(Instant.now()) - val detailDueText = if (isDetailOverdue) { - stringResource(R.string.todos_due_overdue_text, dueText) - } else { - dueText + val isDetailOverdue = !todo.completed && todo.due?.isBefore(Instant.now()) == true + val detailDueText = todo.due?.let { due -> + val dueText = TODO_DUE_TIME_FORMATTER.format(due) + if (isDetailOverdue) { + stringResource(R.string.todos_due_overdue_text, dueText) + } else { + dueText + } } Column( @@ -3265,11 +3994,15 @@ private fun TodayTodoRow( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.ExtraBold, ) - Text( - text = detailDueText, - color = if (isDetailOverdue) colorScheme.error else colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - style = MaterialTheme.typography.bodySmall, - ) + detailDueText?.let { text -> + Text( + text = text, + color = if (isDetailOverdue) colorScheme.error else colorScheme.onSurfaceVariant.copy( + alpha = 0.8f + ), + style = MaterialTheme.typography.bodySmall, + ) + } } } @@ -3297,7 +4030,7 @@ private fun TodoRow( onDelete: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val due = TODO_DUE_DATE_TIME_FORMATTER.format(todo.due) + val due = todo.due?.let(TODO_DUE_DATE_TIME_FORMATTER::format) Card( colors = CardDefaults.cardColors(containerColor = colorScheme.surfaceVariant), @@ -3331,11 +4064,13 @@ private fun TodoRow( style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.ExtraBold, ) - Text( - text = due, - color = colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) + due?.let { text -> + Text( + text = text, + color = colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } } } @@ -3387,16 +4122,16 @@ private fun CircularCheckToggleIcon( @Composable private fun priorityColor(priority: String): Color { return when (priority.lowercase()) { - "high", "urgent", "important" -> Color(0xFFE56A6A) - "medium" -> Color(0xFFE3B368) - else -> Color(0xFF6FBF86) + "high", "urgent", "important" -> Color(0xFFFF3B30) + "medium" -> Color(0xFFFF9500) + else -> Color(0xFF007AFF) } } private fun priorityIconFor(priority: String): ImageVector? { return when (priority.trim().lowercase(Locale.getDefault())) { "medium" -> Icons.Rounded.Flag - "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + "high", "urgent", "important" -> Icons.Rounded.Flag else -> null } } @@ -3421,6 +4156,10 @@ private fun modeAccentColor( TodoListMode.SCHEDULED -> Color(0xFFF29F38) TodoListMode.ALL -> Color(0xFF5E6878) TodoListMode.PRIORITY -> Color(0xFFE65E52) + TodoListMode.FLOATER -> listColorKey + ?.takeIf { it.isNotBlank() } + ?.let(::listAccentColor) + ?: Color(0xFF4D8F83) TodoListMode.LIST -> listAccentColor(listColorKey) } } @@ -3473,6 +4212,7 @@ private data class ListSettingsIconOption( private const val DEFAULT_LIST_COLOR_KEY = "PINK" private const val DEFAULT_LIST_ICON_KEY = "inbox" +private const val FLOATER_LIST_CONTAINER_COLOR_WEIGHT = 0.66f private val LIST_SETTINGS_COLOR_KEYS = listOf( "PINK", diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt index b3f6581a..fc7b0c44 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue +import com.ohmz.tday.compose.core.data.list.FloaterListRepository import com.ohmz.tday.compose.core.data.list.ListRepository import com.ohmz.tday.compose.core.data.settings.SettingsRepository import com.ohmz.tday.compose.core.data.sync.SyncManager @@ -52,6 +53,7 @@ data class TodoListUiState( class TodoListViewModel @Inject constructor( private val todoRepository: TodoRepository, private val listRepository: ListRepository, + private val floaterListRepository: FloaterListRepository, private val settingsRepository: SettingsRepository, private val syncManager: SyncManager, private val cacheManager: OfflineCacheManager, @@ -97,6 +99,7 @@ class TodoListViewModel @Inject constructor( TodoListMode.SCHEDULED -> "Scheduled" TodoListMode.ALL -> "All Tasks" TodoListMode.PRIORITY -> "Priority" + TodoListMode.FLOATER -> listName ?: "Floater" TodoListMode.LIST -> listName ?: "List" }, aiSummaryEnabled = settingsRepository.isAiSummaryEnabledSnapshot(), @@ -119,7 +122,7 @@ class TodoListViewModel @Inject constructor( _uiState.update { it.copy(summaryError = "AI summary is disabled by admin") } return } - if (current.mode == TodoListMode.LIST || current.mode == TodoListMode.OVERDUE) { + if (current.mode == TodoListMode.LIST || current.mode == TodoListMode.OVERDUE || current.mode == TodoListMode.FLOATER) { _uiState.update { it.copy(summaryError = "Summary is available only for Today, Scheduled, All, and Priority") } @@ -186,7 +189,7 @@ class TodoListViewModel @Inject constructor( private fun hydrateFromCache(mode: TodoListMode, listId: String?) { runCatching { val todos = todoRepository.fetchTodosSnapshot(mode = mode, listId = listId) - val lists = listRepository.fetchListsSnapshot() + val lists = fetchListsSnapshotForMode(mode) val aiSummaryEnabled = settingsRepository.isAiSummaryEnabledSnapshot() Triple(todos, lists, aiSummaryEnabled) }.onSuccess { (todos, lists, aiSummaryEnabled) -> @@ -232,7 +235,7 @@ class TodoListViewModel @Inject constructor( .onFailure { /* fall back to local cache */ } } val todos = todoRepository.fetchTodos(mode = mode, listId = listId) - val lists = listRepository.fetchLists() + val lists = fetchListsForMode(mode) todos to lists }.onSuccess { (todos, lists) -> _uiState.update { current -> @@ -268,16 +271,20 @@ class TodoListViewModel @Inject constructor( fun addTask(payload: CreateTaskPayload) { if (payload.title.isBlank()) return val mode = _uiState.value.mode - val listId = payload.listId + val currentListId = _uiState.value.listId viewModelScope.launch { runCatching { - todoRepository.createTodo(payload) + if (mode == TodoListMode.FLOATER) { + todoRepository.createFloater(payload) + } else { + todoRepository.createTodo(payload) + } }.onSuccess { - rescheduleReminders() + if (mode != TodoListMode.FLOATER) rescheduleReminders() runCatching { - val todos = todoRepository.fetchTodosCached(mode = mode, listId = listId) - val lists = listRepository.fetchLists() + val todos = todoRepository.fetchTodosCached(mode = mode, listId = currentListId) + val lists = fetchListsForMode(mode) todos to lists }.onSuccess { (todos, lists) -> _uiState.update { current -> @@ -303,7 +310,8 @@ class TodoListViewModel @Inject constructor( } fun moveTask(todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) { - val movedDue = movedDuePreservingTime(todo.due, targetDate) + val due = todo.due ?: return + val movedDue = movedDuePreservingTime(due, targetDate) val previousState = _uiState.value val mode = previousState.mode val currentListId = previousState.listId @@ -328,7 +336,7 @@ class TodoListViewModel @Inject constructor( rescheduleReminders() runCatching { val todos = todoRepository.fetchTodosCached(mode = mode, listId = currentListId) - val lists = listRepository.fetchLists() + val lists = fetchListsForMode(mode) todos to lists }.onSuccess { (todos, lists) -> _uiState.update { current -> @@ -360,12 +368,12 @@ class TodoListViewModel @Inject constructor( "High" -> "High" else -> "Low" } - val normalizedDue = payload.due val normalizedDescription = payload.description?.trim()?.ifBlank { null } val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } val previousState = _uiState.value val mode = previousState.mode + val normalizedDue = if (mode == TodoListMode.FLOATER) null else payload.due val currentListId = previousState.listId val updatedTodo = visibleTodo.copy( title = normalizedTitle, @@ -380,7 +388,7 @@ class TodoListViewModel @Inject constructor( val optimisticItems = current.items .map { item -> if (item.id == visibleTodo.id) updatedTodo else item } .filterNot { item -> - current.mode == TodoListMode.LIST && + (current.mode == TodoListMode.LIST || current.mode == TodoListMode.FLOATER) && !current.listId.isNullOrBlank() && item.id == visibleTodo.id && item.listId != current.listId @@ -390,22 +398,24 @@ class TodoListViewModel @Inject constructor( viewModelScope.launch { runCatching { - todoRepository.updateTodo( - todo = repositoryTodo, - payload = CreateTaskPayload( - title = normalizedTitle, - description = normalizedDescription, - priority = normalizedPriority, - due = normalizedDue, - rrule = payload.rrule, - listId = normalizedListId, - ), + val normalizedPayload = CreateTaskPayload( + title = normalizedTitle, + description = normalizedDescription, + priority = normalizedPriority, + due = normalizedDue, + rrule = if (mode == TodoListMode.FLOATER) null else payload.rrule, + listId = normalizedListId, ) + if (mode == TodoListMode.FLOATER) { + todoRepository.updateFloater(repositoryTodo, normalizedPayload) + } else { + todoRepository.updateTodo(repositoryTodo, normalizedPayload) + } }.onSuccess { - rescheduleReminders() + if (mode != TodoListMode.FLOATER) rescheduleReminders() runCatching { val todos = todoRepository.fetchTodosCached(mode = mode, listId = currentListId) - val lists = listRepository.fetchLists() + val lists = fetchListsForMode(mode) todos to lists }.onSuccess { (todos, lists) -> _uiState.update { current -> @@ -434,9 +444,13 @@ class TodoListViewModel @Inject constructor( } viewModelScope.launch { runCatching { - todoRepository.completeTodo(todo) + if (_uiState.value.mode == TodoListMode.FLOATER) { + todoRepository.completeFloater(todo) + } else { + todoRepository.completeTodo(todo) + } }.onSuccess { - rescheduleReminders() + if (_uiState.value.mode != TodoListMode.FLOATER) rescheduleReminders() refreshInternal(forceSync = false, showLoading = false) }.onFailure { error -> _uiState.update { @@ -461,13 +475,17 @@ class TodoListViewModel @Inject constructor( } viewModelScope.launch { runCatching { - todoRepository.deleteTodo(todo) + if (mode == TodoListMode.FLOATER) { + todoRepository.deleteFloater(todo) + } else { + todoRepository.deleteTodo(todo) + } }.onSuccess { onDeleted?.invoke() - rescheduleReminders() + if (mode != TodoListMode.FLOATER) rescheduleReminders() runCatching { val todos = todoRepository.fetchTodosCached(mode = mode, listId = listId) - val lists = listRepository.fetchLists() + val lists = fetchListsForMode(mode) todos to lists }.onSuccess { (todos, lists) -> _uiState.update { current -> @@ -514,7 +532,14 @@ class TodoListViewModel @Inject constructor( val previousState = currentState _uiState.update { current -> current.copy( - title = if (current.mode == TodoListMode.LIST) trimmedName else current.title, + title = if ( + current.mode == TodoListMode.LIST || + (current.mode == TodoListMode.FLOATER && !current.listId.isNullOrBlank()) + ) { + trimmedName + } else { + current.title + }, lists = current.lists.map { list -> if (list.id == resolvedListId) { list.copy( @@ -532,12 +557,21 @@ class TodoListViewModel @Inject constructor( viewModelScope.launch { runCatching { - listRepository.updateList( - listId = resolvedListId, - name = trimmedName, - color = color, - iconKey = iconKey, - ) + if (currentState.mode == TodoListMode.FLOATER) { + floaterListRepository.updateList( + listId = resolvedListId, + name = trimmedName, + color = color, + iconKey = iconKey, + ) + } else { + listRepository.updateList( + listId = resolvedListId, + name = trimmedName, + color = color, + iconKey = iconKey, + ) + } }.onSuccess { Log.d(TAG, "updateListSettings persisted listId=$resolvedListId") }.onFailure { error -> @@ -549,6 +583,39 @@ class TodoListViewModel @Inject constructor( } } + fun createList(name: String, color: String? = null, iconKey: String? = null) { + val trimmedName = capitalizeFirstListLetter(name).trim() + if (trimmedName.isBlank()) return + + val currentMode = _uiState.value.mode + viewModelScope.launch { + runCatching { + if (currentMode == TodoListMode.FLOATER) { + floaterListRepository.createList( + name = trimmedName, + color = color, + iconKey = iconKey, + ) + } else { + listRepository.createList( + name = trimmedName, + color = color, + iconKey = iconKey, + ) + } + }.onSuccess { + hydrateFromCache( + mode = _uiState.value.mode, + listId = _uiState.value.listId, + ) + }.onFailure { error -> + _uiState.update { + it.copy(errorMessage = error.userFacingMessage("Could not create list.")) + } + } + } + } + fun deleteList( listId: String, onOptimisticDelete: () -> Unit, @@ -562,21 +629,30 @@ class TodoListViewModel @Inject constructor( viewModelScope.launch { runCatching { - listRepository.deleteList( - listId = resolvedListId, - onOptimisticDelete = { - _uiState.update { current -> - current.copy( - lists = current.lists.filterNot { it.id == resolvedListId }, - items = current.items.filterNot { it.listId == resolvedListId }, - errorMessage = null, - ) - } - onOptimisticDelete() - }, - ) + val optimisticDelete = { + _uiState.update { current -> + current.copy( + lists = current.lists.filterNot { it.id == resolvedListId }, + items = current.items.filterNot { it.listId == resolvedListId }, + errorMessage = null, + ) + } + onOptimisticDelete() + } + + if (currentState.mode == TodoListMode.FLOATER) { + floaterListRepository.deleteList( + listId = resolvedListId, + onOptimisticDelete = optimisticDelete, + ) + } else { + listRepository.deleteList( + listId = resolvedListId, + onOptimisticDelete = optimisticDelete, + ) + } }.onSuccess { - rescheduleReminders() + if (currentState.mode != TodoListMode.FLOATER) rescheduleReminders() }.onFailure { error -> Log.e(TAG, "deleteList failed listId=$resolvedListId", error) _uiState.update { @@ -596,6 +672,22 @@ class TodoListViewModel @Inject constructor( } } + private suspend fun fetchListsForMode(mode: TodoListMode): List { + return if (mode == TodoListMode.FLOATER) { + floaterListRepository.fetchLists() + } else { + listRepository.fetchLists() + } + } + + private fun fetchListsSnapshotForMode(mode: TodoListMode): List { + return if (mode == TodoListMode.FLOATER) { + floaterListRepository.fetchListsSnapshot() + } else { + listRepository.fetchListsSnapshot() + } + } + private companion object { const val TAG = "TodoListViewModel" } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt index 209d6f09..efbd0d91 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt @@ -25,7 +25,6 @@ import androidx.glance.layout.Row import androidx.glance.layout.Spacer import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth -import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.size import androidx.glance.layout.width @@ -60,8 +59,8 @@ class TodayTasksWidget : GlanceAppWidget() { val dayEnd = today.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() val todayTasks = state.todos - .filter { !it.completed && it.dueEpochMs in dayStart until dayEnd } - .sortedBy { it.dueEpochMs } + .filter { task -> !task.completed && task.dueEpochMs?.let { it in dayStart until dayEnd } == true } + .sortedBy { it.dueEpochMs ?: Long.MAX_VALUE } .take(8) provideContent { @@ -142,10 +141,11 @@ private fun WidgetContent(tasks: List) { private fun TaskRow(task: CachedTodoRecord) { val timeFormatter = DateTimeFormatter.ofPattern("h:mm a") .withZone(ZoneId.systemDefault()) - val dueText = timeFormatter.format(Instant.ofEpochMilli(task.dueEpochMs)) + val dueText = task.dueEpochMs + ?.let { timeFormatter.format(Instant.ofEpochMilli(it)) } val priorityColor = when (task.priority.lowercase()) { - "high" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFE53935)) - "medium" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFFB8C00)) + "high" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFFF3B30)) + "medium" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFFF9500)) else -> GlanceTheme.colors.onSurfaceVariant } @@ -179,15 +179,17 @@ private fun TaskRow(task: CachedTodoRecord) { maxLines = 1, ) } - Spacer(modifier = GlanceModifier.width(6.dp)) - Text( - text = dueText, - style = TextStyle( - color = GlanceTheme.colors.onSurfaceVariant, - fontFamily = TdayWidgetFontFamily, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - ), - ) + dueText?.let { text -> + Spacer(modifier = GlanceModifier.width(6.dp)) + Text( + text = text, + style = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontFamily = TdayWidgetFontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ), + ) + } } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt index 37a2a896..2cfb4c02 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt @@ -1,9 +1,9 @@ package com.ohmz.tday.compose.ui.component import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -13,7 +13,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,7 +26,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -40,7 +38,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.rounded.CalendarMonth -import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.LowPriority @@ -55,6 +52,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SelectableDates import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker @@ -72,7 +71,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector @@ -80,15 +78,12 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.core.view.HapticFeedbackConstantsCompat -import androidx.core.view.ViewCompat import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.TodoItem @@ -121,8 +116,11 @@ private fun normalizePriorityValue(value: String?): String { } private const val DEFAULT_TASK_DURATION_MS = 60L * 60L * 1000L +private const val CREATE_TASK_SHEET_CREATE_HEIGHT_FRACTION = 0.74f +private const val CREATE_TASK_SHEET_FLOATER_CREATE_HEIGHT_FRACTION = 0.54f +private const val CREATE_TASK_SHEET_EDIT_HEIGHT_FRACTION = 0.76f +private const val CREATE_TASK_SHEET_FLOATER_EDIT_HEIGHT_FRACTION = 0.54f private const val CREATE_TASK_SHEET_MAX_HEIGHT_FRACTION = 0.86f -private const val CREATE_TASK_SHEET_NORMAL_HEIGHT_FRACTION = 0.70f private const val CREATE_TASK_SHEET_KEYBOARD_HEIGHT_FRACTION = 0.85f private const val CREATE_TASK_SHEET_MOTION_MS = 320 @@ -133,6 +131,8 @@ fun CreateTaskBottomSheet( editingTask: TodoItem? = null, defaultListId: String? = null, defaultPriority: String? = null, + defaultScheduled: Boolean = true, + showScheduleControls: Boolean = true, initialDueEpochMs: Long? = null, onParseTaskTitleNlp: (suspend ( title: String, @@ -180,6 +180,9 @@ fun CreateTaskBottomSheet( var dueEpochMs by rememberSaveable(editingTask?.id, initialDueEpochMs) { mutableStateOf(resolvedDueEpochMs) } + var scheduleEnabled by rememberSaveable(editingTask?.id, defaultScheduled) { + mutableStateOf(showScheduleControls && (editingTask?.due != null || (editingTask == null && defaultScheduled))) + } LaunchedEffect(title, onParseTaskTitleNlp) { val nlpParser = onParseTaskTitleNlp ?: return@LaunchedEffect val inputTitle = title.trim() @@ -199,6 +202,9 @@ fun CreateTaskBottomSheet( if (cleanTitle != title) { title = cleanTitle } + if (showScheduleControls && !scheduleEnabled) { + scheduleEnabled = true + } if (parsedDueEpochMs != dueEpochMs) { dueEpochMs = parsedDueEpochMs } @@ -206,40 +212,55 @@ fun CreateTaskBottomSheet( var selectedRepeat by rememberSaveable(editingTask?.id) { mutableStateOf(repeatPresetFromRrule(editingTask?.rrule).name) } + LaunchedEffect(scheduleEnabled) { + if (!scheduleEnabled) { + selectedRepeat = RepeatPreset.NONE.name + } + } var dueDatePickerOpen by rememberSaveable { mutableStateOf(false) } var dueTimePickerOpen by rememberSaveable { mutableStateOf(false) } var sheetVisible by remember { mutableStateOf(false) } val selectedListName = lists.firstOrNull { it.id == selectedListId }?.name ?: "No list" - val repeatPreset = RepeatPreset.valueOf(selectedRepeat) - val canSubmit = title.isNotBlank() - val colorScheme = MaterialTheme.colorScheme - val isDarkTheme = colorScheme.background.luminance() < 0.5f - val sheetContainerColor = if (isDarkTheme) { - lerp(colorScheme.background, colorScheme.surfaceVariant, 0.34f) - } else { - colorScheme.background - } - val sheetScrimColor = if (isDarkTheme) { - Color.Black.copy(alpha = 0.68f) + val repeatPreset = if (scheduleEnabled && showScheduleControls) { + RepeatPreset.valueOf(selectedRepeat) } else { - Color.Black.copy(alpha = 0.40f) + RepeatPreset.NONE } + val canSubmit = title.isNotBlank() + val colorScheme = MaterialTheme.colorScheme + val sheetContainerColor = TdaySheetDefaults.containerColor() + val sheetScrimColor = TdaySheetDefaults.scrimColor() + val sheetTonalElevation = TdaySheetDefaults.tonalElevation() val screenHeight = LocalConfiguration.current.screenHeightDp.dp val density = LocalDensity.current val keyboardVisible = WindowInsets.ime.getBottom(density) > 0 val maxSheetHeight = screenHeight * CREATE_TASK_SHEET_MAX_HEIGHT_FRACTION - val sheetHeight by animateDpAsState( - targetValue = (screenHeight * if (keyboardVisible) { - CREATE_TASK_SHEET_KEYBOARD_HEIGHT_FRACTION - } else { - CREATE_TASK_SHEET_NORMAL_HEIGHT_FRACTION - }).coerceAtMost(maxSheetHeight), + val usesTallCreateModal = !isEditMode && showScheduleControls + val usesFloaterCreateModal = !isEditMode && !showScheduleControls + val usesScheduledEditModal = isEditMode && showScheduleControls + val usesFloaterEditModal = isEditMode && !showScheduleControls + val usesScrollSizedModal = usesTallCreateModal || + usesFloaterCreateModal || + usesScheduledEditModal || + usesFloaterEditModal + val createSheetHeight = (screenHeight * CREATE_TASK_SHEET_CREATE_HEIGHT_FRACTION) + .coerceAtMost(maxSheetHeight) + val floaterCreateSheetHeight = (screenHeight * CREATE_TASK_SHEET_FLOATER_CREATE_HEIGHT_FRACTION) + .coerceAtMost(maxSheetHeight) + val editSheetHeight = (screenHeight * CREATE_TASK_SHEET_EDIT_HEIGHT_FRACTION) + .coerceAtMost(maxSheetHeight) + val floaterEditSheetHeight = (screenHeight * CREATE_TASK_SHEET_FLOATER_EDIT_HEIGHT_FRACTION) + .coerceAtMost(maxSheetHeight) + val sheetFormScrollState = rememberScrollState() + val keyboardSheetHeight by animateDpAsState( + targetValue = (screenHeight * CREATE_TASK_SHEET_KEYBOARD_HEIGHT_FRACTION) + .coerceAtMost(maxSheetHeight), animationSpec = tween( durationMillis = CREATE_TASK_SHEET_MOTION_MS, easing = FastOutSlowInEasing, ), - label = "createTaskSheetHeight", + label = "createTaskKeyboardSheetHeight", ) LaunchedEffect(Unit) { @@ -247,14 +268,15 @@ fun CreateTaskBottomSheet( } fun submitTask() { - val due = Instant.ofEpochMilli(dueEpochMs) + val due = + if (scheduleEnabled && showScheduleControls) Instant.ofEpochMilli(dueEpochMs) else null val payload = CreateTaskPayload( title = title.trim(), description = notes.trim().ifBlank { null }, priority = selectedPriority, due = due, - rrule = repeatPreset.rrule, + rrule = repeatPreset.rrule?.takeIf { scheduleEnabled && showScheduleControls }, listId = selectedListId, ) val editing = editingTask @@ -309,103 +331,186 @@ fun CreateTaskBottomSheet( Surface( modifier = Modifier .fillMaxWidth() - .height(sheetHeight) + .then( + if (keyboardVisible) { + Modifier.height(keyboardSheetHeight) + } else if (usesTallCreateModal) { + Modifier.height(createSheetHeight) + } else if (usesScheduledEditModal) { + Modifier.height(editSheetHeight) + } else if (usesFloaterCreateModal) { + Modifier + .animateContentSize( + animationSpec = tween( + durationMillis = CREATE_TASK_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + ) + .heightIn(min = floaterCreateSheetHeight, max = maxSheetHeight) + } else if (usesFloaterEditModal) { + Modifier + .animateContentSize( + animationSpec = tween( + durationMillis = CREATE_TASK_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + ) + .heightIn(min = floaterEditSheetHeight, max = maxSheetHeight) + } else { + Modifier + .animateContentSize( + animationSpec = tween( + durationMillis = CREATE_TASK_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + ) + .heightIn(max = maxSheetHeight) + }, + ) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, ) {}, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), + shape = TdaySheetDefaults.TopShape, color = sheetContainerColor, - tonalElevation = if (isDarkTheme) 10.dp else 0.dp, + tonalElevation = sheetTonalElevation, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 18.dp, top = 14.dp, end = 18.dp, bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - SheetHeader( - title = if (isEditMode) "Edit task" else "New task", - leftIcon = Icons.Rounded.Close, - leftContentDescription = "Close", - onLeftClick = { - dismissKeyboard() - onDismiss() - }, - onConfirm = { - dismissKeyboard() - if (canSubmit) { - submitTask() + TdayCenteredSheetContent { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 18.dp, top = 14.dp, end = 18.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TdaySheetHeader( + title = if (isEditMode) "Edit task" else "New task", + leftIcon = Icons.Rounded.Close, + leftContentDescription = "Close", + onLeftClick = { + dismissKeyboard() + onDismiss() + }, + confirmContentDescription = if (isEditMode) "Save task" else "Create task", + onConfirm = { + dismissKeyboard() + if (canSubmit) { + submitTask() + } + }, + confirmEnabled = canSubmit, + ) + + val formModifier = if (keyboardVisible || usesScrollSizedModal) { + Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(sheetFormScrollState) + } else { + Modifier.fillMaxWidth() } - }, - confirmEnabled = canSubmit, - ) - - TaskTextCard( - title = title, - notes = notes, - onTitleChange = { title = it }, - onNotesChange = { notes = it }, - onKeyboardDone = dismissKeyboard, - ) - SectionHeading("Schedule") - GroupCard { - SplitDateTimeRow( - icon = Icons.Rounded.CalendarMonth, - title = "Due", - dateValue = dateOnlyFormatter.format(Instant.ofEpochMilli(dueEpochMs)), - timeValue = timeOnlyFormatter.format(Instant.ofEpochMilli(dueEpochMs)), - onDateClick = { dueDatePickerOpen = true }, - onTimeClick = { dueTimePickerOpen = true }, - ) - } - - SectionHeading("Details") - GroupCard { - SheetDropdownRow( - icon = Icons.AutoMirrored.Rounded.List, - title = "List", - value = selectedListName, - options = listOf(null) + lists, - optionLabel = { option -> option?.name ?: "No list" }, - optionSwatchColor = { option -> - option?.let { - listColorSwatchForSelector( - raw = it.color, - fallback = colorScheme.primary.copy(alpha = 0.75f), + Column( + modifier = formModifier, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TaskTextCard( + title = title, + notes = notes, + onTitleChange = { title = it }, + onNotesChange = { notes = it }, + onKeyboardDone = dismissKeyboard, + ) + + if (showScheduleControls) { + SectionHeading("Schedule") + GroupCard { + ScheduleSwitchRow( + enabled = scheduleEnabled, + onEnabledChange = { enabled -> + scheduleEnabled = enabled + }, + ) + AnimatedVisibility(visible = scheduleEnabled) { + Column { + RowDivider() + SplitDateTimeRow( + icon = Icons.Rounded.CalendarMonth, + title = "Due", + dateValue = dateOnlyFormatter.format( + Instant.ofEpochMilli( + dueEpochMs + ) + ), + timeValue = timeOnlyFormatter.format( + Instant.ofEpochMilli( + dueEpochMs + ) + ), + onDateClick = { dueDatePickerOpen = true }, + onTimeClick = { dueTimePickerOpen = true }, + ) + } + } + } + } + + SectionHeading("Details") + GroupCard { + SheetDropdownRow( + icon = Icons.AutoMirrored.Rounded.List, + title = "List", + value = selectedListName, + options = listOf(null) + lists, + optionLabel = { option -> option?.name ?: "No list" }, + optionSwatchColor = { option -> + option?.let { + listColorSwatchForSelector( + raw = it.color, + fallback = colorScheme.primary.copy(alpha = 0.75f), + ) + } ?: colorScheme.outlineVariant.copy(alpha = 0.95f) + }, + isSelected = { option -> option?.id == selectedListId }, + onOptionSelected = { option -> + selectedListId = option?.id + }, ) - } ?: colorScheme.outlineVariant.copy(alpha = 0.95f) - }, - isSelected = { option -> option?.id == selectedListId }, - onOptionSelected = { option -> selectedListId = option?.id }, - ) - RowDivider() - SheetDropdownRow( - icon = Icons.Rounded.LowPriority, - title = "Priority", - value = selectedPriority, - options = listOf("Low", "Medium", "High"), - optionLabel = { option -> option }, - optionSwatchColor = { option -> prioritySwatchColor(option) }, - isSelected = { option -> selectedPriority == option }, - onOptionSelected = { option -> selectedPriority = option }, - ) - RowDivider() - SheetDropdownRow( - icon = Icons.Rounded.Repeat, - title = "Repeat", - value = repeatPreset.label, - options = RepeatPreset.entries.toList(), - optionLabel = { option -> option.label }, - optionSwatchColor = { option -> repeatSwatchColor(option) }, - isSelected = { option -> selectedRepeat == option.name }, - onOptionSelected = { option -> selectedRepeat = option.name }, - ) - } - - Spacer(modifier = Modifier.height(4.dp)) + RowDivider() + SheetDropdownRow( + icon = Icons.Rounded.LowPriority, + title = "Priority", + value = selectedPriority, + options = listOf("Low", "Medium", "High"), + optionLabel = { option -> option }, + optionSwatchColor = { option -> prioritySwatchColor(option) }, + isSelected = { option -> selectedPriority == option }, + onOptionSelected = { option -> selectedPriority = option }, + ) + if (showScheduleControls) { + RowDivider() + SheetDropdownRow( + icon = Icons.Rounded.Repeat, + title = "Repeat", + value = repeatPreset.label, + options = if (scheduleEnabled) { + RepeatPreset.entries.toList() + } else { + listOf(RepeatPreset.NONE) + }, + optionLabel = { option -> option.label }, + optionSwatchColor = { option -> repeatSwatchColor(option) }, + isSelected = { option -> selectedRepeat == option.name }, + onOptionSelected = { option -> + selectedRepeat = option.name + }, + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + } + } } } } @@ -441,153 +546,16 @@ fun CreateTaskBottomSheet( } } -@Composable -private fun SheetHeader( - title: String, - leftIcon: ImageVector, - leftContentDescription: String, - onLeftClick: () -> Unit, - onConfirm: () -> Unit, - confirmEnabled: Boolean, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CircleActionButton( - icon = leftIcon, - contentDescription = leftContentDescription, - onClick = onLeftClick, - enabled = true, - accentColor = Color(0xFFE35A5A), - ) - - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.ExtraBold, - ) - - CircleActionButton( - icon = Icons.Rounded.Check, - contentDescription = "Create task", - onClick = onConfirm, - enabled = confirmEnabled, - accentColor = Color(0xFF2FA35B), - ) - } -} - -@Composable -private fun CircleActionButton( - icon: ImageVector, - contentDescription: String, - onClick: () -> Unit, - enabled: Boolean, - accentColor: Color, -) { - val view = LocalView.current - val colorScheme = MaterialTheme.colorScheme - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (pressed && enabled) 0.93f else 1f, - label = "sheetHeaderButtonScale", - ) - val elevation by animateDpAsState( - targetValue = when { - pressed && enabled -> 2.dp - enabled -> 8.dp - else -> 5.dp - }, - label = "sheetHeaderButtonElevation", - ) - val offsetY by animateDpAsState( - targetValue = if (pressed && enabled) 1.dp else 0.dp, - label = "sheetHeaderButtonOffsetY", - ) - val containerColor = colorScheme.surfaceVariant - val iconTint = colorScheme.onBackground.copy(alpha = if (enabled) 1f else 0.55f) - val borderColor = if (enabled) { - accentColor.copy(alpha = 0.55f) - } else { - accentColor.copy(alpha = 0.3f) - } - - Card( - modifier = Modifier - .size(54.dp) - .offset(y = offsetY) - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .border( - width = 1.5.dp, - color = borderColor, - shape = RoundedCornerShape(999.dp), - ), - shape = RoundedCornerShape(999.dp), - enabled = enabled, - onClick = { - if (enabled) { - performGentleHaptic(view) - } - onClick() - }, - interactionSource = interactionSource, - colors = CardDefaults.cardColors(containerColor = containerColor), - elevation = CardDefaults.cardElevation( - defaultElevation = elevation, - pressedElevation = elevation, - ), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = iconTint, - modifier = Modifier.size(22.dp), - ) - } - } -} - -private fun performGentleHaptic(view: android.view.View) { - ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) -} - @Composable private fun SectionHeading(text: String) { - Text( + TdaySheetSectionTitle( text = text, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), ) } @Composable private fun GroupCard(content: @Composable ColumnScope.() -> Unit) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Column( - modifier = Modifier.fillMaxWidth(), - content = content, - ) - } + TdaySheetCard(content = content) } @Composable @@ -758,6 +726,55 @@ private fun SplitDateTimeRow( } } +@Composable +private fun ScheduleSwitchRow( + enabled: Boolean, + onEnabledChange: (Boolean) -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onEnabledChange(!enabled) } + .heightIn(min = 72.dp) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.CalendarMonth, + contentDescription = null, + tint = colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + Spacer(modifier = Modifier.size(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Schedule", + style = MaterialTheme.typography.titleMedium, + color = colorScheme.onSurface, + fontWeight = FontWeight.ExtraBold, + ) + Text( + text = if (enabled) "Task has a due date" else "Floater task", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.78f), + fontWeight = FontWeight.Bold, + ) + } + Switch( + checked = enabled, + onCheckedChange = onEnabledChange, + colors = SwitchDefaults.colors( + checkedThumbColor = colorScheme.onPrimary, + checkedTrackColor = colorScheme.primary, + uncheckedThumbColor = colorScheme.onSurfaceVariant, + uncheckedTrackColor = colorScheme.surfaceVariant, + ), + ) + } +} + @Composable private fun SheetRow( icon: ImageVector, @@ -838,7 +855,7 @@ private fun SheetDropdownRow( ) if (selectorOpen) { - CenteredSelectorDialog( + TdayCenteredSelectorDialog( title = title, options = options, optionLabel = optionLabel, @@ -854,116 +871,6 @@ private fun SheetDropdownRow( } } -@Composable -private fun CenteredSelectorDialog( - title: String, - options: List, - optionLabel: (T) -> String, - optionSwatchColor: (T) -> Color, - isSelected: (T) -> Boolean, - onDismiss: () -> Unit, - onOptionSelected: (T) -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.luminance() < 0.5f - val containerColor = if (isDark) { - lerp(colorScheme.surface, colorScheme.surfaceVariant, 0.18f) - } else { - colorScheme.surface - } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Card( - modifier = Modifier - .fillMaxWidth(0.74f) - .heightIn(max = 380.dp), - shape = RoundedCornerShape(32.dp), - colors = CardDefaults.cardColors(containerColor = containerColor), - elevation = CardDefaults.cardElevation(defaultElevation = 18.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(vertical = 10.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = colorScheme.onSurfaceVariant, - fontWeight = FontWeight.ExtraBold, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), - ) - - options.forEachIndexed { index, option -> - if (index > 0) { - RowDivider() - } - CenteredSelectorRow( - title = optionLabel(option), - swatchColor = optionSwatchColor(option), - selected = isSelected(option), - onClick = { onOptionSelected(option) }, - ) - } - } - } - } -} - -@Composable -private fun CenteredSelectorRow( - title: String, - swatchColor: Color, - selected: Boolean, - onClick: () -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 18.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(10.dp) - .background( - color = swatchColor, - shape = RoundedCornerShape(999.dp), - ), - ) - - Spacer(modifier = Modifier.width(14.dp)) - - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = colorScheme.onSurface, - fontWeight = FontWeight.ExtraBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - - if (selected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } else { - Spacer(modifier = Modifier.size(20.dp)) - } - } -} - private fun listColorSwatchForSelector(raw: String?, fallback: Color): Color { if (raw.isNullOrBlank()) return fallback return when (raw.trim().uppercase()) { @@ -989,9 +896,9 @@ private fun listColorSwatchForSelector(raw: String?, fallback: Color): Color { private fun prioritySwatchColor(priority: String): Color { return when (priority.lowercase()) { - "high" -> Color(0xFFE56A6A) - "medium" -> Color(0xFFE3B368) - else -> Color(0xFF6FBF86) + "high" -> Color(0xFFFF3B30) + "medium" -> Color(0xFFFF9500) + else -> Color(0xFF007AFF) } } @@ -1059,8 +966,8 @@ private fun ThemedDatePickerDialog( colorScheme.onSurface, if (isDark) 0.14f else 0.28f, ) - val dialogContainer = colorScheme.background - val calendarSurface = if (isDark) colorScheme.surface else Color.White + val dialogContainer = TdaySheetDefaults.containerColor() + val calendarSurface = TdaySheetDefaults.surfaceColor() val primaryText = colorScheme.onSurface val mutedText = colorScheme.onSurfaceVariant val selectedContentColor = if (pickerAccent.luminance() > 0.45f) colorScheme.surface else Color.White @@ -1131,8 +1038,8 @@ private fun ThemedTimePickerDialog( colorScheme.onSurface, if (isDark) 0.14f else 0.28f, ) - val dialogContainer = colorScheme.background - val pickerSurface = colorScheme.surface + val dialogContainer = TdaySheetDefaults.containerColor() + val pickerSurface = TdaySheetDefaults.surfaceColor() val primaryText = colorScheme.onSurface val mutedText = colorScheme.onSurfaceVariant val selectedContentColor = if (pickerAccent.luminance() > 0.45f) colorScheme.surface else Color.White @@ -1209,76 +1116,89 @@ private fun SpectrumPickerDialog( onConfirm: () -> Unit, content: @Composable () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme - val outerShape = RoundedCornerShape(34.dp) - val innerShape = RoundedCornerShape(28.dp) - Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - Card( + Box( modifier = Modifier - .fillMaxWidth(dialogWidthFraction), - shape = outerShape, - colors = CardDefaults.cardColors(containerColor = containerColor), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + .fillMaxSize() + .background(TdaySheetDefaults.scrimColor()) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, ) { - Column( - modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + Card( + modifier = Modifier + .fillMaxWidth(dialogWidthFraction) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + shape = TdaySheetDefaults.DialogShape, + colors = CardDefaults.cardColors(containerColor = containerColor), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + Column( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Icon( - imageVector = titleIcon, - contentDescription = null, - tint = mutedText, - modifier = Modifier.size(22.dp), - ) - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.ExtraBold, - color = primaryText, - ) - } - - Card( - modifier = Modifier - .fillMaxWidth(), - shape = innerShape, - colors = CardDefaults.cardColors(containerColor = panelColor), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - content() - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 2.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton(onClick = onDismiss) { - Text( - text = "Cancel", - color = mutedText, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.ExtraBold, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = titleIcon, + contentDescription = null, + tint = mutedText, + modifier = Modifier.size(22.dp), ) - } - TextButton(onClick = onConfirm) { Text( - text = "Done", - color = primaryText, - style = MaterialTheme.typography.titleMedium, + text = title, + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.ExtraBold, + color = primaryText, ) } + + Card( + modifier = Modifier + .fillMaxWidth(), + shape = TdaySheetDefaults.CardShape, + colors = CardDefaults.cardColors(containerColor = panelColor), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + content() + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { + Text( + text = "Cancel", + color = mutedText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold, + ) + } + TextButton(onClick = onConfirm) { + Text( + text = "Done", + color = primaryText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold, + ) + } + } } } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt new file mode 100644 index 00000000..c3f26fed --- /dev/null +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt @@ -0,0 +1,422 @@ +package com.ohmz.tday.compose.ui.component + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.BubbleChart +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import androidx.core.view.HapticFeedbackConstantsCompat +import androidx.core.view.ViewCompat +import com.ohmz.tday.compose.R +import com.ohmz.tday.compose.ui.theme.TdayDimens +import kotlinx.coroutines.delay + +enum class RootFeedTab { + HOME, + FLOATER, +} + +private val RootFeedTabs = listOf(RootFeedTab.HOME, RootFeedTab.FLOATER) +private val RootFeedSliderAccent = Color(0xFF7D67B6) +private val RootFeedDockHeight = 58.dp +private val RootFeedDockCollapsedWidth = RootFeedDockHeight +private val RootFeedDockInnerPadding = 5.dp +private val RootFeedDockTabWidth = 102.dp +private val RootFeedDockExpandedWidth = + (RootFeedDockTabWidth * RootFeedTabs.size) + (RootFeedDockInnerPadding * 2) +private val RootFeedDockShape = RoundedCornerShape(22.dp) +private val RootFeedDockSelectorShape = RoundedCornerShape(18.dp) + +private fun RootFeedTab.label(): String { + return when (this) { + RootFeedTab.HOME -> "Home" + RootFeedTab.FLOATER -> "Floater" + } +} + +private fun RootFeedTab.icon(): ImageVector { + return when (this) { + RootFeedTab.HOME -> Icons.Rounded.Home + RootFeedTab.FLOATER -> Icons.Rounded.BubbleChart + } +} + +@Composable +fun RootCreateTaskButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + backgroundColor: Color = Color(0xFF6EA8E1), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val view = LocalView.current + + Card( + modifier = modifier, + onClick = { + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + onClick() + }, + interactionSource = interactionSource, + shape = CircleShape, + border = BorderStroke(1.dp, backgroundColor.copy(alpha = 0.72f)), + colors = CardDefaults.cardColors(containerColor = backgroundColor), + elevation = CardDefaults.cardElevation( + defaultElevation = TdayDimens.FabElevation, + pressedElevation = TdayDimens.FabPressedElevation, + ), + ) { + Box( + modifier = Modifier.size(TdayDimens.FabSize), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(R.string.action_create_task), + tint = Color.White, + modifier = Modifier.size(40.dp), + ) + } + } +} + +@Composable +fun RootFeedDock( + activeTab: RootFeedTab, + collapsed: Boolean, + onTabSelected: (RootFeedTab) -> Unit, + modifier: Modifier = Modifier, +) { + var expandedByTap by remember { mutableStateOf(false) } + val expanded = !collapsed || expandedByTap + val view = LocalView.current + val expansionProgress by animateFloatAsState( + targetValue = if (expanded) 1f else 0f, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockExpansion", + ) + val colorScheme = MaterialTheme.colorScheme + val isDarkTheme = colorScheme.background.luminance() < 0.5f + val trackColor = colorScheme.surfaceVariant.copy(alpha = if (isDarkTheme) 0.76f else 0.68f) + val trackBorderColor = if (isDarkTheme) { + colorScheme.onSurfaceVariant.copy(alpha = 0.12f) + } else { + colorScheme.surface.copy(alpha = 0.72f) + } + val selectorContainerColor = if (isDarkTheme) { + colorScheme.background.copy(alpha = 0.9f) + } else { + colorScheme.surface.copy(alpha = 0.98f) + } + val selectorBorderColor = if (isDarkTheme) { + colorScheme.onSurfaceVariant.copy(alpha = 0.24f) + } else { + colorScheme.onSurface.copy(alpha = 0.1f) + } + val activeIndex = RootFeedTabs.indexOf(activeTab).coerceAtLeast(0) + val interactionSources = remember { + List(RootFeedTabs.size) { MutableInteractionSource() } + } + val pressedStates = interactionSources.map { source -> + source.collectIsPressedAsState() + } + val dockWidth = lerp( + RootFeedDockCollapsedWidth, + RootFeedDockExpandedWidth, + expansionProgress, + ) + + LaunchedEffect(collapsed) { + if (!collapsed) { + expandedByTap = false + } + } + LaunchedEffect(expandedByTap) { + if (expandedByTap) { + delay(2400) + expandedByTap = false + } + } + + Box( + modifier = modifier + .navigationBarsPadding() + .padding(start = 18.dp, bottom = 18.dp) + .width(dockWidth) + .height(RootFeedDockHeight) + .clip(RootFeedDockShape) + .background(trackColor, RootFeedDockShape) + .border( + width = 1.dp, + color = trackBorderColor, + shape = RootFeedDockShape, + ) + .padding(RootFeedDockInnerPadding) + .selectableGroup(), + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val tabWidthTarget = if (maxWidth < RootFeedDockTabWidth) { + maxWidth + } else { + RootFeedDockTabWidth + } + val selectorWidthTarget = tabWidthTarget + val selectorOffsetTarget = if (activeIndex == 0) { + 0.dp + } else { + maxWidth - selectorWidthTarget + } + val selectorWidth by animateDpAsState( + targetValue = selectorWidthTarget, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockSelectorWidth", + ) + val selectorOffset by animateDpAsState( + targetValue = selectorOffsetTarget, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockSelectorOffset", + ) + val activePressed = pressedStates.getOrNull(activeIndex)?.value == true + val selectorScale by animateFloatAsState( + targetValue = if (activePressed) 0.985f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockSelectorPressScale", + ) + + Box( + modifier = Modifier + .offset(x = selectorOffset) + .width(selectorWidth) + .fillMaxHeight() + .padding(2.dp) + .graphicsLayer { + scaleX = selectorScale + scaleY = selectorScale + } + .shadow( + elevation = 12.dp, + shape = RootFeedDockSelectorShape, + ambientColor = RootFeedSliderAccent.copy(alpha = 0.16f), + spotColor = Color.Black.copy(alpha = 0.14f), + ) + .clip(RootFeedDockSelectorShape) + .background(selectorContainerColor, RootFeedDockSelectorShape) + .background( + RootFeedSliderAccent.copy(alpha = if (isDarkTheme) 0.04f else 0.06f), + RootFeedDockSelectorShape, + ) + .border( + width = 1.dp, + color = selectorBorderColor, + shape = RootFeedDockSelectorShape, + ) + ) + + RootFeedTabs.forEachIndexed { index, tab -> + val selected = tab == activeTab + val interactionSource = interactionSources[index] + val tabPressed = pressedStates[index].value + val tabOffsetTarget = if (selected) { + selectorOffsetTarget + } else { + val expandedOffset = tabWidthTarget * index + val hiddenOffset = if (index < activeIndex) { + -tabWidthTarget + } else { + maxWidth + } + lerp(hiddenOffset, expandedOffset, expansionProgress) + } + val tabAlphaTarget = if (selected) { + 1f + } else { + expansionProgress + } + val tabOffset by animateDpAsState( + targetValue = tabOffsetTarget, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockTabOffset", + ) + val tabWidth by animateDpAsState( + targetValue = tabWidthTarget, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockTabWidth", + ) + val tabAlpha by animateFloatAsState( + targetValue = tabAlphaTarget, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockTabAlpha", + ) + val contentScale by animateFloatAsState( + targetValue = if (tabPressed) 0.98f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockContentPressScale", + ) + val contentColor = if (selected) { + colorScheme.onSurface + } else { + colorScheme.onSurfaceVariant.copy(alpha = 0.82f) + } + val animatedContentColor by animateColorAsState( + targetValue = contentColor, + animationSpec = tween(durationMillis = 180), + label = "rootFeedDockContentColor", + ) + + Box( + modifier = Modifier + .offset(x = tabOffset) + .width(tabWidth) + .fillMaxHeight() + .graphicsLayer { alpha = tabAlpha } + .clip(RootFeedDockSelectorShape) + .selectable( + selected = selected, + onClick = { + if (!expanded && selected) { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + expandedByTap = true + } else { + if (!selected) { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + } + onTabSelected(tab) + } + }, + role = Role.RadioButton, + interactionSource = interactionSource, + indication = null, + ), + contentAlignment = Alignment.Center, + ) { + val textAlpha = if (selected) expansionProgress else 1f + val iconAlpha = if (selected) 1f - expansionProgress else 0f + + Icon( + imageVector = tab.icon(), + contentDescription = null, + tint = animatedContentColor, + modifier = Modifier + .size(22.dp) + .graphicsLayer { + alpha = iconAlpha * tabAlpha + scaleX = contentScale * (1f - (0.08f * expansionProgress)) + scaleY = contentScale * (1f - (0.08f * expansionProgress)) + }, + ) + Text( + text = tab.label(), + style = MaterialTheme.typography.titleSmall, + fontWeight = if (selected) FontWeight.Black else FontWeight.ExtraBold, + color = animatedContentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, + modifier = Modifier.graphicsLayer { + alpha = textAlpha + scaleX = contentScale * (0.94f + (0.06f * textAlpha)) + scaleY = contentScale * (0.94f + (0.06f * textAlpha)) + }, + ) + } + } + + if (!expanded) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + expandedByTap = true + }, + ) + } + } + } +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt index 8f8f34b5..676ec5ff 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt @@ -57,8 +57,18 @@ fun TdayPullToRefreshBox( onRefresh: () -> Unit, modifier: Modifier = Modifier, contentAlignment: Alignment = Alignment.TopStart, + enabled: Boolean = true, content: @Composable BoxScope.() -> Unit, ) { + if (!enabled) { + Box( + modifier = modifier, + contentAlignment = contentAlignment, + content = content, + ) + return + } + val state = rememberPullToRefreshState() val pullProgress = state.distanceFraction.coerceIn(0f, 1f) val contentPullProgress = state.distanceFraction.coerceIn(0f, 1.25f) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySegmentedSlider.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySegmentedSlider.kt index d8bb9b84..8462d22f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySegmentedSlider.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySegmentedSlider.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalView import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat @@ -245,6 +246,9 @@ fun TdaySegmentedSlider( style = MaterialTheme.typography.titleSmall, fontWeight = if (selected) FontWeight.Black else FontWeight.ExtraBold, color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, modifier = Modifier.graphicsLayer { scaleX = contentScale scaleY = contentScale diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySheetChrome.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySheetChrome.kt new file mode 100644 index 00000000..8b3b67f8 --- /dev/null +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySheetChrome.kt @@ -0,0 +1,459 @@ +package com.ohmz.tday.compose.ui.component + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.rounded.Check +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.view.HapticFeedbackConstantsCompat +import androidx.core.view.ViewCompat +import com.ohmz.tday.compose.ui.theme.TdayDimens + +object TdaySheetDefaults { + val CloseAccent = Color(0xFFE35A5A) + val ConfirmAccent = Color(0xFF2FA35B) + val TopShape = + RoundedCornerShape(topStart = TdayDimens.RadiusSheet, topEnd = TdayDimens.RadiusSheet) + val DialogShape = RoundedCornerShape(TdayDimens.RadiusSheet) + val CardShape = RoundedCornerShape(28.dp) + val OverlayShape = RoundedCornerShape(30.dp) + val SelectorShape = RoundedCornerShape(32.dp) + val ControlShape = CircleShape + + val HorizontalPadding: Dp = TdayDimens.ContentPaddingHorizontal + val VerticalPadding: Dp = TdayDimens.ContentPaddingVertical + val SectionSpacing: Dp = TdayDimens.SpacingXl + val ActionSize: Dp = TdayDimens.FabSize + val ActionIconSize: Dp = 22.dp + val ActionBorderWidth: Dp = TdayDimens.BorderWidthThick + val MaxContentWidth: Dp = 520.dp + + @Composable + fun isDarkTheme(): Boolean = MaterialTheme.colorScheme.background.luminance() < 0.5f + + @Composable + fun containerColor(): Color { + val colorScheme = MaterialTheme.colorScheme + return if (isDarkTheme()) { + lerp(colorScheme.background, colorScheme.surfaceVariant, 0.34f) + } else { + colorScheme.background + } + } + + @Composable + fun surfaceColor(): Color { + val colorScheme = MaterialTheme.colorScheme + return if (isDarkTheme()) { + lerp(colorScheme.surface, colorScheme.surfaceVariant, 0.18f) + } else { + colorScheme.surface + } + } + + @Composable + fun controlSurfaceColor(): Color = MaterialTheme.colorScheme.surfaceVariant + + @Composable + fun scrimColor(): Color = Color.Black.copy(alpha = if (isDarkTheme()) 0.68f else 0.40f) + + @Composable + fun tonalElevation(): Dp = if (isDarkTheme()) TdayDimens.BottomSheetTonalElevationDark else 0.dp + + @Composable + fun cardStrokeColor(): Color { + return if (isDarkTheme()) { + Color.White.copy(alpha = 0.10f) + } else { + Color.White.copy(alpha = 0.45f) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TdayModalBottomSheet( + onDismissRequest: () -> Unit, + sheetState: SheetState, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + shape = TdaySheetDefaults.TopShape, + containerColor = TdaySheetDefaults.containerColor(), + tonalElevation = TdaySheetDefaults.tonalElevation(), + scrimColor = TdaySheetDefaults.scrimColor(), + modifier = modifier, + ) { + TdayCenteredSheetContent(content = content) + } +} + +@Composable +fun TdayCenteredSheetContent( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + BoxWithConstraints( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter, + ) { + val contentWidth = if (maxWidth < TdaySheetDefaults.MaxContentWidth) { + maxWidth + } else { + TdaySheetDefaults.MaxContentWidth + } + + Column( + modifier = Modifier.width(contentWidth), + content = content, + ) + } +} + +@Composable +fun TdayCenteredSelectorDialog( + title: String, + options: List, + optionLabel: (T) -> String, + optionSwatchColor: (T) -> Color, + isSelected: (T) -> Boolean, + onDismiss: () -> Unit, + onOptionSelected: (T) -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val containerColor = TdaySheetDefaults.surfaceColor() + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(TdaySheetDefaults.scrimColor()) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.74f) + .heightIn(max = 380.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + shape = TdaySheetDefaults.SelectorShape, + colors = CardDefaults.cardColors(containerColor = containerColor), + elevation = CardDefaults.cardElevation(defaultElevation = 18.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(vertical = 10.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = colorScheme.onSurfaceVariant, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), + ) + + options.forEachIndexed { index, option -> + if (index > 0) { + TdayCenteredSelectorDivider() + } + TdayCenteredSelectorRow( + title = optionLabel(option), + swatchColor = optionSwatchColor(option), + selected = isSelected(option), + onClick = { onOptionSelected(option) }, + ) + } + } + } + } + } +} + +@Composable +private fun TdayCenteredSelectorDivider() { + Box( + modifier = Modifier + .padding(horizontal = 18.dp) + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)), + ) +} + +@Composable +private fun TdayCenteredSelectorRow( + title: String, + swatchColor: Color, + selected: Boolean, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(10.dp) + .background( + color = swatchColor, + shape = RoundedCornerShape(999.dp), + ), + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = colorScheme.onSurface, + fontWeight = FontWeight.ExtraBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } else { + Spacer(modifier = Modifier.size(20.dp)) + } + } +} + +@Composable +fun TdaySheetHeader( + title: String, + leftIcon: ImageVector, + leftContentDescription: String, + onLeftClick: () -> Unit, + confirmContentDescription: String = "", + onConfirm: () -> Unit = {}, + confirmEnabled: Boolean = false, + modifier: Modifier = Modifier, + confirmIcon: ImageVector = Icons.Rounded.Check, + showConfirmAction: Boolean = true, +) { + Box( + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + TdaySheetActionButton( + icon = leftIcon, + contentDescription = leftContentDescription, + enabled = true, + accentColor = TdaySheetDefaults.CloseAccent, + onClick = onLeftClick, + ) + + Spacer(modifier = Modifier.weight(1f)) + + if (showConfirmAction) { + TdaySheetActionButton( + icon = confirmIcon, + contentDescription = confirmContentDescription, + enabled = confirmEnabled, + accentColor = TdaySheetDefaults.ConfirmAccent, + onClick = onConfirm, + ) + } else { + Spacer(modifier = Modifier.size(TdaySheetDefaults.ActionSize)) + } + } + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .padding(horizontal = TdaySheetDefaults.ActionSize + 14.dp), + ) + } +} + +@Composable +fun TdaySheetActionButton( + icon: ImageVector, + contentDescription: String, + enabled: Boolean, + accentColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val view = LocalView.current + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed && enabled) 0.93f else 1f, + label = "tdaySheetActionButtonScale", + ) + val elevation by animateDpAsState( + targetValue = when { + pressed && enabled -> 2.dp + enabled -> 8.dp + else -> 5.dp + }, + label = "tdaySheetActionButtonElevation", + ) + val offsetY by animateDpAsState( + targetValue = if (pressed && enabled) 1.dp else 0.dp, + label = "tdaySheetActionButtonOffsetY", + ) + + Card( + modifier = modifier + .size(TdaySheetDefaults.ActionSize) + .offset(y = offsetY) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .border( + width = TdaySheetDefaults.ActionBorderWidth, + color = accentColor.copy(alpha = if (enabled) 0.55f else 0.30f), + shape = TdaySheetDefaults.ControlShape, + ), + onClick = { + if (enabled) { + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + } + onClick() + }, + enabled = enabled, + interactionSource = interactionSource, + shape = TdaySheetDefaults.ControlShape, + colors = CardDefaults.cardColors(containerColor = TdaySheetDefaults.controlSurfaceColor()), + elevation = CardDefaults.cardElevation( + defaultElevation = elevation, + pressedElevation = elevation, + ), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = colorScheme.onBackground.copy(alpha = if (enabled) 1f else 0.55f), + modifier = Modifier.size(TdaySheetDefaults.ActionIconSize), + ) + } + } +} + +@Composable +fun TdaySheetSectionTitle( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(horizontal = 4.dp), + ) +} + +@Composable +fun TdaySheetCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = TdaySheetDefaults.CardShape, + colors = CardDefaults.cardColors(containerColor = TdaySheetDefaults.surfaceColor()), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + content = content, + ) + } +} diff --git a/android-compose/app/src/main/res/values/strings.xml b/android-compose/app/src/main/res/values/strings.xml index cf82050c..abbca56f 100644 --- a/android-compose/app/src/main/res/values/strings.xml +++ b/android-compose/app/src/main/res/values/strings.xml @@ -65,9 +65,20 @@ Set Up T\'Day - Secure onboarding wizard + Set up your workspace + Mode Server Login + Choose your setup + Pick where T\'Day keeps your tasks. + Self-hosted server + Use accounts, sync, and your backend. + Self-hosted + Accounts and sync + This device + No login + No login. Data stays in app storage. + Connect your T\'Day endpoint. Server URL https://app.example.com Reset trusted server @@ -75,11 +86,15 @@ Connect Connecting to server… Checking endpoint, TLS, and workspace settings + Use this device only Email Password Sign in + Open your synced workspace. Create account + Create your server account. Change server + Change setup First name Confirm password Creating account… diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt index ed1ac980..4a78cd53 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt @@ -37,7 +37,7 @@ class CacheMappersTest { assertEquals(todo.title, cached.title) assertEquals(todo.description, cached.description) assertEquals(todo.priority, cached.priority) - assertEquals(todo.due.toEpochMilli(), cached.dueEpochMs) + assertEquals(todo.due?.toEpochMilli(), cached.dueEpochMs) assertEquals(todo.rrule, cached.rrule) assertEquals(todo.instanceDateEpochMillis, cached.instanceDateEpochMs) assertEquals(todo.pinned, cached.pinned) @@ -56,7 +56,7 @@ class CacheMappersTest { assertEquals(cached.title, todo.title) assertEquals(cached.description, todo.description) assertEquals(cached.priority, todo.priority) - assertEquals(Instant.ofEpochMilli(cached.dueEpochMs), todo.due) + assertEquals(cached.dueEpochMs?.let(Instant::ofEpochMilli), todo.due) assertEquals(cached.rrule, todo.rrule) assertNotNull(todo.instanceDate) assertEquals(cached.instanceDateEpochMs, todo.instanceDateEpochMillis) @@ -133,7 +133,7 @@ class CacheMappersTest { assertEquals(item.originalTodoId, cached.originalTodoId) assertEquals(item.title, cached.title) assertEquals(item.priority, cached.priority) - assertEquals(item.due.toEpochMilli(), cached.dueEpochMs) + assertEquals(item.due?.toEpochMilli(), cached.dueEpochMs) assertEquals(item.completedAt?.toEpochMilli() ?: 0L, cached.completedAtEpochMs) assertEquals(item.listId, cached.listId) } @@ -410,6 +410,7 @@ class CacheMappersTest { priority = "High", due = dueInstant.toString(), completedAt = completedInstant.toString(), + completedOnTime = true, listID = "list-1", ) diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt index 45367c98..e4231049 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt @@ -2,6 +2,7 @@ package com.ohmz.tday.compose.feature.app import app.cash.turbine.test import com.ohmz.tday.compose.core.data.ApiCallException +import com.ohmz.tday.compose.core.data.AppDataMode import com.ohmz.tday.compose.core.data.OfflineSyncState import com.ohmz.tday.compose.core.data.ThemePreferenceStore import com.ohmz.tday.compose.core.data.auth.AuthRepository @@ -22,8 +23,10 @@ import com.ohmz.tday.compose.core.ui.SnackbarManager import com.ohmz.tday.compose.feature.auth.MainDispatcherRule import com.ohmz.tday.compose.ui.theme.AppThemeMode import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -75,6 +78,7 @@ class AppViewModelTest { every { reminderPreferenceStore.getDefaultReminder() } returns ReminderOption.DEFAULT every { appVersionManager.state } returns versionState coEvery { appVersionManager.refreshServerCompatibility() } returns Unit + every { serverConfigRepository.getAppDataMode() } returns AppDataMode.SERVER every { serverConfigRepository.hasServerConfigured() } returns true every { serverConfigRepository.getServerUrl() } returns "https://tday.example.com" every { cacheManager.loadOfflineState() } returns OfflineSyncState() @@ -92,6 +96,40 @@ class AppViewModelTest { every { reminderScheduler.cancelAll() } returns Unit } + @Test + fun `local bootstrap opens workspace without server session or sync`() = runTest { + every { serverConfigRepository.getAppDataMode() } returns AppDataMode.LOCAL + every { cacheManager.updateOfflineState(any()) } answers { + firstArg<(OfflineSyncState) -> OfflineSyncState>().invoke( + OfflineSyncState( + pendingMutations = listOf( + com.ohmz.tday.compose.core.data.PendingMutationRecord( + mutationId = "mutation-1", + kind = com.ohmz.tday.compose.core.data.MutationKind.CREATE_TODO, + targetId = "local-todo-1", + timestampEpochMs = 1L, + ), + ), + ), + ) + } + + val viewModel = makeViewModel() + runCurrent() + + assertTrue(viewModel.uiState.value.isLocalMode) + assertTrue(viewModel.uiState.value.isWorkspaceAvailable) + assertFalse(viewModel.uiState.value.authenticated) + assertFalse(viewModel.uiState.value.requiresServerSetup) + assertFalse(viewModel.uiState.value.requiresLogin) + assertEquals(0, viewModel.uiState.value.pendingMutationCount) + + coVerify(exactly = 0) { authRepository.restoreSessionForBootstrap() } + coVerify(exactly = 0) { appVersionManager.refreshServerCompatibility() } + coVerify(exactly = 0) { syncManager.syncCachedData(any(), any(), any(), any()) } + verify(exactly = 0) { realtimeClient.connect() } + } + @Test fun `foreground reconnect retries sync after restoring session`() = runTest { val restoredSession = AuthRepository.RestoredSession( diff --git a/docs/API_GUIDELINES.md b/docs/API_GUIDELINES.md index 1b71636c..c8099ba7 100644 --- a/docs/API_GUIDELINES.md +++ b/docs/API_GUIDELINES.md @@ -1,15 +1,15 @@ # API Guidelines -Conventions for the T'Day REST API served by the Ktor backend. +Conventions for the T'Day REST API served by the Ktor backend. Keep this file aligned with `shared/`, backend routes, mobile Retrofit/URLSession clients, and [`DATA_MODEL.md`](DATA_MODEL.md). ## Base URL -All API routes live under `/api/`. The web SPA consumes them via same-origin requests (Vite proxy in development, same container in production). The Android and iOS clients target them at the user-configured server URL. +All API routes live under `/api/`. The web SPA consumes them via same-origin requests (Vite proxy in development, same container in production). Android and iOS clients target them at the user-configured server URL in Server Mode. Local Mode does not call the API. ## Authentication - All routes require a valid JWE session unless listed as public. -- Public routes: `/api/auth/*` (CSRF, register, login-challenge, credentials-key, callback), `/api/mobile/probe`, `/health`. +- Public routes: `/api/auth/*` (CSRF, register, login-challenge, credentials-key, callback), `/api/mobile/probe`, `/.well-known/apple-app-site-association`, `/health`. - Authentication is enforced by a **Ktor pipeline intercept** in `Security.kt`: 1. Reads a JWE token from `Authorization: Bearer` header or session cookies. 2. Decodes and validates claims (expiry, `tokenVersion`, role, approval status). @@ -194,8 +194,25 @@ Services return `Either` (Arrow) for typed error handling. Routes f | POST | `/api/todo/nlp` | Natural language date/title parsing | | POST | `/api/todo/summary` | AI-powered task summary | +### Floaters + +Floaters are unscheduled Anytime tasks. They are not scheduled todos with a nullable due date. + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/floater` | List all active floaters | +| POST | `/api/floater` | Create a floater | +| PATCH | `/api/floater` | Update a floater | +| DELETE | `/api/floater` | Delete a floater | +| PATCH | `/api/floater/complete` | Complete a floater | +| PATCH | `/api/floater/uncomplete` | Restore a completed floater to active | +| PATCH | `/api/floater/prioritize` | Change floater priority | +| PATCH | `/api/floater/reorder` | Reorder floaters | + ### Lists +Lists group scheduled tasks. + | Method | Path | Purpose | |--------|------|---------| | GET | `/api/list` | List all lists | @@ -204,6 +221,18 @@ Services return `Either` (Arrow) for typed error handling. Routes f | DELETE | `/api/list` | Delete a list | | GET | `/api/list/{id}` | Get list with its todos | +### Floater Lists + +Floater lists group floaters. + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/floaterList` | List all floater lists | +| POST | `/api/floaterList` | Create a floater list | +| PATCH | `/api/floaterList` | Update a floater list | +| DELETE | `/api/floaterList` | Delete one or many floater lists | +| GET | `/api/floaterList/{id}` | Get floater list with its floaters | + ### User | Method | Path | Purpose | @@ -235,8 +264,16 @@ Services return `Either` (Arrow) for typed error handling. Routes f | Method | Path | Purpose | |--------|------|---------| | GET | `/api/completedTodo` | List completed todos | -| DELETE | `/api/completedTodo` | Delete all completed todos | -| PATCH | `/api/completedTodo` | Remove a single completed todo | +| DELETE | `/api/completedTodo` | Delete all completed todos, or delete one when an `id` body is supplied | +| PATCH | `/api/completedTodo` | Update a completed todo, or remove it when no update fields are supplied | + +### Completed Floaters + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/completedFloater` | List completed floaters | +| DELETE | `/api/completedFloater` | Delete all or one completed floater | +| PATCH | `/api/completedFloater` | Update or remove a completed floater | ### Timezone @@ -254,7 +291,13 @@ Services return `Either` (Arrow) for typed error handling. Routes f | Method | Path | Purpose | Auth | |--------|------|---------|------| -| GET | `/api/mobile/probe` | Server discovery | Public | +| GET | `/api/mobile/probe` | Server discovery, compatibility/version metadata, optional encrypted probe payload | Public | + +### Apple App Site Association + +| Method | Path | Purpose | Auth | +|--------|------|---------|------| +| GET | `/.well-known/apple-app-site-association` | iOS webcredentials/deep-link association | Public | ## Cache Headers @@ -264,11 +307,14 @@ Services return `Either` (Arrow) for typed error handling. Routes f ## Adding a New Endpoint -1. Add a route function in `routes/.kt` (or create a new file for a new domain). -2. Use `call.withAuth { }` for authenticated routes. -3. Validate input using Konform validators or shared model validation. -4. Delegate to a service in `services/`. -5. Filter data by `userID` for tenant isolation. -6. Use appropriate HTTP status codes. -7. Add tests in `tday-backend/src/test/kotlin/` if the endpoint involves security or complex logic. -8. Update this document with the new route. +1. Add or update shared request/response models in `shared/` when the endpoint is consumed outside the backend. +2. Add a route function in `routes/.kt` (or create a new file for a new domain). +3. Use `call.withAuth { }` for authenticated routes. +4. Validate input using Konform validators or shared model validation. +5. Delegate to a service in `services/`. +6. Filter data by `userID` for tenant isolation. +7. Use appropriate HTTP status codes. +8. Update Android Retrofit and iOS URLSession clients when mobile consumes it. +9. Update local cache/sync models if the route changes mobile persisted data. +10. Add tests in `tday-backend/src/test/kotlin/` if the endpoint involves security or complex logic. +11. Update this document with the new route. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 98085ed7..b8e63090 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,17 +1,17 @@ # Architecture -This document describes the high-level system design, domain boundaries, and key technical decisions for T'Day. +This document describes the high-level system design, domain boundaries, and key technical decisions for T'Day. Product intent lives in [`PRODUCT_DIRECTION.md`](PRODUCT_DIRECTION.md); durable data shape lives in [`DATA_MODEL.md`](DATA_MODEL.md). ## System Overview -T'Day is a **monorepo application** with a Kotlin/Ktor backend, a React SPA frontend, shared Kotlin Multiplatform code, and native mobile clients: +T'Day is a **monorepo application** with a Kotlin/Ktor backend, a React SPA frontend, shared Kotlin Multiplatform code, and native local-first mobile clients: ``` ┌─────────────────────────────────────────────────────────────┐ │ Clients │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ Web (React) │ │ Android App │ │ iOS App │ │ -│ │ Vite SPA │ │ Compose/Hilt │ │ SwiftUI │ │ +│ │ Vite SPA │ │ Compose/Room │ │ SwiftUI/Cache │ │ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────────┘ │ │ │ │ │ │ └─────────┼─────────────────┼──────────────────┼───────────────┘ @@ -41,9 +41,20 @@ T'Day is a **monorepo application** with a Kotlin/Ktor backend, a React SPA fron └─────────────────┘ └─────────────┘ ``` +### Architectural Direction + +- Web is the desktop/admin/broad-access client. +- Backend owns auth, persistence, tenant isolation, server compatibility, AI summaries, and realtime events. +- Android and iOS own the primary mobile experience and render from local cache first. +- Local Mode is a mobile-only workspace with no server dependency. +- Server Mode uses optimistic local writes plus pending mutation replay. +- Scheduled tasks and floaters are separate concepts; do not make `Todo.due` nullable to represent Anytime work. +- Boundaries should stay readable and directional: clients render state, presentation layers coordinate work, repositories/services own domain/data operations, and transport/storage details sit at the edges. +- Shared abstractions are introduced only when they reduce real duplication or clarify a cross-platform contract. + ### Shared Kotlin Multiplatform Module -The `shared/` module is a Kotlin Multiplatform (KMP) library targeting JVM, Android, and iOS. It provides a single source of truth for: +The `shared/` module is a Kotlin Multiplatform (KMP) library targeting JVM, Android, and iOS frameworks. It provides the source of truth for: - Serializable DTOs (request/response models) - Domain enums (`Priority`, `UserRole`, `ApprovalStatus`, `SortBy`, `GroupBy`, `ProjectColor`, etc.) @@ -53,7 +64,7 @@ The `shared/` module is a Kotlin Multiplatform (KMP) library targeting JVM, Andr |----------|--------|-------------------| | Backend (`tday-backend`) | JVM | Gradle `project(":shared")` | | Android (`android-compose`) | Android | Gradle `project(":shared")` | -| iOS (`ios-swiftUI`) | iOS framework (`TdayShared`) | Swift Package import | +| iOS (`ios-swiftUI`) | Swift models | Mirrored manually in `Core/Model/ApiModels.swift` and checked with contract tests | ## Domain Model @@ -62,12 +73,15 @@ The application is organized around these core domains: | Domain | Description | Models | |--------|------------|--------| | **Auth** | Registration, login, sessions, approval, admin | `User`, `Account`, `AuthThrottle`, `AuthSignal` | -| **Todos** | Task CRUD, RFC 5545 recurrence, priorities, ordering | `Todo`, `TodoInstance`, `CompletedTodo` | -| **Lists** | Project grouping with colors and icons | `List` | +| **Todos** | Scheduled task CRUD, RFC 5545 recurrence, priorities, ordering | `Todo`, `TodoInstance`, `CompletedTodo` | +| **Floaters** | Unscheduled Anytime task CRUD, priorities, ordering | `Floater`, `CompletedFloater` | +| **Lists** | Scheduled-task project grouping with colors and icons | `List` | +| **Floater Lists** | Floater project grouping with colors and icons | `FloaterList` | | **Files** | S3-backed file storage | `File` | | **Preferences** | Sort/group/direction settings per user | `UserPreferences` | | **Admin** | App configuration, user management | `AppConfig` | | **Operations** | Cron jobs, event logging | `CronLog`, `eventLog` | +| **Mobile Sync** | Local cache metadata and pending replay state | Android Room entities, iOS SwiftData entities, `PendingMutationRecord` | ## Backend Architecture (Ktor) @@ -128,8 +142,12 @@ tday-backend/src/main/kotlin/com/ohmz/tday/ │ └── response/ # Response-specific DTOs ├── plugins/ │ ├── Routing.kt # /health, /api/*, /ws, static SPA serving +│ ├── CallLogging.kt # Structured request logging +│ ├── Cors.kt # CORS policy +│ ├── RateLimiting.kt # App-layer request throttling │ ├── Security.kt # JWE bearer + cookie auth, pipeline intercept │ ├── SecurityHeaders.kt # CSP, HSTS, X-Frame-Options, etc. +│ ├── SentryPlugin.kt # Sentry JVM configuration │ ├── Serialization.kt # kotlinx.serialization JSON config │ └── StatusPages.kt # AppError → JSON ApiError mapping ├── routes/ # HTTP route handlers by domain @@ -145,10 +163,12 @@ tday-backend/src/main/kotlin/com/ohmz/tday/ | **Koin** | Dependency injection (config, security, service modules) | | **WebSockets** | Real-time domain event streaming per authenticated user | | **ContentNegotiation** | JSON request/response via kotlinx.serialization | -| **DefaultHeaders** | Security headers (CSP, HSTS in production, X-Frame-Options, etc.) | +| **DefaultHeaders / SecurityHeaders** | Security headers (CSP, HSTS in production, X-Frame-Options, etc.) | | **StatusPages** | Maps `AppError` / generic errors to JSON `ApiError` responses | | **Authentication** | Bearer token provider + pipeline intercept for JWE/cookie auth | | **Routing** | API routes, health check, WebSocket, optional static SPA | +| **CallLogging** | Structured request logging without sensitive payloads | +| **RateLimiting** | App-layer request throttling before handlers | ### Error Handling (Backend) @@ -233,14 +253,14 @@ The web SPA communicates with the Ktor backend via a thin `fetch` wrapper: │ ┌──────────────────────┴────────────────────────────┐ │ Data / App Services │ -│ TodoRepository, ListRepository, CompletedRepo, │ -│ AuthRepository, SettingsRepository, SyncManager, │ -│ OfflineCacheManager, TaskReminderScheduler │ +│ TodoRepository, ListRepository, FloaterListRepo, │ +│ CompletedRepo, AuthRepository, SettingsRepo, │ +│ SyncManager, OfflineCacheManager, Reminder APIs │ └──────┬───────────────────────────────┬────────────┘ │ │ ┌──────┴──────┐ ┌─────────┴─────────┐ -│ Retrofit │ │ EncryptedPrefs │ -│ (Network) │ │ (Local Cache) │ +│ Retrofit │ │ Room + Encrypted │ +│ (Network) │ │ prefs for secrets │ └─────────────┘ └───────────────────┘ ``` @@ -248,11 +268,13 @@ The web SPA communicates with the Ktor backend via a thin `fetch` wrapper: - **MVVM without an Android use-case layer**: ViewModels coordinate repositories and app services directly instead of routing through Android-specific `UseCase` wrappers. - **Domain-specific repositories**: Data access is split by concern (`TodoRepository`, `ListRepository`, `CompletedRepository`, `AuthRepository`, `SettingsRepository`) instead of a single catch-all repository. -- **Offline-first sync**: `OfflineCacheManager` stores the local source of truth, while `SyncManager` replays pending mutations and refreshes remote snapshots. +- **Local-first sync**: `OfflineCacheManager` stores the local source of truth in Room, while `SyncManager` replays pending mutations and refreshes remote snapshots in Server Mode. +- **Local Mode**: Mobile can run without server setup. Server-only operations are hidden or disabled and pending mutations are not retained for replay. - **Cache invalidation**: `OfflineCacheManager.cacheDataVersion` is observed by ViewModels so screens can hydrate from cache when local data changes. - **Auth compatibility**: The Android client implements the JWE credential flow (CSRF token fetch → credential callback → session cookie) using Retrofit + an encrypted cookie store. - **Navigation**: Programmatic Compose Navigation (`NavHost`) with `sealed class AppRoute` — no XML navigation graphs. - **Server discovery**: Runtime server URL configuration with optional certificate fingerprint pinning for self-hosted instances. +- **Root feeds**: `RootFeedDock` switches between Home and Floater/Anytime, with a shared root create action. ### Package Structure (Android) @@ -260,22 +282,26 @@ The web SPA communicates with the Ktor backend via a thin `fetch` wrapper: com.ohmz.tday.compose/ ├── core/ │ ├── data/ # Repositories, OfflineCacheManager, SyncManager, stores +│ │ └── db/ # Room entities, DAOs, and database │ ├── model/ # ApiModels (DTOs), DomainModels (UI types) │ ├── navigation/ # AppRoute sealed class │ ├── network/ # Hilt NetworkModule, TdayApiService, EncryptedCookieStore -│ └── notification/ # Alarms, WorkManager, receivers +│ ├── notification/ # Alarms, WorkManager, receivers +│ ├── security/ # Probe/decryption helpers +│ └── ui/ # Shared non-feature app UI helpers ├── feature/ │ ├── app/ # AppViewModel (bootstrap, sync, session) │ ├── auth/ # AuthViewModel │ ├── home/ # HomeScreen + HomeViewModel -│ ├── todos/ # TodoListScreen + TodoListViewModel +│ ├── todos/ # Todo/Floater list screens + ViewModel │ ├── completed/ # CompletedScreen + CompletedViewModel │ ├── calendar/ # CalendarScreen + CalendarViewModel -│ ├── lists/ # ListsScreen + ListsViewModel │ ├── settings/ # SettingsScreen +│ ├── release/ # In-app update and latest release +│ ├── widget/ # TodayTasks widget │ └── onboarding/ # OnboardingWizardOverlay └── ui/ - ├── component/ # Shared composables (PullRefresh, CreateTaskBottomSheet) + ├── component/ # Shared composables (PullRefresh, CreateTaskBottomSheet, RootFeedDock) └── theme/ # Material 3 theme, colors, typography, dimensions ``` @@ -283,29 +309,41 @@ com.ohmz.tday.compose/ ### Stack -- **SwiftUI** targeting iOS 17+, managed via Swift Package Manager +- **SwiftUI** targeting iOS 17+ - **SwiftData** for local persistence -- Feature-based folder structure with `AppRootView` using TabView + NavigationStack +- **Observation** for ViewModels +- `AppRootView` using `NavigationStack`, root-feed state, onboarding overlay, update gating, deep links, and Local/Server Mode checks ### Structure ``` ios-swiftUI/Tday/ ├── Feature/ -│ ├── Home/ # Home screen -│ ├── Todos/ # Todo management +│ ├── App/ # AppRootView + AppViewModel +│ ├── Home/ # Home root feed +│ ├── Todos/ # Todo/Floater management │ ├── Calendar/ # Calendar views │ ├── Completed/ # Completion history │ ├── Settings/ # User settings │ ├── Auth/ # Login/register │ └── Onboarding/ # First-launch flow ├── Core/ +│ ├── Data/ # AppContainer, repositories, SwiftData cache, sync +│ ├── Domain/ # Focused use cases +│ ├── Model/ # API/domain/offline models +│ ├── Navigation/ # AppRoute │ ├── Network/ # TdayAPIService, RealtimeClient (URLSession + cookies) -│ └── Data/ # Repositories, SwiftData models -└── AppRootView.swift # TabView + NavigationStack with AppRoute destinations +│ ├── Notification/ # Deep links and reminders +│ ├── Security/ # Probe/decryption helpers +│ ├── UI/ # Shared app UI helpers +│ └── Widget/ # TodayTasks snapshot store +├── UI/ +│ ├── Component/ # Shared SwiftUI controls and sheets +│ └── Theme/ # Colors and rounded typography +└── AppRootView.swift # NavigationStack, root feed state, overlays, deep links ``` -No third-party Swift dependencies — all native frameworks. +Sentry Cocoa is the only notable third-party runtime dependency; core app behavior uses native frameworks. ## Database Design @@ -313,8 +351,10 @@ No third-party Swift dependencies — all native frameworks. ``` User ──┬── Todo ──── TodoInstance - │ └──── List (Project) + │ └──── List (scheduled-task project) + ├── Floater ─── FloaterList ├── CompletedTodo + ├── CompletedFloater ├── File ├── UserPreferences ├── Account (OAuth) @@ -335,7 +375,8 @@ User ──┬── Todo ──── TodoInstance ### Key Patterns - **Soft completion**: Completed todos are moved to `CompletedTodo` with metadata (completion time, on-time status, days to complete). -- **RFC 5545 recurrence**: Todos support `rrule`, `dtstart`, `due`, `exdates`, and `durationMinutes`. Instances are materialized in `TodoInstance` for per-occurrence overrides. +- **Floater completion**: Completed floaters are moved to `CompletedFloater` with completion time, days-to-complete metadata, and floater-list metadata. +- **RFC 5545 recurrence**: Todos support `rrule`, `due`, `exdates`, and `durationMinutes`. Instances are materialized in `TodoInstance` for per-occurrence overrides. - **Tenant isolation**: All data queries filter by `userID`. There are no shared/public data models. - **Audit fields**: All major models include `createdAt` and `updatedAt`. @@ -361,7 +402,7 @@ Tokens are encrypted JWTs (JWE) via Nimbus JOSE JWT + BouncyCastle. The Ktor pip `SessionControl` bumps `tokenVersion` on the `User` row. Existing tokens fail on the next request when the intercept detects the version mismatch. -### Android Auth Flow +### Mobile Auth and Workspace Flow ``` App launch → Probe server (GET /api/mobile/probe) @@ -372,6 +413,8 @@ App launch → Probe server (GET /api/mobile/probe) → Subsequent requests include cookie automatically ``` +Local Mode skips server probe/auth and enters a local-only workspace immediately. + ## Real-Time Communication The backend exposes a `WS /ws` WebSocket endpoint for authenticated users. Domain events (`DomainEvent` sealed class) are streamed as JSON frames — covering todo and list changes. Each user has their own WebSocket channel. @@ -393,11 +436,14 @@ The backend exposes a `WS /ws` WebSocket endpoint for authenticated users. Domai ## Caching Strategy - **Web**: No application-level cache layer. API requests use `Cache-Control: no-store`. TanStack React Query provides client-side cache with 60-second stale time. -- **Android**: Offline JSON cache in encrypted shared preferences. Cache is the source of truth for list/todo screens; network sync updates the cache periodically and on pull-to-refresh. +- **Android**: Room-backed local cache for todos, floaters, lists, completed history, pending mutations, and sync metadata. Encrypted preferences still protect credentials, cookies, server config, trust data, theme/reminder preferences, and legacy cache migration input. Cache is the source of truth for screens; network sync updates it periodically, on foreground reconnect, realtime events, and user refresh in Server Mode. +- **iOS**: SwiftData-backed local cache with mirrored `OfflineSyncState` records. Keychain-backed stores protect server config, cookies, credentials, mode state, theme, and reminders. Cache changes notify ViewModels and widget snapshot storage. ## Background Jobs - **Android reminders**: `AlarmManager` for exact-time reminders, `WorkManager` for periodic reminder rescheduling, `BootRescheduleReceiver` for device restart recovery. +- **iOS reminders**: `UserNotifications` scheduling and notification deep-link routing. +- **Widgets**: Android Glance widget and iOS WidgetKit-ready snapshot storage focus on Today tasks. ## Production Deployment @@ -411,7 +457,7 @@ One JVM process serves both the REST API and the SPA. Docker Compose orchestrate ## Future Considerations -- Keep ViewModel orchestration focused on presentation concerns; if shared Android workflows become complex, prefer extracting them into repositories or platform services before adding another app-layer abstraction. -- Consider Room database if the Android cache grows beyond what the current encrypted snapshot approach handles comfortably. +- Keep ViewModel orchestration focused on presentation concerns; if shared mobile workflows become complex, prefer extracting them into repositories, platform services, or focused use cases before adding broad abstractions. +- Define an explicit import/export or migration experience before moving Local Mode data into a server workspace. - Consider Redis or in-memory caching if web API latency becomes a concern under load. - Evaluate SSE as an alternative to WebSocket for clients that don't need bidirectional streaming. diff --git a/docs/CODING_STANDARDS.md b/docs/CODING_STANDARDS.md index 199e863b..4ccd557d 100644 --- a/docs/CODING_STANDARDS.md +++ b/docs/CODING_STANDARDS.md @@ -1,18 +1,30 @@ # Coding Standards -Detailed code quality rules for the TypeScript (web), Kotlin (backend), and Kotlin (Android) codebases. +Detailed code quality rules for the TypeScript web app, Kotlin backend/shared code, Android Compose app, and iOS SwiftUI app. ## Table of Contents - [General Principles](#general-principles) - [TypeScript Standards](#typescript-standards) - [Kotlin Standards](#kotlin-standards) +- [Swift Standards](#swift-standards) - [Shared Rules](#shared-rules) --- ## General Principles +### Product and Documentation Hygiene + +The product direction is part of the coding standard. Before adding behavior, check: + +- [`PRODUCT_DIRECTION.md`](PRODUCT_DIRECTION.md) for Local Mode, Floater/Anytime, mobile parity, and final-product expectations. +- [`DATA_MODEL.md`](DATA_MODEL.md) for scheduled task vs floater semantics, local cache records, and mutation queue rules. +- [`ARCHITECTURE.md`](ARCHITECTURE.md) for module boundaries and data flow. +- [`REPO_HOUSEKEEPING.md`](REPO_HOUSEKEEPING.md) for generated-file and cleanup expectations. + +Update docs in the same change when code changes a rule a future contributor needs to know. + ### Git Commit Hygiene — No AI Trailers Some AI-assisted editors (Cursor, Copilot, etc.) **silently inject trailers** into commit messages — for example, `Made-with: Cursor`. These must never appear in this repository's history. @@ -34,12 +46,14 @@ Some AI-assisted editors (Cursor, Copilot, etc.) **silently inject trailers** in > **Never use `--no-verify`** to skip the hook. If the hook causes problems, fix the hook — don't bypass it. -### Clean Code +### Readable, Focused Code -- Functions do one thing. If a function needs a comment explaining what the "next section" does, extract it. -- Prefer explicit over clever. Readable code wins over terse code. -- No dead code. Delete unused imports, variables, functions, and commented-out blocks. -- No magic numbers. Use named constants. +- A function, component, ViewModel method, or service method should have a narrow job that can be named plainly. +- If a block needs a comment to explain the next phase of work, consider extracting that phase into a well-named helper. +- Prefer explicit control flow and domain names over clever compression. Future readers should not have to reverse-engineer intent. +- Delete unused imports, variables, functions, and commented-out blocks when the owning change makes them obsolete. +- Replace repeated literals and thresholds with named constants close to their domain. +- Keep files navigable. When a screen or service grows multiple independent concerns, split along feature, state, transport, validation, or rendering boundaries. ### DRY — Extract Reusable Logic into Utilities @@ -63,6 +77,9 @@ If a piece of logic appears (or could appear) in more than one place, extract it | **Android (shared)** | `core/` subpackages | Repository helpers, network utilities, model mapping | | **Android (UI shared)** | `ui/component/` | Reusable Composables (e.g., `TdayPullRefresh`, `CreateTaskBottomSheet`) | | **Android (feature-scoped)** | Within the feature package | Helpers used only by that feature | +| **iOS (shared)** | `Core/` subpackages | Repositories, network, cache, navigation, notification, model mapping | +| **iOS (UI shared)** | `UI/Component/`, `Core/UI/`, `UI/Theme/` | Reusable SwiftUI controls, app UI helpers, colors, typography | +| **iOS (feature-scoped)** | `Feature//` | Helpers used only by that feature | **Rules:** @@ -98,13 +115,14 @@ fun Instant?.formatDisplay(timeZone: String): String { val display = todo.due.formatDisplay(userTimeZone) ``` -### SOLID +### Change-Friendly Boundaries -- **S — Single Responsibility**: A module, class, or function should have one reason to change. -- **O — Open/Closed**: Extend behavior through composition or new types, not by modifying existing code. Use sealed classes/interfaces for domain variants. -- **L — Liskov Substitution**: Subclasses must be substitutable for their base types. Applies to error hierarchies (`AppError`, `ApiException`). -- **I — Interface Segregation**: Clients should not depend on methods they don't use. Keep Retrofit interface methods grouped but don't force a single God interface if it grows beyond ~30 methods. -- **D — Dependency Inversion**: High-level modules depend on injected collaborators, not concrete transport details. Android ViewModels depend on repositories and app services provided by Hilt, not on Retrofit directly. Backend services are injected via Koin. +- Keep each module responsible for a coherent slice of the product: route handlers translate HTTP, services own business rules, repositories own persistence/sync, and views render state. +- Add behavior by composing new collaborators, sealed variants, or feature-scoped helpers before widening an already overloaded type. +- Shared abstractions must preserve the expectations of every caller. A common interface is only useful when each implementation can be swapped without surprising its consumers. +- Do not make callers depend on operations they do not use. Split large service/repository/protocol surfaces when unrelated features start sharing one bag of methods. +- Higher-level code should depend on injected contracts and app services, not transport or storage details. Android ViewModels should not call Retrofit directly; backend routes should not build SQL; SwiftUI views should not mutate SwiftData entities directly. +- Keep dependency direction easy to explain: UI -> ViewModel/state -> repository/service -> network/database/cache. Avoid cycles and hidden global access. ### Null Safety, Type Safety, and Explicit Types @@ -142,6 +160,7 @@ Colors and spacing/sizing values must come from the project's centralized design - **Web**: Use Tailwind utility classes that map to CSS custom properties defined in `src/globals.css` (e.g., `bg-card`, `text-foreground`, `border-border`). Never write inline `style={{ color: "#2A6DC2" }}` or raw `hsl(...)` values. If a new semantic color is needed, add a CSS variable in `globals.css` under `:root` and `.dark`, map it in the `@theme inline` block, then use the Tailwind class. - **Android**: Use `MaterialTheme.colorScheme.*` for all colors in Composables. If a color is not in the Material scheme, add it as a named constant in `ui/theme/Color.kt` — never write `Color(0xFF...)` directly in a screen or component file. - **Android dimensions**: Use the centralized `TdayDimens` object (`ui/theme/Dimens.kt`) for all spacing, sizing, corner radius, and elevation values. Never write raw `.dp` literals like `padding(18.dp)` directly in screens — use `TdayDimens.SpacingMd` or similar. +- **iOS**: Use `tdayColors`, `TdayTheme`, shared metrics, and feature-scoped constants that already belong to the local component. New repeated colors/metrics should move into `UI/Theme/` or a narrow shared component metrics type. ```kotlin // Good: colors and dimensions from centralized sources @@ -173,6 +192,7 @@ All user-facing strings must live in a single centralized source — never inlin - **Web**: Use **i18next** translation keys backed by `tday-web/public/locales//translation.json`, with `tday-web/messages/en.json` kept as the bundled English fallback. Components access strings via `useTranslation()`. - **Android**: Use Android string resources (`res/values/strings.xml`). Screens access strings via `stringResource(R.string.*)`. +- **iOS**: Follow the current local SwiftUI string patterns until a broader localization layer exists. Avoid scattering repeated labels; extract repeated app language into narrow constants or shared helpers when it appears in multiple places. - Internal log messages and developer-facing error strings (not shown to users) are exempt. - When adding a new screen or feature, add its strings to the centralized source **first**, then reference the keys. @@ -443,6 +463,8 @@ sealed interface AuthResult { - Android API DTOs live in `core/model/ApiModels.kt`; UI-facing domain models in `core/model/DomainModels.kt`. - Never expose DTOs directly to the Android UI layer — map them in the repository. - Use `kotlinx.serialization` for all JSON serialization (no Gson, no Moshi, no Jackson). +- Scheduled `Todo` and unscheduled `Floater` models must remain distinct. Do not make a due date nullable to represent Anytime work. +- Android local cache shape lives in `core/data/OfflineSyncModels.kt` and Room entities under `core/data/db/`; keep both aligned with `docs/DATA_MODEL.md`. ### Colors and Dimensions (Android) @@ -544,6 +566,42 @@ Within a ViewModel file: --- +## Swift Standards + +These rules apply to the iOS SwiftUI codebase. + +### Architecture and State + +- Use SwiftUI, Observation, SwiftData, URLSession, and Keychain-backed storage patterns already present in the app. +- Keep dependency wiring explicit in `Core/Data/AppContainer.swift`. +- Keep feature screens and ViewModels in `Feature//`. +- Put reusable repositories, sync logic, models, navigation, network, notification, security, and UI helpers under `Core/`. +- Use `@Observable` ViewModels on the main actor for UI-facing state. +- Keep local cache writes centralized through repositories and `OfflineCacheManager`; views should not mutate SwiftData entities directly. + +### Local Mode and Sync + +- Respect `AppDataMode.local` as a first-class workspace. +- Hide or disable pull-to-refresh, manual sync, realtime reconnect expectations, and admin server settings in Local Mode. +- Mirror Android's `OfflineSyncState` when adding cached records or pending mutations. +- Update widget snapshot storage when Today-task cache semantics change. + +### SwiftUI UI Rules + +- Preserve dark mode and rounded typography. +- Prefer platform-native gestures and controls unless the product behavior requires custom handling. +- Keep root-feed behavior aligned with Android: Home and Floater/Anytime are sibling root feeds controlled by `RootFeedDock`. +- Use shared sheet chrome and swipe helpers before creating one-off variants. +- Keep text fitting in compact layouts with `lineLimit`, `minimumScaleFactor`, or layout changes where needed. + +### Error Handling + +- Convert technical failures into `userFacingMessage` before showing them. +- Do not expose raw backend, SQL, keychain, or URLSession internals in UI copy. +- For async work, keep cancellation and task lifetime explicit when work outlives a view update. + +--- + ## Shared Rules ### Folder and Module Structure @@ -551,9 +609,17 @@ Within a ViewModel file: - **Web**: group by technical layer at root (`src/lib/`, `src/components/`, `src/providers/`) and by feature domain in `src/features/`. - **Backend**: group by layer (`routes/`, `services/`, `db/`, `security/`, `domain/`, `config/`, `plugins/`). - **Android**: group by feature (`feature/home/`, `feature/todos/`), shared code in `core/` and `ui/`. -- **Shared KMP**: DTOs, enums, and validators in `shared/` consumed by all three platforms. +- **iOS**: group by feature (`Feature/Home/`, `Feature/Todos/`), shared app code in `Core/`, reusable UI in `UI/`. +- **Shared KMP**: DTOs, enums, validators, and route constants in `shared/` consumed by backend/Android and mirrored by iOS models/tests. - A new feature should create its own subdirectory, not grow an existing file. +### Cross-Platform Mobile Parity + +- Android and iOS should expose the same mobile feature surface. +- Match behavior, information architecture, counts, empty states, disabled states, Local Mode affordances, and navigation rules. +- Use native APIs and patterns on each platform; do not blindly copy implementation details. +- When one platform gets a better interaction, bring the other up to the same product quality. + ### Error Messages - User-facing error messages should be helpful but not leak internal details. @@ -567,6 +633,7 @@ Within a ViewModel file: - Review changelogs before major version bumps. - Keep Android dependencies version-locked in `build.gradle.kts`. - Backend dependencies use Ktor's BOM for server artifacts; pin explicit versions for other libraries. +- iOS dependencies should be added through Xcode/Swift Package Manager and documented in `ios-swiftUI/README.md` when they affect setup, privacy, or build behavior. ### Git Hygiene diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 00000000..268803dc --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,140 @@ +# Data Model + +This document describes the durable and local data structures that define T'Day. Keep it aligned with `shared/`, backend Exposed tables, Android Room entities, and iOS SwiftData entities. + +## Sources of Truth + +| Layer | Files | Purpose | +|-------|-------|---------| +| Shared contracts | `shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/` | Serializable DTOs, request/response bodies, enums, and validators consumed across platforms | +| Backend tables | `tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/` | PostgreSQL schema mapping through Exposed | +| Backend migrations | `tday-backend/src/main/resources/db/migration/` | Flyway SQL history and clean-install schema | +| Android cache | `android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/` and `core/data/OfflineSyncModels.kt` | Room entities plus cache records used by repositories | +| iOS cache | `ios-swiftUI/Tday/Core/Data/Database/` and `Core/Model/OfflineSyncModels.swift` | SwiftData entities plus cache records used by repositories | + +## Core Entities + +| Entity | Backend table | Shared/mobile DTOs | Notes | +|--------|---------------|--------------------|-------| +| User | `Users` | `SessionUser`, auth responses | Owns all private data through `userID`; includes role, approval, and `tokenVersion`. | +| Account | `Accounts` | Auth models | OAuth/account compatibility and credential metadata. | +| Todo | `Todos` | `TodoDto`, `CreateTodoRequest`, `UpdateTodoRequest` | Scheduled task with required `due`, optional `rrule`, priority, pinning, ordering, and optional scheduled-task list. | +| Todo instance | `TodoInstances` | `TodoInstancePatchRequest`, `TodoInstanceDeleteRequest` | Per-occurrence overrides/deletions for recurring tasks. | +| Completed todo | `CompletedTodos` | `CompletedTodoDto` | Completion history preserving original task/list details where possible. | +| List | `Lists` | `ListDto`, `ListDetailResponse` | Scheduled-task project/group with color and icon metadata. | +| Floater | `Floaters` | `FloaterDto`, `CreateFloaterRequest`, `UpdateFloaterRequest` | Unscheduled task for Anytime/Floater planning. No `due`. | +| Floater list | `FloaterLists` / `FloaterProject` | `FloaterListDto`, `FloaterListDetailResponse` | Project/group for floaters. Keep separate from scheduled-task lists. | +| Completed floater | `CompletedFloaters` | `CompletedFloaterDto` | Completion history for floaters. | +| Preferences | `UserPreferences` | `PreferencesDto`, `PreferencesResponse` | Per-user sorting/grouping/direction preferences. | +| App config | `AppConfigs` | `AppSettingsResponse`, `AdminSettingsResponse` | Public/admin app settings such as AI summary availability. | +| Event/auth logs | `EventLogs`, `AuthThrottles`, `AuthSignals`, `CronLogs` | Internal models | Security, throttling, diagnostics, and operational state. | + +## Scheduling Rules + +Scheduled tasks and floaters are intentionally different: + +- `Todo` requires a due timestamp and can participate in Today, Scheduled, Calendar, recurring instances, reminders, and scheduled-task lists. +- `Floater` has no due timestamp and belongs to the Anytime/Floater root feed. +- A task should not be made "unscheduled" by nulling `Todo.due`; use a floater instead. +- Completing a todo creates completed-todo history; completing a floater creates completed-floater history. +- List deletion must preserve completed history metadata (`listName`, `listColor`) where the backend/mobile model supports it. + +## Recurrence + +Recurring scheduled tasks use RFC 5545 RRULE strings. + +| Field | Meaning | +|-------|---------| +| `due` | Canonical due timestamp for the base task or occurrence. | +| `rrule` | RFC 5545 recurrence rule for the series. | +| `instanceDate` / `instanceDateEpochMs` | Occurrence identity for edits/completion/deletion. | +| `exdates` | Backend exclusion timestamps for skipped occurrences. | +| `durationMinutes` | Backend duration metadata for expanded instances. | + +Do not apply recurrence to floaters until a new product decision explicitly defines what "unscheduled recurrence" means. + +## Mobile Offline State + +Android and iOS mirror the same logical `OfflineSyncState`: + +```text +OfflineSyncState +├── todos +├── floaters +├── completedItems +├── completedFloaters +├── lists +├── floaterLists +├── pendingMutations +├── lastSuccessfulSyncEpochMs +├── lastSyncAttemptEpochMs +└── aiSummaryEnabled +``` + +Android stores this state in Room tables: + +- `cached_todos` +- `cached_floaters` +- `cached_lists` +- `cached_floater_lists` +- `cached_completed` +- `cached_completed_floaters` +- `pending_mutations` +- `sync_metadata` + +iOS stores the same logical records in SwiftData: + +- `CachedTodoEntity` +- `CachedFloaterEntity` +- `CachedListEntity` +- `CachedFloaterListEntity` +- `CachedCompletedEntity` +- `CachedCompletedFloaterEntity` +- `PendingMutationEntity` +- `SyncMetadataEntity` + +Android has a one-time migration path from the legacy encrypted JSON cache into Room. New cache work should target Room and SwiftData directly. + +## Local IDs + +Mobile optimistic writes create local IDs until the server returns canonical IDs. + +| Prefix | Meaning | +|--------|---------| +| `local-list-` | Scheduled-task list created locally. | +| `local-floater-list-` | Floater list created locally. | +| `local-todo-` | Scheduled task created locally. | +| `local-floater-` | Floater created locally. | +| `local-completed-` | Completed scheduled item created locally. | +| `local-completed-floater-` | Completed floater created locally. | + +When syncing in Server Mode, repositories must remap local IDs to server IDs and update references in todos, floaters, lists, completed history, and pending mutations. + +## Pending Mutations + +`PendingMutationRecord` preserves user intent while offline or while an immediate network call fails. + +Current mutation kinds: + +- List: `CREATE_LIST`, `UPDATE_LIST`, `DELETE_LIST` +- Floater list: `CREATE_FLOATER_LIST`, `UPDATE_FLOATER_LIST`, `DELETE_FLOATER_LIST` +- Scheduled todo: `CREATE_TODO`, `UPDATE_TODO`, `DELETE_TODO`, `SET_PINNED`, `SET_PRIORITY`, `COMPLETE_TODO`, `COMPLETE_TODO_INSTANCE`, `UNCOMPLETE_TODO` +- Floater: `CREATE_FLOATER`, `UPDATE_FLOATER`, `DELETE_FLOATER`, `COMPLETE_FLOATER`, `UNCOMPLETE_FLOATER` + +Server Mode replays pending mutations through `SyncManager`. Local Mode clears/ignores pending mutations because there is no remote target. + +## Tenant Isolation + +Every backend query that reads or writes private data must filter by the authenticated `userID`. Admin-only operations that touch other users must be behind centralized admin checks and should avoid returning private task content unless the endpoint explicitly requires it. + +## Data Change Checklist + +When changing data shape: + +- Update shared DTOs and validators first when the contract crosses platforms. +- Update Exposed tables and add a Flyway migration for backend persistence changes. +- Update Android Room entities, DAOs, mappers, cache records, and migration/version handling. +- Update iOS SwiftData entities, mappers, cache records, and widget snapshot logic if affected. +- Update REST docs in `docs/API_GUIDELINES.md`. +- Update architecture and platform READMEs if the data flow changes. +- Add or update tests for recurrence, tenant isolation, sync replay, local mode, and destructive operations. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 2d97a651..9ae417c8 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,6 @@ # Deployment -How T'Day is built, deployed, and operated in production. +How T'Day is built, deployed, and operated in production. Product direction and data boundaries are documented in [`PRODUCT_DIRECTION.md`](PRODUCT_DIRECTION.md) and [`DATA_MODEL.md`](DATA_MODEL.md). ## Environments @@ -9,6 +9,8 @@ How T'Day is built, deployed, and operated in production. | Production | `master` | `tday.ohmz.cloud` | Auto via GitHub Actions → Docker image to GHCR | | Development | `develop` | Local only | `docker compose up` or local dev servers | +Mobile clients can also run in Local Mode without a deployed backend. Deployment work affects Server Mode, remote access, app compatibility checks, and server-backed sync. + ## Docker ### Architecture @@ -184,6 +186,8 @@ Every file that contains or controls a version number, grouped by platform. The `TDAY_APP_VERSION` environment variable tells the backend which app version it is compatible with. When `TDAY_UPDATE_REQUIRED=true`, clients that connect with a different version are shown an "Update Required" or "Server Update Needed" screen. +Local Mode does not require this probe. Server Mode Android and iOS clients use `/api/mobile/probe` plus the `X-Tday-App-Version` header to decide whether the installed app and server can safely sync. + | File | Purpose | Notes | |------|---------|-------| | `.env.docker` | **Live value** used by the running Docker container | This is the file that actually controls what the server reports. Update here and recreate the container to take effect. | @@ -209,6 +213,13 @@ Distributable Android release builds must use the same release keystore every ti - The Android app can download a release APK in-app and hand it directly to the system installer. The first sideloaded update still requires enabling "Install unknown apps" for T'Day in Android settings. - Historical note: GitHub Android APKs published before the stable signing fix on April 1, 2026 may have been signed with ephemeral debug certificates from CI runners. Devices on one of those installs must uninstall once and reinstall `v1.8.1` or newer before sideloaded updates will work again. +### iOS Signing and Associated Domains + +- The iOS app uses `ios-swiftUI/TdayApp.xcodeproj`, automatic signing, and the `Tday` scheme. +- `/.well-known/apple-app-site-association` is served by the backend for webcredentials/deep-link support. +- `CFBundleShortVersionString` is synced from `tday-web/package.json` by `scripts/sync-ios-version.sh` during `npm version`. +- `CFBundleVersion` remains the App Store build number and is incremented manually when needed. + ## Configuration ### Environment Variables diff --git a/docs/PRODUCT_DIRECTION.md b/docs/PRODUCT_DIRECTION.md new file mode 100644 index 00000000..915770ed --- /dev/null +++ b/docs/PRODUCT_DIRECTION.md @@ -0,0 +1,96 @@ +# Product Direction + +This document records the intended shape of T'Day so future work moves toward the same product instead of accreting unrelated screens. + +## Product Goal + +T'Day is a private, self-hosted personal task planner that should feel immediate on mobile, dependable offline, and quiet enough to use every day. The long-term product is: + +- A self-hosted planning system for one person or a small private group. +- A native Android and iOS app pair with the same feature surface and platform-native implementation. +- A web app that remains the administrative, desktop, and broad-access surface. +- A local-first mobile experience that can be used without a server, then safely syncs when a server workspace exists. +- A documented monorepo where data contracts, UI rules, verification, deployment, and housekeeping are discoverable before coding starts. + +## Current Product Surfaces + +| Surface | Role | +|---------|------| +| Web SPA | Desktop planner, admin settings, release/version surfaces, full API consumer, i18n reference implementation | +| Backend | Auth, tenant isolation, task/list/floater persistence, recurrence expansion, WebSocket events, AI summaries, mobile probe and version compatibility | +| Shared KMP | DTOs, enums, validators, and shared route constants for backend/Android alignment; iOS mirrors these contracts in Swift models | +| Android | Primary native mobile app using Compose, Room-backed local cache, Hilt, Retrofit, reminders, widgets, and in-app updates | +| iOS | Primary native mobile app using SwiftUI, SwiftData-backed local cache, Observation, URLSession, reminders, widgets, and password/keychain support | + +## Planning Model + +T'Day separates work by scheduling intent. + +| Concept | Meaning | +|---------|---------| +| Scheduled task | A task with a `due` date/time. It appears in Today, Scheduled, Calendar, lists, and recurrence-aware flows. | +| Floater | An unscheduled task for Anytime/Floater planning. It has title, description, priority, pinning, completion, ordering, and optional floater-list membership, but no due date. | +| List | A project/group for scheduled tasks. | +| Floater list | A project/group for floaters. Keep it distinct from scheduled-task lists because the data and UI semantics are different. | +| Completed item | Immutable-ish history created when scheduled tasks or floaters are completed, preserving list metadata where possible. | + +## Mobile Product Rules + +Mobile is now the center of the product experience. Any user-facing Android or iOS change should ask whether the other platform exposes the same behavior, language, counts, empty states, and edge cases. + +- Build Android and iOS as siblings, not clones. Copy behavior and intent; use native APIs and local conventions. +- Treat Home and Floater/Anytime as root feeds. `RootFeedDock` switches between them and collapses into a compact icon state while preserving quick creation. +- Keep pull-to-refresh disabled in Local Mode and enabled for server workspaces. +- Use local cache as the screen source of truth. Network sync updates the cache; screens observe cache changes. +- Keep offline notices calm and rate-limited. Do not interrupt normal use when cached data can satisfy the screen. +- Preserve dark mode, compact layouts, and text fit. Avoid explanatory UI copy when a familiar control can do the job. +- Treat calendar paging as a product contract: headers stay anchored, page content moves horizontally, today jumps keep the active mode, and previous-month navigation remains bounded. + +## Local Mode + +Local Mode is an offline-only workspace on Android and iOS. + +- It does not require server setup, login, or session cookies. +- Data is written directly to the local cache. +- Pending mutation queues are cleared/ignored because there is no remote target. +- Server-only features such as manual sync, remote updates, admin AI settings, and pull-to-refresh should be hidden or disabled. +- Local Mode data should not be silently uploaded later without an explicit migration/import design. + +Server Mode remains the authenticated self-hosted workspace: + +- Mobile writes optimistically to local storage first. +- Sync replays pending mutations and refreshes server snapshots. +- Realtime events and foreground reconnects should refresh cache state without destabilizing the UI. + +## UX Direction + +T'Day should feel like a focused task app, not a marketing surface. + +- Directly usable screens beat onboarding copy. +- Familiar icons, labels, haptics, and expected placement beat custom explanation. +- Cards are for actual grouped content or sheet chrome, not decoration. +- Empty states are short, calm, and consistent. +- Motion should explain continuity: list removals, root-feed switching, calendar paging, sheet transitions, and swipe actions should feel attached to the thing changing. +- If one mobile platform gets a nicer interaction, bring the other platform up to the same product quality. + +## Documentation Expectations + +Documentation is part of the product. + +- Update `README.md` when the project shape, setup, or document map changes. +- Update `docs/ARCHITECTURE.md` when module boundaries, data flow, or platform architecture changes. +- Update `docs/DATA_MODEL.md` when backend tables, shared DTOs, local cache records, or sync mutations change. +- Update `docs/API_GUIDELINES.md` when REST/WebSocket contracts change. +- Update `docs/CODING_STANDARDS.md` when local patterns or guardrails change. +- Update `docs/TESTING.md` when verification expectations change. +- Update platform READMEs when Android or iOS setup, storage, navigation, or feature surface changes. +- Update ADRs when a decision changes direction rather than merely adding implementation detail. + +## Near-Term Direction + +- Keep server/local data boundaries explicit and user-controlled. +- Continue converging Android and iOS around Home, Floater/Anytime, Calendar, Completed, Settings, reminders, and update flows. +- Make list and floater-list behavior clear in UI and contracts. +- Keep backend contracts stable for mobile clients; add compatibility handling before making breaking changes. +- Prefer focused cleanup that reduces future drift: shared DTOs, mirrored cache records, platform design tokens, and documented verification commands. +- Keep implementation pieces small enough to understand in isolation, with clear ownership and dependency direction from UI to state to data services. diff --git a/docs/REMOTE_ACCESS.md b/docs/REMOTE_ACCESS.md index 13f9d401..1927068f 100644 --- a/docs/REMOTE_ACCESS.md +++ b/docs/REMOTE_ACCESS.md @@ -2,6 +2,8 @@ How to reach your self-hosted T'Day instance from outside the local network. +Remote access is only required for Server Mode. Android and iOS can run in Local Mode without any tunnel, VPN, public URL, or backend deployment. + ## Background By default, Docker Compose binds the backend to **`127.0.0.1:2525`** (localhost only). This means no external device — phone, laptop on another network, or browser outside the LAN — can reach T'Day directly. You need an ingress method that bridges external clients to `localhost:2525` on the Docker host. diff --git a/docs/REPO_HOUSEKEEPING.md b/docs/REPO_HOUSEKEEPING.md new file mode 100644 index 00000000..e6bd711e --- /dev/null +++ b/docs/REPO_HOUSEKEEPING.md @@ -0,0 +1,144 @@ +# Repo Housekeeping + +This document captures maintenance expectations for T'Day so the repo stays easy to change. + +## Documentation Audit + +The markdown inventory was checked on 2026-05-29 with `git log -1 --date=short -- `. + +### Markdown Inventory Before This Refresh + +| File | Last commit date | Commit | +|------|------------------|--------| +| `.github/ISSUE_TEMPLATE/bug_report.md` | 2026-03-24 | `d6d55e7` Add comprehensive project documentation, coding guardrails, and CI enforcement | +| `.github/ISSUE_TEMPLATE/feature_request.md` | 2026-03-24 | `d6d55e7` Add comprehensive project documentation, coding guardrails, and CI enforcement | +| `.github/PULL_REQUEST_TEMPLATE.md` | 2026-03-27 | `4bb1f60` docs: align all documentation with current Ktor/Vite/Exposed stack | +| `AGENTS.md` | 2026-05-19 | `11221e0` Add agent project guidance | +| `CONTRIBUTING.md` | 2026-03-27 | `4bb1f60` docs: align all documentation with current Ktor/Vite/Exposed stack | +| `README.md` | 2026-05-19 | `11221e0` Add agent project guidance | +| `SECURITY.md` | 2026-04-02 | `825c6bd` feat: implement app-layer request rate limiting and security hardening | +| `android-compose/README.md` | 2026-03-24 | `d6d55e7` Add comprehensive project documentation, coding guardrails, and CI enforcement | +| `docs/API_GUIDELINES.md` | 2026-04-02 | `825c6bd` feat: implement app-layer request rate limiting and security hardening | +| `docs/ARCHITECTURE.md` | 2026-04-02 | `825c6bd` feat: implement app-layer request rate limiting and security hardening | +| `docs/CODING_STANDARDS.md` | 2026-04-02 | `8dca84e` feat(release): add structured web release metadata | +| `docs/DEPLOYMENT.md` | 2026-05-22 | `f18309d` Implement Android Credential Manager support for seamless login and registration. | +| `docs/REMOTE_ACCESS.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/TELEMETRY.md` | 2026-04-04 | `4b79a1b` feat: add Sentry telemetry across all platforms (v1.14.0) | +| `docs/TESTING.md` | 2026-03-27 | `4bb1f60` docs: align all documentation with current Ktor/Vite/Exposed stack | +| `docs/adr/001-next-js-monolith-with-native-mobile.md` | 2026-03-27 | `4bb1f60` docs: align all documentation with current Ktor/Vite/Exposed stack | +| `docs/adr/002-postgresql-with-exposed.md` | 2026-03-27 | `c13b1cc` fix: harden auth and lazy-load locales | +| `docs/adr/003-jwe-jwt-sessions.md` | 2026-04-01 | `6871121` fix(auth): add rolling web session renewal | +| `docs/adr/004-local-ai-via-ollama.md` | 2026-03-24 | `d6d55e7` Add comprehensive project documentation, coding guardrails, and CI enforcement | +| `docs/adr/005-offline-first-android-with-sync.md` | 2026-03-28 | `dad190d` Inline use-case logic into ViewModels and bump to v1.7.0 | +| `docs/adr/006-rfc5545-recurrence.md` | 2026-03-27 | `4bb1f60` docs: align all documentation with current Ktor/Vite/Exposed stack | +| `docs/remote-access/cloudflare-tunnel.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/frp.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/ngrok.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/ssh-tunnel.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/tailscale.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/wireguard.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/zerotier.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/security/cloudflare-auth-hardening.md` | 2026-03-27 | `c13b1cc` fix: harden auth and lazy-load locales | +| `docs/security/operations-hardening.md` | 2026-04-02 | `825c6bd` feat: implement app-layer request rate limiting and security hardening | +| `ios-swiftUI/README.md` | 2026-05-22 | `a3a3c66` Fix mobile reminder deep links | + +### Summary By Area + +| Area | Last updated before this refresh | Notes | +|------|----------------------------------|-------| +| Root README / agent guide | 2026-05-19 | Behind Local Mode, RootFeedDock, Floater/Anytime, and recent mobile parity work. | +| Contributing / testing / ADR base | 2026-03-27 | Behind the current Ktor/Vite/mobile architecture and native iOS expectations. | +| Architecture / API / security hardening docs | 2026-04-02 | Missing floater APIs, Room cache, SwiftData parity, local mode, and current mobile data flow. | +| Deployment | 2026-05-22 | Mostly current for release/versioning, but now needs mobile local/server mode context. | +| Telemetry | 2026-04-04 | Still accurate in spirit; should mention Local Mode and mobile cache privacy boundaries. | +| Android README | 2026-03-24 | Behind Room cache, RootFeedDock, Floater, Local Mode, widgets, and update flow. | +| iOS README | 2026-05-22 | Behind Local Mode, RootFeedDock, Floater, and current app structure. | +| Issue/PR templates | 2026-03-24 to 2026-03-27 | Missing iOS, Local Mode, data contract, and docs-impact prompts. | +| Remote access guides | 2026-03-30 | Still scoped to ingress setup; update only when ports, host binding, or recommended ingress changes. | + +## Recent Product Changes Audited + +Recent commits after the older documentation introduced or refined: + +- Local Mode for offline-only Android and iOS workspaces. +- Floater/Anytime tasks, floater lists, completed floaters, and root feed navigation. +- `RootFeedDock` icon-to-text transitions across Android and iOS. +- Room-backed Android offline cache and SwiftData-backed iOS cache with mirrored `OfflineSyncState`. +- Unified reminder selectors, pull-to-refresh, empty states, sheet chrome, swipe actions, and task completion animations. +- Calendar paging, drag-and-drop rescheduling, overdue visibility, and cross-platform calendar polish. +- Mobile server credential handling, webcredentials/AASA support, in-app update/version compatibility, and offline notice cooldowns. + +## Git Expectations + +- Start with `git status --short --branch`. +- Keep work scoped and avoid opportunistic refactors. +- Do not revert user changes unless explicitly asked. +- Do not use destructive cleanup commands such as `git reset --hard` or `git checkout --` unless the user explicitly requests them. +- Commit as `ohmzi <6551272+ohmzi@users.noreply.github.com>` when attribution matters. +- Do not add AI attribution trailers or bypass hooks with `--no-verify`. + +## Generated Files and Local Artifacts + +Never commit: + +- `node_modules/` +- `tday-web/dist/` +- `coverage/` +- Gradle `build/`, `.gradle/`, `.kotlin/` +- iOS `.build/`, DerivedData, archives, and user-specific Xcode files +- Android Studio/IDE local metadata unless intentionally tracked +- `.env`, local secrets, signing keys, keystores, DSYM uploads, or generated credentials + +When adding a tool that creates new caches or outputs, update `.gitignore`, this document, and any guardrail test that enforces dependency hygiene. + +## Documentation Maintenance + +Documentation should change in the same PR as the behavior when: + +- A new product surface, route, table, DTO, local cache record, mutation kind, or app mode is added. +- Android and iOS behavior changes in a user-facing way. +- Setup, deployment, versioning, signing, telemetry, or security configuration changes. +- Verification commands, simulator requirements, or CI gates change. +- An implementation decision changes the direction captured by an ADR. + +Small bug fixes do not need broad documentation churn, but they should update docs when they reveal a rule future contributors need to know. + +## Cross-Platform Housekeeping + +Mobile features should be checked in pairs: + +- Android files under `android-compose/app/src/main/java/com/ohmz/tday/compose/feature//` +- iOS files under `ios-swiftUI/Tday/Feature//` +- Shared data contracts under `shared/` +- Backend routes/services/tables under `tday-backend/` when the feature is server-backed + +Before finishing a mobile change, compare: + +- Feature surface and labels +- Counts, empty states, and disabled states +- Local Mode behavior +- Offline/online transitions +- Navigation and deep links +- Dark mode +- Reminder/widget/update implications + +## Cleanup Policy + +- Prefer deleting dead code over preserving unused compatibility stubs. +- Keep the codebase easy to scan: narrow files, named concepts, plain control flow, and boundaries that match product/data ownership. +- Keep feature-specific helpers close to the feature until a second consumer exists. +- Promote repeated mobile styling into platform theme/component layers. +- Keep backend service methods small enough to preserve tenant isolation reviewability. +- Keep shared DTOs minimal; do not leak persistence-only fields into shared contracts unless clients need them. +- Keep dependencies flowing in one direction: UI calls state/actions, state coordinates services/repositories, services/repositories touch network/database/cache. +- When a migration or compatibility shim is temporary, document the removal condition in code or this document. + +## Pre-PR Housekeeping Checklist + +- `git status --short --branch` shows only intentional changes. +- Documentation and templates reflect new behavior. +- New generated outputs are ignored or intentionally tracked. +- Data changes include migration, DTO, local cache, and sync updates as needed. +- API changes are documented and backwards compatibility is considered. +- Android and iOS parity was checked for mobile UI work. +- Relevant verification commands ran, or the reason for skipping is recorded in the PR. diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 931d2b0d..a592e98c 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -4,6 +4,8 @@ T'Day uses [Sentry](https://sentry.io) for crash reporting and performance monitoring across all four platforms (backend, web, Android, iOS). This document explains exactly what is collected, what is not, and why. +Telemetry is crash/performance reporting only. It is not a product analytics system and it does not change the Local Mode data boundary described in [`PRODUCT_DIRECTION.md`](PRODUCT_DIRECTION.md). + ## TL;DR - Sentry tells us **when the app crashes and where in the code it happened**. @@ -37,6 +39,7 @@ The following data is **never** sent to Sentry: | Excluded Data | How It's Enforced | |---------------|-------------------| | Task titles, descriptions, notes, or any user content | Sentry only receives stack traces and HTTP metadata — request/response bodies are never attached | +| Local Mode task/list/floater content | Local Mode data remains on-device; crash reports do not include local cache records | | Email addresses, usernames, display names | `sendDefaultPii = false` on every SDK; `beforeSend` callback strips any residual user fields | | IP addresses | Explicitly nulled in every `beforeSend` callback across all four platforms | | Cookies, auth tokens, session IDs | `sendDefaultPii = false` prevents header capture; sensitive headers are never attached | @@ -91,6 +94,8 @@ The relevant code paths: - Web: `main.tsx` → `Sentry.init({ ... })` - iOS: `SentryConfiguration.swift` → `SentrySDK.start { ... }` +When adding a new Sentry breadcrumb, tag, or transaction name, keep it structural: route names, screen names, status codes, durations, and release versions are allowed; user-created task/list/floater text is not. + ## Self-Hosted Users If you self-host T'Day and build from source, Sentry is **completely inactive** diff --git a/docs/TESTING.md b/docs/TESTING.md index 36266b73..09ca3727 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,12 +1,14 @@ # Testing Strategy -This document defines testing expectations, conventions, and tooling for the web, backend, and Android codebases. +This document defines testing expectations, conventions, and tooling for the web, backend, Android, and iOS codebases. ## Philosophy - Test behavior, not implementation details. - Security-critical code must always have tests. - Tests are first-class code — apply the same quality standards as production code. +- For mobile UI work, verify Android/iOS parity even when only one platform changed. +- For data model work, verify shared DTOs, backend persistence, mobile local cache, and sync replay together. ## Web Testing @@ -112,6 +114,7 @@ npm run test -- tests/guardrails/security # security guardrails only | Android `core/`, `feature/`, `ui/theme/` packages exist | Package structure follows the documented architecture | | Android theme files exist (`Color.kt`, `Theme.kt`, `Type.kt`, `Dimens.kt`) | Design tokens are centralized | | Android `build.gradle.kts` derives version from `package.json` | Single source of version truth | +| iOS docs and project structure are represented in repository docs | Native iOS remains a first-class surface | #### `api-guidelines.test.ts` — API Convention Enforcement @@ -161,6 +164,7 @@ npm run test -- tests/guardrails/security # security guardrails only | `install-hooks.sh` exists | Hook installation is documented and scriptable | | `CODING_STANDARDS.md` documents git commit hygiene | Rules are discoverable | | All required documentation files exist | Complete project documentation | +| Product/data/housekeeping docs exist | Product direction, data structure, and maintenance rules stay discoverable | | Version synchronizes from `package.json` to Android | Single source of version truth | ### Naming Conventions @@ -195,6 +199,8 @@ describe("fieldEncryption", () => { | Guardrails: API guidelines | Yes | Enforce API_GUIDELINES.md patterns | | Guardrails: Android standards | Yes | Enforce Android coding and theme conventions | | Guardrails: dependency hygiene | Yes | Enforce config, CI, and documentation completeness | +| Mobile Local Mode and sync behavior | Recommended | Prevent offline/local regressions | +| Android/iOS parity for visible mobile features | Manual + tests where practical | Avoid product drift | | CRUD routes (happy path) | Recommended | Catch regressions | | Error paths in routes | Recommended | Ensure proper status codes | @@ -267,8 +273,10 @@ android-compose/app/src/ | Area | Type | Priority | |------|------|----------| | Repository data mapping (DTO → domain) | Unit | High | -| Offline cache serialization/deserialization | Unit | High | +| Room cache mapping and legacy cache migration | Unit | High | +| Pending mutation creation/replay behavior | Unit | High | | ViewModel state transitions | Unit | High | +| Local Mode server-only affordances | Unit/manual | High | | Notification scheduling logic | Unit | Medium | | Screen composition (renders, interactions) | Instrumented | Medium | | End-to-end auth flow | Instrumented | Low (manual for now) | @@ -285,6 +293,43 @@ fun `should clear local data when session is invalidated`() { ... } Use backtick-quoted descriptive names: `should when `. +## iOS Testing + +### Tooling + +| Tool | Purpose | +|------|---------| +| XCTest | Unit and integration tests | +| Xcode test runner | Simulator/device execution | +| SwiftData in-memory containers | Repository/cache tests where practical | + +### Running Tests + +```bash +xcodebuild test -project ios-swiftUI/TdayApp.xcodeproj -scheme Tday -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6' +``` + +### Test Locations + +```text +ios-swiftUI/Tests/ +└── TdayCoreTests/ +``` + +### What Should Be Tested (iOS) + +| Area | Type | Priority | +|------|------|----------| +| SwiftData cache mapping | Unit | High | +| Repository data mapping (API → domain/cache) | Unit | High | +| Pending mutation creation/replay behavior | Unit | High | +| ViewModel state transitions | Unit | Medium | +| Local Mode server-only affordances | Unit/manual | High | +| Reminder scheduling helpers | Unit | Medium | +| Navigation/deep-link routing helpers | Unit | Medium | + +For visual polish, build the app and do a simulator/device spot check when automated UI tests are not practical. + ## Coverage Expectations ### Web @@ -306,6 +351,12 @@ Use backtick-quoted descriptive names: `should when