From 11221e0cab570eac6a19eb4f7e71857dfad3f4d2 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 08:50:58 -0400 Subject: [PATCH 01/52] Add agent project guidance --- AGENTS.md | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 163 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1d17721b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,162 @@ +# 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`. + +## Project Shape + +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. + +The native mobile apps should feel like one product expressed through two platform-native implementations. + +## How To Work In This Repo + +- Start by checking `git status --short --branch`. The worktree may already contain user changes. +- Never revert or overwrite user work unless explicitly asked. +- Avoid destructive git commands. Do not use `git reset --hard` or `git checkout --` to clean up. +- Prefer small, focused changes. Do not opportunistically refactor unrelated modules. +- 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. + +## Git And Attribution + +- Commits should be authored as the user's GitHub identity: + - `user.name=ohmzi` + - `user.email=6551272+ohmzi@users.noreply.github.com` +- Check the local git config before committing if attribution matters. +- Do not add AI trailers or tool attribution to commit messages. +- Do not use `--no-verify` to bypass hooks. Fix the hook or the commit message instead. +- Keep commit messages short and human, for example `Refine Android calendar paging polish`. + +## Cross-Platform UX Rule + +Any user-facing mobile change on Android or iOS should trigger a quick parity check on the other platform. + +Before finishing a mobile UI task, ask: + +- Does Android and iOS expose the same feature surface? +- 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? +- 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. + +## Calendar UX Contract + +The calendar is a cross-platform feature and should stay behaviorally aligned across Android and iOS. + +Core rules: + +- Modes are `Month`, `Week`, and `Day`. +- Default mode is `Month`. +- Week starts on Sunday. +- Navigation cannot go before the current month. +- The top-right calendar button jumps to today without changing the active mode. +- The FAB creates a task using the currently selected calendar date. +- 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`. + +Interaction rules: + +- Swipe, chevron buttons, and Today jumps should use the same horizontal paging motion. +- Headers should stay anchored. In month view, the month title and weekday row should not slide with the date grid. In week/day view, the period header should not slide with the date content. +- Animate only the changing calendar content, then commit the selected date/period after the page settles. +- Avoid fade-heavy redraws for calendar paging. It should feel like a native pager, not a full card replacement. +- Prevent rapid repeated taps from stacking broken page transitions. +- If jumping a long distance back to today, animate toward the target in the correct direction and settle directly on today. + +Relevant files: + +- iOS: `ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift` +- Android: `android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt` + +## Mobile UI Direction + +T'Day is a task app, not a marketing site. Mobile screens should feel quiet, useful, and polished. + +- Prefer direct usable UI over explanatory copy. +- Keep controls discoverable through familiar icons, clear labels, and expected placement. +- Use rounded typography and the existing soft, focused visual language. +- Keep cards purposeful. Do not nest decorative cards. +- Use haptics where the surrounding code already does for primary button actions. +- Text must fit in compact mobile layouts without overlap or truncation that hides meaning. +- Empty states should be calm and short. +- Preserve dark mode. + +## Design Tokens And Strings + +- Web strings live in i18next locale files. +- Android strings live in `android-compose/app/src/main/res/values/strings.xml`. +- iOS strings should follow the current local SwiftUI patterns until a broader localization layer exists. +- Prefer theme and dimension tokens over inline colors or magic sizes. +- If a new semantic color or repeated dimension is needed, add it to the platform's theme/token layer instead of scattering literals. +- Existing feature-scoped constants can remain when they are already part of that feature's local style, but do not expand hardcoded styling casually. + +## Architecture Expectations + +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. + +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. +- Use Compose idioms and Material 3. + +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. + +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. + +## Verification Commands + +Run the smallest meaningful verification for the change, then broaden when risk is higher. + +Common commands: + +```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 mobile UI changes, prefer running the platform build even when there are no dedicated UI tests. If a simulator/device visual check is practical, do it. + +## Review Checklist Before Final Response + +- `git status --short --branch` is clean or only contains intentional uncommitted changes. +- User-facing behavior is aligned across Android and iOS when the feature exists on both. +- Strings, colors, and dimensions follow the project conventions. +- No secrets, build outputs, dependency folders, or generated artifacts were added. +- Tests/builds relevant to the touched area were run, or skipped with a clear reason. +- If committed, the commit author is `ohmzi <6551272+ohmzi@users.noreply.github.com>`. +- If pushed/opened as a PR, report the branch, commit, and PR URL. diff --git a/README.md b/README.md index ee9c95d9..bd8a5028 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Tday/ | Document | Purpose | |----------|---------| +| [`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/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | System design, domain boundaries, data flow | From 360baadbbe8b021d5909743567d09b7ef795b5a5 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 10:15:22 -0400 Subject: [PATCH 02/52] Implement elastic top spacing for the first pinned row in the iOS timeline screens. This update introduces a dynamic top inset for the first item in the first section of the timeline, providing a smoother visual transition ("elastic clearance") as the screen title collapses. - **Timeline UI Improvements**: - Implement `firstPinnedRowElasticTopInset` in `TodoListScreen` and `CompletedScreen` to calculate dynamic padding based on `titleCollapseProgress`. - Apply this inset specifically to the first item of the first section when certain view modes (Overdue, Scheduled, or specific "earlier" priority groups) are active. - Update `ForEach` loops in both screens to use enumerated arrays to identify the first item (`itemIndex == 0`) for padding application. - **Metrics & Constants**: - Add `firstPinnedRowElasticClearance` (34pt), `firstPinnedRowElasticStart` (0.42), and `firstPinnedRowElasticEnd` (1.0) to `TodoTimelineMetrics` to control the animation threshold and distance. --- .../Feature/Completed/CompletedScreen.swift | 16 ++++++++++- .../Tday/Feature/Todos/TodoListScreen.swift | 27 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index fbb7eb44..3d65315d 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -145,8 +145,9 @@ struct CompletedScreen: View { Section { if !isCollapsed { - ForEach(section.items) { item in + ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, item in completedTimelineRow(item) + .padding(.top, firstPinnedRowElasticTopInset(isFirstSection: isFirstSection, itemIndex: itemIndex)) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -173,6 +174,19 @@ struct CompletedScreen: View { } } + private func firstPinnedRowElasticTopInset(isFirstSection: Bool, itemIndex: Int) -> CGFloat { + guard isFirstSection, itemIndex == 0 else { + return 0 + } + + let progress = TodoTimelineMetrics.progress( + titleCollapseProgress, + from: TodoTimelineMetrics.firstPinnedRowElasticStart, + to: TodoTimelineMetrics.firstPinnedRowElasticEnd + ) + return TodoTimelineMetrics.firstPinnedRowElasticClearance * progress + } + private func completedTimelineRow(_ item: CompletedItem) -> some View { let completedDate = item.completedAt ?? item.due let showListIndicator = item.listName?.isEmpty == false diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index ee6853b8..a538444a 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -7,6 +7,9 @@ enum TodoTimelineMetrics { static let sectionTitleSize: CGFloat = 22 static let sectionChevronSize: CGFloat = 14 static let sectionSpacing: CGFloat = 10 + static let firstPinnedRowElasticClearance: CGFloat = 34 + static let firstPinnedRowElasticStart: CGFloat = 0.42 + static let firstPinnedRowElasticEnd: CGFloat = 1 static let minimalRowToggleSize: CGFloat = 24 static let minimalRowToggleFrame: CGFloat = 38 static let minimalRowTitleSize: CGFloat = 18 @@ -702,8 +705,9 @@ struct TodoListScreen: View { Section { if !isCollapsed { - ForEach(section.items) { todo in + ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section) + .padding(.top, firstPinnedRowElasticTopInset(section: section, isFirstSection: isFirstSection, itemIndex: itemIndex)) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -730,6 +734,27 @@ struct TodoListScreen: View { } } + private func firstPinnedRowElasticTopInset(section: TodoTimelineSection, isFirstSection: Bool, itemIndex: Int) -> CGFloat { + guard isFirstSection, itemIndex == 0 else { + return 0 + } + + let isOverdueFirstSection = viewModel.mode == .overdue + let isExpandedPriorityEarlier = viewModel.mode == .priority && section.id == "earlier" + let isExpandedAllTasksEarlier = viewModel.mode == .all && section.id == "earlier" + let isScheduledFirstSection = viewModel.mode == .scheduled + guard isOverdueFirstSection || isExpandedPriorityEarlier || isExpandedAllTasksEarlier || isScheduledFirstSection else { + return 0 + } + + let progress = TodoTimelineMetrics.progress( + titleCollapseProgress, + from: TodoTimelineMetrics.firstPinnedRowElasticStart, + to: TodoTimelineMetrics.firstPinnedRowElasticEnd + ) + return TodoTimelineMetrics.firstPinnedRowElasticClearance * progress + } + private func minimalTimelineSubtitle(for todo: TodoItem, in section: TodoTimelineSection) -> String { let timeText = todo.due.formatted(date: .omitted, time: .shortened) let dueBodyText = if viewModel.mode == .priority && section.id == "earlier" { From c04df7f42c4b980f89771e6facfc9a043558631a Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 10:36:16 -0400 Subject: [PATCH 03/52] Implement elastic header and row spacing for the iOS timeline screens. This update refactors the elastic clearance logic used in `CompletedScreen` and `TodoListScreen` to improve visual consistency during scroll interactions. It ensures that pinned headers and the first items in sections respect collapse progress metrics, while also introducing a custom button style for the timeline top bar. - **Elastic Layout Improvements**: - Rename `firstPinnedRowElastic` constants to `firstPinnedElastic` in `TodoTimelineMetrics` to reflect broader usage across headers and rows. - Implement `firstPinnedHeaderElasticTopInset` in both `CompletedScreen` and `TodoListScreen` to apply dynamic top padding to pinned section headers based on scroll progress. - Refactor row clearance into a reusable `firstPinnedElasticClearance` helper function. - Update `shouldApplyFirstPinnedElasticClearance` in `TodoListScreen` to include the "Today" view mode and specific section IDs ("earlier"). - **UI & Components**: - Introduce `TimelineTopBarButtonStyle`, a custom `ButtonStyle` that replaces `TdayPressButtonStyle` for top bar actions, featuring refined ripple effects, scale transforms, and dynamic shadows based on the button's fill state. - Update `minimalTimelineRow` in `TodoListScreen` to use an enumerated `ForEach` to correctly identify the first item in a section for applying elastic offsets. - **Refactoring**: - Consolidate logic for calculating top insets for pinned elements to reduce duplication and improve maintainability of the timeline's "sticky" behavior. --- .../Feature/Completed/CompletedScreen.swift | 32 ++++- .../Tday/Feature/Todos/TodoListScreen.swift | 122 ++++++++++++++---- 2 files changed, 127 insertions(+), 27 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index 3d65315d..39042163 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -168,23 +168,45 @@ struct CompletedScreen: View { } } ) - .listRowInsets(EdgeInsets(top: isFirstSection ? 0 : 8, leading: 0, bottom: 0, trailing: 0)) + .listRowInsets( + EdgeInsets( + top: firstPinnedHeaderElasticTopInset( + isFirstSection: isFirstSection, + defaultTopInset: isFirstSection ? 0 : 8 + ), + leading: 0, + bottom: 0, + trailing: 0 + ) + ) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } } + private func firstPinnedHeaderElasticTopInset(isFirstSection: Bool, defaultTopInset: CGFloat) -> CGFloat { + guard isFirstSection else { + return defaultTopInset + } + + return defaultTopInset + firstPinnedElasticClearance() + } + private func firstPinnedRowElasticTopInset(isFirstSection: Bool, itemIndex: Int) -> CGFloat { guard isFirstSection, itemIndex == 0 else { return 0 } - let progress = TodoTimelineMetrics.progress( + return firstPinnedElasticClearance() + } + + private func firstPinnedElasticClearance() -> CGFloat { + let elasticProgress = TodoTimelineMetrics.progress( titleCollapseProgress, - from: TodoTimelineMetrics.firstPinnedRowElasticStart, - to: TodoTimelineMetrics.firstPinnedRowElasticEnd + from: TodoTimelineMetrics.firstPinnedElasticStart, + to: TodoTimelineMetrics.firstPinnedElasticEnd ) - return TodoTimelineMetrics.firstPinnedRowElasticClearance * progress + return TodoTimelineMetrics.firstPinnedElasticClearance * elasticProgress } private func completedTimelineRow(_ item: CompletedItem) -> some View { diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index a538444a..de758b24 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -7,9 +7,9 @@ enum TodoTimelineMetrics { static let sectionTitleSize: CGFloat = 22 static let sectionChevronSize: CGFloat = 14 static let sectionSpacing: CGFloat = 10 - static let firstPinnedRowElasticClearance: CGFloat = 34 - static let firstPinnedRowElasticStart: CGFloat = 0.42 - static let firstPinnedRowElasticEnd: CGFloat = 1 + static let firstPinnedElasticClearance: CGFloat = 34 + static let firstPinnedElasticStart: CGFloat = 0.42 + static let firstPinnedElasticEnd: CGFloat = 1 static let minimalRowToggleSize: CGFloat = 24 static let minimalRowToggleFrame: CGFloat = 38 static let minimalRowTitleSize: CGFloat = 18 @@ -404,8 +404,9 @@ struct TodoListScreen: View { .listRowSeparator(.hidden) .allowsHitTesting(false) } else { - ForEach(section.items) { todo in + ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section) + .padding(.top, firstPinnedRowElasticTopInset(section: section, isFirstSection: index == 0, itemIndex: itemIndex)) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -417,7 +418,18 @@ struct TodoListScreen: View { title: section.title, isActiveDropTarget: activeDropSectionId == section.id ) - .listRowInsets(EdgeInsets(top: index == 0 ? 0 : 8, leading: 0, bottom: 0, trailing: 0)) + .listRowInsets( + EdgeInsets( + top: firstPinnedHeaderElasticTopInset( + section: section, + isFirstSection: index == 0, + defaultTopInset: index == 0 ? 0 : 8 + ), + leading: 0, + bottom: 0, + trailing: 0 + ) + ) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } @@ -728,31 +740,63 @@ struct TodoListScreen: View { } } : nil ) - .listRowInsets(EdgeInsets(top: isFirstSection ? 0 : 8, leading: 0, bottom: 0, trailing: 0)) + .listRowInsets( + EdgeInsets( + top: firstPinnedHeaderElasticTopInset( + section: section, + isFirstSection: isFirstSection, + defaultTopInset: isFirstSection ? 0 : 8 + ), + leading: 0, + bottom: 0, + trailing: 0 + ) + ) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } } + private func firstPinnedHeaderElasticTopInset(section: TodoTimelineSection, isFirstSection: Bool, defaultTopInset: CGFloat) -> CGFloat { + guard isFirstSection, shouldApplyFirstPinnedElasticClearance(to: section) else { + return defaultTopInset + } + + return defaultTopInset + firstPinnedElasticClearance() + } + private func firstPinnedRowElasticTopInset(section: TodoTimelineSection, isFirstSection: Bool, itemIndex: Int) -> CGFloat { guard isFirstSection, itemIndex == 0 else { return 0 } + guard shouldApplyFirstPinnedElasticClearance(to: section) else { + return 0 + } + + return firstPinnedElasticClearance() + } + + private func shouldApplyFirstPinnedElasticClearance(to section: TodoTimelineSection) -> Bool { let isOverdueFirstSection = viewModel.mode == .overdue + let isTodayFirstSection = viewModel.mode == .today let isExpandedPriorityEarlier = viewModel.mode == .priority && section.id == "earlier" let isExpandedAllTasksEarlier = viewModel.mode == .all && section.id == "earlier" let isScheduledFirstSection = viewModel.mode == .scheduled - guard isOverdueFirstSection || isExpandedPriorityEarlier || isExpandedAllTasksEarlier || isScheduledFirstSection else { - return 0 - } + return isOverdueFirstSection || + isTodayFirstSection || + isExpandedPriorityEarlier || + isExpandedAllTasksEarlier || + isScheduledFirstSection + } - let progress = TodoTimelineMetrics.progress( + private func firstPinnedElasticClearance() -> CGFloat { + let elasticProgress = TodoTimelineMetrics.progress( titleCollapseProgress, - from: TodoTimelineMetrics.firstPinnedRowElasticStart, - to: TodoTimelineMetrics.firstPinnedRowElasticEnd + from: TodoTimelineMetrics.firstPinnedElasticStart, + to: TodoTimelineMetrics.firstPinnedElasticEnd ) - return TodoTimelineMetrics.firstPinnedRowElasticClearance * progress + return TodoTimelineMetrics.firstPinnedElasticClearance * elasticProgress } private func minimalTimelineSubtitle(for todo: TodoItem, in section: TodoTimelineSection) -> String { @@ -956,19 +1000,53 @@ private struct TimelineTopBarButton: View { } .contentShape(Circle()) } - .buttonStyle( - chrome == .filled - ? TdayPressButtonStyle() - : TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0, - normalShadowOpacity: 0 - ) - ) + .buttonStyle(TimelineTopBarButtonStyle(isFilled: chrome == .filled)) .foregroundStyle(chrome == .filled ? colors.onSurface : Color.accentColor) } } +private struct TimelineTopBarButtonStyle: ButtonStyle { + let isFilled: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .tdayRippleEffect(isPressed: configuration.isPressed) + .scaleEffect(configuration.isPressed ? 0.95 : 1) + .offset(y: configuration.isPressed ? 1 : 0) + .shadow( + color: Color.black.opacity(shadowOpacity(isPressed: configuration.isPressed)), + radius: shadowRadius(isPressed: configuration.isPressed), + x: 0, + y: shadowOffsetY(isPressed: configuration.isPressed) + ) + .animation(.easeOut(duration: 0.14), value: configuration.isPressed) + } + + private func shadowOpacity(isPressed: Bool) -> Double { + guard isFilled else { + return 0 + } + + return isPressed ? 0.04 : 0.08 + } + + private func shadowRadius(isPressed: Bool) -> CGFloat { + guard isFilled else { + return 0 + } + + return isPressed ? 3 : 7 + } + + private func shadowOffsetY(isPressed: Bool) -> CGFloat { + guard isFilled else { + return 0 + } + + return isPressed ? 1 : 3 + } +} + private struct TimelineScrollOffsetTrackingRow: View { let onChange: (CGFloat) -> Void From 00c924837415f704f4406123e3cf61eaad945c79 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 10:50:53 -0400 Subject: [PATCH 04/52] Implement elastic top insets for the mode tabs in the iOS Calendar screen. This change introduces a dynamic top inset for the mode tabs that responds to the calendar's scroll progress, ensuring smoother visual transitions as the title collapses. - **Animation & Metrics**: - Add `modeTabsElasticClearance`, `modeTabsElasticStart`, and `modeTabsElasticEnd` constants to `CalendarTitleHandoff` to define the elastic animation range. - Implement `modeTabsElasticTopInset` computed property to calculate the dynamic offset based on `titleCollapseProgress`. - **UI Integration**: Update the `CalendarModeTabs` list row in `CalendarScreen.swift` to apply the calculated elastic top padding. --- .../Feature/Calendar/CalendarScreen.swift | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 7c16da0e..fff71afd 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -4,6 +4,9 @@ import UIKit private enum CalendarTitleHandoff { static let pinnedRevealStart: CGFloat = 0.18 static let pinnedRevealEnd: CGFloat = 0.62 + static let modeTabsElasticClearance: CGFloat = TodoTimelineMetrics.titleCollapseDistance + static let modeTabsElasticStart: CGFloat = 0 + static let modeTabsElasticEnd: CGFloat = 1 } private let calendarNativePagerCenterIndex = 1 @@ -68,6 +71,15 @@ struct CalendarScreen: View { return min(max(calendarScrollOffset / distance, 0), 1) } + private var modeTabsElasticTopInset: CGFloat { + let elasticProgress = TodoTimelineMetrics.progress( + titleCollapseProgress, + from: CalendarTitleHandoff.modeTabsElasticStart, + to: CalendarTitleHandoff.modeTabsElasticEnd + ) + return CalendarTitleHandoff.modeTabsElasticClearance * elasticProgress + } + private var minimumNavigableMonth: Date { calendarMonthStart(for: Date()) } @@ -106,7 +118,14 @@ struct CalendarScreen: View { } } ) - .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 14, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowInsets( + EdgeInsets( + top: modeTabsElasticTopInset, + leading: TodoTimelineMetrics.horizontalPadding, + bottom: 14, + trailing: TodoTimelineMetrics.horizontalPadding + ) + ) .listRowBackground(Color.clear) .listRowSeparator(.hidden) From 18a33464786adf8a6abd654816baf5fd078dfde2 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 10:54:56 -0400 Subject: [PATCH 05/52] Refine calendar day cell interactions in the Android Compose client. This change improves the visual feedback and touch behavior for date cells within the `CalendarScreen` by implementing a bounded ripple effect and ensuring proper clipping. - **UI & Interaction**: - Update `CalendarDayCell` to use a bounded `ripple` indication with a fixed radius of 28.dp. - Explicitly clip the cell to its defined `cellShape` to ensure the click interaction and ripple are contained within the cell boundaries. - Implement `MutableInteractionSource` for the cell's clickable modifier. --- .../tday/compose/feature/calendar/CalendarScreen.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 015a8da4..6bb56de0 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 @@ -1278,11 +1278,13 @@ private fun CalendarDayCell( cell.isCurrentMonth -> colorScheme.onSurface else -> colorScheme.onSurfaceVariant.copy(alpha = 0.45f) } + val interactionSource = remember { MutableInteractionSource() } Box( modifier = modifier .minimumInteractiveComponentSize() .aspectRatio(1f) + .clip(cellShape) .background( color = containerColor, shape = cellShape, @@ -1292,7 +1294,14 @@ private fun CalendarDayCell( color = borderColor, shape = cellShape, ) - .clickable(onClick = onClick), + .clickable( + interactionSource = interactionSource, + indication = ripple( + bounded = true, + radius = 28.dp, + ), + onClick = onClick, + ), ) { Text( text = cell.date.dayOfMonth.toString(), From 757832b5b83bcca29ef54964fdd62374d3faed92 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 10:57:03 -0400 Subject: [PATCH 06/52] Refactor calendar view mode selection to use native segmented controls on iOS and Android. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This update replaces custom-built tab implementations with platform-standard components—`Picker` on iOS and `SegmentedButton` on Android. This change simplifies the UI logic while maintaining a consistent visual identity across both platforms. - **iOS (SwiftUI)**: - Replace the custom `HStack` and `Button` layout in `CalendarViewModeTabs` with a native `Picker` using the `.segmented` picker style. - Add an `accentColor` parameter to `CalendarViewModeTabs` to drive the picker's tint. - Implement a local `Binding` to bridge the picker’s selection state with the existing `onSelect` callback. - **Android (Compose)**: - Replace the custom `Card` and `Row` implementation with Material 3 `SingleChoiceSegmentedButtonRow` and `SegmentedButton`. - Define a custom color scheme for the segmented buttons using `CalendarAccentPurple` for active states and standard surface colors for inactive states. - Simplify the layout logic by using `SegmentedButtonDefaults.itemShape` and removing manual padding and background management. --- .../feature/calendar/CalendarScreen.kt | 59 +++++++++---------- .../Feature/Calendar/CalendarScreen.swift | 38 +++++------- 2 files changed, 41 insertions(+), 56 deletions(-) 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 6bb56de0..30058509 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 @@ -79,6 +79,9 @@ import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.ripple @@ -491,50 +494,42 @@ private enum class CalendarViewMode { DAY, } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CalendarViewModeTabs( selectedMode: CalendarViewMode, onModeSelected: (CalendarViewMode) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - Card( + val segmentColors = SegmentedButtonDefaults.colors( + activeContainerColor = CalendarAccentPurple.copy(alpha = 0.18f), + activeContentColor = CalendarAccentPurple, + activeBorderColor = CalendarAccentPurple.copy(alpha = 0.62f), + inactiveContainerColor = colorScheme.surface, + inactiveContentColor = colorScheme.onSurfaceVariant, + inactiveBorderColor = colorScheme.outline.copy(alpha = 0.42f), + ) + SingleChoiceSegmentedButtonRow( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surfaceVariant.copy(alpha = 0.55f)), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(5.dp), - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - CalendarViewMode.entries.forEach { mode -> - val selected = mode == selectedMode - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(14.dp)) - .background( - color = if (selected) { - colorScheme.background - } else { - Color.Transparent - }, - ) - .sizeIn(minWidth = 48.dp, minHeight = 48.dp) - .clickable { onModeSelected(mode) } - .padding(vertical = 10.dp), - contentAlignment = Alignment.Center, - ) { + CalendarViewMode.entries.forEachIndexed { index, mode -> + val selected = mode == selectedMode + SegmentedButton( + selected = selected, + onClick = { onModeSelected(mode) }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = CalendarViewMode.entries.size, + ), + colors = segmentColors, + icon = {}, + label = { Text( text = mode.name.lowercase(Locale.getDefault()).replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.ExtraBold, - color = if (selected) colorScheme.onSurface else colorScheme.onSurfaceVariant, ) - } - } + }, + ) } } } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index fff71afd..22225a0e 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -109,6 +109,7 @@ struct CalendarScreen: View { Section { CalendarViewModeTabs( selectedMode: displayMode, + accentColor: calendarAccentColor, onSelect: { mode in withAnimation(.easeInOut(duration: 0.2)) { displayMode = mode @@ -363,36 +364,25 @@ struct CalendarScreen: View { private struct CalendarViewModeTabs: View { let selectedMode: CalendarDisplayMode + let accentColor: Color let onSelect: (CalendarDisplayMode) -> Void - @Environment(\.tdayColors) private var colors - var body: some View { - HStack(spacing: 5) { + Picker("Calendar view", selection: selectedModeBinding) { ForEach(CalendarDisplayMode.allCases, id: \.self) { mode in - let isSelected = mode == selectedMode - Button { - onSelect(mode) - } label: { - Text(mode.rawValue.capitalized) - .font(.tdayRounded(size: 15, weight: .heavy)) - .foregroundStyle(isSelected ? colors.onSurface : colors.onSurfaceVariant.opacity(0.86)) - .frame(maxWidth: .infinity, minHeight: 48) - .background { - if isSelected { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(colors.background) - .shadow(color: Color.black.opacity(0.04), radius: 8, x: 0, y: 3) - } - } - .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } - .buttonStyle(.plain) - .accessibilityAddTraits(isSelected ? .isSelected : []) + Text(mode.rawValue.capitalized) + .tag(mode) } } - .padding(5) - .background(colors.surfaceVariant.opacity(0.55), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .pickerStyle(.segmented) + .tint(accentColor) + } + + private var selectedModeBinding: Binding { + Binding( + get: { selectedMode }, + set: { onSelect($0) } + ) } } From 1711b818619bc27757e3f6803724979191b6fa67 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 11:00:54 -0400 Subject: [PATCH 07/52] Refactor calendar title and mode tab layout in the iOS SwiftUI client. This update simplifies the layout logic for the `CalendarScreen` by removing elastic inset calculations for the mode tabs and standardizing the title row height. - **Layout & Metrics**: - Replace `expandedTitleHeight` with a local `titleRowHeight` constant (mapped to `TodoTimelineMetrics.titleCollapseDistance`) for the calendar header. - Remove `modeTabsElasticTopInset` logic, including its associated progress calculation and constants (`modeTabsElasticStart`, `modeTabsElasticEnd`, and `modeTabsElasticClearance`). - Set the top inset of the task mode tabs to a static `0`, removing the dynamic shifting behavior during scroll. - **UI Consistency**: Ensure the header title frame explicitly uses the new `titleRowHeight` for both minimum and maximum height constraints. --- .../Feature/Calendar/CalendarScreen.swift | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 22225a0e..64315251 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -4,9 +4,7 @@ import UIKit private enum CalendarTitleHandoff { static let pinnedRevealStart: CGFloat = 0.18 static let pinnedRevealEnd: CGFloat = 0.62 - static let modeTabsElasticClearance: CGFloat = TodoTimelineMetrics.titleCollapseDistance - static let modeTabsElasticStart: CGFloat = 0 - static let modeTabsElasticEnd: CGFloat = 1 + static let titleRowHeight: CGFloat = TodoTimelineMetrics.titleCollapseDistance } private let calendarNativePagerCenterIndex = 1 @@ -71,15 +69,6 @@ struct CalendarScreen: View { return min(max(calendarScrollOffset / distance, 0), 1) } - private var modeTabsElasticTopInset: CGFloat { - let elasticProgress = TodoTimelineMetrics.progress( - titleCollapseProgress, - from: CalendarTitleHandoff.modeTabsElasticStart, - to: CalendarTitleHandoff.modeTabsElasticEnd - ) - return CalendarTitleHandoff.modeTabsElasticClearance * elasticProgress - } - private var minimumNavigableMonth: Date { calendarMonthStart(for: Date()) } @@ -121,7 +110,7 @@ struct CalendarScreen: View { ) .listRowInsets( EdgeInsets( - top: modeTabsElasticTopInset, + top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 14, trailing: TodoTimelineMetrics.horizontalPadding @@ -1411,8 +1400,8 @@ private struct CalendarExpandedTitleRow: View { .lineLimit(1) .frame( maxWidth: .infinity, - minHeight: TodoTimelineMetrics.expandedTitleHeight, - maxHeight: TodoTimelineMetrics.expandedTitleHeight, + minHeight: CalendarTitleHandoff.titleRowHeight, + maxHeight: CalendarTitleHandoff.titleRowHeight, alignment: .topLeading ) .opacity(Double(1 - fadeProgress)) From 48f7c3450262133922eb245b7e41f422a80556d7 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 11:08:59 -0400 Subject: [PATCH 08/52] Redesign the calendar view mode tabs and refine the mini-calendar UI for the Android Compose client. This update replaces the standard Material 3 `SingleChoiceSegmentedButtonRow` with a custom-built animated selector and adjusts the layout spacing and dimensions of the calendar navigation cards for a more polished look. - **Calendar View Mode Tabs**: - Implement a custom `CalendarViewModeTabs` using a sliding background selector with `animateDpAsState`. - Add tactile feedback using scale animations and custom ripple effects on selection. - Enhance visual styling with a semi-transparent `surfaceVariant` background, custom shadows, and `CalendarAccentPurple` highlights. - Replace the `ExperimentalMaterial3Api` segmented buttons with a manual `Row` and `selectableGroup` implementation. - **Mini-Calendar UI Refinement**: - Adjust `RoundedCornerShape` values across calendar cards from 28.dp to 24.dp. - Standardize card heights and increase vertical spacing/padding for better visual balance. - Update `CalendarDayItem` shape and height to match the new layout constraints. - Add explicit height constraints to navigation rows and animated content containers to prevent layout jumping during transitions. --- .../feature/calendar/CalendarScreen.kt | 169 ++++++++++++++---- 1 file changed, 130 insertions(+), 39 deletions(-) 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 30058509..af125907 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 @@ -2,6 +2,7 @@ package com.ohmz.tday.compose.feature.calendar import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -28,6 +29,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 @@ -41,8 +43,11 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +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 @@ -79,9 +84,6 @@ import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.ripple @@ -99,6 +101,7 @@ 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.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -111,6 +114,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration @@ -494,42 +498,121 @@ private enum class CalendarViewMode { DAY, } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CalendarViewModeTabs( selectedMode: CalendarViewMode, onModeSelected: (CalendarViewMode) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val segmentColors = SegmentedButtonDefaults.colors( - activeContainerColor = CalendarAccentPurple.copy(alpha = 0.18f), - activeContentColor = CalendarAccentPurple, - activeBorderColor = CalendarAccentPurple.copy(alpha = 0.62f), - inactiveContainerColor = colorScheme.surface, - inactiveContentColor = colorScheme.onSurfaceVariant, - inactiveBorderColor = colorScheme.outline.copy(alpha = 0.42f), - ) - SingleChoiceSegmentedButtonRow( - modifier = Modifier.fillMaxWidth(), + val modes = CalendarViewMode.entries + val selectedIndex = modes.indexOf(selectedMode).coerceAtLeast(0) + val containerShape = RoundedCornerShape(22.dp) + val selectorShape = RoundedCornerShape(18.dp) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(58.dp) + .clip(containerShape) + .background(colorScheme.surfaceVariant.copy(alpha = 0.5f), containerShape) + .border( + width = 1.dp, + color = colorScheme.surface.copy(alpha = 0.62f), + shape = containerShape, + ) + .padding(5.dp), ) { - CalendarViewMode.entries.forEachIndexed { index, mode -> - val selected = mode == selectedMode - SegmentedButton( - selected = selected, - onClick = { onModeSelected(mode) }, - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = CalendarViewMode.entries.size, + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val segmentWidth = maxWidth / modes.size + val selectedOffset by animateDpAsState( + targetValue = segmentWidth * selectedIndex, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow, ), - colors = segmentColors, - icon = {}, - label = { - Text( - text = mode.name.lowercase(Locale.getDefault()).replaceFirstChar { it.uppercase() }, - fontWeight = FontWeight.ExtraBold, + label = "calendarViewModeSelectorOffset", + ) + + Box( + modifier = Modifier + .offset(x = selectedOffset) + .width(segmentWidth) + .fillMaxSize() + .padding(2.dp) + .shadow( + elevation = 12.dp, + shape = selectorShape, + ambientColor = CalendarAccentPurple.copy(alpha = 0.16f), + spotColor = Color.Black.copy(alpha = 0.14f), + ) + .clip(selectorShape) + .background(colorScheme.surface.copy(alpha = 0.92f), selectorShape) + .background(CalendarAccentPurple.copy(alpha = 0.08f), selectorShape) + .border( + width = 1.dp, + color = colorScheme.surface.copy(alpha = 0.82f), + shape = selectorShape, ) - }, ) + + Row( + modifier = Modifier + .fillMaxSize() + .selectableGroup(), + ) { + modes.forEach { mode -> + val selected = mode == selectedMode + val interactionSource = remember(mode) { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.96f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow, + ), + label = "calendarViewModePressScale", + ) + val contentColor by animateColorAsState( + targetValue = if (selected) { + CalendarAccentPurple + } else { + colorScheme.onSurfaceVariant.copy(alpha = 0.88f) + }, + label = "calendarViewModeContentColor", + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .clip(selectorShape) + .selectable( + selected = selected, + onClick = { onModeSelected(mode) }, + role = Role.RadioButton, + interactionSource = interactionSource, + indication = ripple( + bounded = true, + radius = 28.dp, + color = CalendarAccentPurple, + ), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = mode.name.lowercase(Locale.getDefault()) + .replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.ExtraBold, + color = contentColor, + ) + } + } + } } } } @@ -588,18 +671,20 @@ private fun CalendarWeekCard( }, ) }, - shape = RoundedCornerShape(28.dp), + shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding(start = 14.dp, top = 16.dp, end = 14.dp, bottom = 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(36.dp), verticalAlignment = Alignment.CenterVertically, ) { MiniCalendarNavButton( @@ -630,6 +715,7 @@ private fun CalendarWeekCard( targetState = weekStart, modifier = Modifier .fillMaxWidth() + .height(78.dp) .graphicsLayer { translationX = dragTranslationX }, transitionSpec = { val movingToFuture = targetState > initialState @@ -697,8 +783,10 @@ private fun CalendarWeekDayCell( } Card( - modifier = modifier.minimumInteractiveComponentSize(), - shape = RoundedCornerShape(14.dp), + modifier = modifier + .height(78.dp) + .minimumInteractiveComponentSize(), + shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = containerColor), border = BorderStroke( width = if (borderColor == Color.Transparent) 0.dp else 1.dp, @@ -791,18 +879,20 @@ private fun CalendarDayCard( }, ) }, - shape = RoundedCornerShape(28.dp), + shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 22.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(36.dp), verticalAlignment = Alignment.CenterVertically, ) { MiniCalendarNavButton( @@ -833,6 +923,7 @@ private fun CalendarDayCard( targetState = selectedDate, modifier = Modifier .fillMaxWidth() + .height(70.dp) .graphicsLayer { translationX = dragTranslationX }, transitionSpec = { val movingToFuture = targetState > initialState From 769e8700821556ecdf900fad8324fe160935818b Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 11:33:10 -0400 Subject: [PATCH 09/52] Refine the calendar UI and task timeline interactions across Android and iOS platforms. This update introduces a custom segmented control for calendar view modes on iOS, enhances the visual feedback of the selection UI on Android, and refactors timeline header elasticity to improve list scrolling behavior. - **iOS Calendar & UI**: - Implement `ThickSegmentedControl`, a custom `UISegmentedControl` subclass with a fixed 52pt height for improved touch targets. - Replace the standard SwiftUI `Picker` with a `UIViewRepresentable` implementation of the new thick control. - Add `tint` support to `TimelineTopBarAction` and `TimelineTopBarButton` to allow accent-colored header actions. - Apply the calendar accent color to the "Jump to Today" button. - **Android Calendar UI**: - Enhance `CalendarViewModeSelector` with sophisticated press animations, including scaling effects and dynamic alpha adjustments for surface and accent layers. - Implement a "press halo" background effect for unselected segments when pressed. - Integrate haptic feedback (`CLOCK_TICK`) when switching between calendar modes. - Update the "Jump to Today" top bar button to support accent-colored styling with a dedicated border and tint. - **Task Timeline Refinement**: - Remove elastic top insets from pinned section headers in `TodoListScreen` and `CompletedScreen`, applying the clearance solely to the first row of the section. - Rename elastic metrics from `firstPinnedElastic*` to `firstPinnedRowElastic*` to more accurately reflect their application to list items rather than headers. - Simplify `EdgeInsets` logic in timeline lists to use static offsets for non-primary sections. --- .../feature/calendar/CalendarScreen.kt | 145 +++++++++++++++--- .../Feature/Calendar/CalendarScreen.swift | 91 +++++++++-- .../Feature/Completed/CompletedScreen.swift | 23 +-- .../Tday/Feature/Todos/TodoListScreen.swift | 59 ++++--- 4 files changed, 235 insertions(+), 83 deletions(-) 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 af125907..854fd51e 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 @@ -504,10 +504,14 @@ private fun CalendarViewModeTabs( onModeSelected: (CalendarViewMode) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme + val view = LocalView.current val modes = CalendarViewMode.entries val selectedIndex = modes.indexOf(selectedMode).coerceAtLeast(0) val containerShape = RoundedCornerShape(22.dp) val selectorShape = RoundedCornerShape(18.dp) + val interactionSources = remember { + modes.associateWith { MutableInteractionSource() } + } Box( modifier = Modifier @@ -524,6 +528,21 @@ private fun CalendarViewModeTabs( ) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val segmentWidth = maxWidth / modes.size + val monthPressed by interactionSources + .getValue(CalendarViewMode.MONTH) + .collectIsPressedAsState() + val weekPressed by interactionSources + .getValue(CalendarViewMode.WEEK) + .collectIsPressedAsState() + val dayPressed by interactionSources + .getValue(CalendarViewMode.DAY) + .collectIsPressedAsState() + val pressedMode = when { + monthPressed -> CalendarViewMode.MONTH + weekPressed -> CalendarViewMode.WEEK + dayPressed -> CalendarViewMode.DAY + else -> null + } val selectedOffset by animateDpAsState( targetValue = segmentWidth * selectedIndex, animationSpec = spring( @@ -532,6 +551,24 @@ private fun CalendarViewModeTabs( ), label = "calendarViewModeSelectorOffset", ) + val selectorScale by animateFloatAsState( + targetValue = if (pressedMode == selectedMode) 0.985f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "calendarViewModeSelectorPressScale", + ) + val selectorSurfaceAlpha by animateFloatAsState( + targetValue = if (pressedMode == selectedMode) 0.98f else 0.92f, + animationSpec = tween(durationMillis = 140, easing = FastOutSlowInEasing), + label = "calendarViewModeSelectorSurfaceAlpha", + ) + val selectorAccentAlpha by animateFloatAsState( + targetValue = if (pressedMode == selectedMode) 0.15f else 0.08f, + animationSpec = tween(durationMillis = 140, easing = FastOutSlowInEasing), + label = "calendarViewModeSelectorAccentAlpha", + ) Box( modifier = Modifier @@ -539,6 +576,10 @@ private fun CalendarViewModeTabs( .width(segmentWidth) .fillMaxSize() .padding(2.dp) + .graphicsLayer { + scaleX = selectorScale + scaleY = selectorScale + } .shadow( elevation = 12.dp, shape = selectorShape, @@ -546,8 +587,14 @@ private fun CalendarViewModeTabs( spotColor = Color.Black.copy(alpha = 0.14f), ) .clip(selectorShape) - .background(colorScheme.surface.copy(alpha = 0.92f), selectorShape) - .background(CalendarAccentPurple.copy(alpha = 0.08f), selectorShape) + .background( + colorScheme.surface.copy(alpha = selectorSurfaceAlpha), + selectorShape + ) + .background( + CalendarAccentPurple.copy(alpha = selectorAccentAlpha), + selectorShape + ) .border( width = 1.dp, color = colorScheme.surface.copy(alpha = 0.82f), @@ -562,15 +609,31 @@ private fun CalendarViewModeTabs( ) { modes.forEach { mode -> val selected = mode == selectedMode - val interactionSource = remember(mode) { MutableInteractionSource() } + val interactionSource = interactionSources.getValue(mode) val isPressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.96f else 1f, + val contentScale by animateFloatAsState( + targetValue = if (isPressed) 0.98f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow, + stiffness = Spring.StiffnessMediumLow, + ), + label = "calendarViewModeContentPressScale", + ) + val pressHaloAlpha by animateFloatAsState( + targetValue = if (isPressed && !selected) 1f else 0f, + animationSpec = tween( + durationMillis = if (isPressed && !selected) 90 else 190, + easing = FastOutSlowInEasing, ), - label = "calendarViewModePressScale", + label = "calendarViewModePressHaloAlpha", + ) + val pressHaloScale by animateFloatAsState( + targetValue = if (isPressed && !selected) 1f else 0.92f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "calendarViewModePressHaloScale", ) val contentColor by animateColorAsState( targetValue = if (selected) { @@ -585,30 +648,52 @@ private fun CalendarViewModeTabs( modifier = Modifier .weight(1f) .fillMaxSize() - .graphicsLayer { - scaleX = scale - scaleY = scale - } .clip(selectorShape) .selectable( selected = selected, - onClick = { onModeSelected(mode) }, + onClick = { + if (!selected) { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + } + onModeSelected(mode) + }, role = Role.RadioButton, interactionSource = interactionSource, - indication = ripple( - bounded = true, - radius = 28.dp, - color = CalendarAccentPurple, - ), + indication = null, ), contentAlignment = Alignment.Center, ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 6.dp, vertical = 3.dp) + .graphicsLayer { + alpha = pressHaloAlpha + scaleX = pressHaloScale + scaleY = pressHaloScale + } + .clip(selectorShape) + .background(colorScheme.surface.copy(alpha = 0.62f), selectorShape) + .background(CalendarAccentPurple.copy(alpha = 0.10f), selectorShape) + .border( + width = 1.dp, + color = colorScheme.surface.copy(alpha = 0.76f), + shape = selectorShape, + ) + ) Text( text = mode.name.lowercase(Locale.getDefault()) .replaceFirstChar { it.uppercase() }, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.ExtraBold, color = contentColor, + modifier = Modifier.graphicsLayer { + scaleX = contentScale + scaleY = contentScale + }, ) } } @@ -1031,6 +1116,7 @@ private fun CalendarTopBar( icon = Icons.Rounded.CalendarMonth, contentDescription = stringResource(R.string.calendar_jump_to_today), onClick = onJumpToday, + isAccentButton = true, ) } if (collapsedTitleAlpha > 0.001f) { @@ -1077,18 +1163,31 @@ private fun CalendarCircleButton( contentDescription: String, onClick: () -> Unit, isBackButton: Boolean = false, + isAccentButton: Boolean = false, ) { val colorScheme = MaterialTheme.colorScheme val view = androidx.compose.ui.platform.LocalView.current val interactionSource = remember { MutableInteractionSource() } val pressed by interactionSource.collectIsPressedAsState() val isDarkTheme = colorScheme.background.luminance() < 0.5f - val containerColor = if (isBackButton) { - if (isDarkTheme) colorScheme.surface.copy(alpha = 0.94f) else Color.White.copy(alpha = 0.96f) - } else { - colorScheme.background + val containerColor = when { + isBackButton -> if (isDarkTheme) colorScheme.surface.copy(alpha = 0.94f) else Color.White.copy( + alpha = 0.96f + ) + + isAccentButton -> CalendarAccentPurple.copy(alpha = if (isDarkTheme) 0.22f else 0.12f) + else -> colorScheme.background + } + val buttonBorder = when { + isBackButton -> null + isAccentButton -> BorderStroke( + 1.dp, + CalendarAccentPurple.copy(alpha = if (isDarkTheme) 0.62f else 0.48f) + ) + + else -> BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.34f)) } - val buttonBorder = if (isBackButton) null else BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.34f)) + val iconTint = if (isAccentButton) CalendarAccentPurple else colorScheme.onSurface val buttonSize = if (isBackButton) TdayDimens.FabSize else 54.dp val iconSize = if (isBackButton) 36.dp else 28.dp val scale by animateFloatAsState( @@ -1127,7 +1226,7 @@ private fun CalendarCircleButton( Icon( imageVector = icon, contentDescription = contentDescription, - tint = colorScheme.onSurface, + tint = iconTint, modifier = Modifier.size(iconSize), ) } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 64315251..f943177d 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -7,6 +7,10 @@ private enum CalendarTitleHandoff { static let titleRowHeight: CGFloat = TodoTimelineMetrics.titleCollapseDistance } +private enum CalendarModeControlMetrics { + static let height: CGFloat = 52 +} + private let calendarNativePagerCenterIndex = 1 private struct CalendarTodayJumpRequest: Equatable { @@ -248,6 +252,7 @@ struct CalendarScreen: View { onBack: { dismiss() }, action: TimelineTopBarAction( systemName: "calendar", + tint: calendarAccentColor, action: jumpToToday ), titleRevealStart: CalendarTitleHandoff.pinnedRevealStart, @@ -357,22 +362,86 @@ private struct CalendarViewModeTabs: View { let onSelect: (CalendarDisplayMode) -> Void var body: some View { - Picker("Calendar view", selection: selectedModeBinding) { - ForEach(CalendarDisplayMode.allCases, id: \.self) { mode in - Text(mode.rawValue.capitalized) - .tag(mode) - } + CalendarThickNativeSegmentedControl( + selectedMode: selectedMode, + accentColor: accentColor, + onSelect: onSelect + ) + .frame(maxWidth: .infinity) + .frame(height: CalendarModeControlMetrics.height) + } +} + +private struct CalendarThickNativeSegmentedControl: UIViewRepresentable { + let selectedMode: CalendarDisplayMode + let accentColor: Color + let onSelect: (CalendarDisplayMode) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onSelect: onSelect) + } + + func makeUIView(context: Context) -> ThickSegmentedControl { + let control = ThickSegmentedControl(items: CalendarDisplayMode.allCases.map { $0.rawValue.capitalized }) + control.selectedSegmentIndex = selectedModeIndex + control.apportionsSegmentWidthsByContent = false + control.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .valueChanged) + applySizingAndTint(to: control) + return control + } + + func updateUIView(_ control: ThickSegmentedControl, context: Context) { + context.coordinator.onSelect = onSelect + if control.selectedSegmentIndex != selectedModeIndex { + control.selectedSegmentIndex = selectedModeIndex } - .pickerStyle(.segmented) - .tint(accentColor) + applySizingAndTint(to: control) } - private var selectedModeBinding: Binding { - Binding( - get: { selectedMode }, - set: { onSelect($0) } + func sizeThatFits(_ proposal: ProposedViewSize, uiView: ThickSegmentedControl, context: Context) -> CGSize? { + CGSize( + width: proposal.width ?? uiView.intrinsicContentSize.width, + height: CalendarModeControlMetrics.height ) } + + private var selectedModeIndex: Int { + CalendarDisplayMode.allCases.firstIndex(of: selectedMode) ?? 0 + } + + private func applySizingAndTint(to control: ThickSegmentedControl) { + control.tintColor = UIColor(accentColor) + control.invalidateIntrinsicContentSize() + } + + final class Coordinator: NSObject { + var onSelect: (CalendarDisplayMode) -> Void + + init(onSelect: @escaping (CalendarDisplayMode) -> Void) { + self.onSelect = onSelect + } + + @objc func didChange(_ sender: UISegmentedControl) { + let modes = CalendarDisplayMode.allCases + guard modes.indices.contains(sender.selectedSegmentIndex) else { + return + } + onSelect(modes[sender.selectedSegmentIndex]) + } + } + + final class ThickSegmentedControl: UISegmentedControl { + override var intrinsicContentSize: CGSize { + let baseSize = super.intrinsicContentSize + return CGSize(width: baseSize.width, height: CalendarModeControlMetrics.height) + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + var fittingSize = super.sizeThatFits(size) + fittingSize.height = CalendarModeControlMetrics.height + return fittingSize + } + } } private struct CalendarMonthGrid: View { diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index 39042163..f2b0631f 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -170,10 +170,7 @@ struct CompletedScreen: View { ) .listRowInsets( EdgeInsets( - top: firstPinnedHeaderElasticTopInset( - isFirstSection: isFirstSection, - defaultTopInset: isFirstSection ? 0 : 8 - ), + top: isFirstSection ? 0 : 8, leading: 0, bottom: 0, trailing: 0 @@ -184,29 +181,17 @@ struct CompletedScreen: View { } } - private func firstPinnedHeaderElasticTopInset(isFirstSection: Bool, defaultTopInset: CGFloat) -> CGFloat { - guard isFirstSection else { - return defaultTopInset - } - - return defaultTopInset + firstPinnedElasticClearance() - } - private func firstPinnedRowElasticTopInset(isFirstSection: Bool, itemIndex: Int) -> CGFloat { guard isFirstSection, itemIndex == 0 else { return 0 } - return firstPinnedElasticClearance() - } - - private func firstPinnedElasticClearance() -> CGFloat { let elasticProgress = TodoTimelineMetrics.progress( titleCollapseProgress, - from: TodoTimelineMetrics.firstPinnedElasticStart, - to: TodoTimelineMetrics.firstPinnedElasticEnd + from: TodoTimelineMetrics.firstPinnedRowElasticStart, + to: TodoTimelineMetrics.firstPinnedRowElasticEnd ) - return TodoTimelineMetrics.firstPinnedElasticClearance * elasticProgress + return TodoTimelineMetrics.firstPinnedRowElasticClearance * elasticProgress } private func completedTimelineRow(_ item: CompletedItem) -> some View { diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index de758b24..6230c39a 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -7,9 +7,9 @@ enum TodoTimelineMetrics { static let sectionTitleSize: CGFloat = 22 static let sectionChevronSize: CGFloat = 14 static let sectionSpacing: CGFloat = 10 - static let firstPinnedElasticClearance: CGFloat = 34 - static let firstPinnedElasticStart: CGFloat = 0.42 - static let firstPinnedElasticEnd: CGFloat = 1 + static let firstPinnedRowElasticClearance: CGFloat = 34 + static let firstPinnedRowElasticStart: CGFloat = 0.42 + static let firstPinnedRowElasticEnd: CGFloat = 1 static let minimalRowToggleSize: CGFloat = 24 static let minimalRowToggleFrame: CGFloat = 38 static let minimalRowTitleSize: CGFloat = 18 @@ -62,7 +62,14 @@ struct TimelineRowDivider: View { struct TimelineTopBarAction { let systemName: String + let tint: Color? let action: () -> Void + + init(systemName: String, tint: Color? = nil, action: @escaping () -> Void) { + self.systemName = systemName + self.tint = tint + self.action = action + } } struct TodoListScreen: View { @@ -420,11 +427,7 @@ struct TodoListScreen: View { ) .listRowInsets( EdgeInsets( - top: firstPinnedHeaderElasticTopInset( - section: section, - isFirstSection: index == 0, - defaultTopInset: index == 0 ? 0 : 8 - ), + top: index == 0 ? 0 : 8, leading: 0, bottom: 0, trailing: 0 @@ -742,11 +745,7 @@ struct TodoListScreen: View { ) .listRowInsets( EdgeInsets( - top: firstPinnedHeaderElasticTopInset( - section: section, - isFirstSection: isFirstSection, - defaultTopInset: isFirstSection ? 0 : 8 - ), + top: isFirstSection ? 0 : 8, leading: 0, bottom: 0, trailing: 0 @@ -757,27 +756,19 @@ struct TodoListScreen: View { } } - private func firstPinnedHeaderElasticTopInset(section: TodoTimelineSection, isFirstSection: Bool, defaultTopInset: CGFloat) -> CGFloat { - guard isFirstSection, shouldApplyFirstPinnedElasticClearance(to: section) else { - return defaultTopInset - } - - return defaultTopInset + firstPinnedElasticClearance() - } - private func firstPinnedRowElasticTopInset(section: TodoTimelineSection, isFirstSection: Bool, itemIndex: Int) -> CGFloat { guard isFirstSection, itemIndex == 0 else { return 0 } - guard shouldApplyFirstPinnedElasticClearance(to: section) else { + guard shouldApplyFirstPinnedRowElasticClearance(to: section) else { return 0 } - return firstPinnedElasticClearance() + return firstPinnedRowElasticClearance() } - private func shouldApplyFirstPinnedElasticClearance(to section: TodoTimelineSection) -> Bool { + private func shouldApplyFirstPinnedRowElasticClearance(to section: TodoTimelineSection) -> Bool { let isOverdueFirstSection = viewModel.mode == .overdue let isTodayFirstSection = viewModel.mode == .today let isExpandedPriorityEarlier = viewModel.mode == .priority && section.id == "earlier" @@ -790,13 +781,13 @@ struct TodoListScreen: View { isScheduledFirstSection } - private func firstPinnedElasticClearance() -> CGFloat { + private func firstPinnedRowElasticClearance() -> CGFloat { let elasticProgress = TodoTimelineMetrics.progress( titleCollapseProgress, - from: TodoTimelineMetrics.firstPinnedElasticStart, - to: TodoTimelineMetrics.firstPinnedElasticEnd + from: TodoTimelineMetrics.firstPinnedRowElasticStart, + to: TodoTimelineMetrics.firstPinnedRowElasticEnd ) - return TodoTimelineMetrics.firstPinnedElasticClearance * elasticProgress + return TodoTimelineMetrics.firstPinnedRowElasticClearance * elasticProgress } private func minimalTimelineSubtitle(for todo: TodoItem, in section: TodoTimelineSection) -> String { @@ -894,7 +885,7 @@ struct TimelineTopBar: View { TimelineTopBarButton(systemName: "chevron.left", chrome: .filled, action: onBack) Spacer(minLength: 0) if let action { - TimelineTopBarButton(systemName: action.systemName, chrome: .plain, action: action.action) + TimelineTopBarButton(systemName: action.systemName, chrome: .plain, tint: action.tint, action: action.action) } else { Color.clear .frame(width: TodoTimelineMetrics.topBarButtonFrame, height: TodoTimelineMetrics.topBarButtonFrame) @@ -983,10 +974,18 @@ private struct TimelineTopBarButton: View { let systemName: String let chrome: Chrome + let tint: Color? let action: () -> Void @Environment(\.tdayColors) private var colors + init(systemName: String, chrome: Chrome, tint: Color? = nil, action: @escaping () -> Void) { + self.systemName = systemName + self.chrome = chrome + self.tint = tint + self.action = action + } + var body: some View { Button(action: action) { Image(systemName: systemName) @@ -1001,7 +1000,7 @@ private struct TimelineTopBarButton: View { .contentShape(Circle()) } .buttonStyle(TimelineTopBarButtonStyle(isFilled: chrome == .filled)) - .foregroundStyle(chrome == .filled ? colors.onSurface : Color.accentColor) + .foregroundStyle(chrome == .filled ? colors.onSurface : (tint ?? Color.accentColor)) } } From 0f9712262a66b1e776484d5f3e04dce28cd468fb Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 11:43:36 -0400 Subject: [PATCH 10/52] Implement interactive pop gesture and refine `TodoListScreen` UI for the iOS client. This update restores the system swipe-to-back gesture when using custom navigation configurations and improves the visual consistency of the todo list interface, particularly for the "List" view mode. - **Navigation**: - Implement `NavigationInteractivePopGestureConfigurator` to re-enable the interactive pop gesture (swipe-to-back) even when the default back button is hidden or customized. - Add `.navigationInteractivePopGesture()` view extension to simplify gesture attachment. - **UI & Components**: - Enhance `TimelineTopBarButton` with a new `.outlined` chrome style featuring a subtle border. - Update `TimelineTopBarAction` to support optional circular chrome, used for the settings button in list view. - Update the "List" mode icon from `slider.horizontal.3` to `ellipsis`. - **Todo List Logic**: - Refactor `TodoListScreen` to treat `.list` mode as a minimal timeline mode, improving layout consistency with `.scheduled` and `.priority` views. - Align `todoTimelineSections` for `.list` mode with the standard future timeline builder to ensure consistent section grouping and sorting. - Update todo row metadata in `.list` mode to explicitly display "Overdue" status when applicable. --- .../Core/UI/NavigationBackHistoryTitle.swift | 82 +++++++++++++++++++ .../Tday/Feature/Todos/TodoListScreen.swift | 62 ++++++++------ 2 files changed, 121 insertions(+), 23 deletions(-) diff --git a/ios-swiftUI/Tday/Core/UI/NavigationBackHistoryTitle.swift b/ios-swiftUI/Tday/Core/UI/NavigationBackHistoryTitle.swift index 7b6f18c6..a9a0c36f 100644 --- a/ios-swiftUI/Tday/Core/UI/NavigationBackHistoryTitle.swift +++ b/ios-swiftUI/Tday/Core/UI/NavigationBackHistoryTitle.swift @@ -4,6 +4,11 @@ import UIKit extension View { func navigationBackButtonBehavior() -> some View { background(NavigationBackButtonConfigurator()) + .navigationInteractivePopGesture() + } + + func navigationInteractivePopGesture() -> some View { + background(NavigationInteractivePopGestureConfigurator()) } func navigationTitleTypography( @@ -21,6 +26,83 @@ extension View { } } +private struct NavigationInteractivePopGestureConfigurator: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> Controller { + Controller() + } + + func updateUIViewController(_ controller: Controller, context: Context) { + controller.scheduleGestureUpdate() + } + + final class Controller: UIViewController, UIGestureRecognizerDelegate { + private weak var configuredNavigationController: UINavigationController? + + override func loadView() { + let view = UIView(frame: .zero) + view.isHidden = true + view.isUserInteractionEnabled = false + self.view = view + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + scheduleGestureUpdate() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + scheduleGestureUpdate() + } + + func scheduleGestureUpdate() { + DispatchQueue.main.async { [weak self] in + self?.applyGestureState() + } + } + + private func applyGestureState() { + guard let navigationController = nearestNavigationController else { + return + } + + configuredNavigationController = navigationController + navigationController.interactivePopGestureRecognizer?.isEnabled = true + navigationController.interactivePopGestureRecognizer?.delegate = self + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard + gestureRecognizer === configuredNavigationController?.interactivePopGestureRecognizer, + let navigationController = configuredNavigationController + else { + return true + } + + return navigationController.viewControllers.count > 1 && + navigationController.transitionCoordinator == nil + } + + private var nearestNavigationController: UINavigationController? { + var current = parent + + while let viewController = current { + if let navigationController = viewController as? UINavigationController { + return navigationController + } + + if let navigationController = viewController.navigationController { + return navigationController + } + + current = viewController.parent + } + + return nil + } + } +} + private struct NavigationBackButtonConfigurator: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> Controller { Controller() diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 6230c39a..84f51707 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -63,11 +63,18 @@ struct TimelineRowDivider: View { struct TimelineTopBarAction { let systemName: String let tint: Color? + let usesCircularChrome: Bool let action: () -> Void - init(systemName: String, tint: Color? = nil, action: @escaping () -> Void) { + init( + systemName: String, + tint: Color? = nil, + usesCircularChrome: Bool = false, + action: @escaping () -> Void + ) { self.systemName = systemName self.tint = tint + self.usesCircularChrome = usesCircularChrome self.action = action } } @@ -102,7 +109,11 @@ struct TodoListScreen: View { } private var isMinimalTimelineMode: Bool { - viewModel.mode == .overdue || viewModel.mode == .scheduled || viewModel.mode == .priority || viewModel.mode == .all + viewModel.mode == .overdue || + viewModel.mode == .scheduled || + viewModel.mode == .priority || + viewModel.mode == .all || + viewModel.mode == .list } private var usesHeroTimelineMode: Bool { @@ -138,7 +149,8 @@ struct TodoListScreen: View { } if viewModel.mode == .list { return TimelineTopBarAction( - systemName: "slider.horizontal.3", + systemName: "ellipsis", + usesCircularChrome: true, action: { showingListSettings = true } ) } @@ -774,11 +786,13 @@ struct TodoListScreen: View { let isExpandedPriorityEarlier = viewModel.mode == .priority && section.id == "earlier" let isExpandedAllTasksEarlier = viewModel.mode == .all && section.id == "earlier" let isScheduledFirstSection = viewModel.mode == .scheduled + let isListFirstSection = viewModel.mode == .list return isOverdueFirstSection || isTodayFirstSection || isExpandedPriorityEarlier || isExpandedAllTasksEarlier || - isScheduledFirstSection + isScheduledFirstSection || + isListFirstSection } private func firstPinnedRowElasticClearance() -> CGFloat { @@ -818,6 +832,11 @@ struct TodoListScreen: View { return "Overdue, \(dueBodyText)" } return "Due \(dueBodyText)" + case .list: + if !todo.completed && todo.due < Date() { + return "Overdue, \(dueBodyText)" + } + return "Due \(dueBodyText)" default: return dueBodyText } @@ -885,7 +904,12 @@ struct TimelineTopBar: View { TimelineTopBarButton(systemName: "chevron.left", chrome: .filled, action: onBack) Spacer(minLength: 0) if let action { - TimelineTopBarButton(systemName: action.systemName, chrome: .plain, tint: action.tint, action: action.action) + TimelineTopBarButton( + systemName: action.systemName, + chrome: action.usesCircularChrome ? .outlined : .plain, + tint: action.tint, + action: action.action + ) } else { Color.clear .frame(width: TodoTimelineMetrics.topBarButtonFrame, height: TodoTimelineMetrics.topBarButtonFrame) @@ -970,6 +994,7 @@ private struct TimelineTopBarButton: View { enum Chrome { case plain case filled + case outlined } let systemName: String @@ -995,12 +1020,19 @@ private struct TimelineTopBarButton: View { if chrome == .filled { Circle() .fill(colors.surface) + } else if chrome == .outlined { + Circle() + .fill(colors.background) + .overlay { + Circle() + .stroke(colors.onSurfaceVariant.opacity(0.28), lineWidth: 1) + } } } .contentShape(Circle()) } .buttonStyle(TimelineTopBarButtonStyle(isFilled: chrome == .filled)) - .foregroundStyle(chrome == .filled ? colors.onSurface : (tint ?? Color.accentColor)) + .foregroundStyle(chrome == .filled || chrome == .outlined ? colors.onSurface : (tint ?? Color.accentColor)) } } @@ -1378,24 +1410,8 @@ private func buildSections(items: [TodoItem], mode: TodoListMode) -> [TodoTimeli targetDate: date ) } - case .all, .priority: + case .all, .priority, .list: return buildFutureTimelineSections(items: items, calendar: calendar) - case .list: - let grouped = Dictionary(grouping: items) { item -> String in - if item.due < calendar.startOfDay(for: Date()) { - return "Earlier" - } - return item.due.formatted(.dateTime.weekday(.wide).month(.abbreviated).day()) - } - return grouped.keys.sorted().map { key in - TodoTimelineSection( - id: key, - title: key, - items: grouped[key]?.sorted(by: todoTimelineSortPrecedes) ?? [], - isCollapsible: key == "Earlier", - targetDate: nil - ) - } } } From e232389f1cad6122107a8cd75fa56e8210863040 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 11:44:05 -0400 Subject: [PATCH 11/52] Enable interactive swipe-back gestures in the iOS app root view. This update applies the `.navigationInteractivePopGesture()` modifier to the main navigation stack in `AppRootView.swift`, ensuring that users can use the standard edge-swipe gesture to navigate back even when custom navigation configurations are present. --- ios-swiftUI/Tday/Feature/App/AppRootView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/ios-swiftUI/Tday/Feature/App/AppRootView.swift b/ios-swiftUI/Tday/Feature/App/AppRootView.swift index 59116146..b8853313 100644 --- a/ios-swiftUI/Tday/Feature/App/AppRootView.swift +++ b/ios-swiftUI/Tday/Feature/App/AppRootView.swift @@ -140,6 +140,7 @@ struct AppRootView: View { } } } + .navigationInteractivePopGesture() .background(TdayTheme.backgroundGradient.ignoresSafeArea()) } } From dfa0274ba4902d569d9c503c0599c7076f20dc83 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 13:54:19 -0400 Subject: [PATCH 12/52] Refine interactive pop gesture reliability and navigation controller discovery. This update improves the consistency of the custom back gesture by introducing more robust navigation controller lookup and addressing timing issues during view transitions. - **Gesture Handling & Reliability**: - Update `scheduleGestureUpdate` to perform multiple state applications with incremental delays (0ms, 50ms, and 200ms) to ensure the gesture state is correctly applied after layout transitions. - Trigger gesture updates in `viewDidAppear` to handle cases where the view re-enters the hierarchy. - Set `cancelsTouchesInView` to true on the `interactivePopGestureRecognizer` to prevent touch conflicts. - Implement `shouldRecognizeSimultaneouslyWith` to allow the interactive pop gesture to operate alongside other gesture recognizers. - **Navigation Controller Discovery**: - Add a recursive extension `nearestNavigationControllerInHierarchy()` to `UIViewController` to better locate the active navigation stack. - Refactor discovery logic to traverse the entire window hierarchy, including child and presented view controllers, ensuring the correct `UINavigationController` is targeted even in complex nested layouts. --- .../Core/UI/NavigationBackHistoryTitle.swift | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/ios-swiftUI/Tday/Core/UI/NavigationBackHistoryTitle.swift b/ios-swiftUI/Tday/Core/UI/NavigationBackHistoryTitle.swift index a9a0c36f..607732ab 100644 --- a/ios-swiftUI/Tday/Core/UI/NavigationBackHistoryTitle.swift +++ b/ios-swiftUI/Tday/Core/UI/NavigationBackHistoryTitle.swift @@ -55,9 +55,16 @@ private struct NavigationInteractivePopGestureConfigurator: UIViewControllerRepr scheduleGestureUpdate() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + scheduleGestureUpdate() + } + func scheduleGestureUpdate() { - DispatchQueue.main.async { [weak self] in - self?.applyGestureState() + [0.0, 0.05, 0.20].forEach { delay in + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.applyGestureState() + } } } @@ -69,6 +76,7 @@ private struct NavigationInteractivePopGestureConfigurator: UIViewControllerRepr configuredNavigationController = navigationController navigationController.interactivePopGestureRecognizer?.isEnabled = true navigationController.interactivePopGestureRecognizer?.delegate = self + navigationController.interactivePopGestureRecognizer?.cancelsTouchesInView = true } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { @@ -83,6 +91,13 @@ private struct NavigationInteractivePopGestureConfigurator: UIViewControllerRepr navigationController.transitionCoordinator == nil } + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === configuredNavigationController?.interactivePopGestureRecognizer + } + private var nearestNavigationController: UINavigationController? { var current = parent @@ -98,11 +113,35 @@ private struct NavigationInteractivePopGestureConfigurator: UIViewControllerRepr current = viewController.parent } - return nil + return view.window?.rootViewController?.nearestNavigationControllerInHierarchy() } } } +private extension UIViewController { + func nearestNavigationControllerInHierarchy() -> UINavigationController? { + if let navigationController = self as? UINavigationController { + return navigationController + } + + if let navigationController { + return navigationController + } + + for child in children { + if let navigationController = child.nearestNavigationControllerInHierarchy() { + return navigationController + } + } + + if let presentedViewController { + return presentedViewController.nearestNavigationControllerInHierarchy() + } + + return nil + } +} + private struct NavigationBackButtonConfigurator: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> Controller { Controller() From 7c3504e90ed30c87e186070d1017262678f98304 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 13:59:00 -0400 Subject: [PATCH 13/52] Implement a custom header for the task creation sheet in the iOS SwiftUI client. This update replaces the standard navigation bar with a custom-designed header to align with the application's visual style. The new header features stylized "Cancel" and "Submit" buttons with a capsule-shaped geometry and refined typography. - **Header Implementation**: - Create `CreateTaskSheetHeader` to replace the default navigation title and toolbar. - Implement `CreateTaskSheetHeaderButton` using a `Capsule` shape, custom border stroke, and `tdayRounded` typography. - Add `CreateTaskSheetHeaderButtonStyle` to handle press interactions with scale and opacity animations. - **UI Refactoring**: - Wrap the `Form` and the new header in a `VStack` within `CreateTaskSheet`. - Hide the standard navigation bar using `.toolbar(.hidden, for: .navigationBar)`. - Adjust padding and layout constraints to ensure the header remains fixed at the top of the sheet. - Update the submit button logic to display "Saving..." during the asynchronous submission process. --- .../Tday/UI/Component/CreateTaskSheet.swift | 167 +++++++++++++----- 1 file changed, 123 insertions(+), 44 deletions(-) diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index b97404a1..ae4797b7 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -71,59 +71,59 @@ struct CreateTaskSheet: View { var body: some View { NavigationStack { - Form { - Section("Task") { - TextField("Title", text: $title) - .textInputAutocapitalization(.sentences) - TextField("Notes", text: $notes, axis: .vertical) - .lineLimit(3 ... 6) - } + VStack(spacing: 0) { + CreateTaskSheetHeader( + title: titleText, + submitTitle: isSubmitting ? "Saving..." : submitText, + isSubmitEnabled: !isSubmitting && !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + onCancel: { + onDismiss() + dismiss() + }, + onSubmit: { + Task { + await submit() + } + } + ) - Section("Schedule") { - DatePicker("Due", selection: $dueDate) - } + Form { + Section("Task") { + TextField("Title", text: $title) + .textInputAutocapitalization(.sentences) + TextField("Notes", text: $notes, axis: .vertical) + .lineLimit(3 ... 6) + } - Section("Details") { - Picker("Priority", selection: $priority) { - ForEach(priorityOptions, id: \.self) { option in - Text(option).tag(option) - } + Section("Schedule") { + DatePicker("Due", selection: $dueDate) } - Picker("List", selection: Binding(get: { selectedListID ?? "" }, set: { selectedListID = $0.isEmpty ? nil : $0 })) { - Text("No list").tag("") - ForEach(lists) { list in - Text(list.name).tag(list.id) + + Section("Details") { + Picker("Priority", selection: $priority) { + ForEach(priorityOptions, id: \.self) { option in + Text(option).tag(option) + } } - } - Picker("Repeat", selection: Binding(get: { repeatRule ?? "" }, set: { newValue in - repeatRule = newValue.isEmpty ? nil : newValue - })) { - ForEach(repeatOptions, id: \.label) { option in - Text(option.label).tag(option.value ?? "") + Picker("List", selection: Binding(get: { selectedListID ?? "" }, set: { selectedListID = $0.isEmpty ? nil : $0 })) { + Text("No list").tag("") + ForEach(lists) { list in + Text(list.name).tag(list.id) + } } - } - } - } - .scrollContentBackground(.hidden) - .background(colors.background) - .navigationTitle(titleText) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - onDismiss() - dismiss() - } - } - ToolbarItem(placement: .confirmationAction) { - Button(isSubmitting ? "Saving..." : submitText) { - Task { - await submit() + Picker("Repeat", selection: Binding(get: { repeatRule ?? "" }, set: { newValue in + repeatRule = newValue.isEmpty ? nil : newValue + })) { + ForEach(repeatOptions, id: \.label) { option in + Text(option.label).tag(option.value ?? "") + } } } - .disabled(isSubmitting || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + .scrollContentBackground(.hidden) } + .background(colors.background) + .toolbar(.hidden, for: .navigationBar) } .presentationDetents([.large]) .task { @@ -184,3 +184,82 @@ struct CreateTaskSheet: View { dismiss() } } + +private struct CreateTaskSheetHeader: View { + let title: String + let submitTitle: String + let isSubmitEnabled: Bool + let onCancel: () -> Void + let onSubmit: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + ZStack { + Text(title) + .font(.tdayRounded(size: 17, weight: .bold)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.horizontal, 132) + + HStack { + CreateTaskSheetHeaderButton( + title: "Cancel", + isEnabled: true, + action: onCancel + ) + + Spacer(minLength: 0) + + CreateTaskSheetHeaderButton( + title: submitTitle, + isEnabled: isSubmitEnabled, + action: onSubmit + ) + } + } + .padding(.horizontal, 18) + .padding(.top, 16) + .padding(.bottom, 14) + .background(colors.background) + } +} + +private struct CreateTaskSheetHeaderButton: View { + let title: String + let isEnabled: Bool + let action: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + Button(action: action) { + Text(title) + .font(.tdayRounded(size: 17, weight: .bold)) + .foregroundStyle(isEnabled ? colors.onSurface : colors.onSurfaceVariant.opacity(0.38)) + .lineLimit(1) + .minimumScaleFactor(0.72) + .allowsTightening(false) + .frame(width: 108, height: 54) + .background(colors.surface, in: Capsule(style: .continuous)) + .overlay { + Capsule(style: .continuous) + .stroke(colors.onSurfaceVariant.opacity(0.12), lineWidth: 1) + } + .contentShape(Capsule(style: .continuous)) + } + .buttonStyle(CreateTaskSheetHeaderButtonStyle()) + .disabled(!isEnabled) + .accessibilityAddTraits(.isButton) + } +} + +private struct CreateTaskSheetHeaderButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .opacity(configuration.isPressed ? 0.78 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} From d770224972ab18f87f7a70ed84739c03252e6d43 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 14:11:26 -0400 Subject: [PATCH 14/52] Refactor and unify calendar card metrics for both iOS and Android clients. This update replaces hardcoded layout values with centralized constants for the calendar period card components, ensuring visual consistency across platforms and simplifying future layout adjustments. - **iOS (SwiftUI)**: - Introduce `CalendarPeriodCardMetrics` to define shared spacing, height, and padding values. - Standardize the pager height at 78pt for both week and day views. - Update `CalendarPeriodCard` and `CalendarDayCard` to use the new metric constants. - **Android (Compose)**: - Introduce `CalendarPeriodCardPageHeight` (78.dp) and `CalendarPeriodCardBottomPadding` (18.dp) constants. - Standardize the height of the calendar pager across the `CalendarPeriodCard` and `DayPager` components. - Adjust the bottom padding of the day view to match the week view's layout. --- .../feature/calendar/CalendarScreen.kt | 15 ++++++++--- .../Feature/Calendar/CalendarScreen.swift | 25 ++++++++++++------- 2 files changed, 27 insertions(+), 13 deletions(-) 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 854fd51e..9b77998e 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,8 @@ import java.time.format.TextStyle import java.util.Locale private val CalendarAccentPurple = Color(0xFF7D67B6) +private val CalendarPeriodCardPageHeight = 78.dp +private val CalendarPeriodCardBottomPadding = 18.dp private fun calendarPageAnimationSpec() = tween( durationMillis = 260, @@ -800,7 +802,7 @@ private fun CalendarWeekCard( targetState = weekStart, modifier = Modifier .fillMaxWidth() - .height(78.dp) + .height(CalendarPeriodCardPageHeight) .graphicsLayer { translationX = dragTranslationX }, transitionSpec = { val movingToFuture = targetState > initialState @@ -869,7 +871,7 @@ private fun CalendarWeekDayCell( Card( modifier = modifier - .height(78.dp) + .height(CalendarPeriodCardPageHeight) .minimumInteractiveComponentSize(), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = containerColor), @@ -971,7 +973,12 @@ private fun CalendarDayCard( Column( modifier = Modifier .fillMaxWidth() - .padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 22.dp), + .padding( + start = 18.dp, + top = 16.dp, + end = 18.dp, + bottom = CalendarPeriodCardBottomPadding + ), verticalArrangement = Arrangement.spacedBy(14.dp), ) { Row( @@ -1008,7 +1015,7 @@ private fun CalendarDayCard( targetState = selectedDate, modifier = Modifier .fillMaxWidth() - .height(70.dp) + .height(CalendarPeriodCardPageHeight) .graphicsLayer { translationX = dragTranslationX }, transitionSpec = { val movingToFuture = targetState > initialState diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index f943177d..8ce2ce31 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -11,6 +11,13 @@ private enum CalendarModeControlMetrics { static let height: CGFloat = 52 } +private enum CalendarPeriodCardMetrics { + static let contentSpacing: CGFloat = 14 + static let pageHeight: CGFloat = 78 + static let topPadding: CGFloat = 16 + static let bottomPadding: CGFloat = 18 +} + private let calendarNativePagerCenterIndex = 1 private struct CalendarTodayJumpRequest: Equatable { @@ -700,7 +707,7 @@ private struct CalendarWeekCard: View { let nextPageWeekDate = jumpDirection == .next ? pendingTodayJump?.targetDate : nextWeekDate let isPagingAtRest = pageSelection == calendarNativePagerCenterIndex - return VStack(spacing: 14) { + return VStack(spacing: CalendarPeriodCardMetrics.contentSpacing) { HStack { CalendarNavButton( systemName: "chevron.left", @@ -737,11 +744,11 @@ private struct CalendarWeekCard: View { selection: $pageSelection, onSettledSelection: settlePageSelection ) - .frame(height: 78) + .frame(height: CalendarPeriodCardMetrics.pageHeight) } .padding(.horizontal, 14) - .padding(.top, 16) - .padding(.bottom, 18) + .padding(.top, CalendarPeriodCardMetrics.topPadding) + .padding(.bottom, CalendarPeriodCardMetrics.bottomPadding) .frame(maxWidth: .infinity) } @@ -876,7 +883,7 @@ private struct CalendarWeekDayCell: View { .font(.tdayRounded(size: 12, weight: .heavy)) .foregroundStyle(taskCount > 0 ? accentColor : colors.onSurfaceVariant.opacity(0.42)) } - .frame(maxWidth: .infinity, minHeight: 78) + .frame(maxWidth: .infinity, minHeight: CalendarPeriodCardMetrics.pageHeight) .background(cellBackground, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) @@ -965,7 +972,7 @@ private struct CalendarDayCard: View { let nextPageDay = jumpDirection == .next ? pendingTodayJump.map { Calendar.current.startOfDay(for: $0.targetDate) } : nextDay let isPagingAtRest = pageSelection == calendarNativePagerCenterIndex - return VStack(alignment: .leading, spacing: 14) { + return VStack(alignment: .leading, spacing: CalendarPeriodCardMetrics.contentSpacing) { HStack { CalendarNavButton( systemName: "chevron.left", @@ -999,11 +1006,11 @@ private struct CalendarDayCard: View { selection: $pageSelection, onSettledSelection: settlePageSelection ) - .frame(height: 70) + .frame(height: CalendarPeriodCardMetrics.pageHeight) } .padding(.horizontal, 18) - .padding(.top, 16) - .padding(.bottom, 22) + .padding(.top, CalendarPeriodCardMetrics.topPadding) + .padding(.bottom, CalendarPeriodCardMetrics.bottomPadding) .frame(maxWidth: .infinity, alignment: .leading) } From 7ac59184b1ec609ecfe81a67e81bc3873029f97e Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 14:42:53 -0400 Subject: [PATCH 15/52] Refactor the task creation and list settings UI on iOS and enhance the calendar's visual consistency across both Android and iOS platforms. - **iOS UI/UX Enhancements**: - Redesign `CreateTaskSheet` and `ListSettingsSheet` headers, replacing text-based "Cancel" and "Save" buttons with circular icon buttons (xmark/checkmark) featuring colored strokes and custom press styles. - Update sheet titles from "Create Task" to "New task" for brevity and increased font weight/size. - Implement a custom `ListSettingsSheetHeader` to standardize the appearance of list configuration screens. - Refine accent colors for destructive and affirmative actions across various action sheets. - **Android Calendar Refinement**: - Introduce a comprehensive set of design tokens for the `CalendarScreen` to manage dimensions, paddings, and typography consistently. - Redesign the mini-calendar month grid: cells now feature a circular background for the selected date/today and a more compact layout for task indicators. - Update navigation buttons with specific dimensions and active/pressed state background colors. - Adjust weekday headers and day cell typography for improved readability and alignment. - **Cross-Platform Consistency**: - Align header heights and horizontal paddings in calendar period cards across both iOS and Android. - Standardize task count summary styling and date range formatting in calendar views. --- .../feature/calendar/CalendarScreen.kt | 187 +++++++++++------- .../Feature/Calendar/CalendarScreen.swift | 21 +- .../Feature/Completed/CompletedScreen.swift | 2 +- .../Tday/Feature/Home/HomeScreen.swift | 18 +- .../Tday/Feature/Todos/TodoListScreen.swift | 129 +++++++++--- .../Tday/UI/Component/CreateTaskSheet.swift | 86 ++++---- 6 files changed, 295 insertions(+), 148 deletions(-) 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 9b77998e..a8ed67b2 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 @@ -34,7 +34,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -117,11 +116,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp +import androidx.compose.ui.unit.sp import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat import com.ohmz.tday.compose.R @@ -142,6 +143,25 @@ import java.time.format.TextStyle import java.util.Locale private val CalendarAccentPurple = Color(0xFF7D67B6) +private val CalendarCardCornerRadius = 24.dp +private val CalendarCardHeaderHeight = 36.dp +private val CalendarCardHeaderHorizontalPadding = 6.dp +private val CalendarCardNavButtonWidth = 40.dp +private val CalendarCardNavButtonHeight = 36.dp +private val CalendarMonthCardHorizontalPadding = 16.dp +private val CalendarMonthCardTopPadding = 18.dp +private val CalendarMonthCardBottomPadding = 20.dp +private val CalendarMonthCardOuterSpacing = 16.dp +private val CalendarMonthGridSpacing = 11.dp +private val CalendarMonthWeekdayHeight = 18.dp +private val CalendarMonthGridHeight = 283.dp +private val CalendarMonthDayCellHeight = 38.dp +private val CalendarMonthDayNumberWidth = 32.dp +private val CalendarMonthDayNumberHeight = 28.dp +private val CalendarMonthHeaderTitleSize = 18.sp +private val CalendarPeriodHeaderTitleSize = 21.sp +private val CalendarDaySummaryTitleSize = 25.sp +private val CalendarDaySummaryCountSize = 18.sp private val CalendarPeriodCardPageHeight = 78.dp private val CalendarPeriodCardBottomPadding = 18.dp @@ -758,20 +778,26 @@ private fun CalendarWeekCard( }, ) }, - shape = RoundedCornerShape(24.dp), + shape = RoundedCornerShape(CalendarCardCornerRadius), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Column( modifier = Modifier .fillMaxWidth() - .padding(start = 14.dp, top = 16.dp, end = 14.dp, bottom = 18.dp), + .padding( + start = CalendarMonthCardHorizontalPadding, + top = 16.dp, + end = CalendarMonthCardHorizontalPadding, + bottom = CalendarPeriodCardBottomPadding, + ), verticalArrangement = Arrangement.spacedBy(14.dp), ) { Row( modifier = Modifier .fillMaxWidth() - .height(36.dp), + .height(CalendarCardHeaderHeight) + .padding(horizontal = CalendarCardHeaderHorizontalPadding), verticalAlignment = Alignment.CenterVertically, ) { MiniCalendarNavButton( @@ -786,7 +812,9 @@ private fun CalendarWeekCard( ) { Text( text = formatWeekRange(weekStart), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = CalendarPeriodHeaderTitleSize, + ), fontWeight = FontWeight.ExtraBold, color = colorScheme.onSurface, ) @@ -966,7 +994,7 @@ private fun CalendarDayCard( }, ) }, - shape = RoundedCornerShape(24.dp), + shape = RoundedCornerShape(CalendarCardCornerRadius), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { @@ -974,17 +1002,18 @@ private fun CalendarDayCard( modifier = Modifier .fillMaxWidth() .padding( - start = 18.dp, + start = CalendarMonthCardHorizontalPadding, top = 16.dp, - end = 18.dp, - bottom = CalendarPeriodCardBottomPadding + end = CalendarMonthCardHorizontalPadding, + bottom = CalendarPeriodCardBottomPadding, ), verticalArrangement = Arrangement.spacedBy(14.dp), ) { Row( modifier = Modifier .fillMaxWidth() - .height(36.dp), + .height(CalendarCardHeaderHeight) + .padding(horizontal = CalendarCardHeaderHorizontalPadding), verticalAlignment = Alignment.CenterVertically, ) { MiniCalendarNavButton( @@ -999,7 +1028,9 @@ private fun CalendarDayCard( ) { Text( text = selectedDate.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.getDefault()), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = CalendarPeriodHeaderTitleSize, + ), color = colorScheme.onSurface, fontWeight = FontWeight.ExtraBold, ) @@ -1042,7 +1073,9 @@ private fun CalendarDayCard( ) { Text( text = displayDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy")), - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.headlineSmall.copy( + fontSize = CalendarDaySummaryTitleSize, + ), color = if (displayDate == today) CalendarAccentPurple else colorScheme.onSurface, fontWeight = FontWeight.ExtraBold, ) @@ -1052,7 +1085,9 @@ private fun CalendarDayCard( } else { stringResource(R.string.calendar_task_count_many, taskCount) }, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = CalendarDaySummaryCountSize, + ), color = colorScheme.onSurfaceVariant, fontWeight = FontWeight.ExtraBold, ) @@ -1294,24 +1329,33 @@ private fun CalendarMonthCard( }, ) }, - shape = androidx.compose.foundation.shape.RoundedCornerShape(28.dp), + shape = RoundedCornerShape(CalendarCardCornerRadius), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding( + start = CalendarMonthCardHorizontalPadding, + top = CalendarMonthCardTopPadding, + end = CalendarMonthCardHorizontalPadding, + bottom = CalendarMonthCardBottomPadding, + ), + verticalArrangement = Arrangement.spacedBy(CalendarMonthCardOuterSpacing), ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(CalendarCardHeaderHeight) + .padding(horizontal = CalendarCardHeaderHorizontalPadding), verticalAlignment = Alignment.CenterVertically, ) { MiniCalendarNavButton( icon = Icons.Rounded.ChevronLeft, contentDescription = stringResource(R.string.calendar_prev_month), enabled = canGoPrevMonth, + iconTint = CalendarAccentPurple, onClick = onPrevMonth, ) Box( @@ -1321,7 +1365,9 @@ private fun CalendarMonthCard( Text( text = visibleMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) + " " + visibleMonth.year, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = CalendarMonthHeaderTitleSize, + ), fontWeight = FontWeight.ExtraBold, color = colorScheme.onSurface, ) @@ -1329,17 +1375,24 @@ private fun CalendarMonthCard( MiniCalendarNavButton( icon = Icons.Rounded.ChevronRight, contentDescription = stringResource(R.string.calendar_next_month), + iconTint = CalendarAccentPurple, onClick = onNextMonth, ) } - Row(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(CalendarMonthWeekdayHeight), + verticalAlignment = Alignment.CenterVertically, + ) { WEEKDAY_HEADERS.forEach { dayLabel -> Text( text = dayLabel, - style = MaterialTheme.typography.labelMedium, - color = colorScheme.onSurfaceVariant.copy(alpha = 0.64f), + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp), + color = colorScheme.onSurfaceVariant.copy(alpha = 0.48f), fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, modifier = Modifier.weight(1f), ) } @@ -1349,6 +1402,7 @@ private fun CalendarMonthCard( targetState = visibleMonth, modifier = Modifier .fillMaxWidth() + .height(CalendarMonthGridHeight) .graphicsLayer { translationX = dragTranslationX }, transitionSpec = { val movingToFuture = targetState > initialState @@ -1371,12 +1425,12 @@ private fun CalendarMonthCard( val monthDays = remember(displayMonth) { buildMonthCells(displayMonth) } Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(CalendarMonthGridSpacing), ) { monthDays.chunked(7).forEach { week -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), ) { week.forEach { cell -> val taskCount = tasksByDate[cell.date]?.size ?: 0 @@ -1404,14 +1458,17 @@ private fun MiniCalendarNavButton( icon: androidx.compose.ui.graphics.vector.ImageVector, contentDescription: String, enabled: Boolean = true, + iconTint: Color? = null, onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() + val enabledIconTint = iconTint ?: colorScheme.onSurfaceVariant Box( modifier = Modifier - .size(34.dp) + .width(CalendarCardNavButtonWidth) + .height(CalendarCardNavButtonHeight) .clip(RoundedCornerShape(12.dp)) .background( color = if (enabled && isPressed) { @@ -1425,7 +1482,7 @@ private fun MiniCalendarNavButton( interactionSource = interactionSource, indication = ripple( bounded = true, - radius = 17.dp, + radius = 20.dp, ), onClick = onClick, ), @@ -1435,7 +1492,7 @@ private fun MiniCalendarNavButton( imageVector = icon, contentDescription = contentDescription, tint = if (enabled) { - colorScheme.onSurfaceVariant + enabledIconTint } else { colorScheme.onSurfaceVariant.copy(alpha = 0.34f) }, @@ -1454,70 +1511,64 @@ private fun CalendarDayCell( modifier: Modifier = Modifier, ) { val colorScheme = MaterialTheme.colorScheme - val containerColor = when { - isSelected -> CalendarAccentPurple.copy(alpha = 0.2f) - isToday -> CalendarAccentPurple.copy(alpha = 0.12f) - else -> Color.Transparent - } - val borderColor = when { - isSelected -> CalendarAccentPurple.copy(alpha = 0.8f) - isToday -> CalendarAccentPurple.copy(alpha = 0.5f) + val numberBackground = when { + isSelected -> CalendarAccentPurple + isToday -> CalendarAccentPurple.copy(alpha = 0.16f) else -> Color.Transparent } - val cellShape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp) val dayTextColor = when { - isSelected || isToday -> CalendarAccentPurple + isSelected -> Color.White + isToday -> CalendarAccentPurple cell.isCurrentMonth -> colorScheme.onSurface else -> colorScheme.onSurfaceVariant.copy(alpha = 0.45f) } val interactionSource = remember { MutableInteractionSource() } - Box( + Column( modifier = modifier - .minimumInteractiveComponentSize() - .aspectRatio(1f) - .clip(cellShape) - .background( - color = containerColor, - shape = cellShape, - ) - .border( - width = if (borderColor == Color.Transparent) 0.dp else 1.2.dp, - color = borderColor, - shape = cellShape, - ) + .fillMaxWidth() + .height(CalendarMonthDayCellHeight) + .graphicsLayer { alpha = if (cell.isCurrentMonth) 1f else 0.45f } + .clip(RoundedCornerShape(12.dp)) .clickable( + enabled = cell.isCurrentMonth, interactionSource = interactionSource, indication = ripple( bounded = true, - radius = 28.dp, + radius = 20.dp, ), onClick = onClick, ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp), ) { - Text( - text = cell.date.dayOfMonth.toString(), - style = MaterialTheme.typography.labelLarge, - color = dayTextColor, - fontWeight = FontWeight.ExtraBold, + Box( modifier = Modifier - .align(Alignment.TopStart) - .padding(start = 8.dp, top = 6.dp), - ) + .width(CalendarMonthDayNumberWidth) + .height(CalendarMonthDayNumberHeight) + .background(numberBackground, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = cell.date.dayOfMonth.toString(), + style = MaterialTheme.typography.labelLarge.copy(fontSize = 18.sp), + color = dayTextColor, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, + ) + } - if (taskCount > 0 && cell.isCurrentMonth) { - Row( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 5.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { + Row( + modifier = Modifier.height(7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + if (taskCount > 0 && cell.isCurrentMonth) { Icon( imageVector = Icons.Rounded.FiberManualRecord, contentDescription = null, tint = CalendarAccentPurple, - modifier = Modifier.size(7.dp), + modifier = Modifier.size(4.dp), ) Text( text = if (taskCount > 9) { @@ -1525,7 +1576,7 @@ private fun CalendarDayCell( } else { taskCount.toString() }, - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = CalendarAccentPurple, fontWeight = FontWeight.ExtraBold, ) diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 8ce2ce31..6c80bc3c 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -13,6 +13,9 @@ private enum CalendarModeControlMetrics { private enum CalendarPeriodCardMetrics { static let contentSpacing: CGFloat = 14 + static let horizontalPadding: CGFloat = 16 + static let headerHeight: CGFloat = 36 + static let headerHorizontalPadding: CGFloat = 6 static let pageHeight: CGFloat = 78 static let topPadding: CGFloat = 16 static let bottomPadding: CGFloat = 18 @@ -222,7 +225,7 @@ struct CalendarScreen: View { .sheet(isPresented: $showingCreateTask) { CreateTaskSheet( lists: viewModel.lists, - titleText: "Create Task", + titleText: "New task", submitText: "Create", initialPayload: CreateTaskPayload(title: "", description: nil, priority: "Low", due: selectedDate, rrule: nil, listId: nil), onParseTaskTitleNlp: { title, dueRef in @@ -237,7 +240,7 @@ struct CalendarScreen: View { .sheet(item: $editingTodo) { todo in CreateTaskSheet( lists: viewModel.lists, - titleText: "Edit Task", + titleText: "Edit task", submitText: "Save", initialPayload: CreateTaskPayload(title: todo.title, description: todo.description, priority: todo.priority, due: todo.due, rrule: todo.rrule, listId: todo.listId), onParseTaskTitleNlp: { title, dueRef in @@ -527,7 +530,8 @@ private struct CalendarMonthGrid: View { .buttonStyle(.plain) .disabled(!isNextEnabled) } - .padding(.horizontal, 6) + .frame(height: CalendarPeriodCardMetrics.headerHeight) + .padding(.horizontal, CalendarPeriodCardMetrics.headerHorizontalPadding) VStack(spacing: 11) { HStack(spacing: 0) { @@ -552,7 +556,7 @@ private struct CalendarMonthGrid: View { .frame(height: 283) } } - .padding(.horizontal, 16) + .padding(.horizontal, CalendarPeriodCardMetrics.horizontalPadding) .padding(.top, 18) .padding(.bottom, 20) .frame(maxWidth: .infinity) @@ -733,7 +737,8 @@ private struct CalendarWeekCard: View { action: goToNextPage ) } - .padding(.horizontal, 6) + .frame(height: CalendarPeriodCardMetrics.headerHeight) + .padding(.horizontal, CalendarPeriodCardMetrics.headerHorizontalPadding) CalendarPagingScrollView( pages: weekPages( @@ -746,7 +751,7 @@ private struct CalendarWeekCard: View { ) .frame(height: CalendarPeriodCardMetrics.pageHeight) } - .padding(.horizontal, 14) + .padding(.horizontal, CalendarPeriodCardMetrics.horizontalPadding) .padding(.top, CalendarPeriodCardMetrics.topPadding) .padding(.bottom, CalendarPeriodCardMetrics.bottomPadding) .frame(maxWidth: .infinity) @@ -996,6 +1001,8 @@ private struct CalendarDayCard: View { action: goToNextPage ) } + .frame(height: CalendarPeriodCardMetrics.headerHeight) + .padding(.horizontal, CalendarPeriodCardMetrics.headerHorizontalPadding) CalendarPagingScrollView( pages: dayPages( @@ -1008,7 +1015,7 @@ private struct CalendarDayCard: View { ) .frame(height: CalendarPeriodCardMetrics.pageHeight) } - .padding(.horizontal, 18) + .padding(.horizontal, CalendarPeriodCardMetrics.horizontalPadding) .padding(.top, CalendarPeriodCardMetrics.topPadding) .padding(.bottom, CalendarPeriodCardMetrics.bottomPadding) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index f2b0631f..f3791aa0 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -69,7 +69,7 @@ struct CompletedScreen: View { .sheet(item: $editingItem) { item in CreateTaskSheet( lists: viewModel.lists, - titleText: "Edit Completed Task", + titleText: "Edit task", submitText: "Save", initialPayload: CreateTaskPayload(title: item.title, description: item.description, priority: item.priority, due: item.due, rrule: item.rrule, listId: nil), onParseTaskTitleNlp: nil, diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 4ef4353d..62787f7b 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -253,7 +253,7 @@ struct HomeScreen: View { .sheet(isPresented: $showingCreateTask) { CreateTaskSheet( lists: viewModel.summary.lists, - titleText: "Create Task", + titleText: "New task", submitText: "Create", initialPayload: nil, onParseTaskTitleNlp: { title, dueRef in @@ -1138,7 +1138,7 @@ private struct CreateListSheetHeader: View { HStack { CreateListSheetActionButton( icon: "xmark", - accentColor: Color(hex: 0xE79A9A), + accentColor: Color(hex: 0xE35A5A), enabled: true, action: onClose ) @@ -1153,7 +1153,7 @@ private struct CreateListSheetHeader: View { CreateListSheetActionButton( icon: "checkmark", - accentColor: Color(hex: 0xA6D4B3), + accentColor: Color(hex: 0x2FA35B), enabled: canCreate, action: onConfirm ) @@ -1173,16 +1173,22 @@ private struct CreateListSheetActionButton: View { Button(action: action) { Image(systemName: icon) .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(colors.onSurface.opacity(enabled ? 1 : 0.4)) + .foregroundStyle(colors.onSurface.opacity(enabled ? 1 : 0.55)) .frame(width: 56, height: 56) .background(colors.surfaceVariant) .clipShape(Circle()) .overlay( Circle() - .stroke(accentColor.opacity(enabled ? 0.8 : 0.42), lineWidth: 1.5) + .stroke(accentColor.opacity(enabled ? 0.55 : 0.3), lineWidth: 1.5) ) } - .buttonStyle(TdayPressButtonStyle()) + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: enabled ? 0.16 : 0.06 + ) + ) .disabled(!enabled) } } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 84f51707..967f671a 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -265,7 +265,7 @@ struct TodoListScreen: View { private var createTaskSheetContent: some View { CreateTaskSheet( lists: viewModel.lists, - titleText: "Create Task", + titleText: "New task", submitText: "Create", initialPayload: CreateTaskPayload(title: "", description: nil, priority: viewModel.mode == .priority ? "High" : "Low", due: Date().addingTimeInterval(60 * 60), rrule: nil, listId: viewModel.listId), onParseTaskTitleNlp: { title, dueRef in @@ -281,7 +281,7 @@ struct TodoListScreen: View { private func editTaskSheetContent(for todo: TodoItem) -> some View { CreateTaskSheet( lists: viewModel.lists, - titleText: "Edit Task", + titleText: "Edit task", submitText: "Save", initialPayload: CreateTaskPayload(title: todo.title, description: todo.description, priority: todo.priority, due: todo.due, rrule: todo.rrule, listId: todo.listId), onParseTaskTitleNlp: { title, dueRef in @@ -1234,6 +1234,7 @@ private struct ListSettingsSheet: View { let list: ListSummary? let onSubmit: (String, String?, String?) -> Void @Environment(\.dismiss) private var dismiss + @Environment(\.tdayColors) private var tdayColors @State private var name = "" @State private var color = "BLUE" @@ -1241,35 +1242,40 @@ private struct ListSettingsSheet: View { private let colors = ["BLUE", "GREEN", "ORANGE", "PINK", "PURPLE", "GRAY"] private let icons = ["inbox", "briefcase", "calendar", "list.bullet", "star", "heart"] + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } var body: some View { NavigationStack { - Form { - TextField("Name", text: $name) - Picker("Color", selection: $color) { - ForEach(colors, id: \.self) { value in - Text(value.capitalized).tag(value) + VStack(spacing: 0) { + ListSettingsSheetHeader( + canSave: canSave, + onClose: { dismiss() }, + onConfirm: { + onSubmit(name.trimmingCharacters(in: .whitespacesAndNewlines), color, iconKey) + dismiss() } - } - Picker("Icon", selection: $iconKey) { - ForEach(icons, id: \.self) { value in - Label(value.replacingOccurrences(of: ".", with: " "), systemImage: value).tag(value) + ) + + Form { + TextField("Name", text: $name) + Picker("Color", selection: $color) { + ForEach(colors, id: \.self) { value in + Text(value.capitalized).tag(value) + } } - } - } - .disableVerticalScrollBounce() - .navigationTitle("List Settings") - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - onSubmit(name, color, iconKey) - dismiss() + Picker("Icon", selection: $iconKey) { + ForEach(icons, id: \.self) { value in + Label(value.replacingOccurrences(of: ".", with: " "), systemImage: value).tag(value) + } } } + .scrollContentBackground(.hidden) } + .background(tdayColors.background) + .disableVerticalScrollBounce() + .toolbar(.hidden, for: .navigationBar) .task { name = list?.name ?? "" color = list?.color ?? "BLUE" @@ -1279,6 +1285,83 @@ private struct ListSettingsSheet: View { } } +private struct ListSettingsSheetHeader: View { + let canSave: Bool + let onClose: () -> Void + let onConfirm: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + HStack { + ListSettingsSheetActionButton( + icon: "xmark", + accessibilityLabel: "Cancel", + accentColor: Color(red: 227.0 / 255.0, green: 90.0 / 255.0, blue: 90.0 / 255.0), + enabled: true, + action: onClose + ) + + Spacer(minLength: 0) + + Text("List Settings") + .font(.tdayRounded(size: 28, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + .minimumScaleFactor(0.78) + + Spacer(minLength: 0) + + ListSettingsSheetActionButton( + icon: "checkmark", + accessibilityLabel: "Save", + accentColor: Color(red: 47.0 / 255.0, green: 163.0 / 255.0, blue: 91.0 / 255.0), + enabled: canSave, + action: onConfirm + ) + } + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 14) + .background(colors.background) + } +} + +private struct ListSettingsSheetActionButton: View { + let icon: String + let accessibilityLabel: String + let accentColor: Color + let enabled: Bool + let action: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(colors.onSurface.opacity(enabled ? 1 : 0.55)) + .frame(width: 56, height: 56) + .background(colors.surfaceVariant, in: Circle()) + .overlay { + Circle() + .stroke(accentColor.opacity(enabled ? 0.55 : 0.3), lineWidth: 1.5) + } + .contentShape(Circle()) + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: enabled ? 0.16 : 0.06 + ) + ) + .disabled(!enabled) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(.isButton) + } +} + private struct TodoTimelineSection: Identifiable, Hashable { let id: String let title: String diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index ae4797b7..b58affb4 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -74,7 +74,7 @@ struct CreateTaskSheet: View { VStack(spacing: 0) { CreateTaskSheetHeader( title: titleText, - submitTitle: isSubmitting ? "Saving..." : submitText, + submitAccessibilityLabel: submitText, isSubmitEnabled: !isSubmitting && !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, onCancel: { onDismiss() @@ -187,7 +187,7 @@ struct CreateTaskSheet: View { private struct CreateTaskSheetHeader: View { let title: String - let submitTitle: String + let submitAccessibilityLabel: String let isSubmitEnabled: Bool let onCancel: () -> Void let onSubmit: () -> Void @@ -195,39 +195,44 @@ private struct CreateTaskSheetHeader: View { @Environment(\.tdayColors) private var colors var body: some View { - ZStack { + HStack { + CreateTaskSheetHeaderButton( + systemName: "xmark", + accessibilityLabel: "Cancel", + accentColor: Color(red: 227.0 / 255.0, green: 90.0 / 255.0, blue: 90.0 / 255.0), + isEnabled: true, + action: onCancel + ) + + Spacer(minLength: 0) + Text(title) - .font(.tdayRounded(size: 17, weight: .bold)) + .font(.tdayRounded(size: 28, weight: .heavy)) .foregroundStyle(colors.onSurface) .lineLimit(1) - .frame(maxWidth: .infinity) - .padding(.horizontal, 132) - - HStack { - CreateTaskSheetHeaderButton( - title: "Cancel", - isEnabled: true, - action: onCancel - ) + .minimumScaleFactor(0.78) - Spacer(minLength: 0) + Spacer(minLength: 0) - CreateTaskSheetHeaderButton( - title: submitTitle, - isEnabled: isSubmitEnabled, - action: onSubmit - ) - } + CreateTaskSheetHeaderButton( + systemName: "checkmark", + accessibilityLabel: submitAccessibilityLabel, + accentColor: Color(red: 47.0 / 255.0, green: 163.0 / 255.0, blue: 91.0 / 255.0), + isEnabled: isSubmitEnabled, + action: onSubmit + ) } .padding(.horizontal, 18) - .padding(.top, 16) + .padding(.top, 14) .padding(.bottom, 14) .background(colors.background) } } private struct CreateTaskSheetHeaderButton: View { - let title: String + let systemName: String + let accessibilityLabel: String + let accentColor: Color let isEnabled: Bool let action: () -> Void @@ -235,31 +240,26 @@ private struct CreateTaskSheetHeaderButton: View { var body: some View { Button(action: action) { - Text(title) - .font(.tdayRounded(size: 17, weight: .bold)) - .foregroundStyle(isEnabled ? colors.onSurface : colors.onSurfaceVariant.opacity(0.38)) - .lineLimit(1) - .minimumScaleFactor(0.72) - .allowsTightening(false) - .frame(width: 108, height: 54) - .background(colors.surface, in: Capsule(style: .continuous)) + Image(systemName: systemName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(colors.onSurface.opacity(isEnabled ? 1 : 0.55)) + .frame(width: 56, height: 56) + .background(colors.surfaceVariant, in: Circle()) .overlay { - Capsule(style: .continuous) - .stroke(colors.onSurfaceVariant.opacity(0.12), lineWidth: 1) + Circle() + .stroke(accentColor.opacity(isEnabled ? 0.55 : 0.3), lineWidth: 1.5) } - .contentShape(Capsule(style: .continuous)) + .contentShape(Circle()) } - .buttonStyle(CreateTaskSheetHeaderButtonStyle()) + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: isEnabled ? 0.16 : 0.06 + ) + ) .disabled(!isEnabled) + .accessibilityLabel(accessibilityLabel) .accessibilityAddTraits(.isButton) } } - -private struct CreateTaskSheetHeaderButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed ? 0.97 : 1) - .opacity(configuration.isPressed ? 0.78 : 1) - .animation(.easeOut(duration: 0.12), value: configuration.isPressed) - } -} From 7266225a0ddcb40738d1ee7acde1f790a1716a74 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 16:14:13 -0400 Subject: [PATCH 16/52] Refine the calendar UI for both Android (Compose) and iOS (SwiftUI) to unify the visual style, improve spacing, and enhance "today" highlighting. - **Unified Styling & Colors**: - Introduce a dedicated "today" tint color (Blue #509AE6) for both platforms to distinguish current day from selection (Purple). - Update day cell backgrounds, borders, and text colors to use subtler opacities and thicker strokes for active states. - Synchronize typography across both platforms, notably increasing the calendar header title size to 21sp/pt. - **Android (Compose) Enhancements**: - Wrap the calendar card in a `Box` with consistent shadow, clipping, and `AnimatedContent` size transformations to prevent layout jumps during view mode transitions. - Refactor `CalendarMonthDateCell` and `CalendarPeriodDayCell` with updated dimensions (42dp height) and rounded rectangle shapes (16dp). - Implement a custom interaction state for month cells, replacing the standard ripple with a bespoke background/border animation. - Adjust grid spacing from 11dp to 8dp and inner content arrangement for a denser, cleaner look. - **iOS (SwiftUI) Enhancements**: - Update `CalendarMonthGridMetrics` and `CalendarPeriodCardMetrics` to match Android's new spacing and sizing logic. - Refactor month day cells to use a `RoundedRectangle` background and stroke instead of the previous `Circle` highlight. - Implement `CalendarNavButton` to standardize the appearance and behavior of navigation chevrons. - Adjust cell horizontal gutters and vertical spacing to improve alignment within the paging component. - **Shared Logic**: - Standardize task count indicators with a consistent dot size (4.6dp) and refined typography. - Update navigation logic to ensure consistency in disabled states and padding across week, month, and day views. --- .../feature/calendar/CalendarScreen.kt | 407 +++++++++++------- .../Feature/Calendar/CalendarScreen.swift | 192 ++++++--- 2 files changed, 383 insertions(+), 216 deletions(-) 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 a8ed67b2..63236cbf 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 @@ -9,8 +9,6 @@ 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.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith @@ -61,7 +59,6 @@ import androidx.compose.material.icons.rounded.ChevronLeft import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material.icons.rounded.DeleteOutline import androidx.compose.material.icons.rounded.DirectionsCar -import androidx.compose.material.icons.rounded.FiberManualRecord import androidx.compose.material.icons.rounded.FitnessCenter import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Flight @@ -143,26 +140,34 @@ import java.time.format.TextStyle import java.util.Locale private val CalendarAccentPurple = Color(0xFF7D67B6) +private val CalendarTodayBlue = Color(0xFF509AE6) private val CalendarCardCornerRadius = 24.dp private val CalendarCardHeaderHeight = 36.dp private val CalendarCardHeaderHorizontalPadding = 6.dp private val CalendarCardNavButtonWidth = 40.dp private val CalendarCardNavButtonHeight = 36.dp -private val CalendarMonthCardHorizontalPadding = 16.dp -private val CalendarMonthCardTopPadding = 18.dp +private val CalendarCardNavIconSize = 28.dp +private val CalendarCardHorizontalPadding = 16.dp +private val CalendarMonthCardTopPadding = 16.dp private val CalendarMonthCardBottomPadding = 20.dp -private val CalendarMonthCardOuterSpacing = 16.dp -private val CalendarMonthGridSpacing = 11.dp +private val CalendarMonthCardOuterSpacing = 14.dp +private val CalendarMonthGridSpacing = 8.dp private val CalendarMonthWeekdayHeight = 18.dp -private val CalendarMonthGridHeight = 283.dp -private val CalendarMonthDayCellHeight = 38.dp -private val CalendarMonthDayNumberWidth = 32.dp -private val CalendarMonthDayNumberHeight = 28.dp -private val CalendarMonthHeaderTitleSize = 18.sp +private val CalendarMonthGridHeight = 292.dp +private val CalendarMonthDayCellHeight = 42.dp +private val CalendarMonthDayHighlightWidth = 42.dp +private val CalendarMonthDayHighlightHeight = 40.dp +private val CalendarMonthDayNumberWidth = 34.dp +private val CalendarMonthDayNumberHeight = 24.dp +private val CalendarMonthTaskCountHeight = 13.dp +private val CalendarMonthTaskDotSize = 4.6.dp +private val CalendarMonthHeaderTitleSize = 21.sp private val CalendarPeriodHeaderTitleSize = 21.sp private val CalendarDaySummaryTitleSize = 25.sp private val CalendarDaySummaryCountSize = 18.sp private val CalendarPeriodCardPageHeight = 78.dp +private val CalendarPeriodWeekDayCellHeight = 72.dp +private val CalendarPeriodPageHorizontalGutter = 2.dp private val CalendarPeriodCardBottomPadding = 18.dp private fun calendarPageAnimationSpec() = tween( @@ -341,60 +346,77 @@ fun CalendarScreen( } item { - AnimatedContent( - targetState = selectedViewMode, - transitionSpec = { - val enteringForward = targetState.ordinal > initialState.ordinal - val enter = slideInHorizontally( - animationSpec = tween(durationMillis = 200), - initialOffsetX = { fullWidth -> - if (enteringForward) fullWidth / 4 else -fullWidth / 4 - }, - ) + fadeIn(animationSpec = tween(durationMillis = 200)) - val exit = slideOutHorizontally( - animationSpec = tween(durationMillis = 180), - targetOffsetX = { fullWidth -> - if (enteringForward) -fullWidth / 4 else fullWidth / 4 - }, - ) + fadeOut(animationSpec = tween(durationMillis = 180)) - enter togetherWith exit - }, - label = "calendarViewModeAnimatedContent", - ) { mode -> - when (mode) { - CalendarViewMode.MONTH -> CalendarMonthCard( - visibleMonth = visibleMonth, - canGoPrevMonth = visibleMonth > minNavigableMonth, - selectedDate = selectedDate, - today = today, - tasksByDate = tasksByDate, - onPrevMonth = { - if (visibleMonth > minNavigableMonth) { - visibleMonthIso = visibleMonth.minusMonths(1).toString() - } - }, - onNextMonth = { visibleMonthIso = visibleMonth.plusMonths(1).toString() }, - onSelectDate = ::selectDate, + Box( + modifier = Modifier + .fillMaxWidth() + .shadow( + elevation = 2.dp, + shape = RoundedCornerShape(CalendarCardCornerRadius), + clip = false, ) + .clip(RoundedCornerShape(CalendarCardCornerRadius)) + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(CalendarCardCornerRadius), + ), + ) { + AnimatedContent( + targetState = selectedViewMode, + transitionSpec = { + val enteringForward = targetState.ordinal > initialState.ordinal + val enter = slideInHorizontally( + animationSpec = tween(durationMillis = 200), + initialOffsetX = { fullWidth -> + if (enteringForward) fullWidth / 4 else -fullWidth / 4 + }, + ) + val exit = slideOutHorizontally( + animationSpec = tween(durationMillis = 180), + targetOffsetX = { fullWidth -> + if (enteringForward) -fullWidth / 4 else fullWidth / 4 + }, + ) + (enter togetherWith exit).using(SizeTransform(clip = true)) + }, + label = "calendarViewModeAnimatedContent", + ) { mode -> + when (mode) { + CalendarViewMode.MONTH -> CalendarMonthCard( + visibleMonth = visibleMonth, + canGoPrevMonth = visibleMonth > minNavigableMonth, + selectedDate = selectedDate, + today = today, + tasksByDate = tasksByDate, + onPrevMonth = { + if (visibleMonth > minNavigableMonth) { + visibleMonthIso = visibleMonth.minusMonths(1).toString() + } + }, + onNextMonth = { + visibleMonthIso = visibleMonth.plusMonths(1).toString() + }, + onSelectDate = ::selectDate, + ) - CalendarViewMode.WEEK -> CalendarWeekCard( - selectedDate = selectedDate, - today = today, - tasksByDate = tasksByDate, - canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), - onPrevWeek = { selectDate(selectedDate.minusWeeks(1)) }, - onNextWeek = { selectDate(selectedDate.plusWeeks(1)) }, - onSelectDate = ::selectDate, - ) + CalendarViewMode.WEEK -> CalendarWeekCard( + selectedDate = selectedDate, + today = today, + tasksByDate = tasksByDate, + canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), + onPrevWeek = { selectDate(selectedDate.minusWeeks(1)) }, + onNextWeek = { selectDate(selectedDate.plusWeeks(1)) }, + onSelectDate = ::selectDate, + ) - CalendarViewMode.DAY -> CalendarDayCard( - selectedDate = selectedDate, - today = today, - tasksByDate = tasksByDate, - canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), - onPrevDay = { selectDate(selectedDate.minusDays(1)) }, - onNextDay = { selectDate(selectedDate.plusDays(1)) }, - ) + CalendarViewMode.DAY -> CalendarDayCard( + selectedDate = selectedDate, + today = today, + tasksByDate = tasksByDate, + canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), + onPrevDay = { selectDate(selectedDate.minusDays(1)) }, + onNextDay = { selectDate(selectedDate.plusDays(1)) }, + ) + } } } } @@ -780,15 +802,15 @@ private fun CalendarWeekCard( }, shape = RoundedCornerShape(CalendarCardCornerRadius), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), ) { Column( modifier = Modifier .fillMaxWidth() .padding( - start = CalendarMonthCardHorizontalPadding, + start = CalendarCardHorizontalPadding, top = 16.dp, - end = CalendarMonthCardHorizontalPadding, + end = CalendarCardHorizontalPadding, bottom = CalendarPeriodCardBottomPadding, ), verticalArrangement = Arrangement.spacedBy(14.dp), @@ -854,7 +876,9 @@ private fun CalendarWeekCard( List(7) { offset -> displayWeekStart.plusDays(offset.toLong()) } } Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CalendarPeriodPageHorizontalGutter), horizontalArrangement = Arrangement.spacedBy(6.dp), ) { weekDays.forEach { day -> @@ -887,57 +911,76 @@ private fun CalendarWeekDayCell( ) { val colorScheme = MaterialTheme.colorScheme val containerColor = when { - isSelected -> CalendarAccentPurple.copy(alpha = 0.2f) - isToday -> CalendarAccentPurple.copy(alpha = 0.12f) + isSelected -> CalendarAccentPurple.copy(alpha = 0.24f) + isToday -> CalendarTodayBlue.copy(alpha = 0.16f) else -> colorScheme.background } val borderColor = when { - isSelected -> CalendarAccentPurple.copy(alpha = 0.8f) - isToday -> CalendarAccentPurple.copy(alpha = 0.42f) + isSelected -> CalendarAccentPurple.copy(alpha = 0.95f) + isToday -> CalendarTodayBlue.copy(alpha = 0.74f) else -> Color.Transparent } + val borderWidth = when { + isSelected -> 1.6.dp + isToday -> 1.4.dp + else -> 0.dp + } + val stateTint = when { + isSelected -> CalendarAccentPurple + isToday -> CalendarTodayBlue + else -> CalendarAccentPurple + } - Card( + Box( modifier = modifier .height(CalendarPeriodCardPageHeight) .minimumInteractiveComponentSize(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = containerColor), - border = BorderStroke( - width = if (borderColor == Color.Transparent) 0.dp else 1.dp, - color = borderColor, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - onClick = onClick, + contentAlignment = Alignment.Center, ) { - Column( + Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(3.dp), + .height(CalendarPeriodWeekDayCellHeight), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = containerColor), + border = BorderStroke( + width = borderWidth, + color = borderColor, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + onClick = onClick, ) { - Text( - text = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()), - style = MaterialTheme.typography.labelMedium, - color = colorScheme.onSurfaceVariant.copy(alpha = 0.9f), - ) - Text( - text = date.dayOfMonth.toString(), - style = MaterialTheme.typography.titleMedium, - color = if (isSelected || isToday) CalendarAccentPurple else colorScheme.onSurface, - fontWeight = FontWeight.ExtraBold, - ) - Text( - text = if (taskCount > 9) { - stringResource(R.string.calendar_task_count_cap) - } else { - taskCount.toString() - }, - style = MaterialTheme.typography.labelSmall, - color = if (taskCount > 0) CalendarAccentPurple else colorScheme.onSurfaceVariant.copy(alpha = 0.42f), - fontWeight = FontWeight.ExtraBold, - ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()), + style = MaterialTheme.typography.labelMedium, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.9f), + ) + Text( + text = date.dayOfMonth.toString(), + style = MaterialTheme.typography.titleMedium, + color = if (isSelected || isToday) stateTint else colorScheme.onSurface, + fontWeight = FontWeight.ExtraBold, + ) + Text( + text = if (taskCount > 9) { + stringResource(R.string.calendar_task_count_cap) + } else { + taskCount.toString() + }, + style = MaterialTheme.typography.labelSmall, + color = if (taskCount > 0) stateTint else colorScheme.onSurfaceVariant.copy( + alpha = 0.42f + ), + fontWeight = FontWeight.ExtraBold, + ) + } } } } @@ -996,15 +1039,15 @@ private fun CalendarDayCard( }, shape = RoundedCornerShape(CalendarCardCornerRadius), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), ) { Column( modifier = Modifier .fillMaxWidth() .padding( - start = CalendarMonthCardHorizontalPadding, + start = CalendarCardHorizontalPadding, top = 16.dp, - end = CalendarMonthCardHorizontalPadding, + end = CalendarCardHorizontalPadding, bottom = CalendarPeriodCardBottomPadding, ), verticalArrangement = Arrangement.spacedBy(14.dp), @@ -1069,7 +1112,7 @@ private fun CalendarDayCard( val taskCount = tasksByDate[displayDate]?.size ?: 0 Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { Text( text = displayDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy")), @@ -1331,15 +1374,15 @@ private fun CalendarMonthCard( }, shape = RoundedCornerShape(CalendarCardCornerRadius), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), ) { Column( modifier = Modifier .fillMaxWidth() .padding( - start = CalendarMonthCardHorizontalPadding, + start = CalendarCardHorizontalPadding, top = CalendarMonthCardTopPadding, - end = CalendarMonthCardHorizontalPadding, + end = CalendarCardHorizontalPadding, bottom = CalendarMonthCardBottomPadding, ), verticalArrangement = Arrangement.spacedBy(CalendarMonthCardOuterSpacing), @@ -1355,7 +1398,6 @@ private fun CalendarMonthCard( icon = Icons.Rounded.ChevronLeft, contentDescription = stringResource(R.string.calendar_prev_month), enabled = canGoPrevMonth, - iconTint = CalendarAccentPurple, onClick = onPrevMonth, ) Box( @@ -1375,7 +1417,6 @@ private fun CalendarMonthCard( MiniCalendarNavButton( icon = Icons.Rounded.ChevronRight, contentDescription = stringResource(R.string.calendar_next_month), - iconTint = CalendarAccentPurple, onClick = onNextMonth, ) } @@ -1496,7 +1537,7 @@ private fun MiniCalendarNavButton( } else { colorScheme.onSurfaceVariant.copy(alpha = 0.34f) }, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(CalendarCardNavIconSize), ) } } @@ -1511,76 +1552,118 @@ private fun CalendarDayCell( modifier: Modifier = Modifier, ) { val colorScheme = MaterialTheme.colorScheme - val numberBackground = when { - isSelected -> CalendarAccentPurple - isToday -> CalendarAccentPurple.copy(alpha = 0.16f) + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val targetCellBackground = when { + isSelected -> CalendarAccentPurple.copy(alpha = if (isPressed) 0.32f else 0.24f) + isToday -> CalendarTodayBlue.copy(alpha = if (isPressed) 0.24f else 0.16f) + isPressed && cell.isCurrentMonth -> colorScheme.onSurfaceVariant.copy(alpha = 0.12f) else -> Color.Transparent } + val cellBackground by animateColorAsState( + targetValue = targetCellBackground, + label = "calendarMonthDateCellBackground", + ) + val targetCellBorderColor = when { + isSelected -> CalendarAccentPurple.copy(alpha = 0.95f) + isToday -> CalendarTodayBlue.copy(alpha = 0.74f) + isPressed && cell.isCurrentMonth -> colorScheme.onSurfaceVariant.copy(alpha = 0.34f) + else -> Color.Transparent + } + val cellBorderColor by animateColorAsState( + targetValue = targetCellBorderColor, + label = "calendarMonthDateCellBorder", + ) + val targetCellBorderWidth = when { + isSelected -> 1.6.dp + isToday -> 1.4.dp + isPressed && cell.isCurrentMonth -> 1.2.dp + else -> 0.dp + } + val cellBorderWidth by animateDpAsState( + targetValue = targetCellBorderWidth, + label = "calendarMonthDateCellBorderWidth", + ) + val stateTint = when { + isSelected -> CalendarAccentPurple + isToday -> CalendarTodayBlue + else -> CalendarAccentPurple + } + val cellShape = RoundedCornerShape(16.dp) val dayTextColor = when { - isSelected -> Color.White - isToday -> CalendarAccentPurple + isSelected || isToday -> stateTint cell.isCurrentMonth -> colorScheme.onSurface else -> colorScheme.onSurfaceVariant.copy(alpha = 0.45f) } - val interactionSource = remember { MutableInteractionSource() } - Column( + Box( modifier = modifier .fillMaxWidth() .height(CalendarMonthDayCellHeight) .graphicsLayer { alpha = if (cell.isCurrentMonth) 1f else 0.45f } - .clip(RoundedCornerShape(12.dp)) .clickable( enabled = cell.isCurrentMonth, interactionSource = interactionSource, - indication = ripple( - bounded = true, - radius = 20.dp, - ), + indication = null, onClick = onClick, ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(3.dp), + contentAlignment = Alignment.Center, ) { - Box( + Column( modifier = Modifier - .width(CalendarMonthDayNumberWidth) - .height(CalendarMonthDayNumberHeight) - .background(numberBackground, CircleShape), - contentAlignment = Alignment.Center, - ) { - Text( - text = cell.date.dayOfMonth.toString(), - style = MaterialTheme.typography.labelLarge.copy(fontSize = 18.sp), - color = dayTextColor, - fontWeight = FontWeight.ExtraBold, - textAlign = TextAlign.Center, - ) - } - - Row( - modifier = Modifier.height(7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(3.dp), + .width(CalendarMonthDayHighlightWidth) + .height(CalendarMonthDayHighlightHeight) + .clip(cellShape) + .background(cellBackground, cellShape) + .border( + width = cellBorderWidth, + color = cellBorderColor, + shape = cellShape, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(1.dp, Alignment.CenterVertically), ) { - if (taskCount > 0 && cell.isCurrentMonth) { - Icon( - imageVector = Icons.Rounded.FiberManualRecord, - contentDescription = null, - tint = CalendarAccentPurple, - modifier = Modifier.size(4.dp), - ) + Box( + modifier = Modifier + .width(CalendarMonthDayNumberWidth) + .height(CalendarMonthDayNumberHeight), + contentAlignment = Alignment.Center, + ) { Text( - text = if (taskCount > 9) { - stringResource(R.string.calendar_task_count_cap) - } else { - taskCount.toString() - }, - style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), - color = CalendarAccentPurple, + text = cell.date.dayOfMonth.toString(), + style = MaterialTheme.typography.labelLarge.copy(fontSize = 19.sp), + color = dayTextColor, fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, ) } + + Row( + modifier = Modifier.height(CalendarMonthTaskCountHeight), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (taskCount > 0 && cell.isCurrentMonth) { + Box( + modifier = Modifier + .size(CalendarMonthTaskDotSize) + .background(stateTint, CircleShape), + ) + Text( + text = if (taskCount > 9) { + stringResource(R.string.calendar_task_count_cap) + } else { + taskCount.toString() + }, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + lineHeight = 11.sp, + ), + color = stateTint, + fontWeight = FontWeight.ExtraBold, + ) + } + } } } } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 6c80bc3c..d9a476d8 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -17,10 +17,27 @@ private enum CalendarPeriodCardMetrics { static let headerHeight: CGFloat = 36 static let headerHorizontalPadding: CGFloat = 6 static let pageHeight: CGFloat = 78 + static let weekDayCellHeight: CGFloat = 72 + static let pageHorizontalGutter: CGFloat = 2 static let topPadding: CGFloat = 16 static let bottomPadding: CGFloat = 18 } +private enum CalendarMonthGridMetrics { + static let spacing: CGFloat = 8 + static let height: CGFloat = 292 + static let dayCellHeight: CGFloat = 42 + static let dayHighlightWidth: CGFloat = 42 + static let dayHighlightHeight: CGFloat = 40 + static let dayNumberWidth: CGFloat = 34 + static let dayNumberHeight: CGFloat = 24 + static let taskCountHeight: CGFloat = 13 + static let taskDotSize: CGFloat = 4.6 + static let cellCornerRadius: CGFloat = 16 +} + +private let calendarTodayTintColor = Color(red: 80.0 / 255.0, green: 154.0 / 255.0, blue: 230.0 / 255.0) + private let calendarNativePagerCenterIndex = 1 private struct CalendarTodayJumpRequest: Equatable { @@ -502,38 +519,34 @@ private struct CalendarMonthGrid: View { let isPreviousEnabled = canGoPrevious && isPagingAtRest let isNextEnabled = isPagingAtRest - return VStack(spacing: 16) { + return VStack(spacing: CalendarPeriodCardMetrics.contentSpacing) { HStack { - Button(action: goToPreviousPage) { - Image(systemName: "chevron.left") - .font(.tdayRounded(size: 20, weight: .heavy)) - .foregroundStyle(isPreviousEnabled ? accentColor : colors.onSurfaceVariant.opacity(0.36)) - .frame(width: 40, height: 36) - } - .buttonStyle(.plain) - .disabled(!isPreviousEnabled) + CalendarNavButton( + systemName: "chevron.left", + isEnabled: isPreviousEnabled, + color: colors.onSurfaceVariant, + action: goToPreviousPage + ) Spacer(minLength: 0) Text(monthTitle(for: displayMonth)) - .font(.tdayRounded(size: 18, weight: .heavy)) + .font(.tdayRounded(size: 21, weight: .heavy)) .foregroundStyle(colors.onSurface) Spacer(minLength: 0) - Button(action: goToNextPage) { - Image(systemName: "chevron.right") - .font(.tdayRounded(size: 20, weight: .heavy)) - .foregroundStyle(isNextEnabled ? accentColor : accentColor.opacity(0.36)) - .frame(width: 40, height: 36) - } - .buttonStyle(.plain) - .disabled(!isNextEnabled) + CalendarNavButton( + systemName: "chevron.right", + isEnabled: isNextEnabled, + color: colors.onSurfaceVariant, + action: goToNextPage + ) } .frame(height: CalendarPeriodCardMetrics.headerHeight) .padding(.horizontal, CalendarPeriodCardMetrics.headerHorizontalPadding) - VStack(spacing: 11) { + VStack(spacing: CalendarMonthGridMetrics.spacing) { HStack(spacing: 0) { ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in Text(symbol) @@ -553,17 +566,17 @@ private struct CalendarMonthGrid: View { selection: $pageSelection, onSettledSelection: settlePageSelection ) - .frame(height: 283) + .frame(height: CalendarMonthGridMetrics.height) } } .padding(.horizontal, CalendarPeriodCardMetrics.horizontalPadding) - .padding(.top, 18) + .padding(.top, CalendarPeriodCardMetrics.topPadding) .padding(.bottom, 20) .frame(maxWidth: .infinity) } private func monthGrid(for displayMonth: Date) -> some View { - LazyVGrid(columns: columns, spacing: 11) { + LazyVGrid(columns: columns, spacing: CalendarMonthGridMetrics.spacing) { ForEach(Self.makeDays(for: displayMonth)) { day in let dayTasks = tasksByDay[Calendar.current.startOfDay(for: day.date)].orEmpty CalendarMonthDayCell( @@ -777,6 +790,7 @@ private struct CalendarWeekCard: View { ) } } + .padding(.horizontal, CalendarPeriodCardMetrics.pageHorizontalGutter) } private func weekPages(previousWeekDate: Date?, displaySelectedDate: Date, nextWeekDate: Date?) -> [CalendarPagerPage] { @@ -886,15 +900,18 @@ private struct CalendarWeekDayCell: View { Text(calendarTaskCountText(taskCount)) .font(.tdayRounded(size: 12, weight: .heavy)) - .foregroundStyle(taskCount > 0 ? accentColor : colors.onSurfaceVariant.opacity(0.42)) + .foregroundStyle(taskCount > 0 ? stateTint : colors.onSurfaceVariant.opacity(0.42)) } - .frame(maxWidth: .infinity, minHeight: CalendarPeriodCardMetrics.pageHeight) + .frame(maxWidth: .infinity) + .frame(height: CalendarPeriodCardMetrics.weekDayCellHeight) .background(cellBackground, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(cellBorderColor, lineWidth: cellBorderWidth) } .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .frame(maxWidth: .infinity) + .frame(height: CalendarPeriodCardMetrics.pageHeight) } .buttonStyle(.plain) .disabled(!isEnabled) @@ -913,34 +930,53 @@ private struct CalendarWeekDayCell: View { private var cellBackground: Color { if isSelected { - return accentColor.opacity(0.20) + return accentColor.opacity(0.24) } if isToday { - return accentColor.opacity(0.12) + return calendarTodayTintColor.opacity(0.16) } return colors.background } private var cellBorderColor: Color { if isSelected { - return accentColor.opacity(0.82) + return accentColor.opacity(0.95) } if isToday { - return accentColor.opacity(0.42) + return calendarTodayTintColor.opacity(0.74) } return .clear } private var cellBorderWidth: CGFloat { - (isSelected || isToday) ? 1.4 : 0 + if isSelected { + return 1.6 + } + if isToday { + return 1.4 + } + return 0 } private var dayTextColor: Color { - if isSelected || isToday { + if isSelected { return accentColor } + if isToday { + return calendarTodayTintColor + } return colors.onSurface } + + private var stateTint: Color { + if isSelected { + return accentColor + } + if isToday { + return calendarTodayTintColor + } + return accentColor + } } private struct CalendarDayCard: View { @@ -1329,37 +1365,48 @@ private struct CalendarMonthDayCell: View { Button { onSelectDate(day.date) } label: { - VStack(spacing: 3) { + VStack(spacing: 1) { Text(dayNumberText) - .font(.tdayRounded(size: 18, weight: .bold)) + .font(.tdayRounded(size: 19, weight: .bold)) .foregroundStyle(dayTextColor) - .frame(width: 32, height: 28) - .background { - if isSelected { - Circle() - .fill(accentColor) - } else if isToday { - Circle() - .fill(accentColor.opacity(0.16)) - } - } + .frame( + width: CalendarMonthGridMetrics.dayNumberWidth, + height: CalendarMonthGridMetrics.dayNumberHeight + ) - HStack(spacing: 3) { + HStack(spacing: 2) { if taskCount > 0 { Circle() - .fill(accentColor) - .frame(width: 4.2, height: 4.2) + .fill(stateTint) + .frame( + width: CalendarMonthGridMetrics.taskDotSize, + height: CalendarMonthGridMetrics.taskDotSize + ) Text(calendarTaskCountText(taskCount)) - .font(.tdayRounded(size: 10, weight: .heavy)) - .foregroundStyle(accentColor) + .font(.tdayRounded(size: 11, weight: .heavy)) + .lineLimit(1) + .minimumScaleFactor(0.82) + .foregroundStyle(stateTint) } } - .frame(height: 6) + .frame(height: CalendarMonthGridMetrics.taskCountHeight) } - .frame(height: 38) + .frame( + width: CalendarMonthGridMetrics.dayHighlightWidth, + height: CalendarMonthGridMetrics.dayHighlightHeight + ) + .background( + cellBackground, + in: RoundedRectangle(cornerRadius: CalendarMonthGridMetrics.cellCornerRadius, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: CalendarMonthGridMetrics.cellCornerRadius, style: .continuous) + .stroke(cellBorderColor, lineWidth: cellBorderWidth) + } + .contentShape(RoundedRectangle(cornerRadius: CalendarMonthGridMetrics.cellCornerRadius, style: .continuous)) .frame(maxWidth: .infinity) - .contentShape(Rectangle()) + .frame(height: CalendarMonthGridMetrics.dayCellHeight) } .buttonStyle(.plain) .disabled(!day.isCurrentMonth) @@ -1371,16 +1418,53 @@ private struct CalendarMonthDayCell: View { } private var dayTextColor: Color { - if isSelected { - return .white - } if !day.isCurrentMonth { return colors.onSurfaceVariant.opacity(0.48) } + if isSelected || isToday { + return stateTint + } + return colors.onSurface + } + + private var cellBackground: Color { + if isSelected { + return accentColor.opacity(0.24) + } if isToday { + return calendarTodayTintColor.opacity(0.16) + } + return .clear + } + + private var cellBorderColor: Color { + if isSelected { + return accentColor.opacity(0.95) + } + if isToday { + return calendarTodayTintColor.opacity(0.74) + } + return .clear + } + + private var cellBorderWidth: CGFloat { + if isSelected { + return 1.6 + } + if isToday { + return 1.4 + } + return 0 + } + + private var stateTint: Color { + if isSelected { return accentColor } - return colors.onSurface + if isToday { + return calendarTodayTintColor + } + return accentColor } } From c00acdcd45c7dd7ed4234301b36116035bbc127a Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 16:52:40 -0400 Subject: [PATCH 17/52] Refine the calendar UI for both Android (Compose) and iOS (SwiftUI) to unify the visual style, improve spacing, and enhance "today" highlighting. - **Unified Styling & Colors**: - Introduce a dedicated "today" tint color (Blue #509AE6) for both platforms to distinguish current day from selection (Purple). - Update day cell backgrounds, borders, and text colors to use subtler opacities and thicker strokes for active states. - Synchronize typography across both platforms, notably increasing the calendar header title size to 21sp/pt. - **Android (Compose) Enhancements**: - Wrap the calendar card in a `Box` with consistent shadow, clipping, and `AnimatedContent` size transformations to prevent layout jumps during view mode transitions. - Refactor `CalendarMonthDateCell` and `CalendarPeriodDayCell` with updated dimensions (42dp height) and rounded rectangle shapes (16dp). - Implement a custom interaction state for month cells, replacing the standard ripple with a bespoke background/border animation. - Adjust grid spacing from 11dp to 8dp and inner content arrangement for a denser, cleaner look. - **iOS (SwiftUI) Enhancements**: - Update `CalendarMonthGridMetrics` and `CalendarPeriodCardMetrics` to match Android's new spacing and sizing logic. - Refactor month day cells to use a `RoundedRectangle` background and stroke instead of the previous `Circle` highlight. - Implement `CalendarNavButton` to standardize the appearance and behavior of navigation chevrons. - Adjust cell horizontal gutters and vertical spacing to improve alignment within the paging component. - **Shared Logic**: - Standardize task count indicators with a consistent dot size (4.6dp) and refined typography. - Update navigation logic to ensure consistency in disabled states and padding across week, month, and day views. --- .../Feature/Calendar/CalendarScreen.swift | 1 + .../Feature/Completed/CompletedScreen.swift | 30 ++++++----- .../Tday/Feature/Todos/TodoListScreen.swift | 54 ++++++++++++++----- 3 files changed, 61 insertions(+), 24 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index d9a476d8..a5e48ef3 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -213,6 +213,7 @@ struct CalendarScreen: View { .foregroundStyle(colors.onSurface) .textCase(nil) .listRowInsets(EdgeInsets(top: 8, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) + .timelinePinnedSectionHeaderBackground() } } .listStyle(.plain) diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index f3791aa0..8934044e 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -42,6 +42,12 @@ struct CompletedScreen: View { return "\(itemIDs)::\(phaseIDs)" } + private var firstVisibleExpandedCompletedSectionID: String? { + groupedItems.first { section in + !section.items.isEmpty && !collapsedSectionIDs.contains(section.id) + }?.id + } + var body: some View { completedTimelineContent .background(colors.background) @@ -147,7 +153,7 @@ struct CompletedScreen: View { if !isCollapsed { ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, item in completedTimelineRow(item) - .padding(.top, firstPinnedRowElasticTopInset(isFirstSection: isFirstSection, itemIndex: itemIndex)) + .padding(.top, firstPinnedRowElasticTopInset(isFirstVisibleExpandedSection: section.id == firstVisibleExpandedCompletedSectionID, itemIndex: itemIndex)) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -168,21 +174,21 @@ struct CompletedScreen: View { } } ) - .listRowInsets( - EdgeInsets( - top: isFirstSection ? 0 : 8, - leading: 0, - bottom: 0, - trailing: 0 - ) + .listRowInsets( + EdgeInsets( + top: isFirstSection ? 0 : 8, + leading: 0, + bottom: 0, + trailing: 0 ) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) + ) + .timelinePinnedSectionHeaderBackground() + .listRowSeparator(.hidden) } } - private func firstPinnedRowElasticTopInset(isFirstSection: Bool, itemIndex: Int) -> CGFloat { - guard isFirstSection, itemIndex == 0 else { + private func firstPinnedRowElasticTopInset(isFirstVisibleExpandedSection: Bool, itemIndex: Int) -> CGFloat { + guard isFirstVisibleExpandedSection, itemIndex == 0 else { return 0 } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 967f671a..db28d521 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -44,6 +44,23 @@ enum TodoTimelineMetrics { } } +struct TimelinePinnedSectionHeaderBackground: ViewModifier { + @Environment(\.tdayColors) private var colors + + func body(content: Content) -> some View { + content + .background(colors.background) + .listRowBackground(colors.background) + .zIndex(1) + } +} + +extension View { + func timelinePinnedSectionHeaderBackground() -> some View { + modifier(TimelinePinnedSectionHeaderBackground()) + } +} + struct TimelineRowDivider: View { @Environment(\.tdayColors) private var colors @@ -136,6 +153,12 @@ struct TodoListScreen: View { return "\(itemIDs)::\(completingIDs)" } + private var firstVisibleExpandedTimelineSectionID: String? { + groupedSections.first { section in + !section.items.isEmpty && !isTimelineSectionCollapsed(section) + }?.id + } + private var canSummarizeCurrentMode: Bool { viewModel.mode != .list && viewModel.mode != .overdue && viewModel.aiSummaryEnabled } @@ -388,6 +411,7 @@ struct TodoListScreen: View { } header: { Text(section.title) .foregroundStyle(activeDropSectionId == section.id ? colors.primary : colors.onSurfaceVariant) + .timelinePinnedSectionHeaderBackground() } } } @@ -425,7 +449,7 @@ struct TodoListScreen: View { } else { ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section) - .padding(.top, firstPinnedRowElasticTopInset(section: section, isFirstSection: index == 0, itemIndex: itemIndex)) + .padding(.top, firstPinnedRowElasticTopInset(section: section, isFirstVisibleExpandedSection: section.id == firstVisibleExpandedTimelineSectionID, itemIndex: itemIndex)) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -445,7 +469,7 @@ struct TodoListScreen: View { trailing: 0 ) ) - .listRowBackground(Color.clear) + .timelinePinnedSectionHeaderBackground() .listRowSeparator(.hidden) } } @@ -723,18 +747,14 @@ struct TodoListScreen: View { @ViewBuilder private func minimalTimelineSection(_ section: TodoTimelineSection, isFirstSection: Bool) -> some View { - let canCollapseSection = if viewModel.mode == .all { - true - } else { - viewModel.mode == .priority && section.isCollapsible - } + let canCollapseSection = canCollapseTimelineSection(section) let isCollapsed = canCollapseSection && collapsedSectionIDs.contains(section.id) Section { if !isCollapsed { ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section) - .padding(.top, firstPinnedRowElasticTopInset(section: section, isFirstSection: isFirstSection, itemIndex: itemIndex)) + .padding(.top, firstPinnedRowElasticTopInset(section: section, isFirstVisibleExpandedSection: section.id == firstVisibleExpandedTimelineSectionID, itemIndex: itemIndex)) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -763,13 +783,13 @@ struct TodoListScreen: View { trailing: 0 ) ) - .listRowBackground(Color.clear) + .timelinePinnedSectionHeaderBackground() .listRowSeparator(.hidden) } } - private func firstPinnedRowElasticTopInset(section: TodoTimelineSection, isFirstSection: Bool, itemIndex: Int) -> CGFloat { - guard isFirstSection, itemIndex == 0 else { + private func firstPinnedRowElasticTopInset(section: TodoTimelineSection, isFirstVisibleExpandedSection: Bool, itemIndex: Int) -> CGFloat { + guard isFirstVisibleExpandedSection, itemIndex == 0 else { return 0 } @@ -804,6 +824,17 @@ struct TodoListScreen: View { return TodoTimelineMetrics.firstPinnedRowElasticClearance * elasticProgress } + private func canCollapseTimelineSection(_ section: TodoTimelineSection) -> Bool { + if viewModel.mode == .all { + return true + } + return viewModel.mode == .priority && section.isCollapsible + } + + private func isTimelineSectionCollapsed(_ section: TodoTimelineSection) -> Bool { + canCollapseTimelineSection(section) && collapsedSectionIDs.contains(section.id) + } + private func minimalTimelineSubtitle(for todo: TodoItem, in section: TodoTimelineSection) -> String { let timeText = todo.due.formatted(date: .omitted, time: .shortened) let dueBodyText = if viewModel.mode == .priority && section.id == "earlier" { @@ -1195,7 +1226,6 @@ struct TimelineSectionHeader: View { .padding(.horizontal, TodoTimelineMetrics.horizontalPadding) .padding(.bottom, 4) .frame(maxWidth: .infinity, alignment: .leading) - .background(colors.background) if let onTap { Button(action: onTap) { From d3e90edbc49f7d2513dec7cd25d62680c0d44626 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 17:06:27 -0400 Subject: [PATCH 18/52] Refine the UI for the "Create List" sheet and todo timeline on both iOS and Android platforms. This update focuses on polishing the layout of the list creation interface, improving typography, and enhancing the visual transition of the top bar during scroll interactions. - **iOS Todo List Enhancements**: - Introduce "handoff cover" metrics and logic in `TodoListScreen` to provide a smoother visual transition for the top bar as content scrolls beneath it. - Simplify `shouldApplyFirstPinnedRowElasticClearance` by using a comprehensive switch statement over `viewModel.mode`. - Add a background overlay to the top bar with dynamic opacity (`handoffCoverOpacity`) to blend with the background during scroll. - **iOS Home & List Creation**: - Remove the outer `ScrollView` from the `CreateListSheet` and adjust the layout to a fixed `VStack` with a flexible `fraction(0.8)` detent. - Reduce font sizes for the "New list" title, section headers, and the list name `TextField` for a more compact design. - Adjust padding and corner radii within the list creation cards and input fields. - **Android Home & List Creation**: - Remove `verticalScroll` from the list creation sheet to maintain a stable layout. - Implement auto-focus logic using `FocusRequester` and `LaunchedEffect` to automatically show the keyboard when the "New list" sheet appears. - Remove the redundant "List" section title to match the streamlined iOS layout. --- .../tday/compose/feature/home/HomeScreen.kt | 15 +- .../Tday/Feature/Home/HomeScreen.swift | 226 +++++++++--------- .../Tday/Feature/Todos/TodoListScreen.swift | 40 ++-- 3 files changed, 150 insertions(+), 131 deletions(-) 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 a7bb9d80..bd47f5bb 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 @@ -48,7 +48,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.DirectionsRun import androidx.compose.material.icons.automirrored.rounded.List @@ -718,6 +717,8 @@ private fun CreateListBottomSheet( onCreate: () -> Unit, ) { val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val colorScheme = MaterialTheme.colorScheme val selectedAccent = listColorAccent(listColor) @@ -735,6 +736,12 @@ private fun CreateListBottomSheet( Color.Black.copy(alpha = 0.40f) } + LaunchedEffect(Unit) { + delay(500) + focusRequester.requestFocus() + keyboardController?.show() + } + ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, @@ -753,7 +760,6 @@ private fun CreateListBottomSheet( modifier = Modifier .fillMaxSize() .navigationBarsPadding() - .verticalScroll(rememberScrollState()) .padding(horizontal = 18.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { @@ -769,7 +775,6 @@ private fun CreateListBottomSheet( confirmEnabled = canCreate, ) - ListSheetSectionTitle(stringResource(R.string.home_section_list)) ListSheetCard { Column( modifier = Modifier @@ -801,7 +806,9 @@ private fun CreateListBottomSheet( color = selectedAccent, fontWeight = FontWeight.ExtraBold, ), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), decorationBox = { innerTextField -> Box( modifier = Modifier diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 62787f7b..d43f9786 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -977,136 +977,134 @@ private struct CreateListSheet: View { } var body: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 14) { - CreateListSheetHeader( - canCreate: canCreate, - onClose: { dismiss() }, - onConfirm: { - onSubmit(trimmedName, color, iconKey) - dismiss() - } - ) + VStack(spacing: 14) { + CreateListSheetHeader( + canCreate: canCreate, + onClose: { dismiss() }, + onConfirm: { + onSubmit(trimmedName, color, iconKey) + dismiss() + } + ) - CreateListSheetSectionTitle(text: "List") - CreateListSheetCard { - VStack(spacing: 18) { - ZStack { - Circle() - .fill(accentColor) - .frame(width: 86, height: 86) - - Image(systemName: selectedSymbolName) - .font(.system(size: 38, weight: .semibold)) - .foregroundStyle(.white) - } + CreateListSheetCard { + VStack(spacing: 18) { + ZStack { + Circle() + .fill(accentColor) + .frame(width: 86, height: 86) - TextField( - "", - text: $name, - prompt: Text("List name") - .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) - ) - .focused($nameFieldFocused) - .textInputAutocapitalization(.words) - .autocorrectionDisabled() - .multilineTextAlignment(.center) - .font(.tdayRounded(size: 28, weight: .bold)) - .foregroundStyle(accentColor) - .padding(.horizontal, 14) - .frame(maxWidth: .infinity) - .frame(height: 74) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(colors.surfaceVariant) - ) + Image(systemName: selectedSymbolName) + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(.white) } - .padding(.horizontal, 18) - .padding(.vertical, 18) + + TextField( + "", + text: $name, + prompt: Text("List name") + .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) + ) + .focused($nameFieldFocused) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + .multilineTextAlignment(.center) + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(accentColor) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .frame(height: 62) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(colors.surfaceVariant) + ) } + .padding(.horizontal, 18) + .padding(.vertical, 18) + } - CreateListSheetSectionTitle(text: "Color") - CreateListSheetCard { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(homeListColorOptions, id: \.key) { option in - let isSelected = option.key == color - Button { - color = option.key - } label: { - Circle() - .fill(option.color) - .frame(width: 48, height: 48) - .overlay( - Circle() - .stroke( - isSelected ? colors.onSurface.opacity(0.3) : .clear, - lineWidth: 3 - ) - ) - } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.04, - normalShadowOpacity: 0.08 + CreateListSheetSectionTitle(text: "Color") + CreateListSheetCard { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(homeListColorOptions, id: \.key) { option in + let isSelected = option.key == color + Button { + color = option.key + } label: { + Circle() + .fill(option.color) + .frame(width: 48, height: 48) + .overlay( + Circle() + .stroke( + isSelected ? colors.onSurface.opacity(0.3) : .clear, + lineWidth: 3 + ) ) - ) } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: 0.08 + ) + ) } - .padding(.horizontal, 14) - .padding(.vertical, 14) } + .padding(.horizontal, 14) + .padding(.vertical, 14) } + } - CreateListSheetSectionTitle(text: "Icon") - CreateListSheetCard { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(homeListIconOptions, id: \.key) { option in - let isSelected = option.key == iconKey - Button { - iconKey = option.key - } label: { - Circle() - .fill(isSelected ? accentColor.opacity(0.2) : colors.surfaceVariant) - .frame(width: 48, height: 48) - .overlay( - Circle() - .stroke( - isSelected ? accentColor.opacity(0.55) : .clear, - lineWidth: 2 - ) - ) - .overlay { - Image(systemName: option.symbolName) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(isSelected ? accentColor : colors.onSurfaceVariant) - } - } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.04, - normalShadowOpacity: 0.08 + CreateListSheetSectionTitle(text: "Icon") + CreateListSheetCard { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(homeListIconOptions, id: \.key) { option in + let isSelected = option.key == iconKey + Button { + iconKey = option.key + } label: { + Circle() + .fill(isSelected ? accentColor.opacity(0.2) : colors.surfaceVariant) + .frame(width: 48, height: 48) + .overlay( + Circle() + .stroke( + isSelected ? accentColor.opacity(0.55) : .clear, + lineWidth: 2 + ) ) - ) - .accessibilityLabel(formattedOptionName(option.key)) + .overlay { + Image(systemName: option.symbolName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(isSelected ? accentColor : colors.onSurfaceVariant) + } } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: 0.08 + ) + ) + .accessibilityLabel(formattedOptionName(option.key)) } - .padding(.horizontal, 14) - .padding(.vertical, 14) } + .padding(.horizontal, 14) + .padding(.vertical, 14) } - - Spacer(minLength: 8) } - .padding(.horizontal, 18) - .padding(.top, 14) - .padding(.bottom, 20) + + Spacer(minLength: 8) } + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(colors.background.ignoresSafeArea()) - .presentationDetents([.fraction(0.78)]) + .presentationDetents([.fraction(0.8)]) .presentationDragIndicator(.hidden) .presentationCornerRadius(34) .presentationBackground(colors.background) @@ -1146,8 +1144,10 @@ private struct CreateListSheetHeader: View { Spacer() Text("New list") - .font(.tdayRounded(size: 34, weight: .heavy)) + .font(.tdayRounded(size: 24, weight: .heavy)) .foregroundStyle(colors.onSurface) + .lineLimit(1) + .minimumScaleFactor(0.82) Spacer() @@ -1200,7 +1200,7 @@ private struct CreateListSheetSectionTitle: View { var body: some View { Text(text) - .font(.tdayRounded(size: 30, weight: .bold)) + .font(.tdayRounded(size: 22, weight: .bold)) .foregroundStyle(colors.onSurfaceVariant) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 4) diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index db28d521..c01c0d22 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -32,6 +32,9 @@ enum TodoTimelineMetrics { static let collapsedTitleRevealDistance: CGFloat = 10 static let collapsedTitleRevealStart: CGFloat = 0.68 static let collapsedTitleRevealEnd: CGFloat = 1 + static let topBarHandoffCoverHeight: CGFloat = 34 + static let topBarHandoffCoverStart: CGFloat = 0.38 + static let topBarHandoffCoverEnd: CGFloat = 0.78 static func smoothstep(_ value: CGFloat) -> CGFloat { let clamped = min(max(value, 0), 1) @@ -793,26 +796,18 @@ struct TodoListScreen: View { return 0 } - guard shouldApplyFirstPinnedRowElasticClearance(to: section) else { + guard shouldApplyFirstPinnedRowElasticClearance() else { return 0 } return firstPinnedRowElasticClearance() } - private func shouldApplyFirstPinnedRowElasticClearance(to section: TodoTimelineSection) -> Bool { - let isOverdueFirstSection = viewModel.mode == .overdue - let isTodayFirstSection = viewModel.mode == .today - let isExpandedPriorityEarlier = viewModel.mode == .priority && section.id == "earlier" - let isExpandedAllTasksEarlier = viewModel.mode == .all && section.id == "earlier" - let isScheduledFirstSection = viewModel.mode == .scheduled - let isListFirstSection = viewModel.mode == .list - return isOverdueFirstSection || - isTodayFirstSection || - isExpandedPriorityEarlier || - isExpandedAllTasksEarlier || - isScheduledFirstSection || - isListFirstSection + private func shouldApplyFirstPinnedRowElasticClearance() -> Bool { + switch viewModel.mode { + case .today, .overdue, .scheduled, .priority, .all, .list: + return true + } } private func firstPinnedRowElasticClearance() -> CGFloat { @@ -922,6 +917,16 @@ struct TimelineTopBar: View { titleRevealDistance * (1 - revealProgress) } + private var handoffCoverOpacity: Double { + Double( + TodoTimelineMetrics.progress( + progress, + from: TodoTimelineMetrics.topBarHandoffCoverStart, + to: TodoTimelineMetrics.topBarHandoffCoverEnd + ) + ) + } + private var titleContent: some View { Text(title) .font(.tdayRounded(size: TodoTimelineMetrics.heroTitleSize, weight: .heavy)) @@ -960,6 +965,13 @@ struct TimelineTopBar: View { .padding(.top, 2) .padding(.bottom, 4) .background(colors.background) + .overlay(alignment: .bottom) { + colors.background + .frame(height: TodoTimelineMetrics.topBarHandoffCoverHeight) + .offset(y: TodoTimelineMetrics.topBarHandoffCoverHeight) + .opacity(handoffCoverOpacity) + .allowsHitTesting(false) + } } } From f27a364b7ccef01737a6f5c25e82a78a13b2c3dc Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 17:09:02 -0400 Subject: [PATCH 19/52] Revert "Refine the UI for the "Create List" sheet and todo timeline on both iOS and Android platforms." This reverts commit 1d94a360049786c8720c18359581de7332cdcce2. --- .../tday/compose/feature/home/HomeScreen.kt | 15 +- .../Tday/Feature/Home/HomeScreen.swift | 226 +++++++++--------- .../Tday/Feature/Todos/TodoListScreen.swift | 40 ++-- 3 files changed, 131 insertions(+), 150 deletions(-) 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 bd47f5bb..a7bb9d80 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 @@ -48,6 +48,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.DirectionsRun import androidx.compose.material.icons.automirrored.rounded.List @@ -717,8 +718,6 @@ private fun CreateListBottomSheet( onCreate: () -> Unit, ) { val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val focusRequester = remember { FocusRequester() } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val colorScheme = MaterialTheme.colorScheme val selectedAccent = listColorAccent(listColor) @@ -736,12 +735,6 @@ private fun CreateListBottomSheet( Color.Black.copy(alpha = 0.40f) } - LaunchedEffect(Unit) { - delay(500) - focusRequester.requestFocus() - keyboardController?.show() - } - ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, @@ -760,6 +753,7 @@ private fun CreateListBottomSheet( modifier = Modifier .fillMaxSize() .navigationBarsPadding() + .verticalScroll(rememberScrollState()) .padding(horizontal = 18.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { @@ -775,6 +769,7 @@ private fun CreateListBottomSheet( confirmEnabled = canCreate, ) + ListSheetSectionTitle(stringResource(R.string.home_section_list)) ListSheetCard { Column( modifier = Modifier @@ -806,9 +801,7 @@ private fun CreateListBottomSheet( color = selectedAccent, fontWeight = FontWeight.ExtraBold, ), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester), + modifier = Modifier.fillMaxWidth(), decorationBox = { innerTextField -> Box( modifier = Modifier diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index d43f9786..62787f7b 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -977,134 +977,136 @@ private struct CreateListSheet: View { } var body: some View { - VStack(spacing: 14) { - CreateListSheetHeader( - canCreate: canCreate, - onClose: { dismiss() }, - onConfirm: { - onSubmit(trimmedName, color, iconKey) - dismiss() - } - ) + ScrollView(showsIndicators: false) { + VStack(spacing: 14) { + CreateListSheetHeader( + canCreate: canCreate, + onClose: { dismiss() }, + onConfirm: { + onSubmit(trimmedName, color, iconKey) + dismiss() + } + ) - CreateListSheetCard { - VStack(spacing: 18) { - ZStack { - Circle() - .fill(accentColor) - .frame(width: 86, height: 86) + CreateListSheetSectionTitle(text: "List") + CreateListSheetCard { + VStack(spacing: 18) { + ZStack { + Circle() + .fill(accentColor) + .frame(width: 86, height: 86) + + Image(systemName: selectedSymbolName) + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(.white) + } - Image(systemName: selectedSymbolName) - .font(.system(size: 38, weight: .semibold)) - .foregroundStyle(.white) + TextField( + "", + text: $name, + prompt: Text("List name") + .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) + ) + .focused($nameFieldFocused) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + .multilineTextAlignment(.center) + .font(.tdayRounded(size: 28, weight: .bold)) + .foregroundStyle(accentColor) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .frame(height: 74) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(colors.surfaceVariant) + ) } - - TextField( - "", - text: $name, - prompt: Text("List name") - .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) - ) - .focused($nameFieldFocused) - .textInputAutocapitalization(.words) - .autocorrectionDisabled() - .multilineTextAlignment(.center) - .font(.tdayRounded(size: 22, weight: .bold)) - .foregroundStyle(accentColor) - .padding(.horizontal, 14) - .frame(maxWidth: .infinity) - .frame(height: 62) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(colors.surfaceVariant) - ) + .padding(.horizontal, 18) + .padding(.vertical, 18) } - .padding(.horizontal, 18) - .padding(.vertical, 18) - } - CreateListSheetSectionTitle(text: "Color") - CreateListSheetCard { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(homeListColorOptions, id: \.key) { option in - let isSelected = option.key == color - Button { - color = option.key - } label: { - Circle() - .fill(option.color) - .frame(width: 48, height: 48) - .overlay( - Circle() - .stroke( - isSelected ? colors.onSurface.opacity(0.3) : .clear, - lineWidth: 3 - ) + CreateListSheetSectionTitle(text: "Color") + CreateListSheetCard { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(homeListColorOptions, id: \.key) { option in + let isSelected = option.key == color + Button { + color = option.key + } label: { + Circle() + .fill(option.color) + .frame(width: 48, height: 48) + .overlay( + Circle() + .stroke( + isSelected ? colors.onSurface.opacity(0.3) : .clear, + lineWidth: 3 + ) + ) + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: 0.08 ) - } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.04, - normalShadowOpacity: 0.08 ) - ) + } } + .padding(.horizontal, 14) + .padding(.vertical, 14) } - .padding(.horizontal, 14) - .padding(.vertical, 14) } - } - CreateListSheetSectionTitle(text: "Icon") - CreateListSheetCard { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(homeListIconOptions, id: \.key) { option in - let isSelected = option.key == iconKey - Button { - iconKey = option.key - } label: { - Circle() - .fill(isSelected ? accentColor.opacity(0.2) : colors.surfaceVariant) - .frame(width: 48, height: 48) - .overlay( - Circle() - .stroke( - isSelected ? accentColor.opacity(0.55) : .clear, - lineWidth: 2 - ) + CreateListSheetSectionTitle(text: "Icon") + CreateListSheetCard { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(homeListIconOptions, id: \.key) { option in + let isSelected = option.key == iconKey + Button { + iconKey = option.key + } label: { + Circle() + .fill(isSelected ? accentColor.opacity(0.2) : colors.surfaceVariant) + .frame(width: 48, height: 48) + .overlay( + Circle() + .stroke( + isSelected ? accentColor.opacity(0.55) : .clear, + lineWidth: 2 + ) + ) + .overlay { + Image(systemName: option.symbolName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(isSelected ? accentColor : colors.onSurfaceVariant) + } + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: 0.08 ) - .overlay { - Image(systemName: option.symbolName) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(isSelected ? accentColor : colors.onSurfaceVariant) - } - } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.04, - normalShadowOpacity: 0.08 ) - ) - .accessibilityLabel(formattedOptionName(option.key)) + .accessibilityLabel(formattedOptionName(option.key)) + } } + .padding(.horizontal, 14) + .padding(.vertical, 14) } - .padding(.horizontal, 14) - .padding(.vertical, 14) } - } - Spacer(minLength: 8) + Spacer(minLength: 8) + } + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 20) } - .padding(.horizontal, 18) - .padding(.top, 14) - .padding(.bottom, 20) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(colors.background.ignoresSafeArea()) - .presentationDetents([.fraction(0.8)]) + .presentationDetents([.fraction(0.78)]) .presentationDragIndicator(.hidden) .presentationCornerRadius(34) .presentationBackground(colors.background) @@ -1144,10 +1146,8 @@ private struct CreateListSheetHeader: View { Spacer() Text("New list") - .font(.tdayRounded(size: 24, weight: .heavy)) + .font(.tdayRounded(size: 34, weight: .heavy)) .foregroundStyle(colors.onSurface) - .lineLimit(1) - .minimumScaleFactor(0.82) Spacer() @@ -1200,7 +1200,7 @@ private struct CreateListSheetSectionTitle: View { var body: some View { Text(text) - .font(.tdayRounded(size: 22, weight: .bold)) + .font(.tdayRounded(size: 30, weight: .bold)) .foregroundStyle(colors.onSurfaceVariant) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 4) diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index c01c0d22..db28d521 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -32,9 +32,6 @@ enum TodoTimelineMetrics { static let collapsedTitleRevealDistance: CGFloat = 10 static let collapsedTitleRevealStart: CGFloat = 0.68 static let collapsedTitleRevealEnd: CGFloat = 1 - static let topBarHandoffCoverHeight: CGFloat = 34 - static let topBarHandoffCoverStart: CGFloat = 0.38 - static let topBarHandoffCoverEnd: CGFloat = 0.78 static func smoothstep(_ value: CGFloat) -> CGFloat { let clamped = min(max(value, 0), 1) @@ -796,18 +793,26 @@ struct TodoListScreen: View { return 0 } - guard shouldApplyFirstPinnedRowElasticClearance() else { + guard shouldApplyFirstPinnedRowElasticClearance(to: section) else { return 0 } return firstPinnedRowElasticClearance() } - private func shouldApplyFirstPinnedRowElasticClearance() -> Bool { - switch viewModel.mode { - case .today, .overdue, .scheduled, .priority, .all, .list: - return true - } + private func shouldApplyFirstPinnedRowElasticClearance(to section: TodoTimelineSection) -> Bool { + let isOverdueFirstSection = viewModel.mode == .overdue + let isTodayFirstSection = viewModel.mode == .today + let isExpandedPriorityEarlier = viewModel.mode == .priority && section.id == "earlier" + let isExpandedAllTasksEarlier = viewModel.mode == .all && section.id == "earlier" + let isScheduledFirstSection = viewModel.mode == .scheduled + let isListFirstSection = viewModel.mode == .list + return isOverdueFirstSection || + isTodayFirstSection || + isExpandedPriorityEarlier || + isExpandedAllTasksEarlier || + isScheduledFirstSection || + isListFirstSection } private func firstPinnedRowElasticClearance() -> CGFloat { @@ -917,16 +922,6 @@ struct TimelineTopBar: View { titleRevealDistance * (1 - revealProgress) } - private var handoffCoverOpacity: Double { - Double( - TodoTimelineMetrics.progress( - progress, - from: TodoTimelineMetrics.topBarHandoffCoverStart, - to: TodoTimelineMetrics.topBarHandoffCoverEnd - ) - ) - } - private var titleContent: some View { Text(title) .font(.tdayRounded(size: TodoTimelineMetrics.heroTitleSize, weight: .heavy)) @@ -965,13 +960,6 @@ struct TimelineTopBar: View { .padding(.top, 2) .padding(.bottom, 4) .background(colors.background) - .overlay(alignment: .bottom) { - colors.background - .frame(height: TodoTimelineMetrics.topBarHandoffCoverHeight) - .offset(y: TodoTimelineMetrics.topBarHandoffCoverHeight) - .opacity(handoffCoverOpacity) - .allowsHitTesting(false) - } } } From 8906f64b175cc9c311f93ef4dee8c98ac52857ef Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 17:12:45 -0400 Subject: [PATCH 20/52] Refactor task and list creation sheets for Android (Compose) and iOS (SwiftUI) to improve UI consistency, simplify navigation, and enhance the user experience. This update replaces multi-page navigation flows with single-page scrollable layouts, introduces dropdown menus for selection on Android, and refines typography and spacing across both platforms. - **Android (Compose)**: - **Simplified Task Creation**: Remove `TaskSheetPage` state and multi-page navigation. Consolidate scheduling and organization into a single view. - **Dropdown Selection**: Replace full-page selection screens for Lists, Priority, and Repeat options with `DropdownMenu` components (`SheetDropdownRow`). - **Improved UX**: Add `FocusRequester` to the "New List" sheet to automatically focus the name field and show the keyboard on launch. - **UI Polish**: Update icons (e.g., `ExpandMore` instead of `ChevronRight`) and refine layout spacing in `CreateTaskBottomSheet` and `HomeScreen`. - **iOS (SwiftUI)**: - **Task Sheet Redesign**: Replace `Form` and `Picker` with custom `CreateTaskSheetGroupCard` and `Menu` based rows for a more modern, integrated look. - **New Components**: Implement `CreateTaskSheetDueRow` with integrated `DatePicker` chips and `CreateTaskSheetMenuRow` for list/priority/repeat selection. - **Color & Style**: Add helper functions for consistent color swatches across lists, priorities, and repeat rules. Update typography to use `tdayRounded` with revised weights and sizes. - **Presentation**: Adjust sheet presentation to use a `0.8` fraction detent and a larger corner radius (34pt). - **Shared UI Logic**: - Harmonize section titles and headers across both platforms for a consistent brand identity. - Update list and task creation headers to use a more compact typography style. --- .../tday/compose/feature/home/HomeScreen.kt | 15 +- .../ui/component/CreateTaskBottomSheet.kt | 415 ++++++---------- .../Tday/Feature/Home/HomeScreen.swift | 226 ++++----- .../Tday/UI/Component/CreateTaskSheet.swift | 442 ++++++++++++++++-- 4 files changed, 671 insertions(+), 427 deletions(-) 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 a7bb9d80..bd47f5bb 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 @@ -48,7 +48,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.DirectionsRun import androidx.compose.material.icons.automirrored.rounded.List @@ -718,6 +717,8 @@ private fun CreateListBottomSheet( onCreate: () -> Unit, ) { val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val colorScheme = MaterialTheme.colorScheme val selectedAccent = listColorAccent(listColor) @@ -735,6 +736,12 @@ private fun CreateListBottomSheet( Color.Black.copy(alpha = 0.40f) } + LaunchedEffect(Unit) { + delay(500) + focusRequester.requestFocus() + keyboardController?.show() + } + ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, @@ -753,7 +760,6 @@ private fun CreateListBottomSheet( modifier = Modifier .fillMaxSize() .navigationBarsPadding() - .verticalScroll(rememberScrollState()) .padding(horizontal = 18.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { @@ -769,7 +775,6 @@ private fun CreateListBottomSheet( confirmEnabled = canCreate, ) - ListSheetSectionTitle(stringResource(R.string.home_section_list)) ListSheetCard { Column( modifier = Modifier @@ -801,7 +806,9 @@ private fun CreateListBottomSheet( color = selectedAccent, fontWeight = FontWeight.ExtraBold, ), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), decorationBox = { innerTextField -> Box( modifier = Modifier 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 264b3828..e33df49e 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,6 +1,5 @@ package com.ohmz.tday.compose.ui.component -import android.graphics.Color as AndroidColor import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background @@ -10,8 +9,8 @@ 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.ColumnScope 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.fillMaxHeight @@ -28,13 +27,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack 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.ChevronRight import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.LowPriority import androidx.compose.material.icons.rounded.Repeat import androidx.compose.material.icons.rounded.Schedule @@ -42,6 +39,8 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -51,12 +50,12 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker import androidx.compose.material3.TimePickerDefaults -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -64,9 +63,6 @@ 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.drawWithCache -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 @@ -85,19 +81,12 @@ import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse +import kotlinx.coroutines.delay import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import kotlinx.coroutines.delay - -private enum class TaskSheetPage { - MAIN, - DETAILS, - LIST, - PRIORITY, - REPEAT, -} +import android.graphics.Color as AndroidColor private enum class RepeatPreset( val label: String, @@ -147,7 +136,6 @@ fun CreateTaskBottomSheet( } val listIdsKey = remember(lists) { lists.joinToString(separator = "|") { it.id } } - var page by rememberSaveable { mutableStateOf(TaskSheetPage.MAIN) } val isEditMode = editingTask != null var title by rememberSaveable(editingTask?.id) { mutableStateOf(editingTask?.title.orEmpty()) @@ -198,9 +186,6 @@ fun CreateTaskBottomSheet( var selectedRepeat by rememberSaveable(editingTask?.id) { mutableStateOf(repeatPresetFromRrule(editingTask?.rrule).name) } - var listReturnPage by rememberSaveable { mutableStateOf(TaskSheetPage.MAIN.name) } - var priorityReturnPage by rememberSaveable { mutableStateOf(TaskSheetPage.DETAILS.name) } - var repeatReturnPage by rememberSaveable { mutableStateOf(TaskSheetPage.DETAILS.name) } var dueDatePickerOpen by rememberSaveable { mutableStateOf(false) } var dueTimePickerOpen by rememberSaveable { mutableStateOf(false) } @@ -262,28 +247,12 @@ fun CreateTaskBottomSheet( verticalArrangement = Arrangement.spacedBy(14.dp), ) { SheetHeader( - title = when (page) { - TaskSheetPage.MAIN -> if (isEditMode) "Edit task" else "New task" - TaskSheetPage.DETAILS -> "Details" - TaskSheetPage.LIST -> "List" - TaskSheetPage.PRIORITY -> "Priority" - TaskSheetPage.REPEAT -> "Repeat" - }, - leftIcon = if (page == TaskSheetPage.MAIN) { - Icons.Rounded.Close - } else { - Icons.AutoMirrored.Rounded.ArrowBack - }, - leftContentDescription = if (page == TaskSheetPage.MAIN) "Close" else "Back", + title = if (isEditMode) "Edit task" else "New task", + leftIcon = Icons.Rounded.Close, + leftContentDescription = "Close", onLeftClick = { focusManager.clearFocus(force = true) - when (page) { - TaskSheetPage.MAIN -> onDismiss() - TaskSheetPage.DETAILS -> page = TaskSheetPage.MAIN - TaskSheetPage.LIST -> page = TaskSheetPage.valueOf(listReturnPage) - TaskSheetPage.PRIORITY -> page = TaskSheetPage.valueOf(priorityReturnPage) - TaskSheetPage.REPEAT -> page = TaskSheetPage.valueOf(repeatReturnPage) - } + onDismiss() }, onConfirm = { focusManager.clearFocus(force = true) @@ -294,180 +263,66 @@ fun CreateTaskBottomSheet( confirmEnabled = canSubmit, ) - when (page) { - TaskSheetPage.MAIN -> { - TaskTextCard( - title = title, - notes = notes, - onTitleChange = { title = it }, - onNotesChange = { notes = it }, - ) - - SectionHeading("Date & Time") - 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("More Options") - GroupCard { - if (lists.isNotEmpty()) { - SheetRow( - icon = Icons.AutoMirrored.Rounded.List, - title = "List", - value = selectedListName, - onClick = { - listReturnPage = TaskSheetPage.MAIN.name - page = TaskSheetPage.LIST - }, - ) - RowDivider() - } - SheetRow( - icon = Icons.Rounded.LowPriority, - title = "Priority", - value = selectedPriority, - onClick = { - priorityReturnPage = TaskSheetPage.MAIN.name - page = TaskSheetPage.PRIORITY - }, - ) - RowDivider() - SheetRow( - icon = Icons.Rounded.Info, - title = "Details", - value = "", - forceChevron = true, - onClick = { page = TaskSheetPage.DETAILS }, - ) - } - } - - TaskSheetPage.DETAILS -> { - SectionHeading("Scheduling") - 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 }, - ) - RowDivider() - SheetRow( - icon = Icons.Rounded.Repeat, - title = "Repeat", - value = repeatPreset.label, - onClick = { - repeatReturnPage = TaskSheetPage.DETAILS.name - page = TaskSheetPage.REPEAT - }, - ) - } - - SectionHeading("Organization") - GroupCard { - SheetRow( - icon = Icons.Rounded.LowPriority, - title = "Priority", - value = selectedPriority, - onClick = { - priorityReturnPage = TaskSheetPage.DETAILS.name - page = TaskSheetPage.PRIORITY - }, - ) - - if (lists.isNotEmpty()) { - RowDivider() - SheetRow( - icon = Icons.AutoMirrored.Rounded.List, - title = "List", - value = selectedListName, - onClick = { - listReturnPage = TaskSheetPage.DETAILS.name - page = TaskSheetPage.LIST - }, - ) - } - } - } - - TaskSheetPage.LIST -> { - SectionHeading("Choose List") - GroupCard { - ListSelectionRow( - title = "No list", - swatchColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.95f), - selected = selectedListId == null, - onClick = { - selectedListId = null - page = TaskSheetPage.valueOf(listReturnPage) - }, - ) - lists.forEach { list -> - RowDivider() - ListSelectionRow( - title = list.name, - swatchColor = listColorSwatchForSelector( - raw = list.color, - fallback = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), - ), - selected = selectedListId == list.id, - onClick = { - selectedListId = list.id - page = TaskSheetPage.valueOf(listReturnPage) - }, - ) - } - } - } + TaskTextCard( + title = title, + notes = notes, + onTitleChange = { title = it }, + onNotesChange = { notes = it }, + ) - TaskSheetPage.PRIORITY -> { - SectionHeading("Choose Priority") - GroupCard { - listOf("Low", "Medium", "High").forEachIndexed { index, option -> - if (index > 0) { - RowDivider() - } - ListSelectionRow( - title = option, - swatchColor = prioritySwatchColor(option), - selected = selectedPriority == option, - onClick = { - selectedPriority = option - page = TaskSheetPage.valueOf(priorityReturnPage) - }, - ) - } - } - } + 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 }, + ) + } - TaskSheetPage.REPEAT -> { - SectionHeading("Choose Repeat") - GroupCard { - RepeatPreset.entries.forEachIndexed { index, option -> - if (index > 0) { - RowDivider() - } - ListSelectionRow( - title = option.label, - swatchColor = repeatSwatchColor(option), - selected = selectedRepeat == option.name, - onClick = { - selectedRepeat = option.name - page = TaskSheetPage.valueOf(repeatReturnPage) - }, + 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 }, + ) + 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(6.dp)) @@ -819,8 +674,9 @@ private fun SheetRow( title: String, value: String, onClick: () -> Unit, - forceChevron: Boolean = false, ) { + val colorScheme = MaterialTheme.colorScheme + Row( modifier = Modifier .fillMaxWidth() @@ -831,7 +687,7 @@ private fun SheetRow( Icon( imageVector = icon, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp), ) Spacer(modifier = Modifier.size(14.dp)) @@ -839,71 +695,94 @@ private fun SheetRow( Text( text = title, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, + color = colorScheme.onSurface, fontWeight = FontWeight.ExtraBold, modifier = Modifier.weight(1f), ) - if (value.isNotBlank()) { - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.ExtraBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 8.dp), - ) - } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + fontWeight = FontWeight.ExtraBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f, fill = false) + .padding(start = 8.dp), + ) - if (forceChevron || value.isNotBlank()) { - Icon( - imageVector = Icons.Rounded.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Icon( + imageVector = Icons.Rounded.ExpandMore, + contentDescription = null, + tint = colorScheme.onSurfaceVariant, + ) } } @Composable -private fun ListSelectionRow( +private fun SheetDropdownRow( + icon: ImageVector, title: String, - swatchColor: Color, - selected: Boolean, - onClick: () -> Unit, + value: String, + options: List, + optionLabel: (T) -> String, + optionSwatchColor: (T) -> Color, + isSelected: (T) -> Boolean, + onOptionSelected: (T) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(10.dp) - .background( - color = swatchColor, - shape = RoundedCornerShape(999.dp), - ), + var expanded by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxWidth()) { + SheetRow( + icon = icon, + title = title, + value = value, + onClick = { expanded = true }, ) - Spacer(modifier = Modifier.size(12.dp)) - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = colorScheme.onSurface, - fontWeight = FontWeight.ExtraBold, - modifier = Modifier.weight(1f), - ) - if (selected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(18.dp), - ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(colorScheme.surface), + ) { + options.forEach { option -> + DropdownMenuItem( + text = { + Text( + text = optionLabel(option), + style = MaterialTheme.typography.bodyLarge, + color = colorScheme.onSurface, + fontWeight = FontWeight.ExtraBold, + ) + }, + onClick = { + onOptionSelected(option) + expanded = false + }, + leadingIcon = { + Box( + modifier = Modifier + .size(10.dp) + .background( + color = optionSwatchColor(option), + shape = RoundedCornerShape(999.dp), + ), + ) + }, + trailingIcon = { + if (isSelected(option)) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + }, + ) + } } } } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 62787f7b..d43f9786 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -977,136 +977,134 @@ private struct CreateListSheet: View { } var body: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 14) { - CreateListSheetHeader( - canCreate: canCreate, - onClose: { dismiss() }, - onConfirm: { - onSubmit(trimmedName, color, iconKey) - dismiss() - } - ) + VStack(spacing: 14) { + CreateListSheetHeader( + canCreate: canCreate, + onClose: { dismiss() }, + onConfirm: { + onSubmit(trimmedName, color, iconKey) + dismiss() + } + ) - CreateListSheetSectionTitle(text: "List") - CreateListSheetCard { - VStack(spacing: 18) { - ZStack { - Circle() - .fill(accentColor) - .frame(width: 86, height: 86) - - Image(systemName: selectedSymbolName) - .font(.system(size: 38, weight: .semibold)) - .foregroundStyle(.white) - } + CreateListSheetCard { + VStack(spacing: 18) { + ZStack { + Circle() + .fill(accentColor) + .frame(width: 86, height: 86) - TextField( - "", - text: $name, - prompt: Text("List name") - .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) - ) - .focused($nameFieldFocused) - .textInputAutocapitalization(.words) - .autocorrectionDisabled() - .multilineTextAlignment(.center) - .font(.tdayRounded(size: 28, weight: .bold)) - .foregroundStyle(accentColor) - .padding(.horizontal, 14) - .frame(maxWidth: .infinity) - .frame(height: 74) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(colors.surfaceVariant) - ) + Image(systemName: selectedSymbolName) + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(.white) } - .padding(.horizontal, 18) - .padding(.vertical, 18) + + TextField( + "", + text: $name, + prompt: Text("List name") + .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) + ) + .focused($nameFieldFocused) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + .multilineTextAlignment(.center) + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(accentColor) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .frame(height: 62) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(colors.surfaceVariant) + ) } + .padding(.horizontal, 18) + .padding(.vertical, 18) + } - CreateListSheetSectionTitle(text: "Color") - CreateListSheetCard { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(homeListColorOptions, id: \.key) { option in - let isSelected = option.key == color - Button { - color = option.key - } label: { - Circle() - .fill(option.color) - .frame(width: 48, height: 48) - .overlay( - Circle() - .stroke( - isSelected ? colors.onSurface.opacity(0.3) : .clear, - lineWidth: 3 - ) - ) - } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.04, - normalShadowOpacity: 0.08 + CreateListSheetSectionTitle(text: "Color") + CreateListSheetCard { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(homeListColorOptions, id: \.key) { option in + let isSelected = option.key == color + Button { + color = option.key + } label: { + Circle() + .fill(option.color) + .frame(width: 48, height: 48) + .overlay( + Circle() + .stroke( + isSelected ? colors.onSurface.opacity(0.3) : .clear, + lineWidth: 3 + ) ) - ) } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: 0.08 + ) + ) } - .padding(.horizontal, 14) - .padding(.vertical, 14) } + .padding(.horizontal, 14) + .padding(.vertical, 14) } + } - CreateListSheetSectionTitle(text: "Icon") - CreateListSheetCard { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(homeListIconOptions, id: \.key) { option in - let isSelected = option.key == iconKey - Button { - iconKey = option.key - } label: { - Circle() - .fill(isSelected ? accentColor.opacity(0.2) : colors.surfaceVariant) - .frame(width: 48, height: 48) - .overlay( - Circle() - .stroke( - isSelected ? accentColor.opacity(0.55) : .clear, - lineWidth: 2 - ) - ) - .overlay { - Image(systemName: option.symbolName) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(isSelected ? accentColor : colors.onSurfaceVariant) - } - } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.04, - normalShadowOpacity: 0.08 + CreateListSheetSectionTitle(text: "Icon") + CreateListSheetCard { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(homeListIconOptions, id: \.key) { option in + let isSelected = option.key == iconKey + Button { + iconKey = option.key + } label: { + Circle() + .fill(isSelected ? accentColor.opacity(0.2) : colors.surfaceVariant) + .frame(width: 48, height: 48) + .overlay( + Circle() + .stroke( + isSelected ? accentColor.opacity(0.55) : .clear, + lineWidth: 2 + ) ) - ) - .accessibilityLabel(formattedOptionName(option.key)) + .overlay { + Image(systemName: option.symbolName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(isSelected ? accentColor : colors.onSurfaceVariant) + } } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: 0.08 + ) + ) + .accessibilityLabel(formattedOptionName(option.key)) } - .padding(.horizontal, 14) - .padding(.vertical, 14) } + .padding(.horizontal, 14) + .padding(.vertical, 14) } - - Spacer(minLength: 8) } - .padding(.horizontal, 18) - .padding(.top, 14) - .padding(.bottom, 20) + + Spacer(minLength: 8) } + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(colors.background.ignoresSafeArea()) - .presentationDetents([.fraction(0.78)]) + .presentationDetents([.fraction(0.8)]) .presentationDragIndicator(.hidden) .presentationCornerRadius(34) .presentationBackground(colors.background) @@ -1146,8 +1144,10 @@ private struct CreateListSheetHeader: View { Spacer() Text("New list") - .font(.tdayRounded(size: 34, weight: .heavy)) + .font(.tdayRounded(size: 24, weight: .heavy)) .foregroundStyle(colors.onSurface) + .lineLimit(1) + .minimumScaleFactor(0.82) Spacer() @@ -1200,7 +1200,7 @@ private struct CreateListSheetSectionTitle: View { var body: some View { Text(text) - .font(.tdayRounded(size: 30, weight: .bold)) + .font(.tdayRounded(size: 22, weight: .bold)) .foregroundStyle(colors.onSurfaceVariant) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 4) diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index b58affb4..508036c0 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -31,6 +31,18 @@ struct CreateTaskSheet: View { ("Yearly", "RRULE:FREQ=YEARLY;INTERVAL=1"), ] + private var selectedListName: String { + guard let selectedListID, + let list = lists.first(where: { $0.id == selectedListID }) else { + return "No list" + } + return list.name + } + + private var selectedRepeatLabel: String { + repeatOptions.first(where: { $0.value == repeatRule })?.label ?? "No repeat" + } + init( lists: [ListSummary], titleText: String, @@ -70,62 +82,114 @@ struct CreateTaskSheet: View { } var body: some View { - NavigationStack { - VStack(spacing: 0) { - CreateTaskSheetHeader( - title: titleText, - submitAccessibilityLabel: submitText, - isSubmitEnabled: !isSubmitting && !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - onCancel: { - onDismiss() - dismiss() - }, - onSubmit: { - Task { - await submit() - } - } - ) - - Form { - Section("Task") { - TextField("Title", text: $title) - .textInputAutocapitalization(.sentences) - TextField("Notes", text: $notes, axis: .vertical) - .lineLimit(3 ... 6) + VStack(spacing: 0) { + CreateTaskSheetHeader( + title: titleText, + submitAccessibilityLabel: submitText, + isSubmitEnabled: !isSubmitting && !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + onCancel: { + onDismiss() + dismiss() + }, + onSubmit: { + Task { + await submit() } + } + ) + + ScrollView(showsIndicators: false) { + VStack(spacing: 14) { + CreateTaskSheetTextCard(title: $title, notes: $notes) - Section("Schedule") { - DatePicker("Due", selection: $dueDate) + CreateTaskSheetSectionTitle(text: "Schedule") + CreateTaskSheetGroupCard { + CreateTaskSheetDueRow(dueDate: $dueDate) } - Section("Details") { - Picker("Priority", selection: $priority) { - ForEach(priorityOptions, id: \.self) { option in - Text(option).tag(option) + CreateTaskSheetSectionTitle(text: "Details") + CreateTaskSheetGroupCard { + CreateTaskSheetMenuRow( + iconName: "list.bullet", + title: "List", + value: selectedListName + ) { + Button { + selectedListID = nil + } label: { + CreateTaskSheetMenuItemLabel( + title: "No list", + selected: selectedListID == nil, + swatchColor: colors.onSurfaceVariant.opacity(0.35) + ) } - } - Picker("List", selection: Binding(get: { selectedListID ?? "" }, set: { selectedListID = $0.isEmpty ? nil : $0 })) { - Text("No list").tag("") + ForEach(lists) { list in - Text(list.name).tag(list.id) + Button { + selectedListID = list.id + } label: { + CreateTaskSheetMenuItemLabel( + title: list.name, + selected: selectedListID == list.id, + swatchColor: createTaskSheetListSwatchColor(list.color) + ) + } + } + } + + CreateTaskSheetDivider() + + CreateTaskSheetMenuRow( + iconName: "text.badge.checkmark", + title: "Priority", + value: priority + ) { + ForEach(priorityOptions, id: \.self) { option in + Button { + priority = option + } label: { + CreateTaskSheetMenuItemLabel( + title: option, + selected: priority == option, + swatchColor: createTaskSheetPrioritySwatchColor(option) + ) + } } } - Picker("Repeat", selection: Binding(get: { repeatRule ?? "" }, set: { newValue in - repeatRule = newValue.isEmpty ? nil : newValue - })) { + + CreateTaskSheetDivider() + + CreateTaskSheetMenuRow( + iconName: "repeat", + title: "Repeat", + value: selectedRepeatLabel + ) { ForEach(repeatOptions, id: \.label) { option in - Text(option.label).tag(option.value ?? "") + Button { + repeatRule = option.value + } label: { + CreateTaskSheetMenuItemLabel( + title: option.label, + selected: repeatRule == option.value, + swatchColor: createTaskSheetRepeatSwatchColor(option.value) + ) + } } } } + + Spacer(minLength: 6) } - .scrollContentBackground(.hidden) + .padding(.horizontal, 18) + .padding(.bottom, 20) } - .background(colors.background) - .toolbar(.hidden, for: .navigationBar) } - .presentationDetents([.large]) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(colors.background.ignoresSafeArea()) + .presentationDetents([.fraction(0.8)]) + .presentationDragIndicator(.hidden) + .presentationCornerRadius(34) + .presentationBackground(colors.background) .task { hydrateFromInitialPayload() } @@ -185,6 +249,225 @@ struct CreateTaskSheet: View { } } +private struct CreateTaskSheetTextCard: View { + @Binding var title: String + @Binding var notes: String + + @Environment(\.tdayColors) private var colors + + var body: some View { + CreateTaskSheetGroupCard { + CreateTaskSheetTextField( + placeholder: "Title", + text: $title, + axis: .horizontal, + lineLimit: 1 ... 1 + ) + + CreateTaskSheetDivider() + + CreateTaskSheetTextField( + placeholder: "Notes", + text: $notes, + axis: .vertical, + lineLimit: 1 ... 3 + ) + } + } +} + +private struct CreateTaskSheetTextField: View { + let placeholder: String + @Binding var text: String + let axis: Axis + let lineLimit: ClosedRange + + @Environment(\.tdayColors) private var colors + + var body: some View { + TextField( + "", + text: $text, + prompt: Text(placeholder) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.65)), + axis: axis + ) + .lineLimit(lineLimit) + .textInputAutocapitalization(.sentences) + .font(.tdayRounded(size: 18, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .tint(colors.primary) + .padding(.horizontal, 18) + .padding(.vertical, 15) + .frame(minHeight: 56) + } +} + +private struct CreateTaskSheetSectionTitle: View { + let text: String + + @Environment(\.tdayColors) private var colors + + var body: some View { + Text(text) + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(colors.onSurfaceVariant) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } +} + +private struct CreateTaskSheetGroupCard: View { + @ViewBuilder let content: Content + + @Environment(\.tdayColors) private var colors + + var body: some View { + VStack(spacing: 0) { + content + } + .frame(maxWidth: .infinity) + .background(colors.surface, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + } +} + +private struct CreateTaskSheetDueRow: View { + @Binding var dueDate: Date + + @Environment(\.tdayColors) private var colors + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 14) { + leadingContent + + Spacer(minLength: 8) + + HStack(spacing: 8) { + CreateTaskSheetDatePickerChip(dueDate: $dueDate, components: .date, width: 130) + CreateTaskSheetDatePickerChip(dueDate: $dueDate, components: .hourAndMinute, width: 90) + } + } + + VStack(alignment: .leading, spacing: 12) { + leadingContent + + HStack(spacing: 8) { + CreateTaskSheetDatePickerChip(dueDate: $dueDate, components: .date, width: 130) + CreateTaskSheetDatePickerChip(dueDate: $dueDate, components: .hourAndMinute, width: 90) + } + .padding(.leading, 36) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + + private var leadingContent: some View { + HStack(spacing: 14) { + Image(systemName: "calendar") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant) + .frame(width: 22, height: 22) + + Text("Due") + .font(.tdayRounded(size: 18, weight: .heavy)) + .foregroundStyle(colors.onSurface) + } + } +} + +private struct CreateTaskSheetDatePickerChip: View { + @Binding var dueDate: Date + let components: DatePickerComponents + let width: CGFloat + + @Environment(\.tdayColors) private var colors + + var body: some View { + DatePicker("", selection: $dueDate, displayedComponents: components) + .labelsHidden() + .datePickerStyle(.compact) + .tint(colors.onSurfaceVariant) + .font(.tdayRounded(size: 13, weight: .heavy)) + .frame(width: width, height: 38) + .background(colors.surfaceVariant.opacity(0.66), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +private struct CreateTaskSheetMenuRow: View { + let iconName: String + let title: String + let value: String + @ViewBuilder let menuContent: () -> MenuContent + + @Environment(\.tdayColors) private var colors + + var body: some View { + Menu { + menuContent() + } label: { + HStack(spacing: 14) { + Image(systemName: iconName) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant) + .frame(width: 22, height: 22) + + Text(title) + .font(.tdayRounded(size: 18, weight: .heavy)) + .foregroundStyle(colors.onSurface) + + Spacer(minLength: 8) + + Text(value) + .font(.tdayRounded(size: 14, weight: .heavy)) + .foregroundStyle(colors.onSurfaceVariant) + .lineLimit(1) + .minimumScaleFactor(0.78) + + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .heavy)) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.72)) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +private struct CreateTaskSheetMenuItemLabel: View { + let title: String + let selected: Bool + let swatchColor: Color + + var body: some View { + HStack(spacing: 10) { + Circle() + .fill(swatchColor) + .frame(width: 10, height: 10) + + Text(title) + + if selected { + Image(systemName: "checkmark") + } + } + } +} + +private struct CreateTaskSheetDivider: View { + @Environment(\.tdayColors) private var colors + + var body: some View { + Rectangle() + .fill(colors.onSurfaceVariant.opacity(0.18)) + .frame(height: 1) + .padding(.horizontal, 18) + } +} + private struct CreateTaskSheetHeader: View { let title: String let submitAccessibilityLabel: String @@ -207,7 +490,7 @@ private struct CreateTaskSheetHeader: View { Spacer(minLength: 0) Text(title) - .font(.tdayRounded(size: 28, weight: .heavy)) + .font(.tdayRounded(size: 24, weight: .heavy)) .foregroundStyle(colors.onSurface) .lineLimit(1) .minimumScaleFactor(0.78) @@ -263,3 +546,78 @@ private struct CreateTaskSheetHeaderButton: View { .accessibilityAddTraits(.isButton) } } + +private func createTaskSheetListSwatchColor(_ raw: String?) -> Color { + switch raw?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { + case "RED": + return createTaskSheetHexColor(0xE65E52) + case "ORANGE": + return createTaskSheetHexColor(0xF29F38) + case "YELLOW": + return createTaskSheetHexColor(0xF3D04A) + case "LIME": + return createTaskSheetHexColor(0x8ACF56) + case "BLUE": + return createTaskSheetHexColor(0x5C9FE7) + case "PURPLE": + return createTaskSheetHexColor(0x8D6CE2) + case "PINK": + return createTaskSheetHexColor(0xDF6DAA) + case "TEAL": + return createTaskSheetHexColor(0x4EB5B0) + case "CORAL": + return createTaskSheetHexColor(0xE3876D) + case "GOLD": + return createTaskSheetHexColor(0xCFAB57) + case "DEEP_BLUE": + return createTaskSheetHexColor(0x4B73D6) + case "ROSE": + return createTaskSheetHexColor(0xD9799A) + case "LIGHT_RED": + return createTaskSheetHexColor(0xE48888) + case "BRICK": + return createTaskSheetHexColor(0xB86A5C) + case "SLATE": + return createTaskSheetHexColor(0x7B8593) + default: + return createTaskSheetHexColor(0x5C9FE7) + } +} + +private func createTaskSheetPrioritySwatchColor(_ priority: String) -> Color { + switch priority.lowercased() { + case "high": + return createTaskSheetHexColor(0xE56A6A) + case "medium": + return createTaskSheetHexColor(0xE3B368) + default: + return createTaskSheetHexColor(0x6FBF86) + } +} + +private func createTaskSheetRepeatSwatchColor(_ rrule: String?) -> Color { + switch rrule { + case "RRULE:FREQ=DAILY;INTERVAL=1": + return createTaskSheetHexColor(0x6FBF86) + case "RRULE:FREQ=WEEKLY;INTERVAL=1": + return createTaskSheetHexColor(0x6FA6E8) + case "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR": + return createTaskSheetHexColor(0x8C7AE6) + case "RRULE:FREQ=MONTHLY;INTERVAL=1": + return createTaskSheetHexColor(0xE3B368) + case "RRULE:FREQ=YEARLY;INTERVAL=1": + return createTaskSheetHexColor(0xE56A6A) + default: + return createTaskSheetHexColor(0xB7BCC8) + } +} + +private func createTaskSheetHexColor(_ hex: UInt) -> Color { + Color( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255, + opacity: 1 + ) +} From 0620a18a765e0486f926fbbda22c5ebe0a06847b Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 18:24:58 -0400 Subject: [PATCH 21/52] Refine the UI for task and list creation sheets on both Android and iOS. This update focuses on improving the visual consistency and responsiveness of "Create Task" and "Create List" sheets. It introduces a custom dialog-based selector on Android to replace standard dropdowns and implements dynamic sheet heights on iOS to better accommodate keyboard states and content size. - **Create Task UI Improvements**: - **iOS**: Replace `ViewThatFits` with a simplified `HStack` layout for date and time chips. Implement a custom `ZStack` for `DatePicker` to allow better text styling (bold, rounded) while maintaining native picker functionality. - **Android**: Refactor `SheetRow` to improve alignment of value text and chevron icons. - **Unified Component**: Update `CreateTaskSheetDatePickerChip` and `SheetRow` with consistent padding and geometry. - **Selection Dialogs (Android)**: - Replace `DropdownMenu` with `CenteredSelectorDialog`, a custom modal dialog providing a more focused selection experience for list colors and icons. - Implement `CenteredSelectorRow` with support for color swatches, checkmark indicators, and bold typography. - **Dynamic Sheet Behavior (iOS)**: - Implement dynamic height adjustment for the "Create List" sheet using `PreferenceKey` and `GeometryReader`. - Add support for multiple presentation detents, automatically switching between a compact height and a "typing" height (80% fraction) when the name field is focused. - **Keyboard & Focus Handling (Android)**: - Update `CreateListBottomSheet` to dynamically toggle between `wrap_content` and `fillMaxHeight(0.8f)` based on IME (keyboard) visibility and focus state. - Center-align text within the list name input field. --- .../tday/compose/feature/home/HomeScreen.kt | 16 +- .../ui/component/CreateTaskBottomSheet.kt | 200 +++++++++++++----- .../Tday/Feature/Home/HomeScreen.swift | 45 +++- .../Tday/UI/Component/CreateTaskSheet.swift | 95 +++++---- 4 files changed, 257 insertions(+), 99 deletions(-) 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 bd47f5bb..892ef6c2 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 @@ -150,6 +150,7 @@ import androidx.compose.ui.draw.clip 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.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -172,6 +173,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics 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.zIndex @@ -704,7 +706,7 @@ fun HomeScreen( } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun CreateListBottomSheet( listName: String, @@ -719,6 +721,9 @@ private fun CreateListBottomSheet( val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current val focusRequester = remember { FocusRequester() } + var nameFieldFocused by remember { mutableStateOf(false) } + val imeVisible = WindowInsets.isImeVisible + val useTypingHeight = nameFieldFocused && imeVisible val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val colorScheme = MaterialTheme.colorScheme val selectedAccent = listColorAccent(listColor) @@ -754,11 +759,12 @@ private fun CreateListBottomSheet( Box( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.8f), + .then(if (useTypingHeight) Modifier.fillMaxHeight(0.8f) else Modifier), ) { Column( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() + .then(if (useTypingHeight) Modifier.fillMaxSize() else Modifier) .navigationBarsPadding() .padding(horizontal = 18.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp), @@ -805,10 +811,12 @@ private fun CreateListBottomSheet( textStyle = MaterialTheme.typography.headlineSmall.copy( color = selectedAccent, fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, ), modifier = Modifier .fillMaxWidth() - .focusRequester(focusRequester), + .focusRequester(focusRequester) + .onFocusChanged { nameFieldFocused = it.isFocused }, decorationBox = { innerTextField -> Box( modifier = Modifier 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 e33df49e..3785c1db 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 @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxHeight 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.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -39,8 +40,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -700,23 +699,29 @@ private fun SheetRow( modifier = Modifier.weight(1f), ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.onSurfaceVariant, - fontWeight = FontWeight.ExtraBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .weight(1f, fill = false) .padding(start = 8.dp), - ) + ) { + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + fontWeight = FontWeight.ExtraBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - Icon( - imageVector = Icons.Rounded.ExpandMore, - contentDescription = null, - tint = colorScheme.onSurfaceVariant, - ) + Spacer(modifier = Modifier.width(2.dp)) + + Icon( + imageVector = Icons.Rounded.ExpandMore, + contentDescription = null, + tint = colorScheme.onSurfaceVariant, + ) + } } } @@ -731,62 +736,143 @@ private fun SheetDropdownRow( isSelected: (T) -> Boolean, onOptionSelected: (T) -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme - var expanded by remember { mutableStateOf(false) } + var selectorOpen by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxWidth()) { SheetRow( icon = icon, title = title, value = value, - onClick = { expanded = true }, + onClick = { selectorOpen = true }, ) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.background(colorScheme.surface), + if (selectorOpen) { + CenteredSelectorDialog( + title = title, + options = options, + optionLabel = optionLabel, + optionSwatchColor = optionSwatchColor, + isSelected = isSelected, + onDismiss = { selectorOpen = false }, + onOptionSelected = { option -> + onOptionSelected(option) + selectorOpen = false + }, + ) + } + } +} + +@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), ) { - options.forEach { option -> - DropdownMenuItem( - text = { - Text( - text = optionLabel(option), - style = MaterialTheme.typography.bodyLarge, - color = colorScheme.onSurface, - fontWeight = FontWeight.ExtraBold, - ) - }, - onClick = { - onOptionSelected(option) - expanded = false - }, - leadingIcon = { - Box( - modifier = Modifier - .size(10.dp) - .background( - color = optionSwatchColor(option), - shape = RoundedCornerShape(999.dp), - ), - ) - }, - trailingIcon = { - if (isSelected(option)) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(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()) { diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index d43f9786..acf3d6f4 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -45,6 +45,18 @@ private struct HomeListIconOption { let symbolName: String } +private enum CreateListSheetMetrics { + static let initialCompactHeight: CGFloat = 620 +} + +private struct CreateListSheetContentHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + struct HomeScreen: View { let onNavigate: (AppRoute) -> Void @@ -959,6 +971,8 @@ private struct CreateListSheet: View { @State private var name = "" @State private var color = "BLUE" @State private var iconKey = "inbox" + @State private var compactSheetHeight: CGFloat = CreateListSheetMetrics.initialCompactHeight + @State private var sheetDetent: PresentationDetent = .height(CreateListSheetMetrics.initialCompactHeight) private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -976,6 +990,14 @@ private struct CreateListSheet: View { homeListSymbolName(for: iconKey) } + private var compactDetent: PresentationDetent { + .height(compactSheetHeight) + } + + private var typingDetent: PresentationDetent { + .fraction(0.8) + } + var body: some View { VStack(spacing: 14) { CreateListSheetHeader( @@ -1102,17 +1124,36 @@ private struct CreateListSheet: View { .padding(.horizontal, 18) .padding(.top, 14) .padding(.bottom, 20) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .frame(maxWidth: .infinity, alignment: .top) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: CreateListSheetContentHeightKey.self, value: ceil(proxy.size.height)) + } + ) .background(colors.background.ignoresSafeArea()) - .presentationDetents([.fraction(0.8)]) + .presentationDetents([compactDetent, typingDetent], selection: $sheetDetent) .presentationDragIndicator(.hidden) .presentationCornerRadius(34) .presentationBackground(colors.background) + .ignoresSafeArea(.keyboard, edges: .bottom) + .onPreferenceChange(CreateListSheetContentHeightKey.self) { height in + let nextHeight = max(height, 1) + compactSheetHeight = nextHeight + if !nameFieldFocused { + sheetDetent = .height(nextHeight) + } + } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { nameFieldFocused = true } } + .onChange(of: nameFieldFocused) { _, focused in + withAnimation(.snappy(duration: 0.24)) { + sheetDetent = focused ? typingDetent : compactDetent + } + } } private func formattedOptionName(_ value: String) -> String { diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index 508036c0..14484155 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -337,34 +337,33 @@ private struct CreateTaskSheetDueRow: View { @Environment(\.tdayColors) private var colors var body: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 14) { - leadingContent - - Spacer(minLength: 8) - - HStack(spacing: 8) { - CreateTaskSheetDatePickerChip(dueDate: $dueDate, components: .date, width: 130) - CreateTaskSheetDatePickerChip(dueDate: $dueDate, components: .hourAndMinute, width: 90) - } - } - - VStack(alignment: .leading, spacing: 12) { - leadingContent - - HStack(spacing: 8) { - CreateTaskSheetDatePickerChip(dueDate: $dueDate, components: .date, width: 130) - CreateTaskSheetDatePickerChip(dueDate: $dueDate, components: .hourAndMinute, width: 90) - } - .padding(.leading, 36) + HStack(spacing: 12) { + leadingContent + + Spacer(minLength: 6) + + HStack(spacing: 6) { + CreateTaskSheetDatePickerChip( + dueDate: $dueDate, + components: .date, + text: dueDate.formatted(.dateTime.month(.abbreviated).day().year()), + width: 126 + ) + CreateTaskSheetDatePickerChip( + dueDate: $dueDate, + components: .hourAndMinute, + text: dueDate.formatted(.dateTime.hour(.defaultDigits(amPM: .abbreviated)).minute()), + width: 86 + ) } } .padding(.horizontal, 16) .padding(.vertical, 14) + .frame(minHeight: 72) } private var leadingContent: some View { - HStack(spacing: 14) { + HStack(spacing: 10) { Image(systemName: "calendar") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(colors.onSurfaceVariant) @@ -380,18 +379,30 @@ private struct CreateTaskSheetDueRow: View { private struct CreateTaskSheetDatePickerChip: View { @Binding var dueDate: Date let components: DatePickerComponents + let text: String let width: CGFloat @Environment(\.tdayColors) private var colors var body: some View { - DatePicker("", selection: $dueDate, displayedComponents: components) - .labelsHidden() - .datePickerStyle(.compact) - .tint(colors.onSurfaceVariant) - .font(.tdayRounded(size: 13, weight: .heavy)) - .frame(width: width, height: 38) - .background(colors.surfaceVariant.opacity(0.66), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + ZStack { + Text(text) + .font(.tdayRounded(size: 15, weight: .bold)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + .minimumScaleFactor(0.82) + .frame(width: width, height: 38) + .background(colors.surfaceVariant.opacity(0.66), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + + DatePicker("", selection: $dueDate, displayedComponents: components) + .labelsHidden() + .datePickerStyle(.compact) + .tint(colors.onSurfaceVariant) + .frame(width: width, height: 38) + .opacity(0.02) + } + .frame(width: width, height: 38) + .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } } @@ -419,16 +430,19 @@ private struct CreateTaskSheetMenuRow: View { Spacer(minLength: 8) - Text(value) - .font(.tdayRounded(size: 14, weight: .heavy)) - .foregroundStyle(colors.onSurfaceVariant) - .lineLimit(1) - .minimumScaleFactor(0.78) + HStack(spacing: 4) { + Text(value) + .font(.tdayRounded(size: 14, weight: .heavy)) + .foregroundStyle(colors.onSurfaceVariant) + .lineLimit(1) + .minimumScaleFactor(0.78) - Image(systemName: "chevron.down") - .font(.system(size: 12, weight: .heavy)) - .foregroundStyle(colors.onSurfaceVariant.opacity(0.72)) + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .heavy)) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.72)) + } } + .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.vertical, 14) .contentShape(Rectangle()) @@ -449,11 +463,20 @@ private struct CreateTaskSheetMenuItemLabel: View { .frame(width: 10, height: 10) Text(title) + .lineLimit(1) + + Spacer(minLength: 24) if selected { Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.blue) + } else { + Color.clear + .frame(width: 14, height: 14) } } + .frame(minWidth: 164, alignment: .leading) } } From 5bd234d11989da36980a1b56e70361ac89896eb0 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 19 May 2026 18:40:12 -0400 Subject: [PATCH 22/52] Refactor the task creation sheet in the iOS SwiftUI client to replace standard system menus with a custom overlay-based selector and a unified date-time control. - **Custom Selector UI**: - Introduce `CreateTaskSheetSelector` enum to manage the state of active selection overlays for lists, priorities, and recurrence rules. - Replace `CreateTaskSheetMenuRow` (using system `Menu`) with `CreateTaskSheetSelectorTriggerRow` and a dedicated `selectorOverlay` using `CreateTaskSheetSelectorCard`. - Implement `CreateTaskSheetSelectorRow` to provide a consistent, branded selection experience with color swatches and checkmark indicators. - **Date & Time Picking**: - Replace individual date and time chips with a unified `CreateTaskSheetDateTimeControl` component. - Refactor the underlying layout to use a single container with a custom border and a vertical divider between date and time labels. - Update typography to use `.tdayRounded` weight `.heavy` for improved legibility in the date-time display. - **Component Refactoring**: - Extract repeated UI patterns into new sub-views: `CreateTaskSheetSelectorCard`, `CreateTaskSheetSelectorRow`, and `CreateTaskSheetSelectorDivider`. - Adjust styles for existing components, including updated corner radii, background opacities, and font weights to align with a more refined design language. --- .../Tday/UI/Component/CreateTaskSheet.swift | 331 ++++++++++++------ 1 file changed, 220 insertions(+), 111 deletions(-) diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index 14484155..e1f5c916 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -20,6 +20,7 @@ struct CreateTaskSheet: View { @State private var repeatRule: String? @State private var isSubmitting = false @State private var parserTask: Task? + @State private var activeSelector: CreateTaskSheetSelector? private let priorityOptions = ["Low", "Medium", "High"] private let repeatOptions: [(label: String, value: String?)] = [ @@ -109,73 +110,30 @@ struct CreateTaskSheet: View { CreateTaskSheetSectionTitle(text: "Details") CreateTaskSheetGroupCard { - CreateTaskSheetMenuRow( + CreateTaskSheetSelectorTriggerRow( iconName: "list.bullet", title: "List", - value: selectedListName - ) { - Button { - selectedListID = nil - } label: { - CreateTaskSheetMenuItemLabel( - title: "No list", - selected: selectedListID == nil, - swatchColor: colors.onSurfaceVariant.opacity(0.35) - ) - } - - ForEach(lists) { list in - Button { - selectedListID = list.id - } label: { - CreateTaskSheetMenuItemLabel( - title: list.name, - selected: selectedListID == list.id, - swatchColor: createTaskSheetListSwatchColor(list.color) - ) - } - } - } + value: selectedListName, + onTap: { activeSelector = .list } + ) CreateTaskSheetDivider() - CreateTaskSheetMenuRow( + CreateTaskSheetSelectorTriggerRow( iconName: "text.badge.checkmark", title: "Priority", - value: priority - ) { - ForEach(priorityOptions, id: \.self) { option in - Button { - priority = option - } label: { - CreateTaskSheetMenuItemLabel( - title: option, - selected: priority == option, - swatchColor: createTaskSheetPrioritySwatchColor(option) - ) - } - } - } + value: priority, + onTap: { activeSelector = .priority } + ) CreateTaskSheetDivider() - CreateTaskSheetMenuRow( + CreateTaskSheetSelectorTriggerRow( iconName: "repeat", title: "Repeat", - value: selectedRepeatLabel - ) { - ForEach(repeatOptions, id: \.label) { option in - Button { - repeatRule = option.value - } label: { - CreateTaskSheetMenuItemLabel( - title: option.label, - selected: repeatRule == option.value, - swatchColor: createTaskSheetRepeatSwatchColor(option.value) - ) - } - } - } + value: selectedRepeatLabel, + onTap: { activeSelector = .repeat } + ) } Spacer(minLength: 6) @@ -190,6 +148,11 @@ struct CreateTaskSheet: View { .presentationDragIndicator(.hidden) .presentationCornerRadius(34) .presentationBackground(colors.background) + .overlay { + if let activeSelector { + selectorOverlay(for: activeSelector) + } + } .task { hydrateFromInitialPayload() } @@ -247,6 +210,93 @@ struct CreateTaskSheet: View { onDismiss() dismiss() } + + @ViewBuilder + private func selectorOverlay(for selector: CreateTaskSheetSelector) -> some View { + ZStack { + Color.black.opacity(0.48) + .ignoresSafeArea() + .onTapGesture { + activeSelector = nil + } + + CreateTaskSheetSelectorCard(title: selector.title) { + switch selector { + case .list: + CreateTaskSheetSelectorRow( + title: "No list", + swatchColor: colors.onSurfaceVariant.opacity(0.35), + selected: selectedListID == nil + ) { + selectedListID = nil + activeSelector = nil + } + + ForEach(lists) { list in + CreateTaskSheetSelectorDivider() + CreateTaskSheetSelectorRow( + title: list.name, + swatchColor: createTaskSheetListSwatchColor(list.color), + selected: selectedListID == list.id + ) { + selectedListID = list.id + activeSelector = nil + } + } + + case .priority: + ForEach(Array(priorityOptions.enumerated()), id: \.element) { index, option in + if index > 0 { + CreateTaskSheetSelectorDivider() + } + CreateTaskSheetSelectorRow( + title: option, + swatchColor: createTaskSheetPrioritySwatchColor(option), + selected: priority == option + ) { + priority = option + activeSelector = nil + } + } + + case .repeat: + ForEach(Array(repeatOptions.enumerated()), id: \.element.label) { index, option in + if index > 0 { + CreateTaskSheetSelectorDivider() + } + CreateTaskSheetSelectorRow( + title: option.label, + swatchColor: createTaskSheetRepeatSwatchColor(option.value), + selected: repeatRule == option.value + ) { + repeatRule = option.value + activeSelector = nil + } + } + } + } + .padding(.horizontal, 54) + } + } +} + +private enum CreateTaskSheetSelector: String, Identifiable { + case list + case priority + case repeat + + var id: String { rawValue } + + var title: String { + switch self { + case .list: + return "List" + case .priority: + return "Priority" + case .repeat: + return "Repeat" + } + } } private struct CreateTaskSheetTextCard: View { @@ -342,20 +392,7 @@ private struct CreateTaskSheetDueRow: View { Spacer(minLength: 6) - HStack(spacing: 6) { - CreateTaskSheetDatePickerChip( - dueDate: $dueDate, - components: .date, - text: dueDate.formatted(.dateTime.month(.abbreviated).day().year()), - width: 126 - ) - CreateTaskSheetDatePickerChip( - dueDate: $dueDate, - components: .hourAndMinute, - text: dueDate.formatted(.dateTime.hour(.defaultDigits(amPM: .abbreviated)).minute()), - width: 86 - ) - } + CreateTaskSheetDateTimeControl(dueDate: $dueDate) } .padding(.horizontal, 16) .padding(.vertical, 14) @@ -376,48 +413,75 @@ private struct CreateTaskSheetDueRow: View { } } -private struct CreateTaskSheetDatePickerChip: View { +private struct CreateTaskSheetDateTimeControl: View { @Binding var dueDate: Date - let components: DatePickerComponents - let text: String - let width: CGFloat @Environment(\.tdayColors) private var colors + private var dateText: String { + dueDate.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day()) + } + + private var timeText: String { + dueDate.formatted(.dateTime.hour(.defaultDigits(amPM: .abbreviated)).minute()) + } + var body: some View { ZStack { - Text(text) - .font(.tdayRounded(size: 15, weight: .bold)) - .foregroundStyle(colors.onSurface) - .lineLimit(1) - .minimumScaleFactor(0.82) - .frame(width: width, height: 38) - .background(colors.surfaceVariant.opacity(0.66), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - - DatePicker("", selection: $dueDate, displayedComponents: components) - .labelsHidden() - .datePickerStyle(.compact) - .tint(colors.onSurfaceVariant) - .frame(width: width, height: 38) - .opacity(0.02) + HStack(spacing: 0) { + Text(dateText) + .frame(maxWidth: .infinity) + + Rectangle() + .fill(colors.onSurfaceVariant.opacity(0.2)) + .frame(width: 1, height: 22) + + Text(timeText) + .frame(maxWidth: .infinity) + } + .font(.tdayRounded(size: 13, weight: .heavy)) + .foregroundStyle(colors.onSurfaceVariant) + .lineLimit(1) + .minimumScaleFactor(0.74) + .padding(.horizontal, 10) + .frame(width: 206, height: 38) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(colors.onSurfaceVariant.opacity(0.24), lineWidth: 1) + } + .background(colors.surfaceVariant.opacity(0.32), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + + HStack(spacing: 0) { + DatePicker("", selection: $dueDate, displayedComponents: .date) + .labelsHidden() + .datePickerStyle(.compact) + .tint(colors.onSurfaceVariant) + .frame(width: 114, height: 38) + .opacity(0.02) + + DatePicker("", selection: $dueDate, displayedComponents: .hourAndMinute) + .labelsHidden() + .datePickerStyle(.compact) + .tint(colors.onSurfaceVariant) + .frame(width: 92, height: 38) + .opacity(0.02) + } } - .frame(width: width, height: 38) - .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .frame(width: 206, height: 38) + .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } -private struct CreateTaskSheetMenuRow: View { +private struct CreateTaskSheetSelectorTriggerRow: View { let iconName: String let title: String let value: String - @ViewBuilder let menuContent: () -> MenuContent + let onTap: () -> Void @Environment(\.tdayColors) private var colors var body: some View { - Menu { - menuContent() - } label: { + Button(action: onTap) { HStack(spacing: 14) { Image(systemName: iconName) .font(.system(size: 20, weight: .semibold)) @@ -451,32 +515,77 @@ private struct CreateTaskSheetMenuRow: View { } } -private struct CreateTaskSheetMenuItemLabel: View { +private struct CreateTaskSheetSelectorCard: View { + let title: String + @ViewBuilder let content: Content + + @Environment(\.tdayColors) private var colors + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(.tdayRounded(size: 18, weight: .heavy)) + .foregroundStyle(colors.onSurfaceVariant) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 12) + + content + } + .padding(.bottom, 14) + .frame(maxWidth: 330) + .background(colors.surface, in: RoundedRectangle(cornerRadius: 32, style: .continuous)) + .shadow(color: Color.black.opacity(0.18), radius: 24, x: 0, y: 18) + } +} + +private struct CreateTaskSheetSelectorRow: View { let title: String - let selected: Bool let swatchColor: Color + let selected: Bool + let action: () -> Void + + @Environment(\.tdayColors) private var colors var body: some View { - HStack(spacing: 10) { - Circle() - .fill(swatchColor) - .frame(width: 10, height: 10) + Button(action: action) { + HStack(spacing: 14) { + Circle() + .fill(swatchColor) + .frame(width: 10, height: 10) - Text(title) - .lineLimit(1) + Text(title) + .font(.tdayRounded(size: 18, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) - Spacer(minLength: 24) + Spacer(minLength: 12) - if selected { - Image(systemName: "checkmark") - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(.blue) - } else { - Color.clear - .frame(width: 14, height: 14) + if selected { + Image(systemName: "checkmark") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(colors.primary) + } else { + Color.clear + .frame(width: 18, height: 18) + } } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .contentShape(Rectangle()) } - .frame(minWidth: 164, alignment: .leading) + .buttonStyle(.plain) + } +} + +private struct CreateTaskSheetSelectorDivider: View { + @Environment(\.tdayColors) private var colors + + var body: some View { + Rectangle() + .fill(colors.onSurfaceVariant.opacity(0.16)) + .frame(height: 1) + .padding(.horizontal, 20) } } From a7ff04bf8c0a55322f5539ca74bf135ba9275be6 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 09:34:22 -0400 Subject: [PATCH 23/52] Refactor `CreateTaskSheet` internal selector naming. This update renames the `.repeat` case to `.recurrence` within the `CreateTaskSheetSelector` enum and its associated logic. This change improves code clarity and avoids potential conflicts with the `repeat` keyword. - **Refactoring**: - Rename `CreateTaskSheetSelector.repeat` to `CreateTaskSheetSelector.recurrence`. - Update `activeSelector` assignments and switch-case statements in `CreateTaskSheet.swift` to reflect the rename. - Ensure the UI display title for the `.recurrence` case remains "Repeat". --- ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index e1f5c916..5d09f7f0 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -132,7 +132,7 @@ struct CreateTaskSheet: View { iconName: "repeat", title: "Repeat", value: selectedRepeatLabel, - onTap: { activeSelector = .repeat } + onTap: { activeSelector = .recurrence } ) } @@ -259,7 +259,7 @@ struct CreateTaskSheet: View { } } - case .repeat: + case .recurrence: ForEach(Array(repeatOptions.enumerated()), id: \.element.label) { index, option in if index > 0 { CreateTaskSheetSelectorDivider() @@ -283,7 +283,7 @@ struct CreateTaskSheet: View { private enum CreateTaskSheetSelector: String, Identifiable { case list case priority - case repeat + case recurrence var id: String { rawValue } @@ -293,7 +293,7 @@ private enum CreateTaskSheetSelector: String, Identifiable { return "List" case .priority: return "Priority" - case .repeat: + case .recurrence: return "Repeat" } } From c5a5fc903b1faed5a8537a755e4b54cfa35fd87a Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 09:49:05 -0400 Subject: [PATCH 24/52] Implement list creation timestamps and synchronize list ordering across Android and iOS clients. This update introduces tracking for list creation dates (`createdAt`) in the local offline cache. This data is utilized to implement a standardized sorting mechanism (`orderListsLikeWeb`) that orders lists by creation date descending, ensuring UI consistency with the web platform. - **Data Model & Persistence**: - Add `createdAtEpochMs` to `CachedListEntity` (Android) and `CachedListRecord` (iOS). - **Android**: Increment Room database version to 3 and implement `MIGRATION_2_3` to add the `createdAtEpochMs` column to the `cached_lists` table. - **iOS**: Update `CachedListEntity` SwiftData model and `CachedListRecord` to include the optional creation timestamp. - Update entity mappers and DTO parsers on both platforms to handle the new `createdAt` field. - **Sorting Logic**: - Implement `orderListsLikeWeb` utility function to sort lists by `createdAtEpochMs` in descending order, falling back to original indices for stability. - Integrate the new sorting logic into `SyncManager`, `TodoRepository`, and `ListRepository` on both Android and iOS to ensure a consistent list display. - Replace previous alphabetical sorting in the iOS `ListRepository` with the creation-date-based order. - **Refinement & Testing**: - Update `ListSummary` domain models to include the `createdAt` property. - Enhance `CacheMappersTest.kt` with new test cases for round-trip mapping and sorting verification of list creation timestamps. - Ensure local list creation accurately captures and persists the current timestamp. --- .../compose/core/data/OfflineSyncModels.kt | 1 + .../compose/core/data/cache/CacheMappers.kt | 17 +++++++++++ .../core/data/db/DatabaseMigrations.kt | 8 ++++++ .../compose/core/data/db/DatabaseModule.kt | 2 +- .../tday/compose/core/data/db/Entities.kt | 1 + .../compose/core/data/db/EntityMappers.kt | 2 ++ .../tday/compose/core/data/db/TdayDatabase.kt | 2 +- .../compose/core/data/list/ListRepository.kt | 14 +++++++--- .../compose/core/data/sync/SyncManager.kt | 11 +++++--- .../compose/core/data/todo/TodoRepository.kt | 7 +++-- .../tday/compose/core/model/DomainModels.kt | 1 + .../core/data/cache/CacheMappersTest.kt | 8 +++++- .../Core/Data/Cache/OfflineCacheManager.swift | 23 ++++++++------- .../Core/Data/Database/SwiftDataModels.swift | 2 ++ .../Tday/Core/Data/List/ListRepository.swift | 28 +++++++++++++------ .../Tday/Core/Data/Sync/CacheMappers.swift | 23 +++++++++++++-- .../Tday/Core/Data/Sync/SyncManager.swift | 12 ++++++-- .../Tday/Core/Data/Todo/TodoRepository.swift | 2 +- .../Tday/Core/Model/DomainModels.swift | 1 + .../Tday/Core/Model/OfflineSyncModels.swift | 1 + 20 files changed, 128 insertions(+), 38 deletions(-) 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 cc6e6654..e0f3f70f 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 @@ -37,6 +37,7 @@ data class CachedListRecord( val iconKey: String? = null, val todoCount: Int = 0, val updatedAtEpochMs: Long = 0L, + val createdAtEpochMs: Long = 0L, ) @Serializable 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 7fb68996..384c2ac3 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 @@ -64,9 +64,20 @@ internal fun listToCache(list: ListSummary): CachedListRecord { iconKey = list.iconKey, todoCount = list.todoCount, updatedAtEpochMs = list.updatedAt?.toEpochMilli() ?: 0L, + createdAtEpochMs = list.createdAt?.toEpochMilli() ?: 0L, ) } +internal fun orderListsLikeWeb(lists: 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, @@ -82,6 +93,11 @@ internal fun listFromCache( } else { null }, + createdAt = if (cache.createdAtEpochMs > 0L) { + Instant.ofEpochMilli(cache.createdAtEpochMs) + } else { + null + }, ) } @@ -168,6 +184,7 @@ internal fun mapListDto(dto: ListDto, iconFallback: String? = null): ListSummary iconKey = dto.iconKey ?: iconFallback, todoCount = dto.todoCount, updatedAt = parseOptionalInstant(dto.updatedAt), + createdAt = parseOptionalInstant(dto.createdAt), ) } 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 index 6a8bc3dd..fcf68f3f 100644 --- 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 @@ -102,3 +102,11 @@ val MIGRATION_1_2 = object : Migration(1, 2) { ) } } + +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", + ) + } +} 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 ccc5c9a2..38d1fae6 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) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .allowMainThreadQueries() .build() } 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 04fbf656..2863c632 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 @@ -35,6 +35,7 @@ data class CachedListEntity( val iconKey: String?, val todoCount: Int, val updatedAtEpochMs: Long, + val createdAtEpochMs: Long, ) @Entity( 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 573201c0..e14924c9 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 @@ -43,6 +43,7 @@ fun CachedListRecord.toEntity() = CachedListEntity( iconKey = iconKey, todoCount = todoCount, updatedAtEpochMs = updatedAtEpochMs, + createdAtEpochMs = createdAtEpochMs, ) fun CachedListEntity.toRecord() = CachedListRecord( @@ -52,6 +53,7 @@ fun CachedListEntity.toRecord() = CachedListRecord( iconKey = iconKey, todoCount = todoCount, updatedAtEpochMs = updatedAtEpochMs, + createdAtEpochMs = createdAtEpochMs, ) fun CachedCompletedRecord.toEntity() = CachedCompletedEntity( 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 ea2427c6..7b6a2ca3 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 @@ -11,7 +11,7 @@ import androidx.room.RoomDatabase PendingMutationEntity::class, SyncMetadataEntity::class, ], - version = 2, + version = 3, exportSchema = false, ) abstract class TdayDatabase : RoomDatabase() { 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 d7fbbefa..4d5bdc6f 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 @@ -9,6 +9,8 @@ import com.ohmz.tday.compose.core.data.SecureConfigStore import com.ohmz.tday.compose.core.data.cache.LOCAL_LIST_PREFIX import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager import com.ohmz.tday.compose.core.data.cache.listFromCache +import com.ohmz.tday.compose.core.data.cache.orderListsLikeWeb +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 @@ -16,7 +18,6 @@ import com.ohmz.tday.compose.core.model.CreateListRequest import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.UpdateListRequest import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter -import com.ohmz.tday.compose.core.data.cache.parseOptionalInstant import com.ohmz.tday.compose.core.network.TdayApiService import java.util.UUID import javax.inject.Inject @@ -55,6 +56,7 @@ class ListRepository @Inject constructor( color = color, iconKey = iconKey, todoCount = 0, + createdAtEpochMs = timestampMs, updatedAtEpochMs = timestampMs, ) state.copy( @@ -84,7 +86,10 @@ class ListRepository @Inject constructor( ).list }.onSuccess { createdList -> if (createdList == null) return@onSuccess - val createdAt = parseOptionalInstant(createdList.updatedAt)?.toEpochMilli() ?: timestampMs + val createdAt = + parseOptionalInstant(createdList.createdAt)?.toEpochMilli() ?: timestampMs + val updatedAt = + parseOptionalInstant(createdList.updatedAt)?.toEpochMilli() ?: timestampMs cacheManager.updateOfflineState { state -> val remapped = replaceLocalListId( state = state, @@ -100,7 +105,8 @@ class ListRepository @Inject constructor( color = createdList.color, iconKey = createdList.iconKey ?: list.iconKey, todoCount = todoCount, - updatedAtEpochMs = createdAt, + updatedAtEpochMs = updatedAt, + createdAtEpochMs = createdAt, ) } else { list @@ -240,7 +246,7 @@ class ListRepository @Inject constructor( .filterNot { it.completed } .groupingBy { it.listId } .eachCount() - return state.lists.map { + return orderListsLikeWeb(state.lists).map { listFromCache(cache = it, todoCountOverride = todoCountsByList[it.id] ?: 0) } } 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 d5b54481..a532400d 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 @@ -17,6 +17,7 @@ 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.mapListDto 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.todoMergeKey import com.ohmz.tday.compose.core.data.cache.todoToCache import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue @@ -28,8 +29,8 @@ import com.ohmz.tday.compose.core.model.CreateTodoRequest import com.ohmz.tday.compose.core.model.DeleteTodoRequest import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.TodoCompleteRequest -import com.ohmz.tday.compose.core.model.TodoItem 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.UpdateListRequest @@ -584,9 +585,11 @@ class SyncManager @Inject constructor( .filterNot { it.completed } .groupingBy { it.listId } .eachCount() - val normalizedLists = mergedLists.map { - it.copy(todoCount = todoCountByList[it.id] ?: 0) - } + val normalizedLists = orderListsLikeWeb( + mergedLists.map { + it.copy(todoCount = todoCountByList[it.id] ?: 0) + }, + ) val dataMergedState = localState.copy( todos = mergedTodos, 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 6ed02eb5..afde972d 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,14 +1,17 @@ package com.ohmz.tday.compose.core.data.todo +import android.util.Log 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_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.listFromCache 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 @@ -28,8 +31,6 @@ import com.ohmz.tday.compose.core.model.TodoTitleNlpRequest import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.model.UpdateTodoRequest import com.ohmz.tday.compose.core.network.TdayApiService -import android.util.Log -import com.ohmz.tday.compose.core.data.cache.LOCAL_LIST_PREFIX import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime @@ -552,7 +553,7 @@ class TodoRepository @Inject constructor( .groupingBy { it.listId } .eachCount() - val lists = state.lists.map { + val lists = orderListsLikeWeb(state.lists).map { listFromCache(cache = it, todoCountOverride = todoCountsByList[it.id] ?: 0) } 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 8994cd21..63f622b0 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 @@ -48,6 +48,7 @@ data class ListSummary( val iconKey: String?, val todoCount: Int, val updatedAt: Instant? = null, + val createdAt: Instant? = null, ) data class DashboardSummary( 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 88250fb8..ac33f297 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 @@ -9,13 +9,13 @@ import com.ohmz.tday.compose.core.model.ListDto import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.TodoDto import com.ohmz.tday.compose.core.model.TodoItem -import java.time.Instant import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.time.Instant class CacheMappersTest { @@ -23,6 +23,7 @@ class CacheMappersTest { private val dueInstant: Instant = Instant.parse("2025-06-15T13:30:00Z") private val completedInstant: Instant = Instant.parse("2025-06-15T14:00:00Z") private val updatedInstant: Instant = Instant.parse("2025-06-15T12:00:00Z") + private val createdInstant: Instant = Instant.parse("2025-06-10T12:00:00Z") // --- todoToCache / todoFromCache round-trip --- @@ -102,6 +103,7 @@ class CacheMappersTest { assertEquals(list.iconKey, cached.iconKey) assertEquals(list.todoCount, cached.todoCount) assertEquals(list.updatedAt?.toEpochMilli() ?: 0L, cached.updatedAtEpochMs) + assertEquals(list.createdAt?.toEpochMilli() ?: 0L, cached.createdAtEpochMs) } @Test @@ -208,6 +210,7 @@ class CacheMappersTest { assertEquals(dto.name, item.name) assertEquals(dto.color, item.color) assertEquals(dto.todoCount, item.todoCount) + assertEquals(createdInstant, item.createdAt) } @Test @@ -347,6 +350,7 @@ class CacheMappersTest { iconKey = "cart", todoCount = 5, updatedAt = updatedInstant, + createdAt = createdInstant, ) private fun makeCachedList() = CachedListRecord( @@ -356,6 +360,7 @@ class CacheMappersTest { iconKey = "cart", todoCount = 5, updatedAtEpochMs = updatedInstant.toEpochMilli(), + createdAtEpochMs = createdInstant.toEpochMilli(), ) private fun makeCompletedItem() = CompletedItem( @@ -410,5 +415,6 @@ class CacheMappersTest { todoCount = 3, iconKey = "cart", updatedAt = updatedInstant.toString(), + createdAt = createdInstant.toString(), ) } diff --git a/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift b/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift index 157ffb2b..1a827326 100644 --- a/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift @@ -39,6 +39,18 @@ final class OfflineCacheManager { let mutations = (try? modelContext.fetch(FetchDescriptor())) ?? [] let metadata = (try? modelContext.fetch(FetchDescriptor()))?.first + let listRecords = lists.map { + CachedListRecord( + id: $0.id, + name: $0.name, + color: $0.color, + iconKey: $0.iconKey, + todoCount: $0.todoCount, + updatedAtEpochMs: $0.updatedAtEpochMs, + createdAtEpochMs: $0.createdAtEpochMs ?? 0 + ) + } + return OfflineSyncState( lastSuccessfulSyncEpochMs: metadata?.lastSuccessfulSyncEpochMs ?? 0, lastSyncAttemptEpochMs: metadata?.lastSyncAttemptEpochMs ?? 0, @@ -73,16 +85,7 @@ final class OfflineCacheManager { listColor: $0.listColor ) }, - lists: lists.map { - CachedListRecord( - id: $0.id, - name: $0.name, - color: $0.color, - iconKey: $0.iconKey, - todoCount: $0.todoCount, - updatedAtEpochMs: $0.updatedAtEpochMs - ) - }, + lists: orderListsLikeWeb(listRecords), pendingMutations: mutations.map { PendingMutationRecord( mutationId: $0.mutationId, diff --git a/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift b/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift index be251f1c..526628bb 100644 --- a/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift +++ b/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift @@ -40,6 +40,7 @@ final class CachedListEntity { var iconKey: String? var todoCount: Int var updatedAtEpochMs: Int64 + var createdAtEpochMs: Int64? init(from record: CachedListRecord) { id = record.id @@ -48,6 +49,7 @@ final class CachedListEntity { iconKey = record.iconKey todoCount = record.todoCount updatedAtEpochMs = record.updatedAtEpochMs + createdAtEpochMs = record.createdAtEpochMs } } diff --git a/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift b/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift index 2d0cf927..84aaf634 100644 --- a/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift @@ -37,7 +37,8 @@ final class ListRepository { color: color, iconKey: iconKey, todoCount: 0, - updatedAtEpochMs: now + updatedAtEpochMs: now, + createdAtEpochMs: now ) ) nextState.pendingMutations.append( @@ -70,7 +71,8 @@ final class ListRepository { guard let createdList = response.list else { return } - let createdAt = parseOptionalDate(createdList.updatedAt)?.epochMilliseconds ?? now + let createdAt = parseOptionalDate(createdList.createdAt)?.epochMilliseconds ?? now + let updatedAt = parseOptionalDate(createdList.updatedAt)?.epochMilliseconds ?? now _ = try await cacheManager.updateOfflineState { state in var nextState = self.replaceLocalListID(state, localListID: localListID, serverListID: createdList.id) let todoCount = nextState.todos.filter { !$0.completed && $0.listId == createdList.id }.count @@ -84,7 +86,8 @@ final class ListRepository { color: createdList.color, iconKey: createdList.iconKey ?? list.iconKey, todoCount: todoCount, - updatedAtEpochMs: createdAt + updatedAtEpochMs: updatedAt, + createdAtEpochMs: createdAt ) } nextState.pendingMutations.removeAll { $0.mutationId == mutationID } @@ -114,7 +117,8 @@ final class ListRepository { color: color ?? list.color, iconKey: iconKey ?? list.iconKey, todoCount: list.todoCount, - updatedAtEpochMs: now + updatedAtEpochMs: now, + createdAtEpochMs: list.createdAtEpochMs ) } nextState.pendingMutations = state.pendingMutations.compactMap { mutation in @@ -153,7 +157,15 @@ final class ListRepository { var nextState = state nextState.lists = state.lists.map { list in guard list.id == listId else { return list } - return CachedListRecord(id: list.id, name: normalizedName, color: color ?? list.color, iconKey: iconKey ?? list.iconKey, todoCount: list.todoCount, updatedAtEpochMs: now) + return CachedListRecord( + id: list.id, + name: normalizedName, + color: color ?? list.color, + iconKey: iconKey ?? list.iconKey, + todoCount: list.todoCount, + updatedAtEpochMs: now, + createdAtEpochMs: list.createdAtEpochMs + ) } nextState.pendingMutations.removeAll { $0.kind == .updateList && $0.targetId == listId } nextState.pendingMutations.append( @@ -187,9 +199,8 @@ final class ListRepository { private func buildLists(from state: OfflineSyncState) -> [ListSummary] { let todoCounts = Dictionary(grouping: state.todos.filter { !$0.completed }, by: { $0.listId }) .mapValues(\.count) - return state.lists + return orderListsLikeWeb(state.lists) .map { listFromCache($0, todoCountOverride: todoCounts[$0.id] ?? 0) } - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } private func replaceLocalListID(_ state: OfflineSyncState, localListID: String, serverListID: String) -> OfflineSyncState { @@ -226,7 +237,8 @@ final class ListRepository { color: list.color, iconKey: list.iconKey, todoCount: list.todoCount, - updatedAtEpochMs: list.updatedAtEpochMs + updatedAtEpochMs: list.updatedAtEpochMs, + createdAtEpochMs: list.createdAtEpochMs ) }, pendingMutations: state.pendingMutations.map { mutation in diff --git a/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift b/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift index 8bc88b7a..0cadf315 100644 --- a/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift +++ b/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift @@ -182,7 +182,8 @@ func mapListDTO(_ dto: ListDTO, iconFallback: String? = nil) -> ListSummary { color: dto.color, iconKey: dto.iconKey ?? iconFallback, todoCount: dto.todoCount, - updatedAt: parseOptionalDate(dto.updatedAt) + updatedAt: parseOptionalDate(dto.updatedAt), + createdAt: parseOptionalDate(dto.createdAt) ) } @@ -193,10 +194,25 @@ func listToCache(_ list: ListSummary) -> CachedListRecord { color: list.color, iconKey: list.iconKey, todoCount: list.todoCount, - updatedAtEpochMs: list.updatedAt.map { Int64($0.timeIntervalSince1970 * 1000.0) } ?? 0 + updatedAtEpochMs: list.updatedAt.map { Int64($0.timeIntervalSince1970 * 1000.0) } ?? 0, + createdAtEpochMs: list.createdAt.map { Int64($0.timeIntervalSince1970 * 1000.0) } ?? 0 ) } +func orderListsLikeWeb(_ lists: [CachedListRecord]) -> [CachedListRecord] { + guard lists.contains(where: { $0.createdAtEpochMs > 0 }) else { + return lists + } + return lists.enumerated() + .sorted { lhs, rhs in + if lhs.element.createdAtEpochMs != rhs.element.createdAtEpochMs { + return lhs.element.createdAtEpochMs > rhs.element.createdAtEpochMs + } + return lhs.offset < rhs.offset + } + .map(\.element) +} + func listFromCache(_ record: CachedListRecord, todoCountOverride: Int? = nil) -> ListSummary { ListSummary( id: record.id, @@ -204,7 +220,8 @@ func listFromCache(_ record: CachedListRecord, todoCountOverride: Int? = nil) -> color: record.color, iconKey: record.iconKey, todoCount: todoCountOverride ?? record.todoCount, - updatedAt: record.updatedAtEpochMs > 0 ? Date(timeIntervalSince1970: TimeInterval(record.updatedAtEpochMs) / 1000.0) : nil + updatedAt: record.updatedAtEpochMs > 0 ? Date(timeIntervalSince1970: TimeInterval(record.updatedAtEpochMs) / 1000.0) : nil, + createdAt: record.createdAtEpochMs > 0 ? Date(timeIntervalSince1970: TimeInterval(record.createdAtEpochMs) / 1000.0) : nil ) } diff --git a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift index 02d396a2..6b5652de 100644 --- a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift @@ -180,7 +180,7 @@ final class SyncManager { lastSyncAttemptEpochMs: localState.lastSyncAttemptEpochMs, todos: mergedTodos.sorted(by: cachedTodoSortPrecedes), completedItems: dedupedCompleted.sorted { $0.completedAtEpochMs > $1.completedAtEpochMs }, - lists: mergedLists.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }, + lists: orderListsLikeWeb(mergedLists), pendingMutations: pendingMutations, aiSummaryEnabled: remote.aiSummaryEnabled ) @@ -554,7 +554,15 @@ final class SyncManager { completedItems: state.completedItems, lists: state.lists.map { list in if list.id == localListID { - return CachedListRecord(id: serverListID, name: list.name, color: list.color, iconKey: list.iconKey, todoCount: list.todoCount, updatedAtEpochMs: list.updatedAtEpochMs) + return CachedListRecord( + id: serverListID, + name: list.name, + color: list.color, + iconKey: list.iconKey, + todoCount: list.todoCount, + updatedAtEpochMs: list.updatedAtEpochMs, + createdAtEpochMs: list.createdAtEpochMs + ) } return list }, diff --git a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift index 0ff3a713..04568a91 100644 --- a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift @@ -418,7 +418,7 @@ final class TodoRepository { let todayTodos = timelineTodos.filter { isTodayTodo($0, now: now) } let scheduledTodos = timelineTodos.filter { isScheduledTodo($0, now: now) } let todoCountsByList = Dictionary(grouping: timelineTodos, by: \.listId).mapValues(\.count) - let lists = state.lists.map { list in + let lists = orderListsLikeWeb(state.lists).map { list in listFromCache(list, todoCountOverride: todoCountsByList[list.id] ?? 0) } diff --git a/ios-swiftUI/Tday/Core/Model/DomainModels.swift b/ios-swiftUI/Tday/Core/Model/DomainModels.swift index a169fb27..4a147944 100644 --- a/ios-swiftUI/Tday/Core/Model/DomainModels.swift +++ b/ios-swiftUI/Tday/Core/Model/DomainModels.swift @@ -87,6 +87,7 @@ struct ListSummary: Identifiable, Equatable, Hashable, Codable { let iconKey: String? let todoCount: Int let updatedAt: Date? + let createdAt: Date? } struct DashboardSummary: Equatable, Hashable, Codable { diff --git a/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift b/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift index 16cc5e37..bf3847d6 100644 --- a/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift +++ b/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift @@ -36,6 +36,7 @@ struct CachedListRecord: Identifiable, Equatable, Codable { let iconKey: String? let todoCount: Int let updatedAtEpochMs: Int64 + let createdAtEpochMs: Int64 } struct CachedCompletedRecord: Identifiable, Equatable, Codable { From 18af7c1bef691542f049601e30e1ae87692286e4 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 10:23:17 -0400 Subject: [PATCH 25/52] Implement custom settings UI and enhance timeline section animations for both Android and iOS. This update replaces the standard system-styled settings on iOS with a bespoke, branded design and introduces a dedicated "App Version" screen to track releases. Additionally, it refines the timeline experience by adding smooth expand/collapse animations and visual feedback for section headers. - **Settings & Versioning (iOS)**: - Redesign `SettingsScreen` with custom cards, iconography, and a tailored typography system, moving away from `Form` and `Picker` defaults. - Implement `LatestReleaseScreen` to display GitHub release notes, version compatibility status, and links to official releases. - Add `GitHubRelease` model and update `AppViewModel` to fetch release metadata and changelogs from the GitHub API. - Update `AppRoute` and `AppRootView` to support navigation to the new version details screen. - **Timeline UI & Animations (Multi-platform)**: - **Android (Compose)**: Implement `AnimatedVisibility` with `expandVertically` and `shrinkVertically` for todo and completed sections. Add rotation animations and state-aware color tinting to section chevrons. - **iOS (SwiftUI)**: Implement a custom multi-phase collapse/reveal system in `CompletedScreen` and `TodoListScreen` to manage view rendering and opacity transitions during section toggling. - Enhance section headers with press-responsive brightness adjustments and smoother haptic-like visual feedback. - **Admin Features**: - Improve the "AI task summary" toggle in settings with loading states, error handling for validation, and clearer "saving" indicators. - **Navigation**: - Add `latest-release` deep-link path and update navigation logic to handle the new versioning view. --- .../feature/completed/CompletedScreen.kt | 94 +- .../compose/feature/todos/TodoListScreen.kt | 120 ++- .../Tday/Core/Navigation/AppRoute.swift | 5 + .../Tday/Feature/App/AppRootView.swift | 2 + .../Tday/Feature/App/AppViewModel.swift | 69 +- .../Feature/Completed/CompletedScreen.swift | 61 +- .../Feature/Settings/SettingsScreen.swift | 875 ++++++++++++++++-- .../Tday/Feature/Todos/TodoListScreen.swift | 77 +- 8 files changed, 1154 insertions(+), 149 deletions(-) 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 72a7319a..aaefe201 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 @@ -1,7 +1,12 @@ package com.ohmz.tday.compose.feature.completed import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState @@ -415,15 +420,27 @@ private fun CompletedTimelineSection( ) { val colorScheme = MaterialTheme.colorScheme val headerInteractionSource = remember { MutableInteractionSource() } + val isHeaderPressed by headerInteractionSource.collectIsPressedAsState() val collapseChevronRotation by animateFloatAsState( - targetValue = if (isCollapsed) 0f else 180f, + targetValue = if (isCollapsed) -90f else 0f, + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), label = "completedSectionChevronRotation", ) + val baseHeaderColor = colorScheme.onSurfaceVariant.copy(alpha = 0.62f) + val headerTextColor = if (isHeaderPressed) { + androidx.compose.ui.graphics.lerp(baseHeaderColor, colorScheme.onSurface, 0.16f) + } else { + baseHeaderColor + } + val baseChevronColor = colorScheme.onSurfaceVariant.copy(alpha = 0.72f) + val chevronColor = if (isHeaderPressed) { + androidx.compose.ui.graphics.lerp(baseChevronColor, colorScheme.onSurface, 0.16f) + } else { + baseChevronColor + } Column( modifier = modifier - .fillMaxWidth() - .animateContentSize(animationSpec = tween(durationMillis = 240)), - verticalArrangement = Arrangement.spacedBy(4.dp), + .fillMaxWidth(), ) { Row( modifier = Modifier @@ -437,7 +454,7 @@ private fun CompletedTimelineSection( ) { Text( text = section.title, - color = colorScheme.onSurfaceVariant.copy(alpha = 0.62f), + color = headerTextColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.ExtraBold, ) @@ -448,7 +465,7 @@ private fun CompletedTimelineSection( } else { stringResource(R.string.action_collapse_section) }, - tint = colorScheme.onSurfaceVariant.copy(alpha = 0.72f), + tint = chevronColor, modifier = Modifier .padding(start = 6.dp) .size(18.dp) @@ -456,26 +473,59 @@ private fun CompletedTimelineSection( ) } - if (isCollapsed) { - return@Column - } - - if (section.items.isEmpty()) { - return@Column - } else { - section.items.forEach { item -> - CompletedSwipeRow( - item = item, - lists = lists, - onInfo = { onInfo(item) }, - onDelete = { onDelete(item) }, - onUncomplete = { onUncomplete(item) }, - ) + CompletedSectionBodyVisibility( + visible = !isCollapsed && section.items.isNotEmpty(), + modifier = Modifier.padding(top = 4.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + section.items.forEach { item -> + CompletedSwipeRow( + item = item, + lists = lists, + onInfo = { onInfo(item) }, + onDelete = { onDelete(item) }, + onUncomplete = { onUncomplete(item) }, + ) + } } } } } +@Composable +private fun CompletedSectionBodyVisibility( + visible: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + AnimatedVisibility( + visible = visible, + modifier = modifier.fillMaxWidth(), + enter = expandVertically( + expandFrom = Alignment.Top, + animationSpec = tween(durationMillis = 240, easing = FastOutSlowInEasing), + ) + fadeIn( + animationSpec = tween( + durationMillis = 150, + delayMillis = 90, + easing = FastOutSlowInEasing + ), + ), + exit = fadeOut( + animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing), + ) + shrinkVertically( + shrinkTowards = Alignment.Top, + animationSpec = tween( + durationMillis = 240, + delayMillis = 45, + easing = FastOutSlowInEasing + ), + ), + ) { + content() + } +} + @Composable private fun CompletedSwipeRow( item: CompletedItem, 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 b3abde0b..9b5975ac 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 @@ -2,6 +2,7 @@ package com.ohmz.tday.compose.feature.todos import android.content.ClipData import android.view.View +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring @@ -9,6 +10,10 @@ 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.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -587,9 +592,10 @@ fun TodoListScreen( ) } - if (!isCollapsed && section.items.isNotEmpty()) { + if (section.items.isNotEmpty()) { item(key = "timeline-body-${section.key}") { - TimelineSectionBody( + TimelineSectionBodyVisibility( + visible = !isCollapsed, modifier = Modifier .animateItem(fadeInSpec = null, fadeOutSpec = null) .timelineSectionDropTarget( @@ -598,28 +604,31 @@ fun TodoListScreen( onDropTargetChanged = onSectionDropTargetChanged, onDragTodoEnd = onSectionDragEnd, onMoveTaskToDate = onMoveTaskToSectionDate, - ) - .padding(bottom = timelineItemSpacing), - section = section, - mode = uiState.mode, - lists = uiState.lists, - useMinimalStyle = usesTodayStyle, - highlightTodoId = flashTodoId, - onComplete = onComplete, - onDelete = onDelete, - onInfo = { todo -> - editTargetTodoId = todo.id - }, - draggedTodo = sectionDraggedTodo, - onDragTodoStart = if (uiState.mode == TodoListMode.SCHEDULED) { - { todo -> - activeDropSectionKey = null - draggedScheduledTodoId = todo.id - } - } else { - null - }, - ) + ), + ) { + TimelineSectionBody( + modifier = Modifier.padding(bottom = timelineItemSpacing), + section = section, + mode = uiState.mode, + lists = uiState.lists, + useMinimalStyle = usesTodayStyle, + highlightTodoId = flashTodoId, + onComplete = onComplete, + onDelete = onDelete, + onInfo = { todo -> + editTargetTodoId = todo.id + }, + draggedTodo = sectionDraggedTodo, + onDragTodoStart = if (uiState.mode == TodoListMode.SCHEDULED) { + { todo -> + activeDropSectionKey = null + draggedScheduledTodoId = todo.id + } + } else { + null + }, + ) + } } } } @@ -1448,10 +1457,29 @@ private fun TimelineSectionHeader( ) { val colorScheme = MaterialTheme.colorScheme val headerInteractionSource = remember { MutableInteractionSource() } + val isHeaderPressed by headerInteractionSource.collectIsPressedAsState() val collapseChevronRotation by animateFloatAsState( - targetValue = if (isCollapsed) 0f else 180f, + targetValue = if (isCollapsed) -90f else 0f, + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), label = "sectionChevronRotation", ) + val baseHeaderColor = if (useMinimalStyle) { + colorScheme.onSurfaceVariant.copy(alpha = 0.62f) + } else { + colorScheme.onSurfaceVariant + } + val headerTextColor = if (isHeaderPressed) { + androidx.compose.ui.graphics.lerp(baseHeaderColor, colorScheme.onSurface, 0.16f) + } else { + baseHeaderColor + } + val baseChevronColor = + colorScheme.onSurfaceVariant.copy(alpha = if (useMinimalStyle) 0.72f else 1f) + val chevronColor = if (isHeaderPressed) { + androidx.compose.ui.graphics.lerp(baseChevronColor, colorScheme.onSurface, 0.16f) + } else { + baseChevronColor + } val minimumHeaderHeight = if (useMinimalStyle) 34.dp else 48.dp val headerClickModifier = when { onHeaderClick != null -> Modifier.clickable( @@ -1492,11 +1520,7 @@ private fun TimelineSectionHeader( ) { Text( text = localizedSectionTitle(section), - color = if (useMinimalStyle) { - colorScheme.onSurfaceVariant.copy(alpha = 0.62f) - } else { - colorScheme.onSurfaceVariant - }, + color = headerTextColor, style = if (useMinimalStyle) { MaterialTheme.typography.headlineSmall } else { @@ -1512,7 +1536,7 @@ private fun TimelineSectionHeader( } else { stringResource(R.string.action_collapse_section) }, - tint = colorScheme.onSurfaceVariant.copy(alpha = if (useMinimalStyle) 0.72f else 1f), + tint = chevronColor, modifier = Modifier .padding(start = 6.dp) .size(18.dp) @@ -1525,6 +1549,40 @@ private fun TimelineSectionHeader( } } +@Composable +private fun TimelineSectionBodyVisibility( + visible: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + AnimatedVisibility( + visible = visible, + modifier = modifier.fillMaxWidth(), + enter = expandVertically( + expandFrom = Alignment.Top, + animationSpec = tween(durationMillis = 240, easing = FastOutSlowInEasing), + ) + fadeIn( + animationSpec = tween( + durationMillis = 150, + delayMillis = 90, + easing = FastOutSlowInEasing + ), + ), + exit = fadeOut( + animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing), + ) + shrinkVertically( + shrinkTowards = Alignment.Top, + animationSpec = tween( + durationMillis = 240, + delayMillis = 45, + easing = FastOutSlowInEasing + ), + ), + ) { + content() + } +} + @Composable private fun TimelineSectionBody( modifier: Modifier = Modifier, diff --git a/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift b/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift index 6029789d..14cd5e49 100644 --- a/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift +++ b/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift @@ -11,6 +11,7 @@ enum AppRoute: Hashable { case completed case calendar case settings + case latestRelease var deepLinkPath: String { switch self { @@ -37,6 +38,8 @@ enum AppRoute: Hashable { return "calendar" case .settings: return "settings" + case .latestRelease: + return "latest-release" } } @@ -62,6 +65,8 @@ enum AppRoute: Hashable { return .calendar case "settings": return .settings + case "latest-release": + return .latestRelease case "todos": let second = components.dropFirst().first ?? "" switch second { diff --git a/ios-swiftUI/Tday/Feature/App/AppRootView.swift b/ios-swiftUI/Tday/Feature/App/AppRootView.swift index b8853313..f8ffe13d 100644 --- a/ios-swiftUI/Tday/Feature/App/AppRootView.swift +++ b/ios-swiftUI/Tday/Feature/App/AppRootView.swift @@ -60,6 +60,8 @@ struct AppRootView: View { CalendarScreen(container: container) case .settings: SettingsScreen(viewModel: appViewModel) + case .latestRelease: + LatestReleaseScreen(viewModel: appViewModel) } } .overlay(alignment: .top) { diff --git a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift index 6e126709..56f085c6 100644 --- a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift +++ b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift @@ -1,6 +1,28 @@ import Foundation import Observation +struct GitHubRelease: Codable, Equatable, Identifiable { + let tagName: String + let name: String? + let body: String? + let publishedAt: String? + let htmlUrl: String + + var id: String { tagName } + + var version: String { + tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName + } + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case name + case body + case publishedAt = "published_at" + case htmlUrl = "html_url" + } +} + @MainActor @Observable final class AppViewModel { @@ -28,6 +50,10 @@ final class AppViewModel { var pendingMutationCount = 0 var navigationPath: [AppRoute] = [] var latestVersionName: String? + var latestRelease: GitHubRelease? + var currentRelease: GitHubRelease? + var isReleaseLoading = false + var releaseError: String? var versionCheckResult: VersionCheckResult = .compatible var backendVersion: String? @@ -110,7 +136,7 @@ final class AppViewModel { startSyncLoop() await container.reminderScheduler.requestAuthorization() await rescheduleReminders() - await checkForUpdate() + await refreshVersionInfo() return } @@ -432,17 +458,46 @@ final class AppViewModel { await container.reminderScheduler.reschedule(tasks: tasks, defaultReminder: selectedReminder) } + func refreshVersionInfo() async { + await recheckVersion() + await refreshGitHubReleases() + } + func checkForUpdate() async { - guard let url = URL(string: "https://api.github.com/repos/ohmzi/Tday/releases/latest") else { return } + await refreshGitHubReleases() + } + + private func refreshGitHubReleases() async { + isReleaseLoading = latestRelease == nil && currentRelease == nil + releaseError = nil + async let latestResult = fetchGitHubRelease(urlString: "https://api.github.com/repos/ohmzi/Tday/releases/latest") + async let currentResult = fetchGitHubRelease(urlString: "https://api.github.com/repos/ohmzi/Tday/releases/tags/v\(currentVersionName)") + let latest = await latestResult + let current = await currentResult + + latestRelease = latest.release + currentRelease = current.release + latestVersionName = latest.release?.version + releaseError = latest.error?.localizedDescription + isReleaseLoading = false + } + + private func fetchGitHubRelease(urlString: String) async -> (release: GitHubRelease?, error: Error?) { + guard let url = URL(string: urlString) else { + return (nil, nil) + } var request = URLRequest(url: url) request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") do { - let (data, _) = try await URLSession.shared.data(for: request) - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let tagName = json["tag_name"] as? String { - latestVersionName = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName + let (data, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) { + return (nil, nil) } - } catch {} + return (try JSONDecoder().decode(GitHubRelease.self, from: data), nil) + } catch { + return (nil, error) + } } nonisolated static func compareVersions(_ a: String, _ b: String) -> Int { diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index 8934044e..60b40c6e 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -13,6 +13,8 @@ struct CompletedScreen: View { @State private var editingItem: CompletedItem? @State private var timelineScrollOffset: CGFloat = 0 @State private var collapsedSectionIDs: Set = [] + @State private var collapsingSectionIDs: Set = [] + @State private var revealingSectionIDs: Set = [] @State private var restorePhases: [String: CompletedRestorePhase] = [:] init(container: AppContainer) { @@ -62,6 +64,8 @@ struct CompletedScreen: View { .toolbar(.hidden, for: .navigationBar) .onChange(of: viewModel.items) { pruneRestorePhases() + collapsingSectionIDs = [] + revealingSectionIDs = [] } .safeAreaInset(edge: .top, spacing: 0) { TimelineTopBar( @@ -148,16 +152,22 @@ struct CompletedScreen: View { @ViewBuilder private func completedTimelineSection(_ section: TimelineSection, isFirstSection: Bool) -> some View { let isCollapsed = collapsedSectionIDs.contains(section.id) + let shouldRenderBody = shouldRenderCompletedSectionBody(section) + let bodyOpacity = completedSectionBodyOpacity(section) Section { - if !isCollapsed { + if shouldRenderBody { ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, item in completedTimelineRow(item) .padding(.top, firstPinnedRowElasticTopInset(isFirstVisibleExpandedSection: section.id == firstVisibleExpandedCompletedSectionID, itemIndex: itemIndex)) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) + .opacity(bodyOpacity) + .animation(.easeOut(duration: 0.14), value: bodyOpacity) TimelineRowDivider() + .opacity(bodyOpacity) + .animation(.easeOut(duration: 0.14), value: bodyOpacity) } } } header: { @@ -167,11 +177,7 @@ struct CompletedScreen: View { isCollapsible: true, isCollapsed: isCollapsed, onTap: { - if isCollapsed { - collapsedSectionIDs.remove(section.id) - } else { - collapsedSectionIDs.insert(section.id) - } + toggleCompletedSection(section) } ) .listRowInsets( @@ -200,6 +206,49 @@ struct CompletedScreen: View { return TodoTimelineMetrics.firstPinnedRowElasticClearance * elasticProgress } + private func shouldRenderCompletedSectionBody(_ section: TimelineSection) -> Bool { + !section.items.isEmpty && + (!collapsedSectionIDs.contains(section.id) || + collapsingSectionIDs.contains(section.id) || + revealingSectionIDs.contains(section.id)) + } + + private func completedSectionBodyOpacity(_ section: TimelineSection) -> Double { + if revealingSectionIDs.contains(section.id) || collapsedSectionIDs.contains(section.id) { + return 0 + } + return 1 + } + + private func toggleCompletedSection(_ section: TimelineSection) { + let id = section.id + if collapsedSectionIDs.contains(id) { + withAnimation(.easeInOut(duration: 0.24)) { + collapsedSectionIDs.remove(id) + collapsingSectionIDs.remove(id) + revealingSectionIDs.insert(id) + } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 90_000_000) + withAnimation(.easeOut(duration: 0.16)) { + revealingSectionIDs.remove(id) + } + } + } else { + withAnimation(.easeOut(duration: 0.12)) { + revealingSectionIDs.remove(id) + collapsingSectionIDs.insert(id) + collapsedSectionIDs.insert(id) + } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 140_000_000) + withAnimation(.easeInOut(duration: 0.24)) { + collapsingSectionIDs.remove(id) + } + } + } + } + private func completedTimelineRow(_ item: CompletedItem) -> some View { let completedDate = item.completedAt ?? item.due let showListIndicator = item.listName?.isEmpty == false diff --git a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift index 216469d7..7360d185 100644 --- a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift +++ b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift @@ -2,44 +2,301 @@ import SwiftUI struct SettingsScreen: View { let viewModel: AppViewModel + + @Environment(\.dismiss) private var dismiss @Environment(\.tdayColors) private var colors + private var isAdminUser: Bool { + (viewModel.user?.role ?? "").uppercased() == "ADMIN" + } + var body: some View { - Form { - Section { - VStack(alignment: .leading, spacing: 6) { - Text(viewModel.user?.name ?? "Unknown user") - .font(.tdayRounded(.headline, weight: .bold)) - Text(viewModel.user?.email ?? "") - .font(.tdayRounded(.subheadline, weight: .bold)) - .foregroundStyle(colors.onSurfaceVariant) - Text("Role: \(viewModel.user?.role ?? "USER")") - .font(.tdayRounded(.caption, weight: .bold)) - .foregroundStyle(colors.onSurfaceVariant) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 12) { + SettingsPageHeader(title: "Settings") { + dismiss() } - .padding(.vertical, 4) - } - Section("Appearance") { - Picker("Theme", selection: Binding(get: { viewModel.themeMode }, set: { viewModel.setThemeMode($0) })) { - ForEach(AppThemeMode.allCases, id: \.self) { mode in - Text(mode.label).tag(mode) + SettingsProfileCard(user: viewModel.user) + + SettingsSectionCard { + SettingsSectionTitle("Appearance") + SettingsThemeSelector( + selectedMode: viewModel.themeMode, + onSelect: viewModel.setThemeMode + ) + SettingsDivider() + SettingsSectionTitle("Reminders") + SettingsReminderSelector( + selectedReminder: viewModel.selectedReminder, + onSelect: viewModel.setDefaultReminder + ) + } + + if isAdminUser { + SettingsSectionCard { + SettingsSectionTitle("Feature toggle") + SettingsAiSummaryRow(viewModel: viewModel) + } + } + + SettingsSectionCard { + SettingsListRow( + title: "App Version", + value: "v\(viewModel.currentVersionName)", + action: { + viewModel.navigationPath.append(.latestRelease) + } + ) + + if viewModel.hasUpdate, let latestVersionName = viewModel.latestVersionName { + Text("v\(latestVersionName) available") + .font(.tdayRounded(size: 11, weight: .heavy)) + .foregroundStyle(colors.primary) + } + + if let backendVersion = viewModel.backendVersion { + SettingsServerVersionRow( + backendVersion: backendVersion, + versionCheckResult: viewModel.versionCheckResult + ) + } + + SettingsDivider() + + SettingsListRow( + title: "Sign out", + value: nil, + titleColor: colors.error, + showChevron: false, + action: { + Task { await viewModel.logout() } + } + ) + } + + Spacer(minLength: 24) + } + .padding(.horizontal, 18) + .padding(.bottom, 24) + } + .background(colors.background.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .navigationBackButtonBehavior() + .task { + await viewModel.refreshAdminAiSummarySetting() + await viewModel.refreshVersionInfo() + } + .alert( + "AI Summary Unavailable", + isPresented: Binding( + get: { viewModel.aiSummaryValidationError != nil }, + set: { visible in + if !visible { + viewModel.dismissAiSummaryValidationError() } } + ) + ) { + Button("OK", role: .cancel) { + viewModel.dismissAiSummaryValidationError() + } + } message: { + Text(viewModel.aiSummaryValidationError ?? "") + } + } +} + +struct SettingsPageHeader: View { + let title: String + let onBack: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: TodoTimelineMetrics.topBarButtonIconSize, weight: .semibold)) + .foregroundStyle(colors.onSurface) + .frame( + width: TodoTimelineMetrics.topBarButtonFrame, + height: TodoTimelineMetrics.topBarButtonFrame + ) + .background(colors.surface, in: Circle()) + .contentShape(Circle()) + } + .buttonStyle(SettingsBackButtonStyle()) + .accessibilityLabel("Back") + + Text(title) + .font(.tdayRounded(size: 32, weight: .heavy)) + .foregroundStyle(colors.onSurface) + } + .padding(.top, 6) + } +} + +private struct SettingsBackButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .tdayRippleEffect(isPressed: configuration.isPressed) + .scaleEffect(configuration.isPressed ? 0.95 : 1) + .offset(y: configuration.isPressed ? 1 : 0) + .shadow( + color: Color.black.opacity(configuration.isPressed ? 0.04 : 0.08), + radius: configuration.isPressed ? 3 : 7, + x: 0, + y: configuration.isPressed ? 1 : 3 + ) + .animation(.easeOut(duration: 0.14), value: configuration.isPressed) + } +} + +private struct SettingsProfileCard: View { + let user: SessionUser? + + @Environment(\.tdayColors) private var colors + + var body: some View { + SettingsSectionCard { + Text(user?.name ?? "Unknown user") + .font(.tdayRounded(size: 22, weight: .heavy)) + .foregroundStyle(colors.onSurface) + + if let email = user?.email, !email.isEmpty { + Text(email) + .font(.tdayRounded(size: 15, weight: .bold)) + .foregroundStyle(colors.onSurface.opacity(0.72)) } - Section("Reminders") { - Picker("Default reminder", selection: Binding(get: { viewModel.selectedReminder }, set: { viewModel.setDefaultReminder($0) })) { - ForEach(ReminderOption.allCases) { option in - Text(option.label).tag(option) + Text("Role: \(user?.role ?? "USER")") + .font(.tdayRounded(size: 13, weight: .bold)) + .foregroundStyle(colors.onSurface.opacity(0.58)) + } + } +} + +private struct SettingsSectionCard: View { + @ViewBuilder let content: Content + + @Environment(\.tdayColors) private var colors + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.vertical, 18) + .background(colors.surface, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(colors.onSurface.opacity(0.05), lineWidth: 1) + } + } +} + +private struct SettingsSectionTitle: View { + let title: String + + @Environment(\.tdayColors) private var colors + + init(_ title: String) { + self.title = title + } + + var body: some View { + Text(title) + .font(.tdayRounded(size: 22, weight: .heavy)) + .foregroundStyle(colors.onSurface) + } +} + +private struct SettingsThemeSelector: View { + let selectedMode: AppThemeMode + let onSelect: (AppThemeMode) -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + HStack(spacing: 4) { + ForEach(AppThemeMode.allCases, id: \.self) { mode in + let selected = selectedMode == mode + Button { + onSelect(mode) + } label: { + Text(mode.label) + .font(.tdayRounded(size: 13, weight: .heavy)) + .foregroundStyle(selected ? colors.onSurface : colors.onSurface.opacity(0.58)) + .frame(maxWidth: .infinity, minHeight: 44) + .background( + selected ? colors.surface : Color.clear, + in: RoundedRectangle(cornerRadius: 14, style: .continuous) + ) + } + .buttonStyle(.plain) + } + } + .padding(4) + .background( + colors.surfaceVariant.opacity(0.76), + in: RoundedRectangle(cornerRadius: 18, style: .continuous) + ) + } +} + +private struct SettingsReminderSelector: View { + let selectedReminder: ReminderOption + let onSelect: (ReminderOption) -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + Menu { + ForEach(ReminderOption.allCases) { option in + Button { + onSelect(option) + } label: { + if option == selectedReminder { + Label(option.label, systemImage: "checkmark") + } else { + Text(option.label) } } } + } label: { + SettingsRowLabel( + title: "Default reminder", + value: selectedReminder.label, + valueColor: colors.primary, + showChevron: true + ) + } + .buttonStyle(.plain) + } +} + +private struct SettingsAiSummaryRow: View { + let viewModel: AppViewModel + + @Environment(\.tdayColors) private var colors - if viewModel.user?.role?.uppercased() == "ADMIN" { - Section("Admin") { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("AI task summary") + .font(.tdayRounded(size: 17, weight: .heavy)) + .foregroundStyle(colors.onSurface) + + Spacer() + + if viewModel.adminAiSummaryEnabled == nil { + ProgressView() + .controlSize(.small) + } else { Toggle( - "AI task summary", + "", isOn: Binding( get: { viewModel.adminAiSummaryEnabled ?? false }, set: { newValue in @@ -47,70 +304,548 @@ struct SettingsScreen: View { } ) ) + .labelsHidden() .disabled(viewModel.isAdminAiSummaryLoading || viewModel.isAdminAiSummarySaving) - - if let adminAiSummaryError = viewModel.adminAiSummaryError { - Text(adminAiSummaryError) - .font(.tdayRounded(.footnote, weight: .bold)) - .foregroundStyle(colors.error) - } } } - Section { - HStack { - Text("Version") - Spacer() - Text("v\(viewModel.currentVersionName)") - .foregroundStyle(colors.onSurfaceVariant) - } - if viewModel.hasUpdate, let latest = viewModel.latestVersionName { - Text("v\(latest) available") - .font(.tdayRounded(.footnote, weight: .bold)) - .foregroundStyle(colors.primary) + if viewModel.isAdminAiSummarySaving { + Text("Saving admin setting...") + .font(.tdayRounded(size: 12, weight: .bold)) + .foregroundStyle(colors.onSurface.opacity(0.58)) + } + + if let error = viewModel.adminAiSummaryError, + viewModel.adminAiSummaryEnabled == true { + Text(error) + .font(.tdayRounded(size: 12, weight: .bold)) + .foregroundStyle(colors.error) + } + } + } +} + +private struct SettingsListRow: View { + let title: String + let value: String? + var titleColor: Color? + var showChevron = true + let action: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + Button(action: action) { + SettingsRowLabel( + title: title, + value: value, + titleColor: titleColor ?? colors.onSurface, + valueColor: colors.onSurface.opacity(0.58), + showChevron: showChevron + ) + } + .buttonStyle(.plain) + } +} + +private struct SettingsServerVersionRow: View { + let backendVersion: String + let versionCheckResult: VersionCheckResult + + @Environment(\.tdayColors) private var colors + + var body: some View { + HStack { + Text("Server") + .font(.tdayRounded(size: 17, weight: .heavy)) + .foregroundStyle(colors.onSurface) + + Spacer(minLength: 12) + + Text("v\(backendVersion)") + .font(.tdayRounded(size: 13, weight: .bold)) + .foregroundStyle(colors.onSurface.opacity(0.58)) + + Text(versionCheckResult == .compatible ? "Compatible" : "Incompatible") + .font(.tdayRounded(size: 11, weight: .heavy)) + .foregroundStyle(versionCheckResult == .compatible ? Color.green : colors.error) + } + .frame(minHeight: 28) + } +} + +private struct SettingsRowLabel: View { + let title: String + let value: String? + var titleColor: Color? + var valueColor: Color? + var showChevron: Bool + + @Environment(\.tdayColors) private var colors + + init( + title: String, + value: String?, + titleColor: Color? = nil, + valueColor: Color? = nil, + showChevron: Bool = true + ) { + self.title = title + self.value = value + self.titleColor = titleColor + self.valueColor = valueColor + self.showChevron = showChevron + } + + var body: some View { + HStack { + Text(title) + .font(.tdayRounded(size: 17, weight: .heavy)) + .foregroundStyle(titleColor ?? colors.onSurface) + + Spacer(minLength: 12) + + if let value { + Text(value) + .font(.tdayRounded(size: 13, weight: .heavy)) + .foregroundStyle(valueColor ?? colors.onSurface.opacity(0.58)) + .lineLimit(1) + .minimumScaleFactor(0.78) + } + + if showChevron { + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .heavy)) + .foregroundStyle(colors.onSurface.opacity(0.42)) + } + } + .frame(maxWidth: .infinity, minHeight: 28, alignment: .center) + .contentShape(Rectangle()) + } +} + +private struct SettingsDivider: View { + @Environment(\.tdayColors) private var colors + + var body: some View { + Rectangle() + .fill(colors.onSurface.opacity(0.06)) + .frame(height: 1) + } +} + +struct LatestReleaseScreen: View { + let viewModel: AppViewModel + + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @Environment(\.tdayColors) private var colors + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 12) { + SettingsPageHeader(title: "App Version") { + dismiss() } - if let backendVersion = viewModel.backendVersion { + + if viewModel.isReleaseLoading && viewModel.currentRelease == nil && viewModel.latestRelease == nil { HStack { - Text("Server") Spacer() - HStack(spacing: 8) { - Text("v\(backendVersion)") - .foregroundStyle(colors.onSurfaceVariant) - let isCompatible = viewModel.versionCheckResult == .compatible - Text(isCompatible ? "Compatible" : "Incompatible") - .font(.tdayRounded(.caption, weight: .bold)) - .foregroundStyle(isCompatible ? Color.green : colors.error) + ProgressView() + .controlSize(.large) + .padding(.top, 48) + Spacer() + } + } else { + if viewModel.releaseError != nil && + viewModel.currentRelease == nil && + viewModel.latestRelease == nil { + ReleaseErrorCard { + Task { await viewModel.refreshVersionInfo() } } } - } - } - Section { - Button(role: .destructive) { - Task { await viewModel.logout() } - } label: { - Text("Sign Out") + ReleaseOverviewCard(viewModel: viewModel) + + if viewModel.hasUpdate, let latestRelease = viewModel.latestRelease { + UpdateAvailableCard(release: latestRelease) { + if let url = URL(string: latestRelease.htmlUrl) { + openURL(url) + } + } + } + + if !viewModel.hasUpdate { + InstalledVersionCard( + currentVersion: viewModel.currentVersionName, + currentRelease: viewModel.currentRelease + ) + } + + if let browseUrl = viewModel.latestRelease?.htmlUrl ?? viewModel.currentRelease?.htmlUrl, + let url = URL(string: browseUrl) { + ReleaseBrowserButton { + openURL(url) + } + } } + + Spacer(minLength: 24) } + .padding(.horizontal, 18) + .padding(.bottom, 24) } - .scrollContentBackground(.hidden) - .background(colors.background) + .background(colors.background.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) .navigationBackButtonBehavior() - .navigationTitle("Settings") - .navigationBarTitleDisplayMode(.inline) .task { - await viewModel.refreshAdminAiSummarySetting() + await viewModel.refreshVersionInfo() } - .alert("Validation Error", isPresented: Binding(get: { viewModel.aiSummaryValidationError != nil }, set: { visible in - if !visible { - viewModel.dismissAiSummaryValidationError() + } +} + +private struct ReleaseErrorCard: View { + let onRetry: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + ReleaseSurfaceCard(borderColor: colors.error.opacity(0.16)) { + Text("Unable to fetch release information. Please check your connection and try again.") + .font(.tdayRounded(size: 15, weight: .bold)) + .foregroundStyle(colors.error) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + Button("Retry", action: onRetry) + .font(.tdayRounded(size: 15, weight: .heavy)) + .frame(maxWidth: .infinity) + } + } +} + +private struct ReleaseOverviewCard: View { + let viewModel: AppViewModel + + @Environment(\.tdayColors) private var colors + + private var isIncompatible: Bool { + viewModel.versionCheckResult != .compatible + } + + private var accent: Color { + if isIncompatible { return colors.error } + if viewModel.hasUpdate { return colors.primary } + return colors.onSurface + } + + private var title: String { + if isIncompatible { return "Version Mismatch" } + if viewModel.hasUpdate { return "Update Available" } + return "Latest" + } + + private var summary: String { + switch viewModel.versionCheckResult { + case let .appUpdateRequired(requiredVersion): + return "The server requires v\(requiredVersion). Update the app to continue." + case let .serverUpdateRequired(serverVersion): + return "This app requires the server to be on v\(viewModel.currentVersionName), but the server is on v\(serverVersion)." + case .compatible: + if viewModel.hasUpdate { + if let latestTag = viewModel.latestRelease?.tagName { + return "Version \(latestTag) is ready to install." + } + return "A newer version is ready to install." } - })) { - Button("OK", role: .cancel) { - viewModel.dismissAiSummaryValidationError() + return "You're running the latest version" + } + } + + var body: some View { + ReleaseSurfaceCard(borderColor: accent.opacity(isIncompatible || viewModel.hasUpdate ? 0.12 : 0.05)) { + ReleaseSectionTitle(title, color: accent) + + Text(summary) + .font(.tdayRounded(size: 15, weight: .bold)) + .foregroundStyle(colors.onSurface.opacity(0.62)) + + ReleasePublishedDate( + publishedAt: viewModel.latestRelease?.publishedAt ?? viewModel.currentRelease?.publishedAt + ) + + ReleaseVersionLine( + label: viewModel.hasUpdate ? "Installed" : "Installed Version", + version: "v\(viewModel.currentVersionName)", + tint: colors.primary + ) + + if let backendVersion = viewModel.backendVersion { + ReleaseVersionLine( + label: "Server", + version: "v\(backendVersion)", + tint: viewModel.versionCheckResult == .compatible ? Color.green : colors.error + ) } - } message: { - Text(viewModel.aiSummaryValidationError ?? "") + + if viewModel.hasUpdate, let latestRelease = viewModel.latestRelease { + ReleaseVersionLine( + label: "Latest", + version: latestRelease.tagName, + tint: colors.tertiary + ) + } + } + } +} + +private struct InstalledVersionCard: View { + let currentVersion: String + let currentRelease: GitHubRelease? + + @Environment(\.tdayColors) private var colors + + var body: some View { + ReleaseSurfaceCard { + ReleaseSectionTitle("Installed Version") + + HStack(spacing: 10) { + ReleaseVersionBadge(text: "v\(currentVersion)") + Text("Latest") + .font(.tdayRounded(size: 13, weight: .heavy)) + .foregroundStyle(colors.onSurface.opacity(0.6)) + } + + ReleasePublishedDate(publishedAt: currentRelease?.publishedAt) + + ReleaseNotesSection( + versionLabel: "v\(currentVersion)", + changelog: parseChangelog(currentRelease?.body), + emptyMessage: currentRelease == nil ? "No release notes available for this version" : nil + ) + } + } +} + +private struct UpdateAvailableCard: View { + let release: GitHubRelease + let onOpen: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + ReleaseSurfaceCard(borderColor: colors.primary.opacity(0.12)) { + ReleaseSectionTitle("Update Available", color: colors.primary) + ReleaseVersionBadge(text: release.tagName) + ReleasePublishedDate(publishedAt: release.publishedAt) + ReleaseNotesSection( + versionLabel: release.tagName, + changelog: parseChangelog(release.body), + emptyMessage: nil + ) + + Button(action: onOpen) { + HStack { + Image(systemName: "arrow.up.forward.square") + Text("Open GitHub release") + } + .font(.tdayRounded(size: 15, weight: .heavy)) + .foregroundStyle(colors.onPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(colors.primary, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + .buttonStyle(.plain) + } + } +} + +private struct ReleaseSurfaceCard: View { + var borderColor: Color? + @ViewBuilder let content: Content + + @Environment(\.tdayColors) private var colors + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.vertical, 18) + .background(colors.surface, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(borderColor ?? colors.onSurface.opacity(0.08), lineWidth: 1) + } + } +} + +private struct ReleaseSectionTitle: View { + let title: String + var color: Color? + + @Environment(\.tdayColors) private var colors + + init(_ title: String, color: Color? = nil) { + self.title = title + self.color = color + } + + var body: some View { + Text(title) + .font(.tdayRounded(size: 22, weight: .heavy)) + .foregroundStyle(color ?? colors.onSurface) + } +} + +private struct ReleaseVersionLine: View { + let label: String + let version: String + let tint: Color + + @Environment(\.tdayColors) private var colors + + var body: some View { + HStack(spacing: 10) { + Text(label) + .font(.tdayRounded(size: 13, weight: .heavy)) + .foregroundStyle(colors.onSurface.opacity(0.58)) + ReleaseVersionBadge(text: version, tint: tint) } } } + +private struct ReleaseVersionBadge: View { + let text: String + var tint: Color? + + @Environment(\.tdayColors) private var colors + + var body: some View { + let accent = tint ?? colors.primary + + Text(text) + .font(.tdayRounded(size: 13, weight: .heavy)) + .foregroundStyle(accent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} + +private struct ReleasePublishedDate: View { + let publishedAt: String? + + @Environment(\.tdayColors) private var colors + + var body: some View { + if let publishedAt { + Text("Published \(formatIsoDate(publishedAt))") + .font(.tdayRounded(size: 13, weight: .bold)) + .foregroundStyle(colors.onSurface.opacity(0.62)) + } + } +} + +private struct ReleaseNotesSection: View { + let versionLabel: String + let changelog: [String] + let emptyMessage: String? + + @Environment(\.tdayColors) private var colors + + var body: some View { + if !changelog.isEmpty { + Text("What's new in \(versionLabel)") + .font(.tdayRounded(size: 17, weight: .heavy)) + .foregroundStyle(colors.onSurface) + + VStack(alignment: .leading, spacing: 10) { + ForEach(changelog, id: \.self) { item in + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(colors.onSurface.opacity(0.3)) + .frame(width: 5, height: 5) + .padding(.top, 8) + Text(item) + .font(.tdayRounded(size: 15, weight: .bold)) + .foregroundStyle(colors.onSurface) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(colors.surfaceVariant.opacity(0.6), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } else if let emptyMessage { + Text(emptyMessage) + .font(.tdayRounded(size: 15, weight: .bold)) + .foregroundStyle(colors.onSurface.opacity(0.6)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(colors.surfaceVariant.opacity(0.6), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + } +} + +private struct ReleaseBrowserButton: View { + let onOpen: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 12) { + Image(systemName: "arrow.up.forward.square") + .font(.system(size: 18, weight: .heavy)) + .foregroundStyle(colors.primary) + + Text("View on GitHub") + .font(.tdayRounded(size: 17, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "arrow.up.forward.square") + .font(.system(size: 18, weight: .heavy)) + .foregroundStyle(colors.primary) + } + .padding(.horizontal, 18) + .padding(.vertical, 15) + .background(colors.surface, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(colors.onSurface.opacity(0.06), lineWidth: 1) + } + } + .buttonStyle(.plain) + } +} + +private func parseChangelog(_ body: String?) -> [String] { + guard let body else { return [] } + return body + .components(separatedBy: .newlines) + .map { line -> String in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") { + return String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespacesAndNewlines) + } + return "" + } + .filter { !$0.isEmpty } +} + +private func formatIsoDate(_ value: String) -> String { + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = parser.date(from: value) ?? { + let fallback = ISO8601DateFormatter() + fallback.formatOptions = [.withInternetDateTime] + return fallback.date(from: value) + }() + + guard let date else { return value } + return date.formatted(.dateTime.month(.wide).day().year()) +} diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index db28d521..5112f271 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -108,6 +108,8 @@ struct TodoListScreen: View { @State private var draggedTodo: TodoItem? @State private var activeDropSectionId: String? @State private var collapsedSectionIDs: Set + @State private var collapsingSectionIDs: Set = [] + @State private var revealingSectionIDs: Set = [] @State private var timelineScrollOffset: CGFloat = 0 @State private var completingTodoIDs: Set = [] @@ -360,6 +362,8 @@ struct TodoListScreen: View { private func handleItemsChanged() { activeDropSectionId = nil draggedTodo = nil + collapsingSectionIDs = [] + revealingSectionIDs = [] if viewModel.mode == .all, highlightedTodoId != nil { collapsedSectionIDs = [] } @@ -749,16 +753,22 @@ struct TodoListScreen: View { private func minimalTimelineSection(_ section: TodoTimelineSection, isFirstSection: Bool) -> some View { let canCollapseSection = canCollapseTimelineSection(section) let isCollapsed = canCollapseSection && collapsedSectionIDs.contains(section.id) + let shouldRenderBody = shouldRenderTimelineSectionBody(section) + let bodyOpacity = timelineSectionBodyOpacity(section) Section { - if !isCollapsed { + if shouldRenderBody { ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section) .padding(.top, firstPinnedRowElasticTopInset(section: section, isFirstVisibleExpandedSection: section.id == firstVisibleExpandedTimelineSectionID, itemIndex: itemIndex)) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) + .opacity(bodyOpacity) + .animation(.easeOut(duration: 0.14), value: bodyOpacity) TimelineRowDivider() + .opacity(bodyOpacity) + .animation(.easeOut(duration: 0.14), value: bodyOpacity) } } } header: { @@ -768,11 +778,7 @@ struct TodoListScreen: View { isCollapsible: canCollapseSection, isCollapsed: isCollapsed, onTap: canCollapseSection ? { - if isCollapsed { - collapsedSectionIDs.remove(section.id) - } else { - collapsedSectionIDs.insert(section.id) - } + toggleTimelineSection(section) } : nil ) .listRowInsets( @@ -835,6 +841,49 @@ struct TodoListScreen: View { canCollapseTimelineSection(section) && collapsedSectionIDs.contains(section.id) } + private func shouldRenderTimelineSectionBody(_ section: TodoTimelineSection) -> Bool { + !section.items.isEmpty && + (!isTimelineSectionCollapsed(section) || + collapsingSectionIDs.contains(section.id) || + revealingSectionIDs.contains(section.id)) + } + + private func timelineSectionBodyOpacity(_ section: TodoTimelineSection) -> Double { + if revealingSectionIDs.contains(section.id) || collapsedSectionIDs.contains(section.id) { + return 0 + } + return 1 + } + + private func toggleTimelineSection(_ section: TodoTimelineSection) { + let id = section.id + if collapsedSectionIDs.contains(id) { + withAnimation(.easeInOut(duration: 0.24)) { + collapsedSectionIDs.remove(id) + collapsingSectionIDs.remove(id) + revealingSectionIDs.insert(id) + } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 90_000_000) + withAnimation(.easeOut(duration: 0.16)) { + revealingSectionIDs.remove(id) + } + } + } else { + withAnimation(.easeOut(duration: 0.12)) { + revealingSectionIDs.remove(id) + collapsingSectionIDs.insert(id) + collapsedSectionIDs.insert(id) + } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 140_000_000) + withAnimation(.easeInOut(duration: 0.24)) { + collapsingSectionIDs.remove(id) + } + } + } + } + private func minimalTimelineSubtitle(for todo: TodoItem, in section: TodoTimelineSection) -> String { let timeText = todo.due.formatted(date: .omitted, time: .shortened) let dueBodyText = if viewModel.mode == .priority && section.id == "earlier" { @@ -1231,19 +1280,21 @@ struct TimelineSectionHeader: View { Button(action: onTap) { content } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0, - normalShadowOpacity: 0 - ) - ) + .buttonStyle(TimelineSectionHeaderButtonStyle()) } else { content } } } +private struct TimelineSectionHeaderButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .brightness(configuration.isPressed ? -0.055 : 0) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + struct TimelineEmptyState: View { let message: String From 31fe67bc80e2f20a1d86f80434bc9b0ed755019e Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 11:01:39 -0400 Subject: [PATCH 26/52] Refactor timeline section expansion and collapse logic across Android and iOS clients to improve performance and animation smoothness. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This update simplifies the rendering logic for collapsible list sections by moving away from manual opacity/visibility tracking in favor of native transition and animation systems. - **Android (Compose)**: - Flatten `LazyColumn` structure by replacing nested `AnimatedVisibility` with direct item-level rendering conditional on the collapse state. - Utilize `animateItem()` with custom `fadeInSpec`, `fadeOutSpec`, and `placementSpec` (FastOutSlowInEasing) for smoother row transitions. - Refactor `CompletedTimelineSection` and `TimelineSectionBody` into granular header and row components to reduce recomposition scope. - Animate the bottom spacing of section headers during transitions using `animateDpAsState`. - **iOS (SwiftUI)**: - Remove redundant `@State` properties (`collapsingSectionIDs`, `revealingSectionIDs`) used for manual animation orchestration. - Replace custom opacity logic with `AnyTransition.asymmetric` combining opacity and vertical movement for rows. - Update `toggleTimelineSection` to use a spring animation (`response: 0.28`, `dampingFraction: 0.9`) for a more responsive feel. - Prevent empty sections from being marked as collapsible. - **General**: - Synchronize animation durations across platforms (approx. 200–300ms) for a consistent user experience. - Improve "Quick Add" logic to properly handle section collapse states. --- .../feature/completed/CompletedScreen.kt | 142 ++++------ .../compose/feature/todos/TodoListScreen.kt | 259 +++++++++--------- .../Feature/Completed/CompletedScreen.swift | 62 ++--- .../Tday/Feature/Todos/TodoListScreen.swift | 68 ++--- 4 files changed, 223 insertions(+), 308 deletions(-) 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 aaefe201..1d5a6e5c 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 @@ -1,11 +1,6 @@ package com.ohmz.tday.compose.feature.completed import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -33,7 +28,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -216,27 +210,63 @@ fun CompletedScreen( .nestedScroll(nestedScrollConnection), state = listState, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 2.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), ) { - items(timelineSections, key = { it.key }) { section -> + timelineSections.forEachIndexed { sectionIndex, section -> val isCollapsed = collapsedSectionKeys.contains(section.key) - CompletedTimelineSection( - modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), - section = section, - isCollapsed = isCollapsed, - onHeaderClick = { - collapsedSectionKeys = - if (isCollapsed) { - collapsedSectionKeys - section.key - } else { - collapsedSectionKeys + section.key - } - }, - lists = uiState.lists, - onInfo = { item -> editTargetId = item.id }, - onDelete = onDelete, - onUncomplete = onUncomplete, - ) + item(key = "completed-header-${section.key}") { + CompletedTimelineSectionHeader( + modifier = Modifier + .animateItem( + fadeInSpec = null, + placementSpec = tween( + durationMillis = 320, + easing = FastOutSlowInEasing, + ), + fadeOutSpec = null, + ) + .padding(top = if (sectionIndex == 0) 0.dp else 8.dp), + section = section, + isCollapsed = isCollapsed, + onHeaderClick = { + collapsedSectionKeys = + if (isCollapsed) { + collapsedSectionKeys - section.key + } else { + collapsedSectionKeys + section.key + } + }, + ) + } + if (!isCollapsed) { + section.items.forEachIndexed { itemIndex, completed -> + item(key = "completed-row-${section.key}-${completed.id}") { + CompletedSwipeRow( + modifier = Modifier + .animateItem( + fadeInSpec = tween( + durationMillis = 190, + easing = FastOutSlowInEasing, + ), + placementSpec = tween( + durationMillis = 320, + easing = FastOutSlowInEasing, + ), + fadeOutSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ), + ) + .padding(top = 4.dp), + item = completed, + lists = uiState.lists, + onInfo = { editTargetId = completed.id }, + onDelete = { onDelete(completed) }, + onUncomplete = { onUncomplete(completed) }, + ) + } + } + } } if (uiState.items.isEmpty()) { @@ -408,22 +438,18 @@ private fun CompletedHeaderButton( } @Composable -private fun CompletedTimelineSection( +private fun CompletedTimelineSectionHeader( modifier: Modifier = Modifier, section: CompletedSection, isCollapsed: Boolean, onHeaderClick: () -> Unit, - lists: List, - onInfo: (CompletedItem) -> Unit, - onDelete: (CompletedItem) -> Unit, - onUncomplete: (CompletedItem) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val headerInteractionSource = remember { MutableInteractionSource() } val isHeaderPressed by headerInteractionSource.collectIsPressedAsState() val collapseChevronRotation by animateFloatAsState( targetValue = if (isCollapsed) -90f else 0f, - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), label = "completedSectionChevronRotation", ) val baseHeaderColor = colorScheme.onSurfaceVariant.copy(alpha = 0.62f) @@ -472,62 +498,12 @@ private fun CompletedTimelineSection( .graphicsLayer { rotationZ = collapseChevronRotation }, ) } - - CompletedSectionBodyVisibility( - visible = !isCollapsed && section.items.isNotEmpty(), - modifier = Modifier.padding(top = 4.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - section.items.forEach { item -> - CompletedSwipeRow( - item = item, - lists = lists, - onInfo = { onInfo(item) }, - onDelete = { onDelete(item) }, - onUncomplete = { onUncomplete(item) }, - ) - } - } - } - } -} - -@Composable -private fun CompletedSectionBodyVisibility( - visible: Boolean, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - AnimatedVisibility( - visible = visible, - modifier = modifier.fillMaxWidth(), - enter = expandVertically( - expandFrom = Alignment.Top, - animationSpec = tween(durationMillis = 240, easing = FastOutSlowInEasing), - ) + fadeIn( - animationSpec = tween( - durationMillis = 150, - delayMillis = 90, - easing = FastOutSlowInEasing - ), - ), - exit = fadeOut( - animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing), - ) + shrinkVertically( - shrinkTowards = Alignment.Top, - animationSpec = tween( - durationMillis = 240, - delayMillis = 45, - easing = FastOutSlowInEasing - ), - ), - ) { - content() } } @Composable private fun CompletedSwipeRow( + modifier: Modifier = Modifier, item: CompletedItem, lists: List, onInfo: () -> Unit, @@ -588,7 +564,7 @@ private fun CompletedSwipeRow( val foregroundColor = colorScheme.background Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .graphicsLayer { alpha = rowAlpha 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 9b5975ac..9b69b518 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 @@ -2,7 +2,6 @@ package com.ohmz.tday.compose.feature.todos import android.content.ClipData import android.view.View -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring @@ -10,10 +9,6 @@ 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.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -512,13 +507,15 @@ fun TodoListScreen( if (showSectionedTimeline) { timelineSections.forEach { section -> - val sectionCanCollapse = when (uiState.mode) { + val sectionHasTasks = section.items.isNotEmpty() + val sectionModeCanCollapse = when (uiState.mode) { TodoListMode.ALL -> true TodoListMode.OVERDUE -> true TodoListMode.SCHEDULED -> true TodoListMode.PRIORITY -> section.key == "earlier" else -> false } + val sectionCanCollapse = sectionModeCanCollapse && sectionHasTasks val isCollapsed = sectionCanCollapse && collapsedSectionKeys.contains(section.key) val sectionDraggedTodo = if (uiState.mode == TodoListMode.SCHEDULED) { draggedScheduledTodo @@ -551,15 +548,24 @@ fun TodoListScreen( null } - stickyHeader(key = "timeline-header-${section.key}") { + item(key = "timeline-header-${section.key}") { TimelineSectionHeader( - modifier = Modifier.timelineSectionDropTarget( - section = section, - draggedTodo = sectionDraggedTodo, - onDropTargetChanged = onSectionDropTargetChanged, - onDragTodoEnd = onSectionDragEnd, - onMoveTaskToDate = onMoveTaskToSectionDate, - ), + modifier = Modifier + .animateItem( + fadeInSpec = null, + placementSpec = tween( + durationMillis = 320, + easing = FastOutSlowInEasing, + ), + fadeOutSpec = null, + ) + .timelineSectionDropTarget( + section = section, + draggedTodo = sectionDraggedTodo, + onDropTargetChanged = onSectionDropTargetChanged, + onDragTodoEnd = onSectionDragEnd, + onMoveTaskToDate = onMoveTaskToSectionDate, + ), section = section, useMinimalStyle = usesTodayStyle, isCollapsed = isCollapsed, @@ -582,7 +588,7 @@ fun TodoListScreen( null }, onTapForQuickAdd = section.quickAddDefaults - ?.takeUnless { sectionCanCollapse } + ?.takeUnless { sectionModeCanCollapse } ?.let { dueEpochMs -> { quickAddDueEpochMs = dueEpochMs @@ -592,35 +598,56 @@ fun TodoListScreen( ) } - if (section.items.isNotEmpty()) { - item(key = "timeline-body-${section.key}") { - TimelineSectionBodyVisibility( - visible = !isCollapsed, - modifier = Modifier - .animateItem(fadeInSpec = null, fadeOutSpec = null) - .timelineSectionDropTarget( - section = section, - draggedTodo = sectionDraggedTodo, - onDropTargetChanged = onSectionDropTargetChanged, - onDragTodoEnd = onSectionDragEnd, - onMoveTaskToDate = onMoveTaskToSectionDate, - ), - ) { - TimelineSectionBody( - modifier = Modifier.padding(bottom = timelineItemSpacing), - section = section, + if (!isCollapsed && section.items.isNotEmpty()) { + val showEarlierDateTimeSubtitle = + section.key == "earlier" && + (uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY) + section.items.forEachIndexed { itemIndex, todo -> + item(key = "timeline-todo-${section.key}-${todo.id}") { + TimelineTaskRow( + modifier = Modifier + .animateItem( + fadeInSpec = tween( + durationMillis = 190, + easing = FastOutSlowInEasing, + ), + placementSpec = tween( + durationMillis = 320, + easing = FastOutSlowInEasing, + ), + fadeOutSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ), + ) + .timelineSectionDropTarget( + section = section, + draggedTodo = sectionDraggedTodo, + onDropTargetChanged = onSectionDropTargetChanged, + onDragTodoEnd = onSectionDragEnd, + onMoveTaskToDate = onMoveTaskToSectionDate, + ) + .padding( + bottom = if (itemIndex == section.items.lastIndex) { + timelineItemSpacing + } else { + 8.dp + }, + ), + todo = todo, mode = uiState.mode, lists = uiState.lists, useMinimalStyle = usesTodayStyle, - highlightTodoId = flashTodoId, - onComplete = onComplete, - onDelete = onDelete, - onInfo = { todo -> + flashHighlight = flashTodoId == todo.id || flashTodoId == todo.canonicalId, + showEarlierDateTimeSubtitle = showEarlierDateTimeSubtitle, + onComplete = { onComplete(todo) }, + onDelete = { onDelete(todo) }, + onInfo = { editTargetTodoId = todo.id }, draggedTodo = sectionDraggedTodo, onDragTodoStart = if (uiState.mode == TodoListMode.SCHEDULED) { - { todo -> + { activeDropSectionKey = null draggedScheduledTodoId = todo.id } @@ -1460,9 +1487,14 @@ private fun TimelineSectionHeader( val isHeaderPressed by headerInteractionSource.collectIsPressedAsState() val collapseChevronRotation by animateFloatAsState( targetValue = if (isCollapsed) -90f else 0f, - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), label = "sectionChevronRotation", ) + val animatedBottomSpacing by animateDpAsState( + targetValue = bottomSpacing, + animationSpec = tween(durationMillis = 240, easing = FastOutSlowInEasing), + label = "sectionBottomSpacing", + ) val baseHeaderColor = if (useMinimalStyle) { colorScheme.onSurfaceVariant.copy(alpha = 0.62f) } else { @@ -1500,7 +1532,7 @@ private fun TimelineSectionHeader( modifier = modifier .fillMaxWidth() .background(colorScheme.background) - .padding(bottom = bottomSpacing), + .padding(bottom = animatedBottomSpacing), ) { Row( modifier = Modifier @@ -1550,109 +1582,70 @@ private fun TimelineSectionHeader( } @Composable -private fun TimelineSectionBodyVisibility( - visible: Boolean, +private fun TimelineTaskRow( modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - AnimatedVisibility( - visible = visible, - modifier = modifier.fillMaxWidth(), - enter = expandVertically( - expandFrom = Alignment.Top, - animationSpec = tween(durationMillis = 240, easing = FastOutSlowInEasing), - ) + fadeIn( - animationSpec = tween( - durationMillis = 150, - delayMillis = 90, - easing = FastOutSlowInEasing - ), - ), - exit = fadeOut( - animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing), - ) + shrinkVertically( - shrinkTowards = Alignment.Top, - animationSpec = tween( - durationMillis = 240, - delayMillis = 45, - easing = FastOutSlowInEasing - ), - ), - ) { - content() - } -} - -@Composable -private fun TimelineSectionBody( - modifier: Modifier = Modifier, - section: TodoSection, + todo: TodoItem, mode: TodoListMode, lists: List, useMinimalStyle: Boolean, - highlightTodoId: String? = null, - onComplete: (TodoItem) -> Unit, - onDelete: (TodoItem) -> Unit, - onInfo: (TodoItem) -> Unit, + flashHighlight: Boolean, + showEarlierDateTimeSubtitle: Boolean, + onComplete: () -> Unit, + onDelete: () -> Unit, + onInfo: () -> Unit, draggedTodo: TodoItem? = null, - onDragTodoStart: ((TodoItem) -> Unit)? = null, + onDragTodoStart: (() -> Unit)? = null, ) { - Column( + Box( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), ) { - val showEarlierDateTimeSubtitle = - section.key == "earlier" && - (mode == TodoListMode.ALL || mode == TodoListMode.PRIORITY) - section.items.forEach { todo -> - if (mode == TodoListMode.ALL) { - AllTaskSwipeRow( - todo = todo, - lists = lists, - flashHighlight = highlightTodoId == todo.id || highlightTodoId == todo.canonicalId, - onComplete = { onComplete(todo) }, - onDelete = { onDelete(todo) }, - onInfo = { onInfo(todo) }, - showDuePrefix = true, - showDueDateInSubtitle = showEarlierDateTimeSubtitle, - ) - } else if ( - useMinimalStyle && - ( + if (mode == TodoListMode.ALL) { + AllTaskSwipeRow( + todo = todo, + lists = lists, + flashHighlight = flashHighlight, + onComplete = onComplete, + onDelete = onDelete, + onInfo = onInfo, + showDuePrefix = true, + showDueDateInSubtitle = showEarlierDateTimeSubtitle, + ) + } else if ( + useMinimalStyle && + ( mode == TodoListMode.TODAY || - mode == TodoListMode.OVERDUE || - mode == TodoListMode.SCHEDULED || - mode == TodoListMode.PRIORITY || - mode == TodoListMode.LIST - ) - ) { - TodayTaskSwipeRow( - todo = todo, - mode = mode, - lists = lists, - flashHighlight = highlightTodoId == todo.id || highlightTodoId == todo.canonicalId, - onComplete = { onComplete(todo) }, - onDelete = { onDelete(todo) }, - onInfo = { onInfo(todo) }, - showDuePrefix = true, - showDueDateInSubtitle = showEarlierDateTimeSubtitle, - dragEnabled = onDragTodoStart != null, - dragging = draggedTodo?.id == todo.id, - onDragStart = { onDragTodoStart?.invoke(todo) }, - ) - } else if (useMinimalStyle) { - TodayTodoRow( - todo = todo, - onComplete = { onComplete(todo) }, - onDelete = { onDelete(todo) }, - ) - } else { - TodoRow( - todo = todo, - onComplete = { onComplete(todo) }, - onDelete = { onDelete(todo) }, - ) - } + mode == TodoListMode.OVERDUE || + mode == TodoListMode.SCHEDULED || + mode == TodoListMode.PRIORITY || + mode == TodoListMode.LIST + ) + ) { + TodayTaskSwipeRow( + todo = todo, + mode = mode, + lists = lists, + flashHighlight = flashHighlight, + onComplete = onComplete, + onDelete = onDelete, + onInfo = onInfo, + showDuePrefix = true, + showDueDateInSubtitle = showEarlierDateTimeSubtitle, + dragEnabled = onDragTodoStart != null, + dragging = draggedTodo?.id == todo.id, + onDragStart = { onDragTodoStart?.invoke() }, + ) + } else if (useMinimalStyle) { + TodayTodoRow( + todo = todo, + onComplete = onComplete, + onDelete = onDelete, + ) + } else { + TodoRow( + todo = todo, + onComplete = onComplete, + onDelete = onDelete, + ) } } } diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index 60b40c6e..4ea25611 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -13,8 +13,6 @@ struct CompletedScreen: View { @State private var editingItem: CompletedItem? @State private var timelineScrollOffset: CGFloat = 0 @State private var collapsedSectionIDs: Set = [] - @State private var collapsingSectionIDs: Set = [] - @State private var revealingSectionIDs: Set = [] @State private var restorePhases: [String: CompletedRestorePhase] = [:] init(container: AppContainer) { @@ -64,8 +62,6 @@ struct CompletedScreen: View { .toolbar(.hidden, for: .navigationBar) .onChange(of: viewModel.items) { pruneRestorePhases() - collapsingSectionIDs = [] - revealingSectionIDs = [] } .safeAreaInset(edge: .top, spacing: 0) { TimelineTopBar( @@ -152,22 +148,18 @@ struct CompletedScreen: View { @ViewBuilder private func completedTimelineSection(_ section: TimelineSection, isFirstSection: Bool) -> some View { let isCollapsed = collapsedSectionIDs.contains(section.id) - let shouldRenderBody = shouldRenderCompletedSectionBody(section) - let bodyOpacity = completedSectionBodyOpacity(section) Section { - if shouldRenderBody { + if !isCollapsed { ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, item in completedTimelineRow(item) .padding(.top, firstPinnedRowElasticTopInset(isFirstVisibleExpandedSection: section.id == firstVisibleExpandedCompletedSectionID, itemIndex: itemIndex)) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .opacity(bodyOpacity) - .animation(.easeOut(duration: 0.14), value: bodyOpacity) + .transition(completedRowTransition()) TimelineRowDivider() - .opacity(bodyOpacity) - .animation(.easeOut(duration: 0.14), value: bodyOpacity) + .transition(completedRowTransition()) } } } header: { @@ -206,49 +198,27 @@ struct CompletedScreen: View { return TodoTimelineMetrics.firstPinnedRowElasticClearance * elasticProgress } - private func shouldRenderCompletedSectionBody(_ section: TimelineSection) -> Bool { - !section.items.isEmpty && - (!collapsedSectionIDs.contains(section.id) || - collapsingSectionIDs.contains(section.id) || - revealingSectionIDs.contains(section.id)) - } - - private func completedSectionBodyOpacity(_ section: TimelineSection) -> Double { - if revealingSectionIDs.contains(section.id) || collapsedSectionIDs.contains(section.id) { - return 0 - } - return 1 - } - private func toggleCompletedSection(_ section: TimelineSection) { let id = section.id - if collapsedSectionIDs.contains(id) { - withAnimation(.easeInOut(duration: 0.24)) { + withAnimation(.spring(response: 0.28, dampingFraction: 0.9)) { + if collapsedSectionIDs.contains(id) { collapsedSectionIDs.remove(id) - collapsingSectionIDs.remove(id) - revealingSectionIDs.insert(id) - } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 90_000_000) - withAnimation(.easeOut(duration: 0.16)) { - revealingSectionIDs.remove(id) - } - } - } else { - withAnimation(.easeOut(duration: 0.12)) { - revealingSectionIDs.remove(id) - collapsingSectionIDs.insert(id) + } else { collapsedSectionIDs.insert(id) } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 140_000_000) - withAnimation(.easeInOut(duration: 0.24)) { - collapsingSectionIDs.remove(id) - } - } } } + private func completedRowTransition() -> AnyTransition { + let insertion = AnyTransition.opacity + .combined(with: .move(edge: .top)) + .animation(.easeOut(duration: 0.16)) + let removal = AnyTransition.opacity + .combined(with: .move(edge: .top)) + .animation(.easeOut(duration: 0.1)) + return .asymmetric(insertion: insertion, removal: removal) + } + private func completedTimelineRow(_ item: CompletedItem) -> some View { let completedDate = item.completedAt ?? item.due let showListIndicator = item.listName?.isEmpty == false diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 5112f271..6bce0e4a 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -108,8 +108,6 @@ struct TodoListScreen: View { @State private var draggedTodo: TodoItem? @State private var activeDropSectionId: String? @State private var collapsedSectionIDs: Set - @State private var collapsingSectionIDs: Set = [] - @State private var revealingSectionIDs: Set = [] @State private var timelineScrollOffset: CGFloat = 0 @State private var completingTodoIDs: Set = [] @@ -362,8 +360,6 @@ struct TodoListScreen: View { private func handleItemsChanged() { activeDropSectionId = nil draggedTodo = nil - collapsingSectionIDs = [] - revealingSectionIDs = [] if viewModel.mode == .all, highlightedTodoId != nil { collapsedSectionIDs = [] } @@ -753,22 +749,18 @@ struct TodoListScreen: View { private func minimalTimelineSection(_ section: TodoTimelineSection, isFirstSection: Bool) -> some View { let canCollapseSection = canCollapseTimelineSection(section) let isCollapsed = canCollapseSection && collapsedSectionIDs.contains(section.id) - let shouldRenderBody = shouldRenderTimelineSectionBody(section) - let bodyOpacity = timelineSectionBodyOpacity(section) Section { - if shouldRenderBody { + if !isCollapsed { ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section) .padding(.top, firstPinnedRowElasticTopInset(section: section, isFirstVisibleExpandedSection: section.id == firstVisibleExpandedTimelineSectionID, itemIndex: itemIndex)) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .opacity(bodyOpacity) - .animation(.easeOut(duration: 0.14), value: bodyOpacity) + .transition(timelineRowTransition()) TimelineRowDivider() - .opacity(bodyOpacity) - .animation(.easeOut(duration: 0.14), value: bodyOpacity) + .transition(timelineRowTransition()) } } } header: { @@ -831,9 +823,15 @@ struct TodoListScreen: View { } private func canCollapseTimelineSection(_ section: TodoTimelineSection) -> Bool { + guard !section.items.isEmpty else { + return false + } if viewModel.mode == .all { return true } + if viewModel.mode == .overdue { + return true + } return viewModel.mode == .priority && section.isCollapsible } @@ -841,49 +839,27 @@ struct TodoListScreen: View { canCollapseTimelineSection(section) && collapsedSectionIDs.contains(section.id) } - private func shouldRenderTimelineSectionBody(_ section: TodoTimelineSection) -> Bool { - !section.items.isEmpty && - (!isTimelineSectionCollapsed(section) || - collapsingSectionIDs.contains(section.id) || - revealingSectionIDs.contains(section.id)) - } - - private func timelineSectionBodyOpacity(_ section: TodoTimelineSection) -> Double { - if revealingSectionIDs.contains(section.id) || collapsedSectionIDs.contains(section.id) { - return 0 - } - return 1 - } - private func toggleTimelineSection(_ section: TodoTimelineSection) { let id = section.id - if collapsedSectionIDs.contains(id) { - withAnimation(.easeInOut(duration: 0.24)) { + withAnimation(.spring(response: 0.28, dampingFraction: 0.9)) { + if collapsedSectionIDs.contains(id) { collapsedSectionIDs.remove(id) - collapsingSectionIDs.remove(id) - revealingSectionIDs.insert(id) - } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 90_000_000) - withAnimation(.easeOut(duration: 0.16)) { - revealingSectionIDs.remove(id) - } - } - } else { - withAnimation(.easeOut(duration: 0.12)) { - revealingSectionIDs.remove(id) - collapsingSectionIDs.insert(id) + } else { collapsedSectionIDs.insert(id) } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 140_000_000) - withAnimation(.easeInOut(duration: 0.24)) { - collapsingSectionIDs.remove(id) - } - } } } + private func timelineRowTransition() -> AnyTransition { + let insertion = AnyTransition.opacity + .combined(with: .move(edge: .top)) + .animation(.easeOut(duration: 0.16)) + let removal = AnyTransition.opacity + .combined(with: .move(edge: .top)) + .animation(.easeOut(duration: 0.1)) + return .asymmetric(insertion: insertion, removal: removal) + } + private func minimalTimelineSubtitle(for todo: TodoItem, in section: TodoTimelineSection) -> String { let timeText = todo.due.formatted(date: .omitted, time: .shortened) let dueBodyText = if viewModel.mode == .priority && section.id == "earlier" { From 16813cb990154ec299e879984ffdb2fa0dd0d97d Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 11:03:34 -0400 Subject: [PATCH 27/52] Refactor `TodoListScreen` to remove empty placeholders in the timeline. This change optimizes the task list rendering by removing invisible spacer rows previously used for empty sections. - **UI Refinement**: Remove the `Color.clear` placeholder and its associated layout logic (frames, insets, and separator hiding) when a timeline section contains no items. - **Cleanup**: Simplify the `ForEach` loop within the task list to conditionally render sections only when they contain one or more items. --- ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 6bce0e4a..375ca968 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -439,14 +439,7 @@ struct TodoListScreen: View { ForEach(Array(groupedSections.enumerated()), id: \.element.id) { index, section in Section { - if section.items.isEmpty { - Color.clear - .frame(height: 42) - .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .allowsHitTesting(false) - } else { + if !section.items.isEmpty { ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section) .padding(.top, firstPinnedRowElasticTopInset(section: section, isFirstVisibleExpandedSection: section.id == firstVisibleExpandedTimelineSectionID, itemIndex: itemIndex)) From 558b098a468d2bae3558f45b5ed2c7f5ef6505ee Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 11:08:41 -0400 Subject: [PATCH 28/52] Implement collapsing top bar navigation for Settings and App Version screens. This update replaces the static headers in the Settings and App Version screens with a dynamic, scroll-responsive navigation system. It introduces smooth transitions between expanded hero titles and compact top bars, aligning these screens with the application's established timeline animation patterns. - **Navigation & UI Consistency**: - Replace `SettingsPageHeader` with `TimelineTopBar` and `TimelineExpandedTitleRow` to support title collapse animations. - Implement `TimelineScrollOffsetObserver` in `SettingsScreen` and `AppVersionScreen` to track scroll progress. - Add `onVerticalScrollSnap` logic to handle snapping transitions based on `TodoTimelineMetrics.titleCollapseDistance`. - Configure `safeAreaInset` to host the persistent top bar and hide the default system navigation bar. - **Refactoring**: - Remove the redundant `SettingsPageHeader` struct and `SettingsBackButtonStyle`. - Centralize title collapse progress calculation logic within the screen views using a private `titleCollapseProgress` property. - Standardize back button behavior using the custom `.navigationBackButtonBehavior()` modifier. --- .../Feature/Settings/SettingsScreen.swift | 110 ++++++++++-------- 1 file changed, 61 insertions(+), 49 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift index 7360d185..a819340b 100644 --- a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift +++ b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift @@ -5,17 +5,22 @@ struct SettingsScreen: View { @Environment(\.dismiss) private var dismiss @Environment(\.tdayColors) private var colors + @State private var settingsScrollOffset: CGFloat = 0 private var isAdminUser: Bool { (viewModel.user?.role ?? "").uppercased() == "ADMIN" } + private var titleCollapseProgress: CGFloat { + let distance = TodoTimelineMetrics.titleCollapseDistance + guard distance > 0 else { return 0 } + return min(max(settingsScrollOffset / distance, 0), 1) + } + var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 12) { - SettingsPageHeader(title: "Settings") { - dismiss() - } + settingsHeroTitleRow SettingsProfileCard(user: viewModel.user) @@ -81,8 +86,19 @@ struct SettingsScreen: View { .padding(.bottom, 24) } .background(colors.background.ignoresSafeArea()) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .navigationBar) .navigationBackButtonBehavior() + .safeAreaInset(edge: .top, spacing: 0) { + TimelineTopBar( + title: "Settings", + accentColor: colors.onSurface, + collapseProgress: titleCollapseProgress, + onBack: { dismiss() }, + action: nil + ) + } .task { await viewModel.refreshAdminAiSummarySetting() await viewModel.refreshVersionInfo() @@ -105,51 +121,18 @@ struct SettingsScreen: View { Text(viewModel.aiSummaryValidationError ?? "") } } -} - -struct SettingsPageHeader: View { - let title: String - let onBack: () -> Void - @Environment(\.tdayColors) private var colors - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.system(size: TodoTimelineMetrics.topBarButtonIconSize, weight: .semibold)) - .foregroundStyle(colors.onSurface) - .frame( - width: TodoTimelineMetrics.topBarButtonFrame, - height: TodoTimelineMetrics.topBarButtonFrame - ) - .background(colors.surface, in: Circle()) - .contentShape(Circle()) - } - .buttonStyle(SettingsBackButtonStyle()) - .accessibilityLabel("Back") - - Text(title) - .font(.tdayRounded(size: 32, weight: .heavy)) - .foregroundStyle(colors.onSurface) + private var settingsHeroTitleRow: some View { + TimelineExpandedTitleRow( + title: "Settings", + accentColor: colors.onSurface, + collapseProgress: titleCollapseProgress + ) + .background { + TimelineScrollOffsetObserver { settingsScrollOffset = $0 } + .frame(width: 0, height: 0) } - .padding(.top, 6) - } -} - -private struct SettingsBackButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .tdayRippleEffect(isPressed: configuration.isPressed) - .scaleEffect(configuration.isPressed ? 0.95 : 1) - .offset(y: configuration.isPressed ? 1 : 0) - .shadow( - color: Color.black.opacity(configuration.isPressed ? 0.04 : 0.08), - radius: configuration.isPressed ? 3 : 7, - x: 0, - y: configuration.isPressed ? 1 : 3 - ) - .animation(.easeOut(duration: 0.14), value: configuration.isPressed) + .onVerticalScrollSnap(collapseDistance: TodoTimelineMetrics.titleCollapseDistance) } } @@ -440,13 +423,18 @@ struct LatestReleaseScreen: View { @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL @Environment(\.tdayColors) private var colors + @State private var releaseScrollOffset: CGFloat = 0 + + private var titleCollapseProgress: CGFloat { + let distance = TodoTimelineMetrics.titleCollapseDistance + guard distance > 0 else { return 0 } + return min(max(releaseScrollOffset / distance, 0), 1) + } var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 12) { - SettingsPageHeader(title: "App Version") { - dismiss() - } + releaseHeroTitleRow if viewModel.isReleaseLoading && viewModel.currentRelease == nil && viewModel.latestRelease == nil { HStack { @@ -496,12 +484,36 @@ struct LatestReleaseScreen: View { .padding(.bottom, 24) } .background(colors.background.ignoresSafeArea()) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .navigationBar) .navigationBackButtonBehavior() + .safeAreaInset(edge: .top, spacing: 0) { + TimelineTopBar( + title: "App Version", + accentColor: colors.onSurface, + collapseProgress: titleCollapseProgress, + onBack: { dismiss() }, + action: nil + ) + } .task { await viewModel.refreshVersionInfo() } } + + private var releaseHeroTitleRow: some View { + TimelineExpandedTitleRow( + title: "App Version", + accentColor: colors.onSurface, + collapseProgress: titleCollapseProgress + ) + .background { + TimelineScrollOffsetObserver { releaseScrollOffset = $0 } + .frame(width: 0, height: 0) + } + .onVerticalScrollSnap(collapseDistance: TodoTimelineMetrics.titleCollapseDistance) + } } private struct ReleaseErrorCard: View { From 00a0496255006e785b4a68a8dd9765e53d29c41f Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 11:18:58 -0400 Subject: [PATCH 29/52] Remove item restoration functionality from the Completed screen in the iOS SwiftUI client. This update simplifies the `CompletedScreen` by removing the ability to "uncomplete" or restore items. It streamlines the UI by replacing the interactive checkmark with a static icon and removing the multi-phase restoration animations. - **CompletedScreen**: - Remove `CompletedRestorePhase` enum and related state tracking logic. - Replace the interactive "Restore" button and checkmark with a static `checkmark.circle.fill` image. - Simplify item styling by making the strikethrough and opacity values static instead of phase-dependent. - Remove the `restoreCompletedItem` function and the leading "Restore" swipe action. - Update `completedTimelineAnimationKey` to only depend on item IDs. - Remove the `onChange` modifier that pruned restoration phases. - **CompletedViewModel**: - Remove the `uncomplete(_:)` method and its associated error handling for task restoration. --- .../Feature/Completed/CompletedScreen.swift | 96 ++----------------- .../Completed/CompletedViewModel.swift | 12 --- 2 files changed, 8 insertions(+), 100 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index 4ea25611..dcdeab5e 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -1,11 +1,5 @@ import SwiftUI -private enum CompletedRestorePhase: String, Hashable { - case unchecked - case unstruck - case fading -} - struct CompletedScreen: View { @State private var viewModel: CompletedViewModel @Environment(\.tdayColors) private var colors @@ -13,7 +7,6 @@ struct CompletedScreen: View { @State private var editingItem: CompletedItem? @State private var timelineScrollOffset: CGFloat = 0 @State private var collapsedSectionIDs: Set = [] - @State private var restorePhases: [String: CompletedRestorePhase] = [:] init(container: AppContainer) { _viewModel = State(initialValue: CompletedViewModel(container: container)) @@ -34,12 +27,7 @@ struct CompletedScreen: View { } private var completedTimelineAnimationKey: String { - let itemIDs = viewModel.items.map(\.id).joined(separator: "|") - let phaseIDs = restorePhases - .sorted { $0.key < $1.key } - .map { "\($0.key):\($0.value.rawValue)" } - .joined(separator: "|") - return "\(itemIDs)::\(phaseIDs)" + viewModel.items.map(\.id).joined(separator: "|") } private var firstVisibleExpandedCompletedSectionID: String? { @@ -60,9 +48,6 @@ struct CompletedScreen: View { .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .navigationBar) - .onChange(of: viewModel.items) { - pruneRestorePhases() - } .safeAreaInset(edge: .top, spacing: 0) { TimelineTopBar( title: "Completed", @@ -223,38 +208,20 @@ struct CompletedScreen: View { let completedDate = item.completedAt ?? item.due let showListIndicator = item.listName?.isEmpty == false let showPriorityFlag = item.priority.lowercased() == "high" - let restorePhase = restorePhases[item.id] - let isRestoring = restorePhase != nil - let showsCompletedCheckmark = restorePhase == nil - let showsStrikethrough = restorePhase == nil || restorePhase == .unchecked - let isFading = restorePhase == .fading return VStack(spacing: 0) { HStack(alignment: .center, spacing: 12) { - Button { - restoreCompletedItem(item) - } label: { - Image(systemName: showsCompletedCheckmark ? "checkmark.circle.fill" : "circle") - .font(.system(size: TodoTimelineMetrics.minimalRowToggleSize, weight: .regular)) - .foregroundStyle(showsCompletedCheckmark ? Color.green : colors.onSurfaceVariant.opacity(0.78)) - .frame(width: TodoTimelineMetrics.minimalRowToggleFrame, height: TodoTimelineMetrics.minimalRowToggleFrame) - } - .disabled(isRestoring) - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0, - normalShadowOpacity: 0 - ) - ) + Image(systemName: "checkmark.circle.fill") + .font(.system(size: TodoTimelineMetrics.minimalRowToggleSize, weight: .regular)) + .foregroundStyle(Color.green) + .frame(width: TodoTimelineMetrics.minimalRowToggleFrame, height: TodoTimelineMetrics.minimalRowToggleFrame) VStack(alignment: .leading, spacing: 4) { Text(item.title) .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowTitleSize, weight: .bold)) - .foregroundStyle(showsStrikethrough ? colors.onSurface.opacity(0.78) : colors.onSurface) - .strikethrough(showsStrikethrough, color: colors.onSurface.opacity(0.65)) + .foregroundStyle(colors.onSurface.opacity(0.78)) + .strikethrough(true, color: colors.onSurface.opacity(0.65)) .lineLimit(2) - .animation(.easeInOut(duration: 0.16), value: showsStrikethrough) Text("Completed, \(completedDate.formatted(date: .omitted, time: .shortened))") .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowSubtitleSize, weight: .semibold)) @@ -282,20 +249,8 @@ struct CompletedScreen: View { .padding(.vertical, TodoTimelineMetrics.minimalRowVerticalPadding) .contentShape(Rectangle()) } - .opacity(isFading ? 0 : 1) - .scaleEffect(isFading ? 0.985 : 1, anchor: .center) - .animation(.easeInOut(duration: 0.22), value: restorePhase) - .allowsHitTesting(!isRestoring) .transition(.opacity.combined(with: .scale(scale: 0.985))) - .swipeRevealHintOnTap(enabled: !isRestoring) - .swipeActions(edge: .leading, allowsFullSwipe: true) { - Button { - restoreCompletedItem(item) - } label: { - Label("Restore", systemImage: "arrow.uturn.backward") - } - .tint(.blue) - } + .swipeRevealHintOnTap() .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { Task { await viewModel.delete(item) } @@ -310,41 +265,6 @@ struct CompletedScreen: View { .tint(colors.secondary) } } - - private func restoreCompletedItem(_ item: CompletedItem) { - guard restorePhases[item.id] == nil else { - return - } - - Task { @MainActor in - withAnimation(.easeInOut(duration: 0.14)) { - restorePhases[item.id] = .unchecked - } - try? await Task.sleep(nanoseconds: 180_000_000) - - withAnimation(.easeInOut(duration: 0.16)) { - restorePhases[item.id] = .unstruck - } - try? await Task.sleep(nanoseconds: 180_000_000) - - withAnimation(.easeInOut(duration: 0.22)) { - restorePhases[item.id] = .fading - } - try? await Task.sleep(nanoseconds: 220_000_000) - - let didRestore = await viewModel.uncomplete(item) - if !didRestore { - withAnimation(.easeInOut(duration: 0.18)) { - _ = restorePhases.removeValue(forKey: item.id) - } - } - } - } - - private func pruneRestorePhases() { - let visibleIDs = Set(viewModel.items.map(\.id)) - restorePhases = restorePhases.filter { visibleIDs.contains($0.key) } - } } private func buildCompletedTimelineSections(items: [CompletedItem]) -> [TimelineSection] { diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedViewModel.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedViewModel.swift index a4f2d485..4461a2b4 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedViewModel.swift @@ -33,18 +33,6 @@ final class CompletedViewModel { isLoading = false } - @discardableResult - func uncomplete(_ item: CompletedItem) async -> Bool { - do { - try await container.completedRepository.uncomplete(item) - hydrateFromCache() - return true - } catch { - errorMessage = userFacingMessage(for: error, fallback: "Could not restore task.") - return false - } - } - func delete(_ item: CompletedItem) async { do { try await container.completedRepository.deleteCompletedTodo(item) From 7fae1bbc8f07a57ef18d0a558472cf7b74b77848 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 11:19:10 -0400 Subject: [PATCH 30/52] Refactor task due date string formatting to use positional arguments. This update replaces static prefix strings with formatted string resources in the Android Compose client. This change ensures better support for localization and follows Android's recommended practices for dynamic string construction. - **String Resources**: - Replace `todos_due_overdue_prefix` and `todos_due_prefix` with formatted versions: `todos_due_overdue_text` ("Overdue, %1$s") and `todos_due_text` ("Due %1$s"). - **UI & Logic**: - Update `TodoListScreen.kt` to use `stringResource` with arguments when displaying due dates and overdue statuses in task subtitles and detail views. - Simplify string concatenation logic by leveraging resource-based formatting. --- .../tday/compose/feature/todos/TodoListScreen.kt | 13 +++++++------ android-compose/app/src/main/res/values/strings.xml | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) 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 9b69b518..9dbd483e 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 @@ -2237,12 +2237,10 @@ private fun SwipeTaskRow( DateTimeFormatter.ofPattern("MMM d, h:mm a").withZone(ZoneId.systemDefault()).format(todo.due) val isOverdue = !todo.completed && todo.due.isBefore(Instant.now()) val dueBodyText = if (showDueDateInSubtitle) dueDateTimeText else dueTimeText - val overduePrefix = stringResource(R.string.todos_due_overdue_prefix) - val duePrefix = stringResource(R.string.todos_due_prefix) val dueSubtitleText = if (isOverdue) { - overduePrefix + dueBodyText + stringResource(R.string.todos_due_overdue_text, dueBodyText) } else if (showDuePrefix) { - duePrefix + dueBodyText + stringResource(R.string.todos_due_text, dueBodyText) } else { dueBodyText } @@ -2566,8 +2564,11 @@ private fun TodayTodoRow( val dueText = DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(todo.due) val isDetailOverdue = !todo.completed && todo.due.isBefore(Instant.now()) - val overduePrefix = stringResource(R.string.todos_due_overdue_prefix) - val detailDueText = if (isDetailOverdue) overduePrefix + dueText else dueText + val detailDueText = if (isDetailOverdue) { + stringResource(R.string.todos_due_overdue_text, dueText) + } else { + dueText + } Column( modifier = Modifier.fillMaxWidth(), diff --git a/android-compose/app/src/main/res/values/strings.xml b/android-compose/app/src/main/res/values/strings.xml index 4df72916..c21bdb1e 100644 --- a/android-compose/app/src/main/res/values/strings.xml +++ b/android-compose/app/src/main/res/values/strings.xml @@ -116,8 +116,8 @@ Creating your task summary… List settings Save list settings - Overdue, - Due + Overdue, %1$s + Due %1$s Completed From 6012f8f52c249239f1540d77c4a039e0c43d35d6 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 11:32:08 -0400 Subject: [PATCH 31/52] Refine task creation and completed screens across Android and iOS clients. This update standardizes text input behavior in the task creation sheet, simplifies the "Completed" screen by removing the undo functionality, and polishes UI elements like timestamps and labels. - **Task Creation Enhancements**: - **iOS**: Update `CreateTaskSheet` to strip newlines from title and notes. Add a "Done" submit label and ensure the keyboard dismisses on submission. - **Android**: Update `CreateTaskBottomSheet` to force single-line input for both title and notes (stripping newlines). Implement `ImeAction.Done` to clear focus and dismiss the keyboard. - **Completed Screen Refactoring**: - **Android**: Remove the "uncomplete" (undo) logic, including the multi-phase restoration animation and the `uncomplete` method in `CompletedViewModel`. - **UI Polish**: Replace the interactive checkmark with a static icon in the completed list. Add a clock icon next to the completion timestamp for better visual hierarchy. - **iOS**: Update the timeline row to include a system clock icon and adjust the opacity of the completion time text. - **Resource & String Updates**: - Refactor `settings_role_prefix` to `settings_role_label` using string placeholders for better localization support. - Remove unused completion-related strings in `strings.xml`. - Fix a UI alignment issue in the Android Settings screen role label. --- .../java/com/ohmz/tday/compose/TdayApp.kt | 1 - .../feature/completed/CompletedScreen.kt | 148 ++++-------------- .../feature/completed/CompletedViewModel.kt | 27 +--- .../feature/settings/SettingsScreen.kt | 19 ++- .../ui/component/CreateTaskBottomSheet.kt | 17 +- .../app/src/main/res/values/strings.xml | 4 +- .../Feature/Completed/CompletedScreen.swift | 11 +- .../Tday/UI/Component/CreateTaskSheet.swift | 31 +++- 8 files changed, 87 insertions(+), 171 deletions(-) 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 600b72c0..fc56a8bb 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 @@ -507,7 +507,6 @@ fun TdayApp() { uiState = uiState, onBack = { navController.popBackStack() }, onRefresh = viewModel::refresh, - onUncomplete = viewModel::uncomplete, onDelete = { item -> viewModel.delete(item) { showTaskDeletedToast() 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 1d5a6e5c..c4a10df0 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 @@ -1,6 +1,5 @@ package com.ohmz.tday.compose.feature.completed -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -51,7 +50,6 @@ import androidx.compose.material.icons.rounded.Info 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.RadioButtonUnchecked import androidx.compose.material.icons.rounded.Restaurant import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.WbSunny @@ -63,7 +61,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -74,7 +71,6 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -107,20 +103,12 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale -private enum class CompletedRestorePhase { - Completed, - Unchecked, - Unstruck, - Fading, -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun CompletedScreen( uiState: CompletedUiState, onBack: () -> Unit, onRefresh: () -> Unit, - onUncomplete: (CompletedItem) -> Unit, onDelete: (CompletedItem) -> Unit, onUpdateTask: (CompletedItem, CreateTaskPayload) -> Unit, ) { @@ -262,7 +250,6 @@ fun CompletedScreen( lists = uiState.lists, onInfo = { editTargetId = completed.id }, onDelete = { onDelete(completed) }, - onUncomplete = { onUncomplete(completed) }, ) } } @@ -508,7 +495,6 @@ private fun CompletedSwipeRow( lists: List, onInfo: () -> Unit, onDelete: () -> Unit, - onUncomplete: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -519,36 +505,11 @@ private fun CompletedSwipeRow( val maxElasticDragPx = actionRevealPx * 1.22f var targetOffsetX by remember(item.id) { mutableFloatStateOf(0f) } var swipeHinting by remember(item.id) { mutableStateOf(false) } - var restorePhase by remember(item.id) { mutableStateOf(CompletedRestorePhase.Completed) } val animatedOffsetX by animateFloatAsState( targetValue = targetOffsetX, animationSpec = spring(stiffness = Spring.StiffnessLow), label = "completedSwipeOffset", ) - val showCompletedCheckmark = restorePhase == CompletedRestorePhase.Completed - val showStrikethrough = - restorePhase == CompletedRestorePhase.Completed || restorePhase == CompletedRestorePhase.Unchecked - val isFading = restorePhase == CompletedRestorePhase.Fading - val isRestoring = restorePhase != CompletedRestorePhase.Completed - val rowAlpha by animateFloatAsState( - targetValue = if (isFading) 0f else 1f, - animationSpec = tween(durationMillis = 220), - label = "completedRestoreRowAlpha", - ) - val rowScale by animateFloatAsState( - targetValue = if (isFading) 0.985f else 1f, - animationSpec = tween(durationMillis = 220), - label = "completedRestoreRowScale", - ) - val titleColor by animateColorAsState( - targetValue = if (showStrikethrough) { - colorScheme.onSurface.copy(alpha = 0.78f) - } else { - colorScheme.onSurface - }, - animationSpec = tween(durationMillis = 160), - label = "completedRestoreTitleColor", - ) val completedAtText = COMPLETED_ROW_TIME_FORMATTER .withZone(ZoneId.systemDefault()) .format(item.completedAt ?: item.due) @@ -565,12 +526,7 @@ private fun CompletedSwipeRow( Column( modifier = modifier - .fillMaxWidth() - .graphicsLayer { - alpha = rowAlpha - scaleX = rowScale - scaleY = rowScale - }, + .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Box( @@ -640,7 +596,7 @@ private fun CompletedSwipeRow( ) { if (targetOffsetX != 0f) { targetOffsetX = 0f - } else if (!swipeHinting && !isRestoring) { + } else if (!swipeHinting) { swipeHinting = true coroutineScope.launch { targetOffsetX = -swipeHintOffsetPx @@ -661,35 +617,13 @@ private fun CompletedSwipeRow( .padding(horizontal = 4.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { - CompletedCircularToggleIcon( - imageVector = if (showCompletedCheckmark) { - Icons.Rounded.CheckCircle - } else { - Icons.Rounded.RadioButtonUnchecked - }, - contentDescription = stringResource(R.string.label_undo_complete), - tint = if (showCompletedCheckmark) { - Color(0xFF6FBF86) - } else { - colorScheme.onSurfaceVariant.copy(alpha = 0.78f) - }, - enabled = !isRestoring, - onClick = { - ViewCompat.performHapticFeedback( - view, - HapticFeedbackConstantsCompat.CLOCK_TICK, - ) - targetOffsetX = 0f - coroutineScope.launch { - restorePhase = CompletedRestorePhase.Unchecked - delay(180) - restorePhase = CompletedRestorePhase.Unstruck - delay(180) - restorePhase = CompletedRestorePhase.Fading - delay(220) - onUncomplete() - } - }, + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = stringResource(R.string.label_completed), + tint = Color(0xFF6FBF86), + modifier = Modifier + .padding(horizontal = 10.dp) + .size(28.dp), ) Column( @@ -699,21 +633,28 @@ private fun CompletedSwipeRow( ) { Text( text = item.title, - color = titleColor, + color = colorScheme.onSurface.copy(alpha = 0.78f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.ExtraBold, - textDecoration = if (showStrikethrough) { - TextDecoration.LineThrough - } else { - TextDecoration.None - }, - ) - Text( - text = stringResource(R.string.completed_at_prefix) + completedAtText, - color = colorScheme.onSurfaceVariant.copy(alpha = 0.84f), - style = MaterialTheme.typography.bodySmall, - maxLines = 1, + textDecoration = TextDecoration.LineThrough, ) + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Schedule, + contentDescription = null, + tint = colorScheme.onSurfaceVariant.copy(alpha = 0.74f), + modifier = Modifier.size(13.dp), + ) + Text( + text = completedAtText, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) + } } if (showPriorityFlag) { @@ -759,39 +700,6 @@ private fun CompletedSwipeRow( } } -@Composable -private fun CompletedCircularToggleIcon( - imageVector: ImageVector, - contentDescription: String, - tint: Color, - onClick: () -> Unit, - enabled: Boolean = true, -) { - val interactionSource = remember { MutableInteractionSource() } - Box( - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple( - bounded = true, - radius = 14.dp, - ), - onClick = onClick, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = imageVector, - contentDescription = contentDescription, - tint = tint, - modifier = Modifier.size(24.dp), - ) - } -} - @Composable private fun SwipeActionCircle( icon: ImageVector, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedViewModel.kt index 9c9bb49a..05b554b2 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedViewModel.kt @@ -6,20 +6,17 @@ import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager import com.ohmz.tday.compose.core.data.completed.CompletedRepository import com.ohmz.tday.compose.core.data.list.ListRepository import com.ohmz.tday.compose.core.data.sync.SyncManager -import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.CompletedItem +import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary -import com.ohmz.tday.compose.core.notification.TaskReminderScheduler import com.ohmz.tday.compose.core.ui.userFacingMessage import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import javax.inject.Inject data class CompletedUiState( val isLoading: Boolean = false, @@ -34,7 +31,6 @@ class CompletedViewModel @Inject constructor( private val listRepository: ListRepository, private val syncManager: SyncManager, private val cacheManager: OfflineCacheManager, - private val reminderScheduler: TaskReminderScheduler, ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -130,19 +126,6 @@ class CompletedViewModel @Inject constructor( } } - fun uncomplete(item: CompletedItem) { - viewModelScope.launch { - runCatching { completedRepository.uncomplete(item) } - .onSuccess { - rescheduleReminders() - loadInternal(forceSync = false, showLoading = false) - } - .onFailure { error -> - _uiState.update { it.copy(errorMessage = error.userFacingMessage("Could not restore task.")) } - } - } - } - fun delete(item: CompletedItem, onDeleted: (() -> Unit)? = null) { viewModelScope.launch { runCatching { completedRepository.deleteCompletedTodo(item) } @@ -165,10 +148,4 @@ class CompletedViewModel @Inject constructor( } } } - - private fun rescheduleReminders() { - viewModelScope.launch(Dispatchers.Default) { - runCatching { reminderScheduler.rescheduleAll() } - } - } } 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 9a87caa1..5cf1850a 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 @@ -50,9 +50,10 @@ 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.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource @@ -64,13 +65,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp -import androidx.compose.ui.geometry.Offset import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat -import com.ohmz.tday.compose.R import com.ohmz.tday.compose.BuildConfig -import com.ohmz.tday.compose.core.model.SessionUser +import com.ohmz.tday.compose.R 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.ui.theme.AppThemeMode import com.ohmz.tday.compose.ui.theme.TdayDimens @@ -348,8 +348,10 @@ private fun SettingsProfileCard( ) } Text( - text = stringResource(R.string.settings_role_prefix) + - (user?.role ?: stringResource(R.string.settings_role_default)), + text = stringResource( + R.string.settings_role_label, + user?.role ?: stringResource(R.string.settings_role_default), + ), style = MaterialTheme.typography.bodySmall, color = colorScheme.onSurface.copy(alpha = 0.58f), ) @@ -626,7 +628,10 @@ private fun ThemeModeSelector( }, ) .clickable { - ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK + ) onThemeModeSelected(mode) }, ) { 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 3785c1db..44d55532 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 @@ -26,6 +26,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.List @@ -70,6 +72,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager 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 @@ -267,6 +270,7 @@ fun CreateTaskBottomSheet( notes = notes, onTitleChange = { title = it }, onNotesChange = { notes = it }, + onKeyboardDone = { focusManager.clearFocus(force = true) }, ) SectionHeading("Schedule") @@ -513,6 +517,7 @@ private fun TaskTextCard( notes: String, onTitleChange: (String) -> Unit, onNotesChange: (String) -> Unit, + onKeyboardDone: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -521,14 +526,14 @@ private fun TaskTextCard( value = title, placeholder = "Title", onValueChange = onTitleChange, - singleLine = true, + onKeyboardDone = onKeyboardDone, ) RowDivider() TaskField( value = notes, placeholder = "Notes", onValueChange = onNotesChange, - singleLine = false, + onKeyboardDone = onKeyboardDone, ) } } @@ -538,14 +543,16 @@ private fun TaskField( value: String, placeholder: String, onValueChange: (String) -> Unit, - singleLine: Boolean, + onKeyboardDone: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme BasicTextField( value = value, - onValueChange = onValueChange, - singleLine = singleLine, + onValueChange = { onValueChange(it.replace('\n', ' ').replace('\r', ' ')) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onKeyboardDone() }), textStyle = MaterialTheme.typography.titleMedium.copy( color = colorScheme.onSurface, fontWeight = FontWeight.ExtraBold, diff --git a/android-compose/app/src/main/res/values/strings.xml b/android-compose/app/src/main/res/values/strings.xml index c21bdb1e..1019567c 100644 --- a/android-compose/app/src/main/res/values/strings.xml +++ b/android-compose/app/src/main/res/values/strings.xml @@ -52,7 +52,7 @@ Settings Unknown user - Role: + Role: %1$s USER Appearance Reminders @@ -122,8 +122,6 @@ Completed No completed tasks yet - Created: - Completed, Earlier Today Tomorrow diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index dcdeab5e..db5c356a 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -206,6 +206,7 @@ struct CompletedScreen: View { private func completedTimelineRow(_ item: CompletedItem) -> some View { let completedDate = item.completedAt ?? item.due + let completedTimeText = completedDate.formatted(date: .omitted, time: .shortened) let showListIndicator = item.listName?.isEmpty == false let showPriorityFlag = item.priority.lowercased() == "high" @@ -223,9 +224,13 @@ struct CompletedScreen: View { .strikethrough(true, color: colors.onSurface.opacity(0.65)) .lineLimit(2) - Text("Completed, \(completedDate.formatted(date: .omitted, time: .shortened))") - .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowSubtitleSize, weight: .semibold)) - .foregroundStyle(colors.onSurfaceVariant.opacity(0.8)) + HStack(spacing: 5) { + Image(systemName: "clock") + .font(.system(size: 10, weight: .bold)) + Text(completedTimeText) + .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowSubtitleSize, weight: .semibold)) + } + .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) } Spacer(minLength: 0) diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index 5d09f7f0..8835de56 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit struct CreateTaskSheet: View { let lists: [ListSummary] @@ -310,7 +311,6 @@ private struct CreateTaskSheetTextCard: View { CreateTaskSheetTextField( placeholder: "Title", text: $title, - axis: .horizontal, lineLimit: 1 ... 1 ) @@ -319,8 +319,7 @@ private struct CreateTaskSheetTextCard: View { CreateTaskSheetTextField( placeholder: "Notes", text: $notes, - axis: .vertical, - lineLimit: 1 ... 3 + lineLimit: 1 ... 1 ) } } @@ -329,20 +328,38 @@ private struct CreateTaskSheetTextCard: View { private struct CreateTaskSheetTextField: View { let placeholder: String @Binding var text: String - let axis: Axis let lineLimit: ClosedRange @Environment(\.tdayColors) private var colors + private var normalizedText: Binding { + Binding( + get: { text }, + set: { + text = $0 + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + } + ) + } + var body: some View { TextField( "", - text: $text, + text: normalizedText, prompt: Text(placeholder) - .foregroundStyle(colors.onSurfaceVariant.opacity(0.65)), - axis: axis + .foregroundStyle(colors.onSurfaceVariant.opacity(0.65)) ) .lineLimit(lineLimit) + .submitLabel(.done) + .onSubmit { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } .textInputAutocapitalization(.sentences) .font(.tdayRounded(size: 18, weight: .heavy)) .foregroundStyle(colors.onSurface) From cb876e83c8c23468d64ff1b99a5c06f830eb368e Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 11:36:44 -0400 Subject: [PATCH 32/52] Enhance `SettingsScreen` header animations and scroll behavior for the iOS SwiftUI client. This update synchronizes the settings screen's visual transitions with the app's timeline-based UI patterns. It introduces precise title reveal thresholds, vertical scroll snapping, and dynamic layout adjustments based on scroll progress. - **Scroll & Transition Logic**: - Implement `TimelineScrollOffsetObserver` to track vertical scroll position and update `settingsScrollOffset`. - Apply `onVerticalScrollSnap` using `TodoTimelineMetrics.titleCollapseDistance` and disable vertical scroll bounce for a more controlled navigation experience. - Define `topTitleRevealStart` (0.48) and `topTitleRevealEnd` (0.76) constants to fine-tune the timing of the header title visibility. - **Header UI Enhancements**: - Update `SettingsHeader` to support granular title reveal parameters, including start/end thresholds and a reveal distance of 6. - Add a dynamic `profileCardTopClearance` property that calculates bottom padding for the header based on `titleCollapseProgress`. - Refactor the header layout to move scroll observation and snapping modifiers from the header subview to the primary `ScrollView` container. --- .../Feature/Settings/SettingsScreen.swift | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift index a819340b..b80ca46d 100644 --- a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift +++ b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift @@ -7,6 +7,9 @@ struct SettingsScreen: View { @Environment(\.tdayColors) private var colors @State private var settingsScrollOffset: CGFloat = 0 + private let topTitleRevealStart: CGFloat = 0.48 + private let topTitleRevealEnd: CGFloat = 0.76 + private var isAdminUser: Bool { (viewModel.user?.role ?? "").uppercased() == "ADMIN" } @@ -84,6 +87,12 @@ struct SettingsScreen: View { } .padding(.horizontal, 18) .padding(.bottom, 24) + .background { + TimelineScrollOffsetObserver { settingsScrollOffset = $0 } + .frame(width: 0, height: 0) + } + .onVerticalScrollSnap(collapseDistance: TodoTimelineMetrics.titleCollapseDistance) + .disableVerticalScrollBounce() } .background(colors.background.ignoresSafeArea()) .navigationTitle("") @@ -96,7 +105,10 @@ struct SettingsScreen: View { accentColor: colors.onSurface, collapseProgress: titleCollapseProgress, onBack: { dismiss() }, - action: nil + action: nil, + titleRevealStart: topTitleRevealStart, + titleRevealEnd: topTitleRevealEnd, + titleRevealDistance: 6 ) } .task { @@ -128,11 +140,16 @@ struct SettingsScreen: View { accentColor: colors.onSurface, collapseProgress: titleCollapseProgress ) - .background { - TimelineScrollOffsetObserver { settingsScrollOffset = $0 } - .frame(width: 0, height: 0) - } - .onVerticalScrollSnap(collapseDistance: TodoTimelineMetrics.titleCollapseDistance) + .padding(.bottom, profileCardTopClearance) + } + + private var profileCardTopClearance: CGFloat { + let progress = TodoTimelineMetrics.progress( + titleCollapseProgress, + from: 0.54, + to: 1 + ) + return 10 * progress } } From 6931a25189617ab9430707d12a35ad9acada1468 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 11:39:36 -0400 Subject: [PATCH 33/52] Improve keyboard and focus management in `CreateTaskBottomSheet`. This update ensures that the software keyboard is explicitly hidden and focus is cleared when dismissing the bottom sheet, completing a task, or triggering the "Done" IME action. - **Focus & Keyboard Handling**: - Introduce `dismissKeyboard` helper using `LocalSoftwareKeyboardController` and `LocalFocusManager` to ensure the keyboard is retracted and focus is cleared simultaneously. - Apply `dismissKeyboard` to the "Close" and "Confirm" actions within the bottom sheet. - Update `TaskInputFields` to explicitly hide the keyboard and trigger the default `ImeAction.Done` behavior when the user finishes editing. --- .../ui/component/CreateTaskBottomSheet.kt | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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 44d55532..710b956d 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 @@ -70,6 +70,7 @@ import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector 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 @@ -129,6 +130,11 @@ fun CreateTaskBottomSheet( onUpdateTask: ((todo: TodoItem, payload: CreateTaskPayload) -> Unit)? = null, ) { val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val dismissKeyboard = { + keyboardController?.hide() + focusManager.clearFocus(force = true) + } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val dateOnlyFormatter = remember { DateTimeFormatter.ofPattern("EEE, MMM d").withZone(ZoneId.systemDefault()) @@ -253,11 +259,11 @@ fun CreateTaskBottomSheet( leftIcon = Icons.Rounded.Close, leftContentDescription = "Close", onLeftClick = { - focusManager.clearFocus(force = true) + dismissKeyboard() onDismiss() }, onConfirm = { - focusManager.clearFocus(force = true) + dismissKeyboard() if (canSubmit) { submitTask() } @@ -270,7 +276,7 @@ fun CreateTaskBottomSheet( notes = notes, onTitleChange = { title = it }, onNotesChange = { notes = it }, - onKeyboardDone = { focusManager.clearFocus(force = true) }, + onKeyboardDone = dismissKeyboard, ) SectionHeading("Schedule") @@ -552,7 +558,12 @@ private fun TaskField( onValueChange = { onValueChange(it.replace('\n', ' ').replace('\r', ' ')) }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { onKeyboardDone() }), + keyboardActions = KeyboardActions( + onDone = { + onKeyboardDone() + defaultKeyboardAction(ImeAction.Done) + }, + ), textStyle = MaterialTheme.typography.titleMedium.copy( color = colorScheme.onSurface, fontWeight = FontWeight.ExtraBold, From 0df0d283a2dc010bd988ae0b1641446c8797fcd0 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 11:41:21 -0400 Subject: [PATCH 34/52] Refactor the settings screen UI implementation from a `ScrollView` to a `List` for the iOS SwiftUI client. This update migrates the layout architecture to use a `List` component, providing better integration with system list behaviors while maintaining the custom collapsing top bar effect and visual styling. - **UI Refactoring**: - Replace `ScrollView` and `VStack` with a `List` utilizing `.plain` style and hidden background. - Implement `settingsListRow` helper to standardize row insets, backgrounds, and spacing across all settings sections. - Move `TimelineScrollOffsetObserver` and scroll snapping logic into the hero title row to maintain dynamic header animations. - Adjust navigation bar configuration, ensuring the `TimelineTopBar` correctly overlays the list with the proper safe area insets. - **Styling & Layout**: - Remove hardcoded title reveal constants (`topTitleRevealStart`, `topTitleRevealEnd`) in favor of standard `TimelineTopBar` defaults. - Replace manual `Spacer` and padding with list-based spacing and a clear footer row. - Apply `navigationTitleTypography` and `navigationBackButtonBehavior` modifiers for consistent header appearance. - Ensure vertical scroll bounce is disabled to match the previous interaction model. --- .../Feature/Settings/SettingsScreen.swift | 154 ++++++++++-------- 1 file changed, 89 insertions(+), 65 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift index b80ca46d..5b650f45 100644 --- a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift +++ b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift @@ -7,9 +7,6 @@ struct SettingsScreen: View { @Environment(\.tdayColors) private var colors @State private var settingsScrollOffset: CGFloat = 0 - private let topTitleRevealStart: CGFloat = 0.48 - private let topTitleRevealEnd: CGFloat = 0.76 - private var isAdminUser: Bool { (viewModel.user?.role ?? "").uppercased() == "ADMIN" } @@ -21,12 +18,58 @@ struct SettingsScreen: View { } var body: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 12) { - settingsHeroTitleRow + settingsContent + .background(colors.background) + .navigationBackButtonBehavior() + .navigationTitleTypography( + largeTitleColor: colors.onSurface, + inlineTitleColor: colors.onSurface, + backgroundColor: colors.background + ) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top, spacing: 0) { + TimelineTopBar( + title: "Settings", + accentColor: colors.onSurface, + collapseProgress: titleCollapseProgress, + onBack: { dismiss() }, + action: nil + ) + } + .task { + await viewModel.refreshAdminAiSummarySetting() + await viewModel.refreshVersionInfo() + } + .alert( + "AI Summary Unavailable", + isPresented: Binding( + get: { viewModel.aiSummaryValidationError != nil }, + set: { visible in + if !visible { + viewModel.dismissAiSummaryValidationError() + } + } + ) + ) { + Button("OK", role: .cancel) { + viewModel.dismissAiSummaryValidationError() + } + } message: { + Text(viewModel.aiSummaryValidationError ?? "") + } + } + + private var settingsContent: some View { + List { + settingsHeroTitleRow + settingsListRow { SettingsProfileCard(user: viewModel.user) + } + settingsListRow { SettingsSectionCard { SettingsSectionTitle("Appearance") SettingsThemeSelector( @@ -40,14 +83,18 @@ struct SettingsScreen: View { onSelect: viewModel.setDefaultReminder ) } + } - if isAdminUser { + if isAdminUser { + settingsListRow { SettingsSectionCard { SettingsSectionTitle("Feature toggle") SettingsAiSummaryRow(viewModel: viewModel) } } + } + settingsListRow { SettingsSectionCard { SettingsListRow( title: "App Version", @@ -82,56 +129,21 @@ struct SettingsScreen: View { } ) } - - Spacer(minLength: 24) - } - .padding(.horizontal, 18) - .padding(.bottom, 24) - .background { - TimelineScrollOffsetObserver { settingsScrollOffset = $0 } - .frame(width: 0, height: 0) - } - .onVerticalScrollSnap(collapseDistance: TodoTimelineMetrics.titleCollapseDistance) - .disableVerticalScrollBounce() - } - .background(colors.background.ignoresSafeArea()) - .navigationTitle("") - .navigationBarTitleDisplayMode(.inline) - .toolbar(.hidden, for: .navigationBar) - .navigationBackButtonBehavior() - .safeAreaInset(edge: .top, spacing: 0) { - TimelineTopBar( - title: "Settings", - accentColor: colors.onSurface, - collapseProgress: titleCollapseProgress, - onBack: { dismiss() }, - action: nil, - titleRevealStart: topTitleRevealStart, - titleRevealEnd: topTitleRevealEnd, - titleRevealDistance: 6 - ) - } - .task { - await viewModel.refreshAdminAiSummarySetting() - await viewModel.refreshVersionInfo() - } - .alert( - "AI Summary Unavailable", - isPresented: Binding( - get: { viewModel.aiSummaryValidationError != nil }, - set: { visible in - if !visible { - viewModel.dismissAiSummaryValidationError() - } - } - ) - ) { - Button("OK", role: .cancel) { - viewModel.dismissAiSummaryValidationError() } - } message: { - Text(viewModel.aiSummaryValidationError ?? "") + + Color.clear + .frame(height: 24) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .disableVerticalScrollBounce() } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .contentMargins(.top, 0, for: .scrollContent) + .listSectionSpacing(0) + .environment(\.defaultMinListRowHeight, 1) + .disableVerticalScrollBounce() } private var settingsHeroTitleRow: some View { @@ -140,16 +152,28 @@ struct SettingsScreen: View { accentColor: colors.onSurface, collapseProgress: titleCollapseProgress ) - .padding(.bottom, profileCardTopClearance) - } - - private var profileCardTopClearance: CGFloat { - let progress = TodoTimelineMetrics.progress( - titleCollapseProgress, - from: 0.54, - to: 1 - ) - return 10 * progress + .background { + TimelineScrollOffsetObserver { settingsScrollOffset = $0 } + .frame(width: 0, height: 0) + } + .onVerticalScrollSnap(collapseDistance: TodoTimelineMetrics.titleCollapseDistance) + .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + + private func settingsListRow(@ViewBuilder content: () -> Content) -> some View { + content() + .listRowInsets( + EdgeInsets( + top: 0, + leading: TodoTimelineMetrics.horizontalPadding, + bottom: 12, + trailing: TodoTimelineMetrics.horizontalPadding + ) + ) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } } From 4bcfaf2f17901ea81c1b2b31511241aa6fd2c8ed Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 12:00:35 -0400 Subject: [PATCH 35/52] Refactor `SettingsScreen` and the version release view in the iOS SwiftUI client to improve scrolling behavior and layout consistency. This update migrates the release information view from a `ScrollView` to a `List` implementation, matching the structure of the main settings screen. It also introduces dynamic top insets to handle "hero title" collapse animations gracefully. - **Layout & Scroll Performance**: - Convert the "App Version" screen from `ScrollView` + `VStack` to a `List` with custom row styling. - Implement `utilityFirstContentTopInset` to calculate dynamic spacing for the first content row based on the title's collapse progress. - Add a transparent footer spacer in both `SettingsScreen` and the release view to ensure content is not obscured by UI elements and supports proper scroll snapping. - Apply `.disableVerticalScrollBounce()` and hide standard list backgrounds to maintain a clean, custom design. - **Component Refactoring**: - Update `settingsListRow` in `SettingsScreen` to support a configurable `topInset`. - Implement `releaseListRow` in the version view to standardize row insets and separators. - Refactor `releaseHeroTitleRow` to integrate with the list-based scrolling logic, including vertical scroll snapping. - **Bug Fixes & UX**: - Fix layout issues where version info content could overlap or lack proper padding when transitioning between loading, error, and data states. - Ensure the "App Version" screen correctly initializes version info via `.task` and respects the custom navigation bar behavior. --- .../Feature/Settings/SettingsScreen.swift | 136 +++++++++++++----- 1 file changed, 99 insertions(+), 37 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift index 5b650f45..4d8aeb1d 100644 --- a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift +++ b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift @@ -65,7 +65,7 @@ struct SettingsScreen: View { List { settingsHeroTitleRow - settingsListRow { + settingsListRow(topInset: utilityFirstContentTopInset(collapseProgress: titleCollapseProgress)) { SettingsProfileCard(user: viewModel.user) } @@ -132,7 +132,7 @@ struct SettingsScreen: View { } Color.clear - .frame(height: 24) + .frame(height: TodoTimelineMetrics.titleCollapseDistance + TodoTimelineMetrics.topBarRowHeight + 24) .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -162,11 +162,14 @@ struct SettingsScreen: View { .listRowSeparator(.hidden) } - private func settingsListRow(@ViewBuilder content: () -> Content) -> some View { + private func settingsListRow( + topInset: CGFloat = 0, + @ViewBuilder content: () -> Content + ) -> some View { content() .listRowInsets( EdgeInsets( - top: 0, + top: topInset, leading: TodoTimelineMetrics.horizontalPadding, bottom: 12, trailing: TodoTimelineMetrics.horizontalPadding @@ -177,6 +180,15 @@ struct SettingsScreen: View { } } +private func utilityFirstContentTopInset(collapseProgress: CGFloat) -> CGFloat { + let elasticProgress = TodoTimelineMetrics.progress( + collapseProgress, + from: TodoTimelineMetrics.firstPinnedRowElasticStart, + to: TodoTimelineMetrics.firstPinnedRowElasticEnd + ) + return TodoTimelineMetrics.firstPinnedRowElasticClearance * elasticProgress +} + private struct SettingsProfileCard: View { let user: SessionUser? @@ -473,11 +485,37 @@ struct LatestReleaseScreen: View { } var body: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 12) { - releaseHeroTitleRow + releaseContent + .background(colors.background) + .navigationBackButtonBehavior() + .navigationTitleTypography( + largeTitleColor: colors.onSurface, + inlineTitleColor: colors.onSurface, + backgroundColor: colors.background + ) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top, spacing: 0) { + TimelineTopBar( + title: "App Version", + accentColor: colors.onSurface, + collapseProgress: titleCollapseProgress, + onBack: { dismiss() }, + action: nil + ) + } + .task { + await viewModel.refreshVersionInfo() + } + } + + private var releaseContent: some View { + List { + releaseHeroTitleRow - if viewModel.isReleaseLoading && viewModel.currentRelease == nil && viewModel.latestRelease == nil { + if viewModel.isReleaseLoading && viewModel.currentRelease == nil && viewModel.latestRelease == nil { + releaseListRow(topInset: utilityFirstContentTopInset(collapseProgress: titleCollapseProgress)) { HStack { Spacer() ProgressView() @@ -485,62 +523,66 @@ struct LatestReleaseScreen: View { .padding(.top, 48) Spacer() } - } else { - if viewModel.releaseError != nil && - viewModel.currentRelease == nil && - viewModel.latestRelease == nil { + } + } else { + let hasInitialReleaseError = viewModel.releaseError != nil && + viewModel.currentRelease == nil && + viewModel.latestRelease == nil + + if hasInitialReleaseError { + releaseListRow(topInset: utilityFirstContentTopInset(collapseProgress: titleCollapseProgress)) { ReleaseErrorCard { Task { await viewModel.refreshVersionInfo() } } } + } + releaseListRow(topInset: hasInitialReleaseError ? 0 : utilityFirstContentTopInset(collapseProgress: titleCollapseProgress)) { ReleaseOverviewCard(viewModel: viewModel) + } - if viewModel.hasUpdate, let latestRelease = viewModel.latestRelease { + if viewModel.hasUpdate, let latestRelease = viewModel.latestRelease { + releaseListRow { UpdateAvailableCard(release: latestRelease) { if let url = URL(string: latestRelease.htmlUrl) { openURL(url) } } } + } - if !viewModel.hasUpdate { + if !viewModel.hasUpdate { + releaseListRow { InstalledVersionCard( currentVersion: viewModel.currentVersionName, currentRelease: viewModel.currentRelease ) } + } - if let browseUrl = viewModel.latestRelease?.htmlUrl ?? viewModel.currentRelease?.htmlUrl, - let url = URL(string: browseUrl) { + if let browseUrl = viewModel.latestRelease?.htmlUrl ?? viewModel.currentRelease?.htmlUrl, + let url = URL(string: browseUrl) { + releaseListRow { ReleaseBrowserButton { openURL(url) } } } - - Spacer(minLength: 24) } - .padding(.horizontal, 18) - .padding(.bottom, 24) - } - .background(colors.background.ignoresSafeArea()) - .navigationTitle("") - .navigationBarTitleDisplayMode(.inline) - .toolbar(.hidden, for: .navigationBar) - .navigationBackButtonBehavior() - .safeAreaInset(edge: .top, spacing: 0) { - TimelineTopBar( - title: "App Version", - accentColor: colors.onSurface, - collapseProgress: titleCollapseProgress, - onBack: { dismiss() }, - action: nil - ) - } - .task { - await viewModel.refreshVersionInfo() + + Color.clear + .frame(height: TodoTimelineMetrics.titleCollapseDistance + TodoTimelineMetrics.topBarRowHeight + 24) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .disableVerticalScrollBounce() } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .contentMargins(.top, 0, for: .scrollContent) + .listSectionSpacing(0) + .environment(\.defaultMinListRowHeight, 1) + .disableVerticalScrollBounce() } private var releaseHeroTitleRow: some View { @@ -554,6 +596,26 @@ struct LatestReleaseScreen: View { .frame(width: 0, height: 0) } .onVerticalScrollSnap(collapseDistance: TodoTimelineMetrics.titleCollapseDistance) + .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + + private func releaseListRow( + topInset: CGFloat = 0, + @ViewBuilder content: () -> Content + ) -> some View { + content() + .listRowInsets( + EdgeInsets( + top: topInset, + leading: TodoTimelineMetrics.horizontalPadding, + bottom: 12, + trailing: TodoTimelineMetrics.horizontalPadding + ) + ) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } } From 802d68c8f2fb56300ea76c03b0e1111ddd0b78e3 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 12:20:11 -0400 Subject: [PATCH 36/52] Refactor `SettingsScreen` and the version release view in the iOS SwiftUI client to improve scrolling behavior and layout consistency. This update migrates the release information view from a `ScrollView` to a `List` implementation, matching the structure of the main settings screen. It also introduces dynamic top insets to handle "hero title" collapse animations gracefully. - **Layout & Scroll Performance**: - Convert the "App Version" screen from `ScrollView` + `VStack` to a `List` with custom row styling. - Implement `utilityFirstContentTopInset` to calculate dynamic spacing for the first content row based on the title's collapse progress. - Add a transparent footer spacer in both `SettingsScreen` and the release view to ensure content is not obscured by UI elements and supports proper scroll snapping. - Apply `.disableVerticalScrollBounce()` and hide standard list backgrounds to maintain a clean, custom design. - **Component Refactoring**: - Update `settingsListRow` in `SettingsScreen` to support a configurable `topInset`. - Implement `releaseListRow` in the version view to standardize row insets and separators. - Refactor `releaseHeroTitleRow` to integrate with the list-based scrolling logic, including vertical scroll snapping. - **Bug Fixes & UX**: - Fix layout issues where version info content could overlap or lack proper padding when transitioning between loading, error, and data states. - Ensure the "App Version" screen correctly initializes version info via `.task` and respects the custom navigation bar behavior. --- .../Feature/Settings/SettingsScreen.swift | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift index 4d8aeb1d..07a32776 100644 --- a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift +++ b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift @@ -12,11 +12,19 @@ struct SettingsScreen: View { } private var titleCollapseProgress: CGFloat { + utilitySnappedTitleCollapseProgress(rawProgress: rawTitleCollapseProgress) + } + + private var rawTitleCollapseProgress: CGFloat { let distance = TodoTimelineMetrics.titleCollapseDistance guard distance > 0 else { return 0 } return min(max(settingsScrollOffset / distance, 0), 1) } + private var firstContentTopInset: CGFloat { + utilityFirstContentTopInset(collapseProgress: rawTitleCollapseProgress) + } + var body: some View { settingsContent .background(colors.background) @@ -65,7 +73,7 @@ struct SettingsScreen: View { List { settingsHeroTitleRow - settingsListRow(topInset: utilityFirstContentTopInset(collapseProgress: titleCollapseProgress)) { + settingsListRow(topInset: firstContentTopInset) { SettingsProfileCard(user: viewModel.user) } @@ -189,6 +197,10 @@ private func utilityFirstContentTopInset(collapseProgress: CGFloat) -> CGFloat { return TodoTimelineMetrics.firstPinnedRowElasticClearance * elasticProgress } +private func utilitySnappedTitleCollapseProgress(rawProgress: CGFloat) -> CGFloat { + rawProgress >= 0.5 ? 1 : 0 +} + private struct SettingsProfileCard: View { let user: SessionUser? @@ -479,11 +491,19 @@ struct LatestReleaseScreen: View { @State private var releaseScrollOffset: CGFloat = 0 private var titleCollapseProgress: CGFloat { + utilitySnappedTitleCollapseProgress(rawProgress: rawTitleCollapseProgress) + } + + private var rawTitleCollapseProgress: CGFloat { let distance = TodoTimelineMetrics.titleCollapseDistance guard distance > 0 else { return 0 } return min(max(releaseScrollOffset / distance, 0), 1) } + private var firstContentTopInset: CGFloat { + utilityFirstContentTopInset(collapseProgress: rawTitleCollapseProgress) + } + var body: some View { releaseContent .background(colors.background) @@ -515,7 +535,7 @@ struct LatestReleaseScreen: View { releaseHeroTitleRow if viewModel.isReleaseLoading && viewModel.currentRelease == nil && viewModel.latestRelease == nil { - releaseListRow(topInset: utilityFirstContentTopInset(collapseProgress: titleCollapseProgress)) { + releaseListRow(topInset: firstContentTopInset) { HStack { Spacer() ProgressView() @@ -530,14 +550,14 @@ struct LatestReleaseScreen: View { viewModel.latestRelease == nil if hasInitialReleaseError { - releaseListRow(topInset: utilityFirstContentTopInset(collapseProgress: titleCollapseProgress)) { + releaseListRow(topInset: firstContentTopInset) { ReleaseErrorCard { Task { await viewModel.refreshVersionInfo() } } } } - releaseListRow(topInset: hasInitialReleaseError ? 0 : utilityFirstContentTopInset(collapseProgress: titleCollapseProgress)) { + releaseListRow(topInset: hasInitialReleaseError ? 0 : firstContentTopInset) { ReleaseOverviewCard(viewModel: viewModel) } From af33bce07d0506dcb5bb2ec62f11cd497199910c Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 12:46:27 -0400 Subject: [PATCH 37/52] Refine top bar action button styling and apply circular chrome to key navigation items. This update enhances the visual consistency of the `TimelineTopBarActionButton` by introducing dynamic color logic for outlined states and applying the circular chrome style to primary actions in the Calendar and Todo List screens. - **Calendar & Todo List Integration**: - Enable `usesCircularChrome` for the "jump to today" (calendar icon) action in `CalendarScreen`. - Enable `usesCircularChrome` for the AI summary (sparkles icon) action in `TodoListScreen`. - **UI Component Refinement**: - **Dynamic Styling**: Refactor `TimelineTopBarActionButton` to use computed properties for `foregroundColor`, `outlinedFillColor`, and `outlinedBorderColor`. - **Color Logic**: Update the `.outlined` chrome style to derive its background fill (12% opacity) and border (48% opacity) from the action's `tint` color when available. - **Icon Scaling**: Adjust icon font size based on the chrome type, increasing the size to 28 for non-filled styles while maintaining the standard size for filled buttons. - **Consistency**: Centralize foreground color selection to ensure `tint` is correctly applied across filled, outlined, and plain button variants. --- .../Feature/Calendar/CalendarScreen.swift | 1 + .../Tday/Feature/Todos/TodoListScreen.swift | 38 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index a5e48ef3..54bcc4a9 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -281,6 +281,7 @@ struct CalendarScreen: View { action: TimelineTopBarAction( systemName: "calendar", tint: calendarAccentColor, + usesCircularChrome: true, action: jumpToToday ), titleRevealStart: CalendarTitleHandoff.pinnedRevealStart, diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 375ca968..7e43bbcf 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -167,6 +167,7 @@ struct TodoListScreen: View { if canSummarizeCurrentMode { return TimelineTopBarAction( systemName: "sparkles", + usesCircularChrome: true, action: presentSummary ) } @@ -1063,7 +1064,7 @@ private struct TimelineTopBarButton: View { var body: some View { Button(action: action) { Image(systemName: systemName) - .font(.system(size: TodoTimelineMetrics.topBarButtonIconSize, weight: .semibold)) + .font(.system(size: iconSize, weight: .semibold)) .frame(width: TodoTimelineMetrics.topBarButtonFrame, height: TodoTimelineMetrics.topBarButtonFrame) .background { if chrome == .filled { @@ -1071,17 +1072,46 @@ private struct TimelineTopBarButton: View { .fill(colors.surface) } else if chrome == .outlined { Circle() - .fill(colors.background) + .fill(outlinedFillColor) .overlay { Circle() - .stroke(colors.onSurfaceVariant.opacity(0.28), lineWidth: 1) + .stroke(outlinedBorderColor, lineWidth: 1) } } } .contentShape(Circle()) } .buttonStyle(TimelineTopBarButtonStyle(isFilled: chrome == .filled)) - .foregroundStyle(chrome == .filled || chrome == .outlined ? colors.onSurface : (tint ?? Color.accentColor)) + .foregroundStyle(foregroundColor) + } + + private var iconSize: CGFloat { + chrome == .filled ? TodoTimelineMetrics.topBarButtonIconSize : 28 + } + + private var foregroundColor: Color { + switch chrome { + case .filled: + return colors.onSurface + case .outlined: + return tint ?? colors.onSurface + case .plain: + return tint ?? Color.accentColor + } + } + + private var outlinedFillColor: Color { + if let tint { + return tint.opacity(0.12) + } + return colors.background + } + + private var outlinedBorderColor: Color { + if let tint { + return tint.opacity(0.48) + } + return colors.onSurfaceVariant.opacity(0.28) } } From 75364dd4d32855328434e4c5deaaf68527fb6ff7 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 15:48:32 -0400 Subject: [PATCH 38/52] Update the Home screen search UI to support full-width expansion on iOS and Android. This change modifies the top bar behavior to allow the search field to occupy the entire width of the screen when active, providing a more focused search experience and preventing layout overflow. - **Search Bar Refinement**: - Update `expandedSearchWidth` calculations in both SwiftUI and Compose implementations to utilize the full available width instead of reserving space for action buttons. - Implement conditional rendering to hide the "Create List" and "Settings/More" buttons when the search bar is expanded. - **Cross-Platform Synchronization**: - **iOS**: Updated `HomeScreen.swift` to use `totalWidth` for expanded search and wrap secondary buttons in a visibility check. - **Android**: Updated `HomeScreen.kt` to use `maxWidth` for expanded search and wrap action icons in a `searchExpanded` conditional block. --- .../tday/compose/feature/home/HomeScreen.kt | 29 ++++++++++--------- .../Tday/Feature/Home/HomeScreen.swift | 14 +++++---- 2 files changed, 23 insertions(+), 20 deletions(-) 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 892ef6c2..26f0f85f 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 @@ -1127,9 +1127,8 @@ private fun TopSearchBar( BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val buttonSize = TdayDimens.FabSize val buttonGap = 8.dp - val fixedActionWidth = (buttonSize * 2) + buttonGap val collapsedSearchWidth = buttonSize - val expandedSearchWidth = (maxWidth - fixedActionWidth - buttonGap).coerceAtLeast(buttonSize) + val expandedSearchWidth = maxWidth.coerceAtLeast(buttonSize) val animatedSearchWidth by animateDpAsState( targetValue = if (searchExpanded) expandedSearchWidth else collapsedSearchWidth, label = "topSearchBarSearchWidth", @@ -1244,18 +1243,20 @@ private fun TopSearchBar( } } - PressableIconButton( - icon = Icons.AutoMirrored.Rounded.PlaylistAdd, - contentDescription = stringResource(R.string.action_create_list), - tint = colorScheme.onSurface, - onClick = onCreateList, - ) - PressableIconButton( - icon = Icons.Rounded.MoreHoriz, - contentDescription = stringResource(R.string.action_more), - tint = colorScheme.onSurface, - onClick = onOpenSettings, - ) + if (!searchExpanded) { + PressableIconButton( + icon = Icons.AutoMirrored.Rounded.PlaylistAdd, + contentDescription = stringResource(R.string.action_create_list), + tint = colorScheme.onSurface, + onClick = onCreateList, + ) + PressableIconButton( + icon = Icons.Rounded.MoreHoriz, + contentDescription = stringResource(R.string.action_more), + tint = colorScheme.onSurface, + onClick = onOpenSettings, + ) + } } } } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index acf3d6f4..b29a52d7 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -323,7 +323,7 @@ private struct HomeTopBar: View { var body: some View { let buttonSize = HomeMetrics.topBarButtonSize let buttonGap: CGFloat = 8 - let expandedSearchWidth = max(buttonSize, totalWidth - (buttonSize * 2) - (buttonGap * 2)) + let expandedSearchWidth = max(buttonSize, totalWidth) let searchWidth = searchExpanded ? expandedSearchWidth : buttonSize ZStack(alignment: .trailing) { @@ -407,12 +407,14 @@ private struct HomeTopBar: View { } ) - HomeIconCircleButton(icon: "text.badge.plus") { - onCreateList() - } + if !searchExpanded { + HomeIconCircleButton(icon: "text.badge.plus") { + onCreateList() + } - HomeIconCircleButton(icon: "ellipsis") { - onOpenSettings() + HomeIconCircleButton(icon: "ellipsis") { + onOpenSettings() + } } } .animation(.spring(response: 0.28, dampingFraction: 0.86), value: searchExpanded) From 0703a4ed765fb4fd400989bc248170cf901bbcd5 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 15:57:57 -0400 Subject: [PATCH 39/52] Update the Home screen search UI to support full-width expansion on iOS and Android. This change modifies the top bar behavior to allow the search field to occupy the entire width of the screen when active, providing a more focused search experience and preventing layout overflow. - **Search Bar Refinement**: - Update `expandedSearchWidth` calculations in both SwiftUI and Compose implementations to utilize the full available width instead of reserving space for action buttons. - Implement conditional rendering to hide the "Create List" and "Settings/More" buttons when the search bar is expanded. - **Cross-Platform Synchronization**: - **iOS**: Updated `HomeScreen.swift` to use `totalWidth` for expanded search and wrap secondary buttons in a visibility check. - **Android**: Updated `HomeScreen.kt` to use `maxWidth` for expanded search and wrap action icons in a `searchExpanded` conditional block. --- .../tday/compose/feature/home/HomeScreen.kt | 207 +++++++++--------- .../Tday/Feature/Home/HomeScreen.swift | 149 +++++++------ 2 files changed, 185 insertions(+), 171 deletions(-) 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 26f0f85f..53e78c8a 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 @@ -1129,37 +1129,70 @@ private fun TopSearchBar( val buttonGap = 8.dp val collapsedSearchWidth = buttonSize val expandedSearchWidth = maxWidth.coerceAtLeast(buttonSize) + val collapsedSearchOffset = -((buttonSize * 2) + (buttonGap * 2)) val animatedSearchWidth by animateDpAsState( targetValue = if (searchExpanded) expandedSearchWidth else collapsedSearchWidth, label = "topSearchBarSearchWidth", ) + val animatedSearchOffset by animateDpAsState( + targetValue = if (searchExpanded) 0.dp else collapsedSearchOffset, + label = "topSearchBarSearchOffset", + ) + val actionsAlpha by animateFloatAsState( + targetValue = if (searchExpanded) 0f else 1f, + label = "topSearchBarActionsAlpha", + ) - if (!searchExpanded) { - Row( - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 2.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = homeTitleIcon, - contentDescription = null, - tint = homeTitleIconTint, - modifier = Modifier.size(26.dp), - ) - Text( - text = stringResource(R.string.home_title), - style = MaterialTheme.typography.headlineLarge, - color = colorScheme.onBackground, - fontWeight = FontWeight.ExtraBold, - ) - } + Row( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 2.dp) + .graphicsLayer { alpha = actionsAlpha }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = homeTitleIcon, + contentDescription = null, + tint = homeTitleIconTint, + modifier = Modifier.size(26.dp), + ) + Text( + text = stringResource(R.string.home_title), + style = MaterialTheme.typography.headlineLarge, + color = colorScheme.onBackground, + fontWeight = FontWeight.ExtraBold, + ) } - Column( + Row( modifier = Modifier .align(Alignment.CenterEnd) + .graphicsLayer { alpha = actionsAlpha }, + horizontalArrangement = Arrangement.spacedBy(buttonGap), + verticalAlignment = Alignment.CenterVertically, + ) { + PressableIconButton( + icon = Icons.AutoMirrored.Rounded.PlaylistAdd, + contentDescription = stringResource(R.string.action_create_list), + tint = colorScheme.onSurface, + onClick = onCreateList, + ) + PressableIconButton( + icon = Icons.Rounded.MoreHoriz, + contentDescription = stringResource(R.string.action_more), + tint = colorScheme.onSurface, + onClick = onOpenSettings, + ) + } + + Card( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = animatedSearchOffset) + .width(animatedSearchWidth) + .height(buttonSize) + .zIndex(2f) .then( if (searchExpanded) { Modifier.onGloballyPositioned { coordinates -> @@ -1169,92 +1202,70 @@ private fun TopSearchBar( Modifier } ), - verticalArrangement = Arrangement.spacedBy(8.dp), + onClick = { + if (!searchExpanded) { + onSearchExpandedChange(true) + } + }, + shape = RoundedCornerShape(28.dp), + border = BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.26f)), + colors = CardDefaults.cardColors(containerColor = colorScheme.surface), + elevation = CardDefaults.cardElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp + ), ) { Row( - horizontalArrangement = Arrangement.spacedBy(buttonGap), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 14.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Card( - modifier = Modifier - .width(animatedSearchWidth) - .height(buttonSize), + PressableIconButton( + icon = Icons.Rounded.Search, + contentDescription = if (searchExpanded) stringResource(R.string.action_close_search) else stringResource( + R.string.action_search + ), + tint = colorScheme.onSurface, + compact = true, onClick = { - if (!searchExpanded) { + if (searchExpanded) { + onSearchClose() + } else { onSearchExpandedChange(true) } }, - shape = RoundedCornerShape(28.dp), - border = BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.26f)), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp, pressedElevation = 0.dp), - ) { - Row( + ) + + if (searchExpanded) { + BasicTextField( modifier = Modifier - .fillMaxSize() - .padding(horizontal = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - PressableIconButton( - icon = Icons.Rounded.Search, - contentDescription = if (searchExpanded) stringResource(R.string.action_close_search) else stringResource(R.string.action_search), - tint = colorScheme.onSurface, - compact = true, - onClick = { - if (searchExpanded) { - onSearchClose() - } else { - onSearchExpandedChange(true) + .weight(1f) + .focusRequester(focusRequester), + value = searchQuery, + onValueChange = onSearchQueryChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = colorScheme.onSurface, + fontWeight = FontWeight.ExtraBold, + ), + cursorBrush = SolidColor(colorScheme.primary), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterStart, + ) { + if (searchQuery.isBlank()) { + Text( + text = stringResource(R.string.home_search_placeholder), + style = MaterialTheme.typography.bodyLarge, + color = colorScheme.onSurfaceVariant, + ) } - }, - ) - - if (searchExpanded) { - BasicTextField( - modifier = Modifier - .weight(1f) - .focusRequester(focusRequester), - value = searchQuery, - onValueChange = onSearchQueryChange, - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge.copy( - color = colorScheme.onSurface, - fontWeight = FontWeight.ExtraBold, - ), - cursorBrush = SolidColor(colorScheme.primary), - decorationBox = { innerTextField -> - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.CenterStart, - ) { - if (searchQuery.isBlank()) { - Text( - text = stringResource(R.string.home_search_placeholder), - style = MaterialTheme.typography.bodyLarge, - color = colorScheme.onSurfaceVariant, - ) - } - innerTextField() - } - }, - ) - } - } - } - - if (!searchExpanded) { - PressableIconButton( - icon = Icons.AutoMirrored.Rounded.PlaylistAdd, - contentDescription = stringResource(R.string.action_create_list), - tint = colorScheme.onSurface, - onClick = onCreateList, - ) - PressableIconButton( - icon = Icons.Rounded.MoreHoriz, - contentDescription = stringResource(R.string.action_more), - tint = colorScheme.onSurface, - onClick = onOpenSettings, + innerTextField() + } + }, ) } } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index b29a52d7..4ad14e60 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -325,98 +325,101 @@ private struct HomeTopBar: View { let buttonGap: CGFloat = 8 let expandedSearchWidth = max(buttonSize, totalWidth) let searchWidth = searchExpanded ? expandedSearchWidth : buttonSize + let collapsedSearchOffset = -((buttonSize * 2) + (buttonGap * 2)) + let searchOffsetX = searchExpanded ? 0 : collapsedSearchOffset ZStack(alignment: .trailing) { - if !searchExpanded { - TimelineView(.periodic(from: .now, by: 60)) { context in - let daytime = isHomeDaytime(context.date) + TimelineView(.periodic(from: .now, by: 60)) { context in + let daytime = isHomeDaytime(context.date) - HStack(spacing: 8) { - Image(systemName: daytime ? "sun.max.fill" : "moon.stars.fill") - .font(.system(size: 26, weight: .regular)) - .foregroundStyle(Color(hex: daytime ? 0xF4C542 : 0xA8B8E8)) + HStack(spacing: 8) { + Image(systemName: daytime ? "sun.max.fill" : "moon.stars.fill") + .font(.system(size: 26, weight: .regular)) + .foregroundStyle(Color(hex: daytime ? 0xF4C542 : 0xA8B8E8)) - Text("T'Day") - .font(.tdayRounded(size: 32, weight: .heavy)) - .foregroundStyle(colors.onSurface) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 2) - .transition(.opacity) + Text("T'Day") + .font(.tdayRounded(size: 32, weight: .heavy)) + .foregroundStyle(colors.onSurface) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 2) + .opacity(searchExpanded ? 0 : 1) + .allowsHitTesting(false) } HStack(spacing: buttonGap) { - Group { - if searchExpanded { - HStack(spacing: 10) { - HomeIconCircleButton(icon: "magnifyingglass", compact: true) { - withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { - searchExpanded = false - } - searchQuery = "" - } + HomeIconCircleButton(icon: "text.badge.plus") { + onCreateList() + } - TextField("", text: $searchQuery, prompt: Text("Search").foregroundStyle(colors.onSurfaceVariant)) - .focused(searchFieldFocused) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .font(.tdayRounded(size: 18, weight: .bold)) - .foregroundStyle(colors.onSurface) - .tint(colors.primary) + HomeIconCircleButton(icon: "ellipsis") { + onOpenSettings() + } + } + .opacity(searchExpanded ? 0 : 1) + .allowsHitTesting(!searchExpanded) - Button { - withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { - searchExpanded = false - } - searchQuery = "" - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) + Group { + if searchExpanded { + HStack(spacing: 10) { + HomeIconCircleButton(icon: "magnifyingglass", compact: true) { + withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { + searchExpanded = false } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0, - normalShadowOpacity: 0 - ) - ) - .accessibilityLabel("Close search") + searchQuery = "" } - .padding(.horizontal, 14) - .frame(width: searchWidth, height: buttonSize) - .background(colors.surface, in: Capsule()) - .overlay( - Capsule() - .stroke(colors.onSurface.opacity(0.26), lineWidth: 1) - ) - } else { - HomeIconCircleButton(icon: "magnifyingglass") { + + TextField("", text: $searchQuery, prompt: Text("Search").foregroundStyle(colors.onSurfaceVariant)) + .focused(searchFieldFocused) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.tdayRounded(size: 18, weight: .bold)) + .foregroundStyle(colors.onSurface) + .tint(colors.primary) + + Button { withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { - searchExpanded = true + searchExpanded = false } + searchQuery = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) } - .frame(width: searchWidth, height: buttonSize) - } - } - .background( - GeometryReader { proxy in - Color.clear - .preference(key: HomeSearchBarFrameKey.self, value: proxy.frame(in: .named("home-root"))) - } - ) - - if !searchExpanded { - HomeIconCircleButton(icon: "text.badge.plus") { - onCreateList() + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0, + normalShadowOpacity: 0 + ) + ) + .accessibilityLabel("Close search") } - - HomeIconCircleButton(icon: "ellipsis") { - onOpenSettings() + .padding(.horizontal, 14) + .frame(width: searchWidth, height: buttonSize) + .background(colors.surface, in: Capsule()) + .overlay( + Capsule() + .stroke(colors.onSurface.opacity(0.26), lineWidth: 1) + ) + } else { + HomeIconCircleButton(icon: "magnifyingglass") { + withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { + searchExpanded = true + } } + .frame(width: searchWidth, height: buttonSize) } } + .offset(x: searchOffsetX) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: HomeSearchBarFrameKey.self, value: proxy.frame(in: .named("home-root"))) + } + ) + .zIndex(2) .animation(.spring(response: 0.28, dampingFraction: 0.86), value: searchExpanded) } .frame(maxWidth: .infinity, minHeight: HomeMetrics.topBarButtonSize) From 41fb15309b782d079b160f6d5f6a27ef99fde2aa Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 16:28:59 -0400 Subject: [PATCH 40/52] Adjust search input focus delay for iOS and Android. This change increases the delay before the search field gains focus when expanded, ensuring smoother transitions and allowing expansion animations to complete before the keyboard appears. - **iOS (SwiftUI)**: - Increase focus delay from 0.05s to 0.30s in `HomeScreen.swift`. - Explicitly reset focus state before the delay and verify `searchExpanded` remains true before applying focus. - **Android (Compose)**: - Add a 300ms `delay` to the `LaunchedEffect` in `HomeScreen.kt` before requesting focus and showing the software keyboard. --- .../java/com/ohmz/tday/compose/feature/home/HomeScreen.kt | 1 + ios-swiftUI/Tday/Feature/Home/HomeScreen.swift | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) 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 53e78c8a..fdf98ba7 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 @@ -1116,6 +1116,7 @@ private fun TopSearchBar( LaunchedEffect(searchExpanded) { if (searchExpanded) { + delay(300) focusRequester.requestFocus() keyboardController?.show() } else { diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 4ad14e60..64dbb78a 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -254,8 +254,11 @@ struct HomeScreen: View { } .onChange(of: searchExpanded) { _, expanded in if expanded { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - searchFieldFocused = true + searchFieldFocused = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.30) { + if searchExpanded { + searchFieldFocused = true + } } } else { searchFieldFocused = false From 6a745ff3dbe374164b4df54424a81de18b53b465 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 17:06:34 -0400 Subject: [PATCH 41/52] Refactor the home screen search bar for the iOS and Android clients. This update transitions the search UI from a conditional layout to a layered animation approach, providing a smoother visual transition between the collapsed icon and expanded text field. - **iOS (SwiftUI)**: - Replace the `Group` layout with a `ZStack` containing both collapsed and expanded states. - Implement a dedicated button for the collapsed state with a spring animation to trigger expansion. - Update the expanded state to use a static magnifying glass icon and a clear/cancel button. - Add `searchExpanded` state checks to `allowsHitTesting` and `disabled` modifiers to prevent interaction with hidden elements. - **Android (Compose)**: - Introduce `searchContentAlpha` to animate the visibility transition between the search icon and the full search input. - Refactor the search bar container to use a `Box` for layering the collapsed icon and expanded `Row` content. - Apply `graphicsLayer` alpha transformations to elements based on the search expansion state. - Replace the toggling `PressableIconButton` with a static `Icon` in the expanded state and a dedicated `Close` button for dismissal. - Ensure the `BasicTextField` is disabled when the search bar is not expanded. --- .../tday/compose/feature/home/HomeScreen.kt | 61 +++++++---- .../Tday/Feature/Home/HomeScreen.swift | 101 ++++++++++-------- 2 files changed, 95 insertions(+), 67 deletions(-) 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 fdf98ba7..bfc126ac 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 @@ -1143,6 +1143,10 @@ private fun TopSearchBar( targetValue = if (searchExpanded) 0f else 1f, label = "topSearchBarActionsAlpha", ) + val searchContentAlpha by animateFloatAsState( + targetValue = if (searchExpanded) 1f else 0f, + label = "topSearchBarContentAlpha", + ) Row( modifier = Modifier @@ -1216,36 +1220,45 @@ private fun TopSearchBar( pressedElevation = 0.dp ), ) { - Row( + Box( modifier = Modifier .fillMaxSize() - .padding(horizontal = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - PressableIconButton( - icon = Icons.Rounded.Search, - contentDescription = if (searchExpanded) stringResource(R.string.action_close_search) else stringResource( - R.string.action_search - ), - tint = colorScheme.onSurface, - compact = true, - onClick = { - if (searchExpanded) { - onSearchClose() - } else { - onSearchExpandedChange(true) - } - }, - ) + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = 1f - searchContentAlpha }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = stringResource(R.string.action_search), + tint = colorScheme.onSurface, + modifier = Modifier.size(22.dp), + ) + } - if (searchExpanded) { + Row( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = searchContentAlpha } + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) BasicTextField( modifier = Modifier .weight(1f) .focusRequester(focusRequester), value = searchQuery, onValueChange = onSearchQueryChange, + enabled = searchExpanded, singleLine = true, textStyle = MaterialTheme.typography.bodyLarge.copy( color = colorScheme.onSurface, @@ -1268,6 +1281,14 @@ private fun TopSearchBar( } }, ) + + PressableIconButton( + icon = Icons.Rounded.Close, + contentDescription = stringResource(R.string.action_close_search), + tint = colorScheme.onSurfaceVariant.copy(alpha = 0.82f), + compact = true, + onClick = onSearchClose, + ) } } } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 64dbb78a..1d51c829 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -362,59 +362,66 @@ private struct HomeTopBar: View { .opacity(searchExpanded ? 0 : 1) .allowsHitTesting(!searchExpanded) - Group { - if searchExpanded { - HStack(spacing: 10) { - HomeIconCircleButton(icon: "magnifyingglass", compact: true) { - withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { - searchExpanded = false - } - searchQuery = "" - } - - TextField("", text: $searchQuery, prompt: Text("Search").foregroundStyle(colors.onSurfaceVariant)) - .focused(searchFieldFocused) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .font(.tdayRounded(size: 18, weight: .bold)) - .foregroundStyle(colors.onSurface) - .tint(colors.primary) - - Button { - withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { - searchExpanded = false - } - searchQuery = "" - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) - } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0, - normalShadowOpacity: 0 - ) - ) - .accessibilityLabel("Close search") + ZStack { + Button { + withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { + searchExpanded = true } - .padding(.horizontal, 14) - .frame(width: searchWidth, height: buttonSize) - .background(colors.surface, in: Capsule()) - .overlay( - Capsule() - .stroke(colors.onSurface.opacity(0.26), lineWidth: 1) - ) - } else { - HomeIconCircleButton(icon: "magnifyingglass") { + } label: { + Image(systemName: "magnifyingglass") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(colors.onSurface) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .buttonStyle(HomeIconButtonStyle(compact: false)) + .opacity(searchExpanded ? 0 : 1) + .allowsHitTesting(!searchExpanded) + .accessibilityLabel("Search") + + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(colors.onSurface) + .frame(width: HomeMetrics.compactButtonSize, height: HomeMetrics.compactButtonSize) + + TextField("", text: $searchQuery, prompt: Text("Search").foregroundStyle(colors.onSurfaceVariant)) + .focused(searchFieldFocused) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.tdayRounded(size: 18, weight: .bold)) + .foregroundStyle(colors.onSurface) + .tint(colors.primary) + .disabled(!searchExpanded) + + Button { withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { - searchExpanded = true + searchExpanded = false } + searchQuery = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) } - .frame(width: searchWidth, height: buttonSize) + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0, + normalShadowOpacity: 0 + ) + ) + .accessibilityLabel("Cancel search") } + .padding(.horizontal, 14) + .opacity(searchExpanded ? 1 : 0) + .allowsHitTesting(searchExpanded) } + .frame(width: searchWidth, height: buttonSize) + .background(colors.surface, in: Capsule()) + .overlay( + Capsule() + .stroke(colors.onSurface.opacity(0.26), lineWidth: 1) + ) .offset(x: searchOffsetX) .background( GeometryReader { proxy in From 43469b54492b2984ecd8d569def9ffc580915520 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 17:16:41 -0400 Subject: [PATCH 42/52] Refine the presentation detent and height management logic for the Create List sheet in the iOS SwiftUI client. This update improves the responsiveness and safety of the bottom sheet by constraining its height to the screen dimensions and simplifying how the app toggles between compact and expanded (typing) states. - **Height & Constraint Management**: - Introduce `maximumHeightFraction` (80%) to ensure the sheet does not exceed screen bounds on smaller devices. - Update `compactSheetHeight` to be the minimum of the initial constant and the calculated maximum height. - Adjust `onPreferenceChange` logic to clamp the content height within the defined maximum. - **Detent & Interaction Logic**: - Refactor detent selection to use a computed `activeDetents` set, switching between `typingDetent` and `compactDetent` based on the `nameFieldFocused` state. - Replace manual `@State` tracking of `sheetDetent` with a reactive approach using the `.presentationDetents(activeDetents)` modifier. - Enable `.presentationContentInteraction(.resizes)` for smoother transitions between sheet states. - **Cleanup & Animation**: - Replace the manual `onChange` animation block with a declarative `.animation(.snappy, value: nameFieldFocused)` modifier. - Remove the `onAppear` delay that automatically focused the name field. --- .../Tday/Feature/Home/HomeScreen.swift | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 1d51c829..ca0ab192 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -47,6 +47,7 @@ private struct HomeListIconOption { private enum CreateListSheetMetrics { static let initialCompactHeight: CGFloat = 620 + static let maximumHeightFraction: CGFloat = 0.8 } private struct CreateListSheetContentHeightKey: PreferenceKey { @@ -986,8 +987,10 @@ private struct CreateListSheet: View { @State private var name = "" @State private var color = "BLUE" @State private var iconKey = "inbox" - @State private var compactSheetHeight: CGFloat = CreateListSheetMetrics.initialCompactHeight - @State private var sheetDetent: PresentationDetent = .height(CreateListSheetMetrics.initialCompactHeight) + @State private var compactSheetHeight: CGFloat = min( + CreateListSheetMetrics.initialCompactHeight, + UIScreen.main.bounds.height * CreateListSheetMetrics.maximumHeightFraction + ) private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1005,12 +1008,20 @@ private struct CreateListSheet: View { homeListSymbolName(for: iconKey) } + private var maximumSheetHeight: CGFloat { + max(1, UIScreen.main.bounds.height * CreateListSheetMetrics.maximumHeightFraction) + } + private var compactDetent: PresentationDetent { .height(compactSheetHeight) } private var typingDetent: PresentationDetent { - .fraction(0.8) + .fraction(CreateListSheetMetrics.maximumHeightFraction) + } + + private var activeDetents: Set { + nameFieldFocused ? [typingDetent] : [compactDetent] } var body: some View { @@ -1147,28 +1158,18 @@ private struct CreateListSheet: View { } ) .background(colors.background.ignoresSafeArea()) - .presentationDetents([compactDetent, typingDetent], selection: $sheetDetent) + .presentationDetents(activeDetents) + .presentationContentInteraction(.resizes) .presentationDragIndicator(.hidden) .presentationCornerRadius(34) .presentationBackground(colors.background) .ignoresSafeArea(.keyboard, edges: .bottom) .onPreferenceChange(CreateListSheetContentHeightKey.self) { height in - let nextHeight = max(height, 1) + guard !nameFieldFocused else { return } + let nextHeight = min(max(height, 1), maximumSheetHeight) compactSheetHeight = nextHeight - if !nameFieldFocused { - sheetDetent = .height(nextHeight) - } - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - nameFieldFocused = true - } - } - .onChange(of: nameFieldFocused) { _, focused in - withAnimation(.snappy(duration: 0.24)) { - sheetDetent = focused ? typingDetent : compactDetent - } } + .animation(.snappy(duration: 0.24), value: nameFieldFocused) } private func formattedOptionName(_ value: String) -> String { From 511496a3829a581eab91a4dc00eb78c0377dadc6 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 17:24:30 -0400 Subject: [PATCH 43/52] Adjust the maximum height for the "Create List" sheet in the iOS SwiftUI client. This change reduces the `maximumHeightFraction` for the list creation bottom sheet from 0.8 to 0.7 to improve UI scaling and presentation on the home screen. --- ios-swiftUI/Tday/Feature/Home/HomeScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index ca0ab192..04e54d9a 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -47,7 +47,7 @@ private struct HomeListIconOption { private enum CreateListSheetMetrics { static let initialCompactHeight: CGFloat = 620 - static let maximumHeightFraction: CGFloat = 0.8 + static let maximumHeightFraction: CGFloat = 0.7 } private struct CreateListSheetContentHeightKey: PreferenceKey { From 7f3676a236a639b3b238a6db52a3f4801e6a0618 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 17:48:40 -0400 Subject: [PATCH 44/52] Implement dynamic height adjustment for the task creation sheet in the iOS SwiftUI client. This update introduces a preference-key-based measurement system to automatically resize the `CreateTaskSheet` based on its content, replacing the static 80% screen height detent with a more responsive layout. - **Dynamic Sheet Sizing**: - Implement `CreateTaskSheetHeaderHeightKey` and `CreateTaskSheetFormHeightKey` to measure component heights using `GeometryReader` and `PreferenceKey`. - Define `CreateTaskSheetMetrics` to manage the initial height (560pt) and the maximum allowable height (86% of screen height). - Update `presentationDetents` to use the measured height, capped by the device screen bounds. - **UI & Layout**: - Refactor the sheet container to use a `ScrollView` that only enables scrolling when the content exceeds the maximum allowed sheet height. - Add `.onPreferenceChange` listeners to update state and trigger re-layouts when header or form content changes. - Adjust internal padding and spacing (reduced bottom padding from 20pt to 14pt) to optimize the vertical layout. --- .../Tday/UI/Component/CreateTaskSheet.swift | 62 +++++++++++++++++-- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index 8835de56..97826a40 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -1,6 +1,27 @@ import SwiftUI import UIKit +private enum CreateTaskSheetMetrics { + static let initialSheetHeight: CGFloat = 560 + static let maximumHeightFraction: CGFloat = 0.86 +} + +private struct CreateTaskSheetHeaderHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct CreateTaskSheetFormHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + struct CreateTaskSheet: View { let lists: [ListSummary] let titleText: String @@ -22,6 +43,8 @@ struct CreateTaskSheet: View { @State private var isSubmitting = false @State private var parserTask: Task? @State private var activeSelector: CreateTaskSheetSelector? + @State private var headerHeight: CGFloat = 84 + @State private var formHeight: CGFloat = CreateTaskSheetMetrics.initialSheetHeight - 84 private let priorityOptions = ["Low", "Medium", "High"] private let repeatOptions: [(label: String, value: String?)] = [ @@ -45,6 +68,18 @@ struct CreateTaskSheet: View { repeatOptions.first(where: { $0.value == repeatRule })?.label ?? "No repeat" } + private var maximumSheetHeight: CGFloat { + max(1, UIScreen.main.bounds.height * CreateTaskSheetMetrics.maximumHeightFraction) + } + + private var measuredSheetHeight: CGFloat { + min(max(headerHeight + formHeight, 1), maximumSheetHeight) + } + + private var formNeedsScrolling: Bool { + headerHeight + formHeight > maximumSheetHeight + } + init( lists: [ListSummary], titleText: String, @@ -99,6 +134,12 @@ struct CreateTaskSheet: View { } } ) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: CreateTaskSheetHeaderHeightKey.self, value: ceil(proxy.size.height)) + } + ) ScrollView(showsIndicators: false) { VStack(spacing: 14) { @@ -136,16 +177,21 @@ struct CreateTaskSheet: View { onTap: { activeSelector = .recurrence } ) } - - Spacer(minLength: 6) } .padding(.horizontal, 18) - .padding(.bottom, 20) + .padding(.bottom, 14) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: CreateTaskSheetFormHeightKey.self, value: ceil(proxy.size.height)) + } + ) } + .scrollDisabled(!formNeedsScrolling) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .frame(maxWidth: .infinity, alignment: .top) .background(colors.background.ignoresSafeArea()) - .presentationDetents([.fraction(0.8)]) + .presentationDetents([.height(measuredSheetHeight)]) .presentationDragIndicator(.hidden) .presentationCornerRadius(34) .presentationBackground(colors.background) @@ -160,6 +206,12 @@ struct CreateTaskSheet: View { .onChange(of: title) { _, _ in scheduleNlpParse() } + .onPreferenceChange(CreateTaskSheetHeaderHeightKey.self) { height in + headerHeight = max(height, 1) + } + .onPreferenceChange(CreateTaskSheetFormHeightKey.self) { height in + formHeight = max(height, 1) + } } private func hydrateFromInitialPayload() { From fb5adb346c502c3a7e9ad2ee4a00ccce701caef2 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 18:18:35 -0400 Subject: [PATCH 45/52] Refine layout and background styling for the "Create List" sheet in the iOS SwiftUI client. This update adjusts the vertical spacing and safe area behavior for the list creation sheet to ensure a more consistent visual appearance across different device sizes. - **Layout Adjustments**: - Replace the flexible `Spacer` with a fixed bottom padding constant (`bottomContentPadding`) in the `CreateListSheet` layout. - Reduce the bottom padding of the sheet content from 20pt to 8pt. - **Styling & Presentation**: - Update the sheet's background to ignore the bottom safe area container edges, ensuring the background color extends to the bottom of the screen. - Centralize sheet metrics by adding `bottomContentPadding` to `CreateListSheetMetrics`. --- ios-swiftUI/Tday/Feature/Home/HomeScreen.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 04e54d9a..5254bae6 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -48,6 +48,7 @@ private struct HomeListIconOption { private enum CreateListSheetMetrics { static let initialCompactHeight: CGFloat = 620 static let maximumHeightFraction: CGFloat = 0.7 + static let bottomContentPadding: CGFloat = 8 } private struct CreateListSheetContentHeightKey: PreferenceKey { @@ -1145,11 +1146,10 @@ private struct CreateListSheet: View { } } - Spacer(minLength: 8) } .padding(.horizontal, 18) .padding(.top, 14) - .padding(.bottom, 20) + .padding(.bottom, CreateListSheetMetrics.bottomContentPadding) .frame(maxWidth: .infinity, alignment: .top) .background( GeometryReader { proxy in @@ -1162,7 +1162,10 @@ private struct CreateListSheet: View { .presentationContentInteraction(.resizes) .presentationDragIndicator(.hidden) .presentationCornerRadius(34) - .presentationBackground(colors.background) + .presentationBackground { + colors.background + .ignoresSafeArea(.container, edges: .bottom) + } .ignoresSafeArea(.keyboard, edges: .bottom) .onPreferenceChange(CreateListSheetContentHeightKey.self) { height in guard !nameFieldFocused else { return } From dae975d5356e566573e46f221b1d717b1e60ae6b Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 18:18:47 -0400 Subject: [PATCH 46/52] Refine layout and background styling for the "Create List" sheet in the iOS SwiftUI client. This update adjusts the vertical spacing and safe area behavior for the list creation sheet to ensure a more consistent visual appearance across different device sizes. - **Layout Adjustments**: - Replace the flexible `Spacer` with a fixed bottom padding constant (`bottomContentPadding`) in the `CreateListSheet` layout. - Reduce the bottom padding of the sheet content from 20pt to 8pt. - **Styling & Presentation**: - Update the sheet's background to ignore the bottom safe area container edges, ensuring the background color extends to the bottom of the screen. - Centralize sheet metrics by adding `bottomContentPadding` to `CreateListSheetMetrics`. --- .../ui/component/CreateTaskBottomSheet.kt | 203 ++++++++++-------- .../Tday/UI/Component/CreateTaskSheet.swift | 8 +- 2 files changed, 117 insertions(+), 94 deletions(-) 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 710b956d..eaa50f33 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 @@ -13,7 +13,6 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -45,14 +44,13 @@ import androidx.compose.material3.DatePickerDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker import androidx.compose.material3.TimePickerDefaults import androidx.compose.material3.rememberDatePickerState -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -69,6 +67,7 @@ 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.LocalConfiguration import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView @@ -112,6 +111,7 @@ private fun normalizePriorityValue(value: String?): String { } private const val DEFAULT_TASK_DURATION_MS = 60L * 60L * 1000L +private const val CREATE_TASK_SHEET_MAX_HEIGHT_FRACTION = 0.86f @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -135,7 +135,6 @@ fun CreateTaskBottomSheet( keyboardController?.hide() focusManager.clearFocus(force = true) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val dateOnlyFormatter = remember { DateTimeFormatter.ofPattern("EEE, MMM d").withZone(ZoneId.systemDefault()) } @@ -212,6 +211,8 @@ fun CreateTaskBottomSheet( } else { Color.Black.copy(alpha = 0.40f) } + val maxSheetHeight = + LocalConfiguration.current.screenHeightDp.dp * CREATE_TASK_SHEET_MAX_HEIGHT_FRACTION fun submitTask() { val due = Instant.ofEpochMilli(dueEpochMs) @@ -232,109 +233,127 @@ fun CreateTaskBottomSheet( } } - ModalBottomSheet( + Dialog( onDismissRequest = onDismiss, - sheetState = sheetState, - dragHandle = null, - containerColor = sheetContainerColor, - tonalElevation = if (isDarkTheme) 10.dp else 0.dp, - scrimColor = sheetScrimColor, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), ) { Box( modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.8f), + .fillMaxSize(), ) { - Column( + Box( modifier = Modifier .fillMaxSize() - .navigationBarsPadding() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 18.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - SheetHeader( - title = if (isEditMode) "Edit task" else "New task", - leftIcon = Icons.Rounded.Close, - leftContentDescription = "Close", - onLeftClick = { + .background(sheetScrimColor) + .clickable { dismissKeyboard() onDismiss() }, - onConfirm = { - dismissKeyboard() - if (canSubmit) { - submitTask() - } - }, - 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), - ) - } ?: colorScheme.outlineVariant.copy(alpha = 0.95f) + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .heightIn(max = maxSheetHeight) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) {}, + shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), + color = sheetContainerColor, + tonalElevation = if (isDarkTheme) 10.dp else 0.dp, + ) { + 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() }, - 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 }, + onConfirm = { + dismissKeyboard() + if (canSubmit) { + submitTask() + } + }, + confirmEnabled = canSubmit, ) - 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 }, + + TaskTextCard( + title = title, + notes = notes, + onTitleChange = { title = it }, + onNotesChange = { notes = it }, + onKeyboardDone = dismissKeyboard, ) - } - Spacer(modifier = Modifier.height(6.dp)) + 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), + ) + } ?: 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 }, + ) + } + } } } } diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index 97826a40..bb4cd623 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -4,6 +4,7 @@ import UIKit private enum CreateTaskSheetMetrics { static let initialSheetHeight: CGFloat = 560 static let maximumHeightFraction: CGFloat = 0.86 + static let bottomContentPadding: CGFloat = 8 } private struct CreateTaskSheetHeaderHeightKey: PreferenceKey { @@ -179,7 +180,7 @@ struct CreateTaskSheet: View { } } .padding(.horizontal, 18) - .padding(.bottom, 14) + .padding(.bottom, CreateTaskSheetMetrics.bottomContentPadding) .background( GeometryReader { proxy in Color.clear @@ -194,7 +195,10 @@ struct CreateTaskSheet: View { .presentationDetents([.height(measuredSheetHeight)]) .presentationDragIndicator(.hidden) .presentationCornerRadius(34) - .presentationBackground(colors.background) + .presentationBackground { + colors.background + .ignoresSafeArea(.container, edges: .bottom) + } .overlay { if let activeSelector { selectorOverlay(for: activeSelector) From bfbc10f4f4a0f996c80094c9e8229f51c8c34b48 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 18:19:20 -0400 Subject: [PATCH 47/52] Refine the checkmark color on the iOS Completed screen. This change updates the visual style of the completed task indicator by replacing the standard system green with a custom brand-aligned shade. - **UI & Styling**: - Define a new `completedCheckmarkColor` using a specific RGB value (111, 191, 134). - Update the `checkmark.circle.fill` icon in `CompletedScreen` to use the new color instead of `Color.green`. --- ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index db5c356a..3104fe75 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -20,6 +20,10 @@ struct CompletedScreen: View { Color(.sRGB, red: 94.0 / 255.0, green: 104.0 / 255.0, blue: 120.0 / 255.0, opacity: 1) } + private var completedCheckmarkColor: Color { + Color(.sRGB, red: 111.0 / 255.0, green: 191.0 / 255.0, blue: 134.0 / 255.0, opacity: 1) + } + private var titleCollapseProgress: CGFloat { let distance = TodoTimelineMetrics.titleCollapseDistance guard distance > 0 else { return 0 } @@ -214,7 +218,7 @@ struct CompletedScreen: View { HStack(alignment: .center, spacing: 12) { Image(systemName: "checkmark.circle.fill") .font(.system(size: TodoTimelineMetrics.minimalRowToggleSize, weight: .regular)) - .foregroundStyle(Color.green) + .foregroundStyle(completedCheckmarkColor) .frame(width: TodoTimelineMetrics.minimalRowToggleFrame, height: TodoTimelineMetrics.minimalRowToggleFrame) VStack(alignment: .leading, spacing: 4) { From 82a01b118ac72940a1362cc58ef209416a8e18c3 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 18:36:32 -0400 Subject: [PATCH 48/52] Implement standard bottom sheet sizing for iOS 18+ in the SwiftUI client. This update introduces a shared view modifier to ensure consistent bottom sheet behavior across the application, specifically utilizing the new `presentationSizing` API available in iOS 18. - **Theme & Modifiers**: - Add `tdayBottomSheetSizing()` to `View` extension in `TdayTheme.swift`. - Configure the modifier to apply `.presentationSizing(.page)` on iOS 18.0 and later, providing a more native-feeling sheet appearance. - **UI Integration**: - Apply `.tdayBottomSheetSizing()` to `CreateTaskSheet` to standardize its presentation height and behavior. - Apply `.tdayBottomSheetSizing()` to the list creation sheet within `HomeScreen`. --- ios-swiftUI/Tday/Feature/Home/HomeScreen.swift | 1 + ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift | 1 + ios-swiftUI/Tday/UI/Theme/TdayTheme.swift | 9 +++++++++ 3 files changed, 11 insertions(+) diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 5254bae6..66197f03 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -1166,6 +1166,7 @@ private struct CreateListSheet: View { colors.background .ignoresSafeArea(.container, edges: .bottom) } + .tdayBottomSheetSizing() .ignoresSafeArea(.keyboard, edges: .bottom) .onPreferenceChange(CreateListSheetContentHeightKey.self) { height in guard !nameFieldFocused else { return } diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index bb4cd623..b272f89f 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -199,6 +199,7 @@ struct CreateTaskSheet: View { colors.background .ignoresSafeArea(.container, edges: .bottom) } + .tdayBottomSheetSizing() .overlay { if let activeSelector { selectorOverlay(for: activeSelector) diff --git a/ios-swiftUI/Tday/UI/Theme/TdayTheme.swift b/ios-swiftUI/Tday/UI/Theme/TdayTheme.swift index d7f6a379..ba76e4e6 100644 --- a/ios-swiftUI/Tday/UI/Theme/TdayTheme.swift +++ b/ios-swiftUI/Tday/UI/Theme/TdayTheme.swift @@ -195,4 +195,13 @@ extension View { func tdayAppTypography() -> some View { font(.tdayRounded(.body, weight: .bold)) } + + @ViewBuilder + func tdayBottomSheetSizing() -> some View { + if #available(iOS 18.0, *) { + presentationSizing(.page) + } else { + self + } + } } From 3c5e5fd3033e7d8088afc0034314bf4eff2a2dcc Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 18:37:23 -0400 Subject: [PATCH 49/52] Revert "Implement standard bottom sheet sizing for iOS 18+ in the SwiftUI client." This reverts commit a943931634a25602b85d61fbed6e8022837016cc. --- ios-swiftUI/Tday/Feature/Home/HomeScreen.swift | 1 - ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift | 1 - ios-swiftUI/Tday/UI/Theme/TdayTheme.swift | 9 --------- 3 files changed, 11 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 66197f03..5254bae6 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -1166,7 +1166,6 @@ private struct CreateListSheet: View { colors.background .ignoresSafeArea(.container, edges: .bottom) } - .tdayBottomSheetSizing() .ignoresSafeArea(.keyboard, edges: .bottom) .onPreferenceChange(CreateListSheetContentHeightKey.self) { height in guard !nameFieldFocused else { return } diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index b272f89f..bb4cd623 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -199,7 +199,6 @@ struct CreateTaskSheet: View { colors.background .ignoresSafeArea(.container, edges: .bottom) } - .tdayBottomSheetSizing() .overlay { if let activeSelector { selectorOverlay(for: activeSelector) diff --git a/ios-swiftUI/Tday/UI/Theme/TdayTheme.swift b/ios-swiftUI/Tday/UI/Theme/TdayTheme.swift index ba76e4e6..d7f6a379 100644 --- a/ios-swiftUI/Tday/UI/Theme/TdayTheme.swift +++ b/ios-swiftUI/Tday/UI/Theme/TdayTheme.swift @@ -195,13 +195,4 @@ extension View { func tdayAppTypography() -> some View { font(.tdayRounded(.body, weight: .bold)) } - - @ViewBuilder - func tdayBottomSheetSizing() -> some View { - if #available(iOS 18.0, *) { - presentationSizing(.page) - } else { - self - } - } } From 448bbd2d8cd6a8b9d9caea95fd068de56090e960 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 18:50:34 -0400 Subject: [PATCH 50/52] Improve dynamic height handling and animations for "Create Task" and "Create List" bottom sheets on Android and iOS. This update refines the bottom sheet behavior across both platforms, ensuring better visibility when the software keyboard is active and smoother transitions during layout changes. - **Android (Compose)**: - **CreateTaskBottomSheet**: Implement dynamic height adjustment based on keyboard visibility, transitioning between 70% and 85% of screen height. Added `AnimatedVisibility` with slide and fade transitions for the sheet entrance/exit. - **CreateListBottomSheet**: Replace static height fractions with an animated `keyboardExtraHeight` (10% of screen height) that pushes content up when the name field is focused. - Standardized motion durations using a new `CREATE_LIST_SHEET_MOTION_MS` constant (320ms) with `FastOutSlowInEasing`. - **iOS (SwiftUI)**: - **CreateListSheet**: Refactor height calculation to use `PreferenceKey` for measuring header and content separately. - Wrap the list creation form in a `ScrollView` that selectively enables scrolling only when the content exceeds the maximum allowed sheet height (85% of screen). - Update `presentationDetents` to use a measured height that accounts for the combined height of the header and scrollable content. - Remove `.presentationContentInteraction(.resizes)` to rely on manual scroll control. - **UI/UX Refinements**: - Added small bottom spacers to ensure content isn't clipped by sheet rounded corners or navigation bars. - Improved keyboard interaction logic to prevent "jumping" layouts on iOS by observing focus state and content height changes simultaneously. --- .../tday/compose/feature/home/HomeScreen.kt | 23 +- .../ui/component/CreateTaskBottomSheet.kt | 86 ++++-- .../Tday/Feature/Home/HomeScreen.swift | 248 ++++++++++-------- 3 files changed, 225 insertions(+), 132 deletions(-) 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 bfc126ac..eced1985 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 @@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -166,6 +165,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -724,6 +724,19 @@ private fun CreateListBottomSheet( var nameFieldFocused by remember { mutableStateOf(false) } val imeVisible = WindowInsets.isImeVisible val useTypingHeight = nameFieldFocused && imeVisible + val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val keyboardExtraHeight by animateDpAsState( + targetValue = if (useTypingHeight) { + screenHeight * CREATE_LIST_SHEET_KEYBOARD_EXTRA_HEIGHT_FRACTION + } else { + 0.dp + }, + animationSpec = tween( + durationMillis = CREATE_LIST_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + label = "createListSheetKeyboardExtraHeight", + ) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val colorScheme = MaterialTheme.colorScheme val selectedAccent = listColorAccent(listColor) @@ -758,13 +771,11 @@ private fun CreateListBottomSheet( ) { Box( modifier = Modifier - .fillMaxWidth() - .then(if (useTypingHeight) Modifier.fillMaxHeight(0.8f) else Modifier), + .fillMaxWidth(), ) { Column( modifier = Modifier .fillMaxWidth() - .then(if (useTypingHeight) Modifier.fillMaxSize() else Modifier) .navigationBarsPadding() .padding(horizontal = 18.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp), @@ -923,7 +934,7 @@ private fun CreateListBottomSheet( } } - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(4.dp + keyboardExtraHeight)) } } } @@ -1847,6 +1858,8 @@ private data class ListIconOption( private const val DEFAULT_LIST_COLOR = "BLUE" private const val DEFAULT_LIST_ICON_KEY = "inbox" +private const val CREATE_LIST_SHEET_KEYBOARD_EXTRA_HEIGHT_FRACTION = 0.10f +private const val CREATE_LIST_SHEET_MOTION_MS = 320 private fun performGentleHaptic(view: android.view.View) { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) 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 eaa50f33..9055aedf 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,7 +1,14 @@ package com.ohmz.tday.compose.ui.component +import androidx.compose.animation.AnimatedVisibility +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 +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -13,10 +20,12 @@ 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.WindowInsets 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.ime import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -68,6 +77,7 @@ import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector 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 @@ -112,6 +122,9 @@ private fun normalizePriorityValue(value: String?): String { private const val DEFAULT_TASK_DURATION_MS = 60L * 60L * 1000L 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 @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -195,6 +208,7 @@ fun CreateTaskBottomSheet( } 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) @@ -211,8 +225,26 @@ fun CreateTaskBottomSheet( } else { Color.Black.copy(alpha = 0.40f) } - val maxSheetHeight = - LocalConfiguration.current.screenHeightDp.dp * CREATE_TASK_SHEET_MAX_HEIGHT_FRACTION + 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), + animationSpec = tween( + durationMillis = CREATE_TASK_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + label = "createTaskSheetHeight", + ) + + LaunchedEffect(Unit) { + sheetVisible = true + } fun submitTask() { val due = Instant.ofEpochMilli(dueEpochMs) @@ -254,26 +286,45 @@ fun CreateTaskBottomSheet( }, ) - Surface( + AnimatedVisibility( + visible = sheetVisible, modifier = Modifier .align(Alignment.BottomCenter) - .fillMaxWidth() - .heightIn(max = maxSheetHeight) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) {}, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), - color = sheetContainerColor, - tonalElevation = if (isDarkTheme) 10.dp else 0.dp, + .fillMaxWidth(), + enter = slideInVertically( + animationSpec = tween( + durationMillis = CREATE_TASK_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + initialOffsetY = { fullHeight -> fullHeight }, + ) + fadeIn(animationSpec = tween(durationMillis = CREATE_TASK_SHEET_MOTION_MS)), + exit = slideOutVertically( + animationSpec = tween( + durationMillis = CREATE_TASK_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + targetOffsetY = { fullHeight -> fullHeight }, + ) + fadeOut(animationSpec = tween(durationMillis = CREATE_TASK_SHEET_MOTION_MS)), ) { - Column( + Surface( modifier = Modifier .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 18.dp, top = 14.dp, end = 18.dp, bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + .height(sheetHeight) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) {}, + shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), + color = sheetContainerColor, + tonalElevation = if (isDarkTheme) 10.dp else 0.dp, ) { + 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, @@ -353,6 +404,9 @@ fun CreateTaskBottomSheet( onOptionSelected = { option -> selectedRepeat = option.name }, ) } + + Spacer(modifier = Modifier.height(4.dp)) + } } } } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 5254bae6..e4b902d9 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -59,6 +59,14 @@ private struct CreateListSheetContentHeightKey: PreferenceKey { } } +private struct CreateListSheetHeaderHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + struct HomeScreen: View { let onNavigate: (AppRoute) -> Void @@ -988,10 +996,8 @@ private struct CreateListSheet: View { @State private var name = "" @State private var color = "BLUE" @State private var iconKey = "inbox" - @State private var compactSheetHeight: CGFloat = min( - CreateListSheetMetrics.initialCompactHeight, - UIScreen.main.bounds.height * CreateListSheetMetrics.maximumHeightFraction - ) + @State private var headerHeight: CGFloat = 84 + @State private var contentHeight: CGFloat = CreateListSheetMetrics.initialCompactHeight - 84 private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1013,8 +1019,16 @@ private struct CreateListSheet: View { max(1, UIScreen.main.bounds.height * CreateListSheetMetrics.maximumHeightFraction) } + private var measuredCompactSheetHeight: CGFloat { + min(max(headerHeight + contentHeight, 1), maximumSheetHeight) + } + + private var contentNeedsScrolling: Bool { + headerHeight + contentHeight > maximumSheetHeight + } + private var compactDetent: PresentationDetent { - .height(compactSheetHeight) + .height(measuredCompactSheetHeight) } private var typingDetent: PresentationDetent { @@ -1026,7 +1040,7 @@ private struct CreateListSheet: View { } var body: some View { - VStack(spacing: 14) { + VStack(spacing: 0) { CreateListSheetHeader( canCreate: canCreate, onClose: { dismiss() }, @@ -1035,131 +1049,142 @@ private struct CreateListSheet: View { dismiss() } ) + .padding(.horizontal, 18) + .padding(.top, 14) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: CreateListSheetHeaderHeightKey.self, value: ceil(proxy.size.height)) + } + ) - CreateListSheetCard { - VStack(spacing: 18) { - ZStack { - Circle() - .fill(accentColor) - .frame(width: 86, height: 86) + ScrollView(showsIndicators: false) { + VStack(spacing: 14) { + CreateListSheetCard { + VStack(spacing: 18) { + ZStack { + Circle() + .fill(accentColor) + .frame(width: 86, height: 86) - Image(systemName: selectedSymbolName) - .font(.system(size: 38, weight: .semibold)) - .foregroundStyle(.white) - } + Image(systemName: selectedSymbolName) + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(.white) + } - TextField( - "", - text: $name, - prompt: Text("List name") - .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) - ) - .focused($nameFieldFocused) - .textInputAutocapitalization(.words) - .autocorrectionDisabled() - .multilineTextAlignment(.center) - .font(.tdayRounded(size: 22, weight: .bold)) - .foregroundStyle(accentColor) - .padding(.horizontal, 14) - .frame(maxWidth: .infinity) - .frame(height: 62) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(colors.surfaceVariant) - ) - } - .padding(.horizontal, 18) - .padding(.vertical, 18) - } + TextField( + "", + text: $name, + prompt: Text("List name") + .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) + ) + .focused($nameFieldFocused) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + .multilineTextAlignment(.center) + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(accentColor) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .frame(height: 62) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(colors.surfaceVariant) + ) + } + .padding(.horizontal, 18) + .padding(.vertical, 18) + } - CreateListSheetSectionTitle(text: "Color") - CreateListSheetCard { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(homeListColorOptions, id: \.key) { option in - let isSelected = option.key == color - Button { - color = option.key - } label: { - Circle() - .fill(option.color) - .frame(width: 48, height: 48) - .overlay( + CreateListSheetSectionTitle(text: "Color") + CreateListSheetCard { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(homeListColorOptions, id: \.key) { option in + let isSelected = option.key == color + Button { + color = option.key + } label: { Circle() - .stroke( - isSelected ? colors.onSurface.opacity(0.3) : .clear, - lineWidth: 3 + .fill(option.color) + .frame(width: 48, height: 48) + .overlay( + Circle() + .stroke( + isSelected ? colors.onSurface.opacity(0.3) : .clear, + lineWidth: 3 + ) ) + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: 0.08 + ) ) + } } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.04, - normalShadowOpacity: 0.08 - ) - ) + .padding(.horizontal, 14) + .padding(.vertical, 14) } } - .padding(.horizontal, 14) - .padding(.vertical, 14) - } - } - CreateListSheetSectionTitle(text: "Icon") - CreateListSheetCard { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(homeListIconOptions, id: \.key) { option in - let isSelected = option.key == iconKey - Button { - iconKey = option.key - } label: { - Circle() - .fill(isSelected ? accentColor.opacity(0.2) : colors.surfaceVariant) - .frame(width: 48, height: 48) - .overlay( + CreateListSheetSectionTitle(text: "Icon") + CreateListSheetCard { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(homeListIconOptions, id: \.key) { option in + let isSelected = option.key == iconKey + Button { + iconKey = option.key + } label: { Circle() - .stroke( - isSelected ? accentColor.opacity(0.55) : .clear, - lineWidth: 2 + .fill(isSelected ? accentColor.opacity(0.2) : colors.surfaceVariant) + .frame(width: 48, height: 48) + .overlay( + Circle() + .stroke( + isSelected ? accentColor.opacity(0.55) : .clear, + lineWidth: 2 + ) ) - ) - .overlay { - Image(systemName: option.symbolName) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(isSelected ? accentColor : colors.onSurfaceVariant) + .overlay { + Image(systemName: option.symbolName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(isSelected ? accentColor : colors.onSurfaceVariant) + } } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: 0.08 + ) + ) + .accessibilityLabel(formattedOptionName(option.key)) + } } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.04, - normalShadowOpacity: 0.08 - ) - ) - .accessibilityLabel(formattedOptionName(option.key)) + .padding(.horizontal, 14) + .padding(.vertical, 14) } } - .padding(.horizontal, 14) - .padding(.vertical, 14) } + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, CreateListSheetMetrics.bottomContentPadding) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: CreateListSheetContentHeightKey.self, value: ceil(proxy.size.height)) + } + ) } - + .scrollDisabled(!contentNeedsScrolling) } - .padding(.horizontal, 18) - .padding(.top, 14) - .padding(.bottom, CreateListSheetMetrics.bottomContentPadding) .frame(maxWidth: .infinity, alignment: .top) - .background( - GeometryReader { proxy in - Color.clear - .preference(key: CreateListSheetContentHeightKey.self, value: ceil(proxy.size.height)) - } - ) .background(colors.background.ignoresSafeArea()) .presentationDetents(activeDetents) - .presentationContentInteraction(.resizes) .presentationDragIndicator(.hidden) .presentationCornerRadius(34) .presentationBackground { @@ -1167,10 +1192,11 @@ private struct CreateListSheet: View { .ignoresSafeArea(.container, edges: .bottom) } .ignoresSafeArea(.keyboard, edges: .bottom) + .onPreferenceChange(CreateListSheetHeaderHeightKey.self) { height in + headerHeight = max(height, 1) + } .onPreferenceChange(CreateListSheetContentHeightKey.self) { height in - guard !nameFieldFocused else { return } - let nextHeight = min(max(height, 1), maximumSheetHeight) - compactSheetHeight = nextHeight + contentHeight = max(height, 1) } .animation(.snappy(duration: 0.24), value: nameFieldFocused) } From 9d2cabaea78a314edc273cb5edf4fb2756677282 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 19:19:23 -0400 Subject: [PATCH 51/52] Improve dynamic height handling and animations for "Create Task" and "Create List" bottom sheets on Android and iOS. This update refines the bottom sheet behavior across both platforms, ensuring better visibility when the software keyboard is active and smoother transitions during layout changes. - **Android (Compose)**: - **CreateTaskBottomSheet**: Implement dynamic height adjustment based on keyboard visibility, transitioning between 70% and 85% of screen height. Added `AnimatedVisibility` with slide and fade transitions for the sheet entrance/exit. - **CreateListBottomSheet**: Replace static height fractions with an animated `keyboardExtraHeight` (10% of screen height) that pushes content up when the name field is focused. - Standardized motion durations using a new `CREATE_LIST_SHEET_MOTION_MS` constant (320ms) with `FastOutSlowInEasing`. - **iOS (SwiftUI)**: - **CreateListSheet**: Refactor height calculation to use `PreferenceKey` for measuring header and content separately. - Wrap the list creation form in a `ScrollView` that selectively enables scrolling only when the content exceeds the maximum allowed sheet height (85% of screen). - Update `presentationDetents` to use a measured height that accounts for the combined height of the header and scrollable content. - Remove `.presentationContentInteraction(.resizes)` to rely on manual scroll control. - **UI/UX Refinements**: - Added small bottom spacers to ensure content isn't clipped by sheet rounded corners or navigation bars. - Improved keyboard interaction logic to prevent "jumping" layouts on iOS by observing focus state and content height changes simultaneously. --- .../tday/compose/feature/home/HomeScreen.kt | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) 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 eced1985..bfc126ac 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 @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -165,7 +166,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -724,19 +724,6 @@ private fun CreateListBottomSheet( var nameFieldFocused by remember { mutableStateOf(false) } val imeVisible = WindowInsets.isImeVisible val useTypingHeight = nameFieldFocused && imeVisible - val screenHeight = LocalConfiguration.current.screenHeightDp.dp - val keyboardExtraHeight by animateDpAsState( - targetValue = if (useTypingHeight) { - screenHeight * CREATE_LIST_SHEET_KEYBOARD_EXTRA_HEIGHT_FRACTION - } else { - 0.dp - }, - animationSpec = tween( - durationMillis = CREATE_LIST_SHEET_MOTION_MS, - easing = FastOutSlowInEasing, - ), - label = "createListSheetKeyboardExtraHeight", - ) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val colorScheme = MaterialTheme.colorScheme val selectedAccent = listColorAccent(listColor) @@ -771,11 +758,13 @@ private fun CreateListBottomSheet( ) { Box( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .then(if (useTypingHeight) Modifier.fillMaxHeight(0.8f) else Modifier), ) { Column( modifier = Modifier .fillMaxWidth() + .then(if (useTypingHeight) Modifier.fillMaxSize() else Modifier) .navigationBarsPadding() .padding(horizontal = 18.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp), @@ -934,7 +923,7 @@ private fun CreateListBottomSheet( } } - Spacer(Modifier.height(4.dp + keyboardExtraHeight)) + Spacer(Modifier.height(4.dp)) } } } @@ -1858,8 +1847,6 @@ private data class ListIconOption( private const val DEFAULT_LIST_COLOR = "BLUE" private const val DEFAULT_LIST_ICON_KEY = "inbox" -private const val CREATE_LIST_SHEET_KEYBOARD_EXTRA_HEIGHT_FRACTION = 0.10f -private const val CREATE_LIST_SHEET_MOTION_MS = 320 private fun performGentleHaptic(view: android.view.View) { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) From 2039f2e1c22cb5ba6daa3b77eb150fe9984480b7 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Wed, 20 May 2026 19:40:03 -0400 Subject: [PATCH 52/52] Replace `ModalBottomSheet` with a custom animated `Dialog` for the list creation flow. This update refactors the list creation UI from a standard Material 3 `ModalBottomSheet` to a custom `Dialog` implementation. This change provides finer control over sheet animations, height adjustments based on keyboard visibility, and ensures consistent behavior across different screen sizes. - **Custom Sheet Implementation**: - Replace `ModalBottomSheet` with a `Dialog` using `decorFitsSystemWindows = false` to enable custom full-screen positioning and scrim handling. - Implement `AnimatedVisibility` with `slideInVertically` and `fadeIn` transitions to replicate bottom sheet motion. - Add logic to animate the sheet height between normal (70%) and keyboard-visible (80%) states to optimize the typing experience. - Define constants for animation duration (`320ms`) and height fractions. - **Interaction & Focus**: - Update `CreateListBottomSheet` to explicitly handle keyboard dismissal and focus clearing when dismissing the dialog or confirming input. - Use `WindowInsets.ime` to detect keyboard visibility for dynamic height transitions. - Wrap the dialog content in a `Box` with a custom scrim and clickable background to handle dismissals outside the sheet area. - **Clean-up**: - Remove unused `ExperimentalMaterial3Api` and `ModalBottomSheet` related imports and state variables. - Refactor the sheet content into a `Surface` within the dialog for proper elevation and shape rendering. --- .../tday/compose/feature/home/HomeScreen.kt | 389 +++++++++++------- 1 file changed, 232 insertions(+), 157 deletions(-) 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 bfc126ac..22ef800d 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 @@ -1,11 +1,16 @@ package com.ohmz.tday.compose.feature.home import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi @@ -30,11 +35,11 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxHeight 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.ime import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset @@ -127,13 +132,11 @@ import androidx.compose.material.icons.rounded.Work import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ColorScheme -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -166,6 +169,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -176,6 +180,8 @@ 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.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.zIndex import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat @@ -191,7 +197,7 @@ import java.time.Instant import java.time.LocalTime import java.util.Locale -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) @Composable fun HomeScreen( uiState: HomeUiState, @@ -706,7 +712,6 @@ fun HomeScreen( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun CreateListBottomSheet( listName: String, @@ -720,15 +725,34 @@ private fun CreateListBottomSheet( ) { val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current + val dismissKeyboard = { + keyboardController?.hide() + focusManager.clearFocus(force = true) + } val focusRequester = remember { FocusRequester() } var nameFieldFocused by remember { mutableStateOf(false) } - val imeVisible = WindowInsets.isImeVisible - val useTypingHeight = nameFieldFocused && imeVisible - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var sheetVisible by remember { mutableStateOf(false) } val colorScheme = MaterialTheme.colorScheme val selectedAccent = listColorAccent(listColor) val canCreate = listName.isNotBlank() val selectedIcon = listIconForKey(listIconKey) + val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val density = LocalDensity.current + val keyboardVisible = WindowInsets.ime.getBottom(density) > 0 + val useTypingHeight = nameFieldFocused && keyboardVisible + val maxSheetHeight = screenHeight * CREATE_LIST_SHEET_MAX_HEIGHT_FRACTION + val sheetHeight by animateDpAsState( + targetValue = (screenHeight * if (useTypingHeight) { + CREATE_LIST_SHEET_KEYBOARD_HEIGHT_FRACTION + } else { + CREATE_LIST_SHEET_NORMAL_HEIGHT_FRACTION + }).coerceAtMost(maxSheetHeight), + animationSpec = tween( + durationMillis = CREATE_LIST_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + label = "createListSheetHeight", + ) val isDarkTheme = colorScheme.background.luminance() < 0.5f val sheetContainerColor = if (isDarkTheme) { lerp(colorScheme.background, colorScheme.surfaceVariant, 0.34f) @@ -742,188 +766,235 @@ private fun CreateListBottomSheet( } LaunchedEffect(Unit) { + sheetVisible = true delay(500) focusRequester.requestFocus() keyboardController?.show() } - ModalBottomSheet( - 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, + Dialog( + onDismissRequest = { + dismissKeyboard() + onDismiss() + }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), ) { Box( modifier = Modifier - .fillMaxWidth() - .then(if (useTypingHeight) Modifier.fillMaxHeight(0.8f) else Modifier), + .fillMaxSize(), ) { - Column( + Box( modifier = Modifier - .fillMaxWidth() - .then(if (useTypingHeight) Modifier.fillMaxSize() else Modifier) - .navigationBarsPadding() - .padding(horizontal = 18.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - ListSheetHeader( - onClose = { - focusManager.clearFocus(force = true) + .fillMaxSize() + .background(sheetScrimColor) + .clickable { + dismissKeyboard() onDismiss() }, - onConfirm = { - focusManager.clearFocus(force = true) - if (canCreate) onCreate() - }, - confirmEnabled = canCreate, - ) + ) - ListSheetCard { + AnimatedVisibility( + visible = sheetVisible, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + enter = slideInVertically( + animationSpec = tween( + durationMillis = CREATE_LIST_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + initialOffsetY = { fullHeight -> fullHeight }, + ) + fadeIn(animationSpec = tween(durationMillis = CREATE_LIST_SHEET_MOTION_MS)), + exit = slideOutVertically( + animationSpec = tween( + durationMillis = CREATE_LIST_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + targetOffsetY = { fullHeight -> fullHeight }, + ) + fadeOut(animationSpec = tween(durationMillis = CREATE_LIST_SHEET_MOTION_MS)), + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(sheetHeight) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) {}, + shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), + color = sheetContainerColor, + tonalElevation = if (isDarkTheme) 10.dp else 0.dp, + ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 18.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .navigationBarsPadding() + .padding(horizontal = 18.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Box( - modifier = Modifier - .size(86.dp) - .clip(RoundedCornerShape(999.dp)) - .background(selectedAccent), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = selectedIcon, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(42.dp), - ) - } + ListSheetHeader( + onClose = { + dismissKeyboard() + onDismiss() + }, + onConfirm = { + dismissKeyboard() + if (canCreate) onCreate() + }, + confirmEnabled = canCreate, + ) - BasicTextField( - value = listName, - onValueChange = onListNameChange, - singleLine = true, - textStyle = MaterialTheme.typography.headlineSmall.copy( - color = selectedAccent, - fontWeight = FontWeight.ExtraBold, - textAlign = TextAlign.Center, - ), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .onFocusChanged { nameFieldFocused = it.isFocused }, - decorationBox = { innerTextField -> + ListSheetCard { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Box( modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(colorScheme.surfaceVariant) - .padding(horizontal = 14.dp, vertical = 12.dp), + .size(86.dp) + .clip(RoundedCornerShape(999.dp)) + .background(selectedAccent), contentAlignment = Alignment.Center, ) { - if (listName.isBlank()) { - Text( - text = stringResource(R.string.home_list_name_placeholder), - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onSurfaceVariant.copy(alpha = 0.78f), - fontWeight = FontWeight.ExtraBold, - ) - } - innerTextField() + Icon( + imageVector = selectedIcon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(42.dp), + ) } - }, - ) - } - } - ListSheetSectionTitle(stringResource(R.string.home_section_color)) - ListSheetCard { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 14.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - LIST_COLOR_OPTIONS.forEach { option -> - val selected = listColor == option.key - val interactionSource = remember { MutableInteractionSource() } - Box( + BasicTextField( + value = listName, + onValueChange = onListNameChange, + singleLine = true, + textStyle = MaterialTheme.typography.headlineSmall.copy( + color = selectedAccent, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { nameFieldFocused = it.isFocused }, + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(colorScheme.surfaceVariant) + .padding(horizontal = 14.dp, vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + if (listName.isBlank()) { + Text( + text = stringResource(R.string.home_list_name_placeholder), + style = MaterialTheme.typography.headlineSmall, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.78f), + fontWeight = FontWeight.ExtraBold, + ) + } + innerTextField() + } + }, + ) + } + } + + ListSheetSectionTitle(stringResource(R.string.home_section_color)) + ListSheetCard { + Row( modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(option.color) - .border( - width = if (selected) 3.dp else 0.dp, - color = if (selected) colorScheme.onBackground.copy(alpha = 0.32f) else Color.Transparent, - shape = CircleShape, + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 14.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + LIST_COLOR_OPTIONS.forEach { option -> + val selected = listColor == option.key + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(option.color) + .border( + width = if (selected) 3.dp else 0.dp, + color = if (selected) colorScheme.onBackground.copy( + alpha = 0.32f + ) else Color.Transparent, + shape = CircleShape, + ) + .clickable( + interactionSource = interactionSource, + indication = ripple( + bounded = true, + radius = 24.dp, + ), + ) { onListColorChange(option.key) }, ) - .clickable( - interactionSource = interactionSource, - indication = ripple( - bounded = true, - radius = 24.dp, - ), - ) { onListColorChange(option.key) }, - ) + } + } } - } - } - ListSheetSectionTitle(stringResource(R.string.home_section_icon)) - ListSheetCard { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 14.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - LIST_ICON_OPTIONS.forEach { option -> - val selected = listIconKey == option.key - val interactionSource = remember { MutableInteractionSource() } - val iconOptionDescription = stringResource(R.string.home_list_icon_option, option.key) - Box( + ListSheetSectionTitle(stringResource(R.string.home_section_icon)) + ListSheetCard { + Row( modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background( - if (selected) { - selectedAccent.copy(alpha = 0.2f) - } else { - colorScheme.surfaceVariant - }, - ) - .border( - width = if (selected) 2.dp else 0.dp, - color = if (selected) selectedAccent.copy(alpha = 0.55f) else Color.Transparent, - shape = CircleShape, - ) - .clickable( - interactionSource = interactionSource, - indication = ripple( - bounded = true, - radius = 24.dp, - ), - ) { onListIconChange(option.key) }, - contentAlignment = Alignment.Center, + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 14.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Icon( - imageVector = option.icon, - contentDescription = iconOptionDescription, - tint = if (selected) selectedAccent else colorScheme.onSurfaceVariant, - ) + LIST_ICON_OPTIONS.forEach { option -> + val selected = listIconKey == option.key + val interactionSource = remember { MutableInteractionSource() } + val iconOptionDescription = + stringResource(R.string.home_list_icon_option, option.key) + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background( + if (selected) { + selectedAccent.copy(alpha = 0.2f) + } else { + colorScheme.surfaceVariant + }, + ) + .border( + width = if (selected) 2.dp else 0.dp, + color = if (selected) selectedAccent.copy(alpha = 0.55f) else Color.Transparent, + shape = CircleShape, + ) + .clickable( + interactionSource = interactionSource, + indication = ripple( + bounded = true, + radius = 24.dp, + ), + ) { onListIconChange(option.key) }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = option.icon, + contentDescription = iconOptionDescription, + tint = if (selected) selectedAccent else colorScheme.onSurfaceVariant, + ) + } + } } } + + Spacer(Modifier.height(4.dp)) } } - - Spacer(Modifier.height(4.dp)) } } } @@ -1847,6 +1918,10 @@ private data class ListIconOption( private const val DEFAULT_LIST_COLOR = "BLUE" private const val DEFAULT_LIST_ICON_KEY = "inbox" +private const val CREATE_LIST_SHEET_MAX_HEIGHT_FRACTION = 0.80f +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 fun performGentleHaptic(view: android.view.View) { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK)