diff --git a/.github/screenshots/readme/calendar-month.png b/.github/screenshots/readme/calendar-month.png index f6fe92a..f788422 100644 Binary files a/.github/screenshots/readme/calendar-month.png and b/.github/screenshots/readme/calendar-month.png differ diff --git a/.github/screenshots/readme/calendar-week.png b/.github/screenshots/readme/calendar-week.png index 9f63241..cf882bb 100644 Binary files a/.github/screenshots/readme/calendar-week.png and b/.github/screenshots/readme/calendar-week.png differ diff --git a/.github/screenshots/readme/edit-entry.png b/.github/screenshots/readme/edit-entry.png index bfd14a0..91f796c 100644 Binary files a/.github/screenshots/readme/edit-entry.png and b/.github/screenshots/readme/edit-entry.png differ diff --git a/.github/screenshots/readme/history.png b/.github/screenshots/readme/history.png index 543d9db..84a1d3b 100644 Binary files a/.github/screenshots/readme/history.png and b/.github/screenshots/readme/history.png differ diff --git a/.github/screenshots/readme/inbox.png b/.github/screenshots/readme/inbox.png index 9c9664c..f8b33bc 100644 Binary files a/.github/screenshots/readme/inbox.png and b/.github/screenshots/readme/inbox.png differ diff --git a/.github/screenshots/readme/review.png b/.github/screenshots/readme/review.png index 007875a..9c6e141 100644 Binary files a/.github/screenshots/readme/review.png and b/.github/screenshots/readme/review.png differ diff --git a/.github/screenshots/readme/statistics.png b/.github/screenshots/readme/statistics.png index ab571c7..3271a83 100644 Binary files a/.github/screenshots/readme/statistics.png and b/.github/screenshots/readme/statistics.png differ diff --git a/.github/screenshots/readme/templates.png b/.github/screenshots/readme/templates.png index 9f5d638..96b9b12 100644 Binary files a/.github/screenshots/readme/templates.png and b/.github/screenshots/readme/templates.png differ diff --git a/.github/screenshots/readme/track.png b/.github/screenshots/readme/track.png index efbf536..253ed59 100644 Binary files a/.github/screenshots/readme/track.png and b/.github/screenshots/readme/track.png differ diff --git a/FEATURE_GAP_ANALYSIS.md b/FEATURE_GAP_ANALYSIS.md deleted file mode 100644 index 8358285..0000000 --- a/FEATURE_GAP_ANALYSIS.md +++ /dev/null @@ -1,859 +0,0 @@ -# SolidVerdant Feature Gap Analysis and Product Roadmap - -## Executive summary - -SolidVerdant already has a strong identity as a native Android client for Solidtime. Its Quick Settings tile, persistent notification, home-screen widget, recent-project shortcuts, offline cache, OAuth2/PKCE support, and self-hosted server configuration make starting and stopping a timer unusually accessible. - -The largest opportunity is not to reproduce every administrative feature found in broad workforce platforms. It is to become the fastest and most trustworthy way to capture, review, and correct Solidtime entries on Android. - -The recommended product position is: - -> **The fastest, most trustworthy way to capture and repair Solidtime entries on Android.** - -The numbered additions below are grouped by product area. Priorities use the following scale: - -- **P0:** Completes or protects a core workflow. -- **P1:** High-value differentiation after the core is dependable. -- **P2:** Valuable expansion that can follow the main product loop. -- **Defer:** Large platform features that would dilute the Android-first focus. - -## Timer and quick capture - -### 1. Favorite and pinned timers — P0 - -Allow users to pin common combinations of organization, project, task, tags, billable status, and description. Pinned timers should appear above recent entries in the app and optionally in the Quick Settings selection screen. - -Recent entries are useful, but they change constantly. Favorites provide a stable home for recurring activities such as “daily stand-up,” “support,” or “client administration.” Starting one should require a single tap, while still allowing its details to be edited after the timer starts. - -### 2. Configurable timer defaults — P1 - -Let users define default behavior globally and per project. Examples include default billable status, tags, task, description, and whether starting a new timer automatically stops the current one. - -This reduces repetitive cleanup and makes entries more consistent. Per-project defaults are particularly useful because billable state and required tags often differ between internal and client work. - -### 3. Forgotten-timer protection — P0 - -Warn users when a timer has run unusually long, crosses a configurable time of day, or continues after a calendar event has ended. The warning should offer Stop, Keep Running, and Adjust End Time actions. - -The app should learn only from local history or simple user settings at first. A user-configurable maximum duration is easier to understand and more trustworthy than a hidden heuristic. - -### 4. Tracking reminders — P0 - -Add optional reminders when no timer is active during chosen working hours. Reminders could also appear at the end of the day when tracked time is below a personal target or when uncategorized entries remain. - -Users should be able to configure days, working hours, reminder frequency, and quiet periods. Notifications must stay actionable and allow starting a favorite without opening the full app. - -### 5. Focus and Pomodoro mode — P2 - -Offer an optional focus session attached to a time entry, with configurable work and break intervals. This should remain lightweight rather than becoming a separate task manager. - -The differentiating detail would be integration with Android surfaces: focus progress in the notification, pause/resume controls, and automatic continuation of the same Solidtime entry after a break. - -### 6. Voice, NFC, and automation starts — P2 - -Expose safe Android intents or app shortcuts for starting a configured timer. This enables voice assistants, NFC tags, QR routines, Tasker, MacroDroid, and other automation tools without requiring a custom integration for each one. - -The app should document the supported actions and require explicit project identifiers or saved favorite IDs. Destructive or ambiguous actions should still open a confirmation surface. - -### 7. Wear OS companion — P2 - -Provide a small Wear OS experience for viewing the current timer, starting a favorite, pausing, resuming, and stopping. Complications could show the current timer or today’s total. - -Wear support fits the product’s existing “capture from anywhere” advantage much better than broad web-style administration features. - -## Smart assistance - -### 8. Contextual quick-start suggestions — Experimental/P2 - -Begin with deterministic, user-visible ranking only: pinned timers first, followed by timers previously used on the same weekday or within the same time-of-day bucket. Calendar suggestions should require a mapping explicitly created by the user, such as “Events from this calendar use this project.” Do not initially use location, phone sensors, application usage, or a trained model. - -Every suggestion must be explainable—such as “Used at this time last Monday”—and computed on-device. A suggestion may prefill a form, but it must never create, edit, submit, or mark an entry billable without confirmation. Users must be able to disable each suggestion source. - -### 9. Description and metadata templates — P1 - -Expand favorites into reusable templates with placeholder values. A template could prefill a project, tags, and a description such as “Review: {topic}” while asking for the missing topic before starting. - -This serves users whose work is repetitive but not identical, and improves report quality without forcing them to type the same structure every time. - -### 10. Private activity recall — Research/Defer - -If this is ever pursued, study ActivityWatch’s watcher/event architecture and Toggl Timeline’s user-controlled conversion flow instead of designing a new monitoring system. Offer an opt-in local timeline only to help users reconstruct missing time. The activity history must stay on the device and must never become a surveillance feature. - -The purpose is recall, not classification, productivity scoring, or automatic billing. Android application-usage access is highly sensitive and requires special user-granted access, so this feature needs a separate privacy review and threat model before implementation. Users must choose which fragments become time entries, and excluded apps must never be recorded. - -## History and correction - -### 11. Search and advanced history filters — P0 - -Add search across descriptions, projects, tasks, tags, and customers. Filters should cover date range, billable status, organization, project, task, tag, sync state, running entries, and entries without a project or description. - -Filters should be easy to clear and should show the active conditions prominently. A few saved filter presets—such as “Needs categorization”—would turn history into a useful review tool. - -### 12. Multi-select and bulk editing — P1 - -Allow users to select multiple entries and change their project, task, tags, billable status, or description together. Bulk delete should require confirmation and provide a short undo window where technically possible. - -Bulk correction is essential when someone tracks a whole day against the wrong project or needs to apply a billing tag at the end of the week. - -### 13. Duplicate and split entries — P1 - -Add actions to duplicate an entry and split it at a chosen time. Duplicating is useful for repeated manual work, while splitting lets users correct a timer that covered two activities without recreating both periods manually. - -When splitting, the app should preserve metadata on both halves and then allow the second half to be edited immediately. - -### 14. Undo for destructive actions — P0 - -Provide undo for deletion and other reversible history operations. Where server synchronization makes true undo impossible, the app can recreate the entry using its cached data. - -This small safety net materially increases trust, especially on mobile where accidental taps are common. - -### 15. Per-entry sync state — P0 - -Show whether an entry is synced, pending, retrying, or failed. Users should be able to tap the status for a plain-language explanation and retry a failed operation. - -The current global sync warning is useful, but it does not answer the most important question: “Did this particular entry reach the server?” - -## Daily review and time accuracy - -### 16. Deterministic Time Inbox — P1, primary differentiator - -Create a focused review screen that collects entries needing attention using explicit, inspectable rules: - -- Gaps during configured working hours. -- Overlapping entries. -- Entries exceeding a user-configured maximum duration. -- Entries with no project, task, description, or required tag. -- Entries still pending synchronization. -- Calendar events that may represent untracked work. - -Each item should have a quick repair action. The inbox should feel finite and reassuring: once every issue is addressed, the user reaches a clear “All caught up” state. - -This is the strongest opportunity for SolidVerdant to stand out. Most timer apps make capturing data easy; fewer make daily cleanup genuinely pleasant on a phone. The inbox should call items “checks” or “items to review,” not anomalies, mistakes, fraud, or unproductive time. - -### 17. Deterministic overlap and gap detection — P0 - -Detect overlaps with exact interval intersection and identify gaps only inside working hours explicitly configured by the user. A configurable minimum gap length prevents the inbox from filling with harmless transitions. No statistical anomaly detector is necessary. - -Solidtime’s API exposes the organization setting `prevent_overlapping_time_entries`. Fetch and honor that policy: when enabled, prevention is a server-owned organization rule; when disabled, SolidVerdant may still offer an optional local review check but must not imply the overlap violates organization policy. Working hours remain separate local state because they are not present in the supplied API specification. - -Warnings should not block saving. Some users legitimately track overlapping activities, so the app should allow dismissing an issue or marking it intentional. - -### 18. End-of-day review — P1 - -Offer an optional end-of-day notification that opens a compact review: total tracked time, missing periods, uncategorized entries, billable percentage, and failed sync operations. - -The review should prioritize corrections over analytics. Its goal is to help the user finish the day confident that tomorrow’s reports will be accurate. It must report only facts and user-configured rule violations, not inferred productivity or intent. - -### 19. Personal goals and streaks — P2 - -Allow users to set daily or weekly tracking targets and optional billable-hour goals. Progress can appear in the app, widget, and end-of-day review. - -Streaks should reward completing or reviewing timesheets rather than maximizing hours worked. The tone should support healthy consistency rather than encouraging overwork. - -## Calendar - -### 20. Week view on phones — P1 - -Add a phone-friendly week view between the current month heatmap and single-day timeline. It should make gaps and workload patterns visible without requiring a tablet-sized layout. - -A three-day option may work better than seven columns on narrow screens. Users should be able to swipe between periods and jump back to today. - -### 21. Drag to create, move, and resize — P1 - -Allow users to create an entry by dragging across an empty time range and adjust existing entries by moving or resizing their blocks. Editing metadata can happen in a bottom sheet after the time range is chosen. - -The interactions need strong snapping, haptics, and accessibility alternatives. Precise text fields must remain available for users who cannot or do not want to drag. - -### 22. Device calendar overlay — P1 - -Overlay selected Android calendars onto the SolidVerdant timeline. Calendar events should remain visually distinct from tracked entries and should not be uploaded automatically. - -Users should be able to convert an event into a time entry, choose a suggested project, or hide it. Calendar access must be opt-in with clear privacy language. - -### 23. Calendar reconciliation — P1 - -Go beyond merely displaying events by matching them against existing entries. The app could show that a one-hour meeting has only 35 minutes tracked, or that a completed event has no corresponding entry. - -Suggested matches must always be reviewable. Titles and attendee information should remain local unless the user explicitly chooses to use them in an entry description. - -## Statistics and reporting - -### 24. Complete custom date ranges — P0 - -Finish the custom-range control already anticipated in the statistics screen. Include common shortcuts such as Today, Yesterday, Last 7 Days, Last Week, This Month, and Previous Month. - -Custom ranges are table stakes for meaningful analysis and should also be reusable by history filters and exports. - -### 25. Rich statistics filters — P0 - -Allow statistics to be filtered by organization, project, task, tag, customer, and billable status. The screen should always make its active scope clear. - -Selecting a chart segment should drill into the matching entries rather than leaving charts as static decoration. - -Use Solidtime’s existing time-entry filter parameters for client, project, task, tag, member, billable state, active state, and date boundaries when the requested range is not already complete in Room. This avoids downloading an unbounded history merely to filter it locally. - -### 26. Previous-period comparisons — P1 - -Compare the selected period with the preceding equivalent period. Useful comparisons include total tracked time, billable time, average per day, and the largest project changes. - -The UI should show absolute and percentage changes while handling tiny or empty prior periods gracefully. - -### 27. Additional breakdowns — P1 - -Add breakdowns by task, tag, customer, and billable versus non-billable time. Users should be able to switch the grouping rather than scrolling through many permanent charts. - -Long lists should expose the complete data, not silently stop after the top six projects. Small segments can be grouped visually as “Other” while remaining available in details. - -The supplied API includes aggregate endpoints with grouping and subgrouping by day, week, month, year, user, project, task, client, billable state, description, and tag. Prefer these endpoints for authoritative large-range reports, with local aggregation as an offline or already-cached fallback. - -### 28. Estimates, budgets, and progress — P1 - -Surface project and task estimates already available from Solidtime data. Show tracked versus estimated time, remaining time, and percentage consumed. - -Optional alerts can warn when a project approaches its estimate. This converts statistics from a retrospective view into something that can prevent scope overrun. - -### 29. Earnings and billable value — P2 - -Where the server exposes suitable billing rates, show estimated billable value by period and project. Monetary figures should clearly state their currency and source. - -This should remain a reporting aid rather than becoming a complete accounting system. - -### 30. CSV export and Android sharing — P1 - -Allow the current filtered data to be exported as CSV and shared through Android’s system share sheet. Include clear timezone, duration, organization, project, task, tags, description, and billable fields. - -CSV delivers most of the practical portability value without the complexity of designing branded reports. Solidtime already provides time-entry and aggregate export endpoints with filters and format selection; use those when online so exported results follow server permissions and reporting semantics. A clearly labelled local export can remain available for cached/offline personal data. - -### 31. PDF reports and scheduled summaries — P2 - -Add readable personal PDF summaries only after filtering and CSV export are mature. A scheduled weekly summary notification or email handoff could show totals, project distribution, and entries needing review. - -Avoid building public report hosting inside the app; that is better handled by Solidtime or a web service. - -### 32. Rounding and duration display preferences — P1 - -Support report-only rounding rules and user-selected duration formats, such as `1h 30m`, `1:30`, or `1.50 h`. Raw time entries should remain unchanged unless the user explicitly edits them. - -This distinction prevents display preferences from silently changing billable data. Solidtime’s API already accepts `rounding_type` and `rounding_minutes` on reporting and export requests, while the organization resource supplies number, date, interval, and time formats. Fetch those values and use server-side rounding for server reports; reserve local preferences for optional Android display overrides. - -## Offline operation and synchronization - -### 33. Dedicated sync center — P0 - -Add a screen showing the last successful sync, pending changes, recent failures, retry schedule, and affected entries. Provide Retry All and item-specific retry actions. - -Messages should be actionable and avoid exposing implementation terms. For example: “This edit has not reached the server yet” is more useful than a raw HTTP error. - -### 34. Conflict resolution — P0 - -When the same entry changes locally and remotely, present both versions and allow the user to keep the phone version, keep the server version, or combine fields where safe. - -Conflicts should never be resolved silently when doing so could lose time or billing metadata. - -### 35. Clear freshness indicators — P1 - -Show when projects, tasks, organizations, and entries were last refreshed. Cached data should remain usable offline, but the app should communicate when a list may be stale. - -This is especially important for the Quick Settings tile, whose state can otherwise diverge from changes made on web or desktop clients. - -### 36. Offline capability explanation — P1 - -Explain which actions work offline and when they will synchronize. The explanation can appear in onboarding and in the sync center rather than interrupting normal use. - -Good offline behavior becomes a differentiator only when users understand and trust it. - -## Android integration - -### 37. Project-specific widgets — P1 - -Allow a widget to be configured for a favorite or project. Tapping it should start that exact timer or prompt only for the missing description. - -Multiple widgets could serve different work contexts, while a distinct active appearance prevents accidentally starting duplicate work. - -### 38. Daily-total and goal widgets — P2 - -Add compact widget variants showing today’s tracked total, billable total, goal progress, or pending review count. - -These widgets support awareness without forcing the user into the app and make better use of Android’s resizable widget surface. - -### 39. Customizable notification actions — P1 - -Let users choose which actions appear on the active and idle notifications. Options might include Pause, Stop, Switch Project, Add Note, and Start Favorite. - -Android’s action limit means personalization is more valuable than trying to display every control simultaneously. - -### 40. Better cross-device state refresh — P0 - -Refresh the current timer more reliably after returning to the app and provide a lightweight way to reconcile tile, widget, and notification state with the server. - -Where continuous remote updates are impossible, show freshness honestly and add a refresh action rather than presenting stale state as authoritative. - -## Projects, organizations, and accounts - -### 41. Faster organization switching — P1 - -Remember the most recently used project and filters per organization. Organization switching should make the data boundary explicit and avoid resetting more UI state than necessary. - -If switching while a timer is active is unsupported, explain why and offer to stop or retain the current timer before switching. - -### 42. Cross-organization overview — P2 - -Provide an optional combined personal summary across organizations. Individual entries should still retain clear organization labels, and editing should occur within the correct organization context. - -This helps consultants and contributors who work across several Solidtime organizations. - -### 43. Project progress in selection — P1 - -Show useful context in project selection, such as customer, active/archived state, recently used status, and estimate consumption. Keep the default list compact and reveal extra detail only where it helps selection. - -Users should be able to hide archived projects and pin favorites. - -### 44. Multiple saved server profiles — P2 - -Allow advanced users to save more than one Solidtime endpoint/account profile and switch deliberately. Each profile must keep authentication, cached data, and encryption boundaries separate. - -This would be valuable to developers or consultants managing personal and company-hosted instances, but it should not complicate the default login experience. - -## Self-hosting, setup, and trust - -### 45. Connection test and setup diagnostics — P0 - -Add a Test Connection action to custom OAuth configuration. Validate the URL, TLS connection, expected API shape, redirect URI, and client ID where possible before launching login. - -Errors should explain whether the problem is connectivity, an unsupported server, OAuth configuration, or an invalid endpoint. - -### 46. QR-based configuration — P1 - -Let self-hosting administrators encode the endpoint and client ID in a QR code or configuration link. Scanning or opening it should preview the values before saving them. - -This reduces error-prone manual copying while preserving user confirmation and avoiding secrets in the configuration payload. - -### 47. Server compatibility information — P1 - -Display detected server/version information and which optional capabilities are available. If a feature is unsupported, hide it or explain the requirement instead of failing after interaction. - -This becomes increasingly important as Solidtime and self-hosted installations evolve at different speeds. - -### 48. Privacy and data-management page — P1 - -Explain what is stored locally, what is sent to the selected server, how tokens are protected, and which optional permissions enable calendar, location, or notifications. - -Provide controls to clear caches, revoke the local session, and inspect storage usage without requiring a full logout unless security demands it. - -### 49. Diagnostic export — P1 - -Create a privacy-reviewed diagnostic bundle containing app version, Android version, server capability summary, and sanitized recent sync errors. Never include access tokens, descriptions, project names, or other work content by default. - -This can make self-hosting support dramatically easier while maintaining the app’s trustworthiness. - -## Accessibility, localization, and personalization - -### 50. Complete localization coverage — P0 - -Move all user-facing calendar and statistics strings into Android resources and update English, Dutch, and Japanese translations. Dates, week starts, number formats, and duration formats should follow locale conventions. - -Partially translated screens make a multilingual app feel unfinished even when the underlying feature works correctly. Locale translation remains an app responsibility, but date, interval, time, number, and currency presentation should honor the corresponding organization formats exposed by Solidtime unless the user explicitly chooses an Android-only override. - -### 51. TalkBack and keyboard audit — P0 - -Test the complete timer, calendar, editing, statistics, login, widget configuration, and Quick Settings flows with TalkBack and external keyboard navigation. - -Charts need textual summaries, project colors need non-color labels, touch targets need adequate size, and icon-only actions need stable descriptions. - -### 52. Large text and responsive layout audit — P0 - -Verify screens at the largest supported font and display sizes. Timer controls, tags, history rows, bottom sheets, and charts should reflow without clipping or hiding primary actions. - -The wide/tablet layouts are a good foundation, but accessibility scaling must also work on narrow phones. - -### 53. Reduced motion and color-independent states — P1 - -Respect system animation preferences and ensure tracking, paused, error, and selected states are not communicated by color alone. - -Text, icons, shape, and content descriptions should reinforce every meaningful status. - -### 54. Configurable day and week rules — P1 - -Fetch the user’s Solidtime `week_start` and `timezone` fields and use them as the default across calendar, history, statistics, and exports. If the API permits profile updates, changing these should be presented as a Solidtime account preference because it affects other clients; otherwise SolidVerdant may offer a clearly labelled device-only override. - -An optional workday boundary for overnight shifts remains local because no such field appears in the supplied API specification. - -Entries crossing midnight need predictable treatment in history, statistics, and calendar views. - -### 55. Configurable home experience — P2 - -Let users choose whether the Track, Calendar, Stats, or Time Inbox screen opens by default. Power users could also hide unused bottom-navigation destinations. - -Keep the standard setup opinionated; customization should not turn the first-run experience into a settings exercise. - -## Features to defer - -### 56. Full invoicing — Defer - -Generating tax-compliant invoices, handling currencies, payment states, discounts, tax rules, and branding is a product of its own. SolidVerdant should expose billable value and export clean data before attempting accounting workflows. - -### 57. Payroll and attendance management — Defer - -Payroll, overtime policy, leave, compliance, and attendance rules vary widely by jurisdiction. These features would move the app away from its focused Solidtime companion role. - -### 58. Employee monitoring, screenshots, and GPS surveillance — Avoid - -Do not imitate workforce-monitoring products that collect screenshots, detailed application activity, or continuous employee location. If location or activity context is added, it should be private, optional, local-first, and designed solely to help the device owner recall their own time. - -### 59. Kiosk and shift scheduling — Defer - -Shared-device clock-in, shift assignment, and workforce scheduling solve a different operational problem and require significant administrative interfaces. They do not reinforce the app’s Android personal-capture advantage. - -### 60. Full team administration — Defer - -Role management, member provisioning, timesheet approvals, locked periods, and audit logs are better handled by Solidtime’s web administration unless the server later exposes a narrow, mobile-appropriate approval workflow. - -## Evidence-backed patterns and hard cases we can use - -The items in this section distinguish reusable, well-understood behavior from speculative inference. They are implementation constraints for the roadmap, not optional polish. - -### 61. Use ActivityWatch as the precedent for private recall - -[ActivityWatch](https://github.com/ActivityWatch/activitywatch) is an open-source, privacy-focused automatic activity tracker. Its most reusable idea is architectural: independent watchers produce timestamped events, local storage keeps the evidence under the user’s control, and higher-level views interpret events without changing the originals. Its [documentation](https://docs.activitywatch.net/en/latest/) also demonstrates AFK and window watchers as separate data sources. - -If SolidVerdant ever implements activity recall, use the same separation: - -1. A collector records a narrow event type. -2. Raw events remain local and expire according to a visible retention setting. -3. Excluded applications are filtered before persistence. -4. A recall screen displays evidence without creating a Solidtime entry. -5. Only an explicit user action converts selected evidence into a draft entry. - -Do not copy ActivityWatch wholesale into the main app. First evaluate whether interoperability with an existing ActivityWatch installation is possible, or whether a minimal optional companion is safer than adding monitoring permissions to SolidVerdant. - -### 62. Use Toggl Timeline as the conversion-flow precedent - -[Toggl Timeline](https://toggl.com/track/automated-time-tracker/) keeps automatically captured activity private and lets the user decide what becomes a time entry. This is the correct interaction boundary for financial records. - -The reusable flow is **observe → display → suggest → confirm**, never **observe → submit**. SolidVerdant should preserve the source event, show the proposed start/end and metadata, and require confirmation in the normal entry editor. Rejecting a suggestion must not alter future records silently. - -### 63. Use Kimai as the precedent for deterministic timesheet validation - -[Kimai](https://github.com/kimai/kimai) is a mature open-source time-tracking system with explicit configuration for overlaps, future entries, budgets, exports, rates, and validation. Its [configuration documentation](https://www.kimai.org/documentation/configurations.html) is a useful catalogue of rules whose behavior can be explained to users. - -SolidVerdant can reuse the domain patterns without depending on Kimai code: - -- Overlap checks based on interval intersection. -- Optional rejection or warning for entries in the future. -- Configurable handling of concurrent timers. -- Budget and estimate comparisons based on direct arithmetic. -- Explicit working-time and break rules. -- Search and export behavior built from stored fields rather than inferred categories. - -Server rules remain authoritative. Local checks should prevent obvious mistakes early but must not claim that an entry is accepted until Solidtime confirms it. - -### 64. Exact overlap detection - -Two half-open intervals `[startA, endA)` and `[startB, endB)` overlap exactly when: - -```text -startA < endB AND startB < endA -``` - -Using half-open intervals means one entry ending at 10:00 and another starting at 10:00 do not overlap. Running timers use the current instant as a provisional end only for display. Invalid or missing timestamps should produce a data-quality check rather than being coerced into a result. - -Important hard cases to test: - -1. Adjacent entries with equal boundary times. -2. One interval fully containing another. -3. Identical intervals. -4. Entries crossing midnight. -5. Daylight-saving transitions and repeated local times. -6. A running entry overlapping a completed entry. -7. Entries belonging to different organizations. -8. Server timestamps with different offsets representing the same instant. - -All comparisons should use instants. Local dates and timezones are for grouping and presentation only. - -### 65. Exact gap detection - -Gap detection should operate only inside working windows configured by the user. For each window: - -1. Clip completed entry intervals to the working window. -2. Sort them by start instant. -3. Merge overlapping or adjacent intervals. -4. Return uncovered intervals at least as long as the configured minimum gap. - -Hard cases include lunch breaks, split shifts, overnight shifts, holidays, entries crossing a window boundary, overlapping entries, daylight-saving changes, and users who intentionally do not account for every working minute. Consequently, gaps are invitations to review—not errors—and the entire feature must be optional. - -### 66. User-configured long-timer checks - -The first implementation should use an explicit threshold such as “remind me after 4 hours.” It should not calculate an anomaly score from the user’s history. - -The notification must say what factual rule fired: “This timer has been running for 4 hours.” Actions should include Keep Running, Stop Now, and Choose End Time. Choosing Keep Running can snooze the warning for a configured duration, but must not teach a hidden model. - -This approach is predictable, testable, and safe for users whose work sessions legitimately vary. - -### 67. Deterministic suggestion ranking - -Quick-start suggestions can be useful without machine learning. Rank only entries or templates the user has previously created, using an inspectable sequence such as: - -1. Pinned favorites in the user’s chosen order. -2. Explicit calendar-to-project mappings matching an active event. -3. Most recently used entries. -4. Most frequently used entries within the same weekday and coarse time bucket. - -Do not combine these into an opaque confidence score. Display the reason next to each suggestion, deduplicate identical project/task combinations, and fall back to recents when there is insufficient history. - -Evaluation should measure whether users accept a suggestion unchanged, edit it, or ignore it. A low acceptance rate means the ranking should be removed or simplified—not made more aggressive. - -### 68. Deterministic calendar reconciliation - -Calendar reconciliation should begin as a visual comparison, not entity inference. Safe capabilities include: - -- Displaying selected calendar events beside tracked entries. -- Showing uncovered portions of an event using interval arithmetic. -- Converting an event into a draft entry. -- Applying a project only through a mapping explicitly saved by the user. -- Remembering that the user dismissed a particular event instance. - -Do not infer customer, project, billable status, or productivity from attendees, event title, location, or conference URL. These fields may contain sensitive data and the same event pattern can represent different work contexts. - -### 69. Research warning: phone context does not establish work intent - -Context-aware activity-recognition research shows that time, location, and sensors can improve recognition of activities, but it does not validate classification of professional intent or billable work. For example, [CAVIAR](https://arxiv.org/abs/1906.03033) evaluated context-aware activity recognition on 26 subjects and emphasizes uncertainty and asking the user when confidence is insufficient. Another [multi-context activity study](https://pmc.ncbi.nlm.nih.gov/articles/PMC4610464/) used only three participants despite reporting strong aggregate classification results. - -More directly, the CHI paper [Understanding Personal Productivity](https://par.nsf.gov/servlets/purl/10132820) explains that automated tracking misses off-device work and cannot reliably infer intent from an application or website: the same software can be used for personal and professional purposes, and duration does not establish productivity. - -Therefore SolidVerdant must not use phone context to automatically: - -- Select a customer or project. -- Mark time billable. -- Label time productive or unproductive. -- Accuse a user of a mistake or suspicious behavior. -- Create, edit, stop, or submit a timesheet. - -### 70. Human-in-the-loop safety contract - -Research such as [A Reliable Framework for Human-in-the-Loop Anomaly Detection in Time Series](https://arxiv.org/abs/2405.03234) supports bidirectional review and correction rather than blind automation. It is a design reference, not a production library suitable for SolidVerdant’s data. - -Every experimental suggestion in SolidVerdant must obey this contract: - -1. **No silent writes.** Suggestions never create, edit, delete, stop, mark billable, or submit entries automatically. -2. **Explain the trigger.** Show the exact event or rule that produced the suggestion. -3. **Preserve evidence.** Keep source evidence separate from the proposed timesheet. -4. **Require confirmation.** Conversion occurs through a normal editable draft. -5. **Fail to no suggestion.** Missing data, ambiguous state, or low support produces nothing. -6. **Stay local-first.** Context data remains on-device unless the user explicitly submits selected fields as part of an entry. -7. **Be optional.** Each data source and suggestion category can be disabled independently. -8. **Avoid judgment.** Use neutral language such as “Review this period,” never “unproductive,” “fraudulent,” or “incorrect.” -9. **Allow dismissal.** Users can dismiss a suggestion without being repeatedly challenged. -10. **Measure harm.** Track local acceptance, edit, dismissal, and disablement rates before expanding a feature; do not collect content or behavioral telemetry without separate consent. - -The Android [`UsageStatsManager`](https://developer.android.com/reference/android/app/usage/UsageStatsManager) exposes sensitive device-usage history and requires special user-granted access for querying other applications. Any proposal to use it requires a dedicated privacy threat model, a retention design, permission education, and a clear demonstration that calendar and explicit user input cannot solve the problem first. - -## Data ownership and local application state - -SolidVerdant cannot assume every roadmap concept exists in Solidtime. There are also two different capability sets to distinguish: - -1. **What Solidtime’s supplied OpenAPI specification supports.** The repository’s [`api-1.yaml`](api-1.yaml) includes users, organizations, members, clients, projects, tasks, tags, time entries, reports, filtering, aggregation, bulk entry changes, and exports. -2. **What the Android client currently integrates.** The current Retrofit interface and models use only users, memberships, active/history time entries, projects, tasks, and tags. Several server capabilities therefore require Android integration work but do not require inventing local product state. - -Project records include a client ID, billable defaults/rates, estimated time, and spent time, while task records include estimates and spent time. The full API additionally provides client resources; organization rate and formatting settings; member rates; overlap policy; user timezone and week-start preferences; report definitions; server-side aggregation, rounding, bulk updates, and exports. Neither the current client nor the supplied API specification exposes working hours, calendar mappings, goals, favorites, reminder rules, or review decisions. - -The specification confirms the examples previously identified from Solidtime’s [timesheet documentation](https://docs.solidtime.io/user-guide/timesheet): `UserResource` contains `timezone` and `week_start`, while `OrganizationResource` contains number, currency, date, interval, and time formats. These should be integrated rather than duplicated locally. Prefer the server value whenever it exists and is relevant across clients; use local state when the preference is specifically about this Android app or no supported server field exists. - -### 71. Define four data classes before implementing roadmap features - -Every new field should be assigned to exactly one primary class: - -1. **Solidtime source data:** Authoritative business records fetched from or written to Solidtime, such as time entries, organizations, projects, tasks, tags, billable state, and estimates. -2. **Derived local data:** Recomputable views produced from Solidtime data, such as totals, overlaps, trends, and project frequency. This data may be cached but must not become a second source of truth. -3. **Durable SolidVerdant preferences:** User choices that Solidtime does not represent, such as a long-timer threshold, notification actions, or selected calendars. -4. **Ephemeral device state:** Temporary state such as an open draft, notification presentation, current widget rendering, or a calendar query result. - -Do not store a derived value as durable truth when it can be recomputed from authoritative entries. Do not upload a local preference by hiding it inside a time-entry description, tag, or other unrelated Solidtime field. - -### 72. Scope local state by server, user, and organization - -Local state cannot safely use a single global preference namespace. A person can log out and another person can log in, and the roadmap also proposes multiple self-hosted profiles. At minimum, locally owned business-adjacent state needs a scope composed from: - -```text -serverProfileId + userId + organizationId -``` - -The server profile ID should be a stable local identifier, not the raw access token. Server-wide preferences omit the organization ID; genuinely device-wide appearance preferences may omit all account identifiers. - -Examples: - -- Theme and reduced-motion choices can be device-wide. -- A favorite timer must be scoped to server, user, and organization. -- Working hours normally belong to a user and may optionally differ by organization. -- Calendar-to-project mappings must be scoped to the organization containing that project. -- Dismissed review checks must include the account and entry/event identity. -- Cached project IDs must never be reused after switching servers merely because the UUID happens to match. - -Logging out should remove sensitive caches and ephemeral records. The product must deliberately choose whether non-sensitive conveniences such as favorites survive logout; if they do, they must remain inaccessible to a different logged-in user. - -### 73. Solidtime-owned data available to current SolidVerdant - -The following fields can be treated as server-owned with the current integration: - -| Data | Current source | Appropriate uses | Important limitation | -| ------------------- | -------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| User | Solidtime user endpoint | Identity, account scoping, timezone, and first day of week | Current Android model omits profile photo, timezone, and week start even though the API supplies them | -| Membership and role | Membership endpoint | Organization selection and coarse permission awareness | Permission capabilities should still be confirmed by server responses | -| Organization | Membership/organization endpoint | ID, name, currency, formats, billable-rate policy, employee capabilities, and overlap policy | Current Android model keeps only ID, name, and currency; no working schedule exists in the API specification | -| Time entry | Time-entry endpoints | Timer state, history, overlap checks, gaps, statistics, exports | Local pending edits can temporarily differ from server state | -| Client | Client endpoint | Customer names, archived state, filters, and project context | Supported by the API but not integrated or cached by the Android client | -| Project | Project endpoint | Selection, archived status, color, client ID, billable defaults, estimates and progress | Android already receives these fields but must resolve client names through the unintegrated client endpoint | -| Task | Task endpoint | Selection, completion state, project relation, estimates and progress | Task permissions and additional fields may require capability checks | -| Tag | Tag endpoint | Entry metadata and filters | Tags should not be repurposed to store hidden app configuration | - -[Solidtime’s feature overview](https://github.com/solidtime-io/solidtime) also lists clients, rates, permissions, and imports, while its [billable-rate documentation](https://docs.solidtime.io/user-guide/billable-rates) describes rate precedence across organization, organization member, project, and project member. The supplied API exposes organization, member, project, and project-member rate resources, but the Android client currently models only the project rate. Earnings features therefore require integrating all applicable levels or obtaining an effective server-calculated value; calculating revenue from the project rate alone could be wrong and must not ship as authoritative reporting. - -### 74. Working hours are SolidVerdant-owned unless an API source is added - -The current SolidVerdant models and API calls contain no working-hours or work-schedule field. Gap detection must therefore be disabled by default and introduced as an explicit local preference. - -A local work-schedule model needs more than one start and end time: - -```text -WorkSchedule -- enabled -- timezoneId -- per-day list of zero or more working windows -- minimumGapMinutes -- optional effectiveFrom date -- optional organizationId override -``` - -Multiple windows support lunch breaks and split shifts. Zero windows represent a non-working day. Overnight shifts should be represented deliberately, either as a window that crosses midnight or as two normalized windows with a clear ownership rule for the workday. - -The schedule must store an IANA timezone ID rather than only a UTC offset so daylight-saving transitions remain meaningful. Device-timezone changes should prompt the user to keep the schedule in its original timezone or migrate it; the app must not silently reinterpret historical review results. - -This schedule is a review aid, not proof of an employment obligation. It must never be sent to Solidtime as tracked time or used to accuse the user of missing work. - -### 75. Feature-by-feature local state inventory - -| Feature | Solidtime data used | Additional local state required | Suggested persistence | -| ---------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| Favorite timers | Project, task, tags, organization | Favorite ID, label, pinned order, description template, default billable choice | Room, account/organization scoped | -| Timer defaults | Project/task metadata | Per-project default fields and start behavior | Room or typed DataStore, organization scoped | -| Long-timer protection | Active entry start | Enabled flag, threshold, snooze duration, last warning for entry | DataStore for settings; ephemeral/Room for warning state | -| Tracking reminders | Active entry and daily totals | Days, time windows, quiet hours, target, last notification | DataStore plus scheduled-work metadata | -| Focus/Pomodoro | Active entry | Interval lengths, current focus phase, phase deadline | DataStore settings; saved transient session for process death | -| Automation starts | Project/task IDs | User-created automation/favorite ID and allowed action | Room; never embed OAuth secrets in intents | -| Contextual ranking | Historical entries | Optional ranking preferences; accepted/dismissed counters if retained | Prefer derived queries; persist only explicit feedback | -| Description templates | Project/task/tag IDs | Template text, placeholders, pinned order | Room, organization scoped | -| History filters | Entries and catalogue | Saved-filter name and predicates | Room or serialized DataStore, organization scoped | -| Undo deletion | Deleted entry snapshot | Undo expiry and pending/recreate state | Room transaction/outbox, short retention | -| Per-entry sync state | Entry and server responses | Pending operation, retry count, error category, timestamps | Existing Room/outbox, expanded state machine | -| Time Inbox | Entries and project/task/tag metadata | Work schedule, thresholds, dismissed check keys, review completion | Room plus DataStore settings | -| Overlap policy | Organization `prevent_overlapping_time_entries` and entries | Optional device-only review preference when server permits overlaps | Server policy cached in Room; local review toggle in DataStore | -| Personal goals | Daily/weekly derived totals | Goal amount, period, enabled state, completion preference | DataStore, user or organization scoped | -| Calendar overlay | None unless creating an entry | Selected calendar IDs, permission state, display colors | DataStore; event content queried ephemerally | -| Calendar mappings | Project/task/tag IDs | Local calendar ID/event rule to favorite or project mapping | Room, organization scoped | -| Calendar dismissals | Entry intervals | Event occurrence key, dismissal/review state, expiry | Room with limited retention | -| Statistics filters | Server filter/aggregate endpoints and cached entries/catalogue | Selected range and grouping; only explicitly local saved views need storage | Ephemeral by default; server `ReportResource` when saving cross-client reports, Room for device-only views | -| Estimates/progress | Project/task estimates and spent time | Usually none beyond display settings | Derived/cache only | -| Earnings | Currency and rates | None if authoritative rates become available | Do not persist calculated money as truth | -| CSV/PDF export | Solidtime export and aggregate-export endpoints, or filtered cached entries offline | Last Android share destination only if useful | Generated files via temporary/cache storage; no duplicate business records | -| Rounding display | Server report rounding parameters and organization formats | Optional Android-only display override | Server query parameters for reports; DataStore only for an override | -| Sync center | Server responses and entries | Attempts, errors, last success, conflict snapshots | Room; sanitized and retention-limited | -| Project widget | Project/favorite IDs | Per-widget configuration keyed by app-widget ID | Widget preferences/Room, account scoped | -| Notification actions | Active entry | User-selected action order and idle behavior | DataStore, device-wide or account scoped | -| Organization switching | Memberships | Last selected organization and per-organization UI state | DataStore, server/user scoped | -| Client filtering | Solidtime client endpoint and project client IDs | No business state beyond cache freshness | Room catalogue cache, organization scoped | -| Multiple servers | Server endpoint and account | Profile label, endpoint, client ID, selected profile | Encrypted secrets store plus non-secret profile DB | -| Connection diagnostics | Server responses | Last test result and timestamp, preferably short-lived | Ephemeral or short-retention local record | -| Privacy controls | Cached/local datasets | Retention periods and enabled data sources | DataStore, device/account scoped as appropriate | - -### 76. Time Inbox check state requires a stable identity - -Inbox checks are derived, but user decisions about them may need persistence. A dismissal key should be based on the factual subject and rule version rather than list position. Examples include: - -```text -overlap:{entryAId}:{entryBId}:{entryAUpdatedAt}:{entryBUpdatedAt}:{ruleVersion} -gap:{scheduleId}:{localDate}:{startInstant}:{endInstant}:{ruleVersion} -long-timer:{entryId}:{thresholdMinutes}:{entryStart} -calendar:{calendarId}:{eventInstanceId}:{eventStart}:{mappingVersion} -``` - -If an entry changes, its previous dismissal should normally stop applying because the underlying facts changed. Dismissals should have a retention limit so local storage does not accumulate indefinitely. “Intentional overlap” may be remembered longer than “not now,” but neither state should be uploaded to Solidtime unless Solidtime introduces a purpose-built field. - -### 77. Calendar state is device-local and permission-sensitive - -Calendar events do not belong to Solidtime unless the user explicitly converts one into an entry. Store only identifiers and mappings needed for the feature; query event title, time, location, and attendees from Android’s calendar provider when rendering. - -Prefer storing: - -- The selected local calendar ID and owning Android account identifier where necessary. -- A user-visible alias in case provider names change. -- Explicit mapping to a SolidVerdant favorite or Solidtime project/task. -- Dismissal keys for specific event occurrences with an expiry. - -Avoid persisting full event descriptions, attendee lists, meeting URLs, or historical event copies. Calendar IDs may change when an account is removed and re-added, so broken mappings need a visible repair state rather than silently targeting another calendar. - -Revoking calendar permission should stop queries immediately and offer deletion of mappings and dismissals. Logging into another Solidtime account must not expose mappings created for the previous account. - -### 78. Reminder and scheduler state must survive Android constraints - -Reminder preferences are local because the current Solidtime data does not describe Android notification behavior. Persist the user’s requested rule, but treat the exact delivery time as best-effort because Android may defer background work. - -Required local fields include enabled state, selected weekdays, timezone, earliest/latest reminder times, repeat interval, quiet hours, minimum tracked target if applicable, and the last delivered rule occurrence. The last-delivered key prevents duplicate reminders after reboot or process recreation. - -WorkManager is appropriate for deferrable review reminders; exact alarms should not be requested merely to deliver productivity nudges. Re-evaluate schedules after reboot, timezone changes, locale changes, notification-permission changes, and login/logout. - -### 79. Sync state must be richer than `SYNCED` or `PENDING` - -The current Room entity has `SYNCED` and `PENDING`, plus a pending-delete flag. A sync center and trustworthy per-entry status will require an operation-level state model closer to: - -```text -QUEUED -IN_FLIGHT -RETRY_SCHEDULED -BLOCKED_AUTH -CONFLICT -FAILED_PERMANENT -SYNCED -``` - -The local outbox should retain operation type, target ID, account/organization scope, created time, last attempt, retry count, safe error category, and the payload or before/after snapshots required for conflict handling. Raw tokens, server response bodies, and sensitive descriptions must not appear in diagnostic logs. - -“Last synced” also has multiple meanings: last catalogue refresh, last history refresh, last active-timer refresh, and last successful outbox flush. Store and display them separately rather than presenting one misleading global timestamp. - -### 80. Conflict resolution requires local and server snapshots - -A meaningful conflict screen needs the base version the user originally edited, the pending local version, and the newly fetched server version. SolidVerdant’s current time-entry model does not expose a server revision or `updated_at`, so robust optimistic concurrency may require API support. - -Without a server revision or conditional-update mechanism, the app can detect some likely conflicts but cannot prove that an update is conflict-free. Until the API supports this properly: - -- Avoid claiming strong conflict detection. -- Re-fetch immediately before risky delayed edits where practical. -- Never merge start/end times automatically. -- Allow field-by-field review only when all three snapshots are available. -- Preserve a local recovery copy before overwriting a pending edit. - -### 81. Favorites and templates are local references, not copies of server objects - -A favorite should store IDs for its Solidtime organization, project, task, and tags, plus locally owned fields such as label and pinned order. Resolve names and colors from the latest cached server catalogue when displayed. - -If a referenced project is archived, a task is completed, a tag is deleted, or access is revoked, mark the favorite unavailable and explain why. Do not silently substitute an item with the same name. Offer repair, partial start without the missing field, or deletion depending on server validation rules. - -Descriptions deserve a deliberate choice: a fixed description stored in a favorite is local work content and should receive the same protection as cached entries. A template may be safer than retaining many previously used free-form descriptions. - -### 82. Goals, streaks, and review completion are local-only product concepts - -The current Solidtime integration contains tracked durations but no personal target, streak, or “review complete” record. Store the goal definition locally and derive progress from entries. - -A streak day should be defined by an explicit rule such as “review completed” or “tracked target reached,” including timezone and workday boundary. Never rewrite historical streaks from cached values alone: recalculate when entries sync or change. If the user changes a goal, decide visibly whether the new rule applies prospectively or recomputes history. - -These features should remain private personal aids, not team performance metrics. - -### 83. Statistics should remain derived wherever possible - -Totals, billable percentages, averages, project distributions, trends, previous-period comparisons, overlaps, and estimate progress are deterministic functions of fetched entries and catalogue data. They should be calculated from the Room cache and invalidated when relevant records change. - -Persist only explicitly named saved views and display preferences. Avoid maintaining independent aggregate truth unless performance measurements demonstrate a need; stale aggregates are particularly dangerous after offline edits, deletes, organization switches, or timezone changes. - -Calculations must document whether running entries are included, how entries crossing date boundaries are allocated, which timezone defines a day, and whether totals use server duration or start/end instants. - -### 84. Client names and authoritative earnings need more Android integration - -Projects currently expose `clientId`, and the supplied specification already defines client list/create/update/delete endpoints. SolidVerdant does not yet call or cache them, so customer-facing filters and labels require Android client integration and a Room client catalogue—not a new server feature. Showing a raw client UUID is not a useful substitute. - -Likewise, Solidtime billable rates can be inherited from organization, organization member, project, or project member, according to the official [billable-rate documentation](https://docs.solidtime.io/user-guide/billable-rates). The specification contains resources for these levels, but the current app’s project-only model is insufficient to calculate authoritative earnings. Before implementing earnings: - -1. Determine whether a report/aggregate response returns the effective monetary result; otherwise fetch every level needed to reproduce Solidtime’s precedence exactly. -2. Confirm units, currency, historical-rate behavior, and rounding. -3. Test changes to rates that affect existing entries. -4. Treat the server’s result as authoritative. - -Until then, retain billable-duration reporting but defer monetary totals. - -### 85. Choose storage by data shape and sensitivity - -Use typed DataStore for small preference records such as theme, thresholds, notification action choices, and selected home screen. Use Room for collections, relationships, review decisions, favorites, templates, saved filters, calendar mappings, and sync operations. Use Android Keystore-backed encryption for OAuth secrets and any future server-profile credentials. - -Do not put growing JSON collections into preferences merely because serialization is convenient. Collections need migration, indexing, referential cleanup, account scoping, and transactional updates. Conversely, do not create a database table for a single device-wide boolean. - -Sensitive local work content includes descriptions, calendar metadata, activity history, customer/project mappings, and conflict snapshots. Minimize it, define retention, exclude it from logs, and ensure the app’s backup policy matches the intended threat model. - -### 86. Add deletion and retention rules before collecting new state - -Each local dataset needs an owner, retention rule, and deletion trigger: - -| Local dataset | Recommended retention/deletion behavior | -| ---------------------------------- | ------------------------------------------------------------------------------ | -| Cached Solidtime catalogue/history | Replace through sync; clear on logout for that profile | -| Outbox operations | Keep until synced or explicitly resolved; preserve recoverable failure details | -| Conflict snapshots | Delete after resolution plus a short recovery period | -| Favorites/templates | Keep for the same account unless user deletes them; never expose cross-account | -| Work schedules/goals | Keep for the same account; offer reset/export if these become substantial | -| Inbox dismissals | Expire by rule type and invalidate when source data changes | -| Calendar occurrence dismissals | Short retention, such as the surrounding review period | -| Full calendar event content | Do not persist by default | -| Activity recall events | Deferred; requires an explicit short retention setting before collection | -| Generated exports | Use user-selected destinations or temporary cache with cleanup | -| Diagnostic history | Sanitized, bounded, and deleted on logout or explicit clear | - -A privacy page should provide separate actions for clearing convenience data, calendar mappings, diagnostics, and all account data. “Clear cache” must explain whether favorites and work schedules survive. - -### 87. Server capability detection must gate optional features - -Self-hosted Solidtime instances may run different versions. At login or refresh, detect supported fields and endpoints through an official version/capability mechanism if available. Cache capability results per server profile with a timestamp. - -The supplied specification confirms clients, multi-entry update/delete, filters, aggregation, exports, reports, organization formats/overlap policy, and user timezone/week-start fields. Feature gates should test those capabilities on older self-hosted versions rather than assuming every instance matches the checked-in specification. Features depending on effective monetary results or revision tokens still need particular scrutiny because those are not evident in `TimeEntryResource`. A failed API call must not be interpreted as the user having no data. Distinguish unsupported endpoint, forbidden operation, network failure, and empty result. - -### 88. Data-source labels should appear in settings and diagnostics - -For user trust and developer clarity, settings should identify where behavior comes from: - -- **From Solidtime:** entries, clients, projects, tasks, tags, estimates, reports, profile timezone/week start, organization formatting and overlap policy. -- **Calculated on this device:** totals, gaps, overlaps, trends, and suggestion ordering. -- **Stored only on this device:** work schedule, favorites, reminders, mappings, dismissals, and goals. -- **From Android with permission:** calendar events and any future device-usage evidence. - -This vocabulary should also appear in diagnostics and feature documentation. It prevents a local preference from being mistaken for something that will automatically follow the user to another phone. - -## Recommended delivery sequence - -### Phase 1: Make the core trustworthy - -1. Finish custom statistics ranges. -2. Add history search and filters. -3. Add per-entry sync states and a sync center. -4. Add overlap and long-timer warnings. -5. Add undo for deletion. -6. Complete localization and accessibility audits. -7. Add connection testing for self-hosted setup. -8. Integrate the complete Solidtime user profile fields and display the server-provided profile picture in the account header and settings surfaces. Cache the image through the normal image-loading cache, show initials as the fallback, avoid persisting a separate bitmap, and clear account-specific cached presentation on logout. - -### Phase 2: Create the differentiating review loop - -1. Build the Time Inbox. -2. Add end-of-day review and configurable reminders. -3. Add favorites and templates. -4. Add a week calendar and calendar-event overlay. -5. Add statistics filters, comparisons, and drill-down. -6. Add CSV export. - -### Phase 3: Own Android-native capture - -1. Add deterministic, explainable ranking for favorites and recents; keep broader contextual suggestions experimental. -2. Add project-specific and daily-total widgets. -3. Add customizable notification actions. -4. Publish automation intents for Tasker, NFC, and voice workflows. -5. Add Wear OS controls. - -Private activity recall, sensor-based context recognition, location-based suggestions, productivity classification, and automatic metadata inference are not part of Phase 3. They remain deferred research items unless a separate privacy and validation proposal satisfies the safety contract above. - -## Success measures - -The roadmap should be judged by workflow outcomes rather than raw feature count: - -- Median taps required to start a correct timer. -- Percentage of entries that need later metadata correction. -- Number of unresolved sync failures after 24 hours. -- Time required to complete an end-of-day review. -- Percentage of active users using a tile, widget, shortcut, or notification action. -- Percentage of Time Inbox issues resolved rather than dismissed. -- Crash-free and offline-success rates for timer start, stop, edit, and delete actions. - -These measures reinforce the product’s strongest potential: dependable capture with low-effort correction, built specifically for Android and Solidtime. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f599c0..456a654 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -168,21 +168,11 @@ ksp { // // ./gradlew detekt // -// The baseline (config/detekt/baseline.xml) is generated separately and is NOT hand-written: -// -// ./gradlew detektBaseline -// -// It is referenced only when present so a fresh checkout without a baseline still configures. detekt { // Layer our overrides on top of detekt's shipped defaults instead of replacing the ruleset. buildUponDefaultConfig = true config.setFrom(files("$rootDir/config/detekt/detekt.yml")) - // Guard the baseline: a missing baseline file would otherwise fail configuration. - val detektBaseline = file("$rootDir/config/detekt/baseline.xml") - if (detektBaseline.exists()) { - baseline = detektBaseline - } } // Keep detekt off the default verification path: remove the `detekt` dependency that the diff --git a/app/schemas/dev.tricked.solidverdant.data.local.db.AppDatabase/5.json b/app/schemas/dev.tricked.solidverdant.data.local.db.AppDatabase/5.json new file mode 100644 index 0000000..25cc193 --- /dev/null +++ b/app/schemas/dev.tricked.solidverdant.data.local.db.AppDatabase/5.json @@ -0,0 +1,688 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "47fdd4a45b471e8df634fefc51949d22", + "entities": [ + { + "tableName": "time_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT, `userId` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT, `duration` INTEGER, `taskId` TEXT, `projectId` TEXT, `billable` INTEGER NOT NULL, `organizationId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `syncState` TEXT NOT NULL, `pendingDelete` INTEGER NOT NULL, `conflictServerJson` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "taskId", + "columnName": "taskId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "billable", + "columnName": "billable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pendingDelete", + "columnName": "pendingDelete", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conflictServerJson", + "columnName": "conflictServerJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_time_entries_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_time_entries_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + }, + { + "name": "index_time_entries_start", + "unique": false, + "columnNames": [ + "start" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_time_entries_start` ON `${TABLE_NAME}` (`start`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "projects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` TEXT NOT NULL, `clientId` TEXT, `isArchived` INTEGER NOT NULL, `billableRate` INTEGER, `isBillable` INTEGER NOT NULL, `estimatedTime` INTEGER, `spentTime` INTEGER NOT NULL, `isPublic` INTEGER NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "billableRate", + "columnName": "billableRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isBillable", + "columnName": "isBillable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "estimatedTime", + "columnName": "estimatedTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spentTime", + "columnName": "spentTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `isDone` INTEGER NOT NULL, `projectId` TEXT NOT NULL, `estimatedTime` INTEGER, `spentTime` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDone", + "columnName": "isDone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "estimatedTime", + "columnName": "estimatedTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spentTime", + "columnName": "spentTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "organizations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currency` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "memberships", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `role` TEXT NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "time_entry_tag_cross_ref", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timeEntryId` TEXT NOT NULL, `tagId` TEXT NOT NULL, PRIMARY KEY(`timeEntryId`, `tagId`))", + "fields": [ + { + "fieldPath": "timeEntryId", + "columnName": "timeEntryId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timeEntryId", + "tagId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sync_meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`organizationId` TEXT NOT NULL, `lastFullSyncAtMs` INTEGER NOT NULL, PRIMARY KEY(`organizationId`))", + "fields": [ + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFullSyncAtMs", + "columnName": "lastFullSyncAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "organizationId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opType` TEXT NOT NULL, `organizationId` TEXT NOT NULL, `timeEntryId` TEXT NOT NULL, `payloadJson` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastError` TEXT, `clientId` TEXT NOT NULL, `deadLettered` INTEGER NOT NULL, `baseSnapshotJson` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opType", + "columnName": "opType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeEntryId", + "columnName": "timeEntryId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deadLettered", + "columnName": "deadLettered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseSnapshotJson", + "columnName": "baseSnapshotJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "clients", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_clients_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_clients_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "entry_templates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `organizationId` TEXT NOT NULL, `name` TEXT, `projectId` TEXT, `taskId` TEXT, `description` TEXT, `tagIds` TEXT NOT NULL, `billable` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "taskId", + "columnName": "taskId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagIds", + "columnName": "tagIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "billable", + "columnName": "billable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_entry_templates_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_entry_templates_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "inbox_dismissals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`issueKey` TEXT NOT NULL, `organizationId` TEXT NOT NULL, `dismissedAtMs` INTEGER NOT NULL, PRIMARY KEY(`issueKey`))", + "fields": [ + { + "fieldPath": "issueKey", + "columnName": "issueKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dismissedAtMs", + "columnName": "dismissedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "issueKey" + ] + }, + "indices": [ + { + "name": "index_inbox_dismissals_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_inbox_dismissals_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '47fdd4a45b471e8df634fefc51949d22')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/dev.tricked.solidverdant.data.local.db.AppDatabase/6.json b/app/schemas/dev.tricked.solidverdant.data.local.db.AppDatabase/6.json new file mode 100644 index 0000000..f428d4e --- /dev/null +++ b/app/schemas/dev.tricked.solidverdant.data.local.db.AppDatabase/6.json @@ -0,0 +1,710 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "62fd48d7b4041439055829d3c32b8866", + "entities": [ + { + "tableName": "time_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT, `userId` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT, `duration` INTEGER, `taskId` TEXT, `projectId` TEXT, `billable` INTEGER NOT NULL, `organizationId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `syncState` TEXT NOT NULL, `pendingDelete` INTEGER NOT NULL, `conflictServerJson` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "taskId", + "columnName": "taskId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "billable", + "columnName": "billable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pendingDelete", + "columnName": "pendingDelete", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conflictServerJson", + "columnName": "conflictServerJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_time_entries_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_time_entries_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + }, + { + "name": "index_time_entries_start", + "unique": false, + "columnNames": [ + "start" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_time_entries_start` ON `${TABLE_NAME}` (`start`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "projects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` TEXT NOT NULL, `clientId` TEXT, `isArchived` INTEGER NOT NULL, `billableRate` INTEGER, `isBillable` INTEGER NOT NULL, `estimatedTime` INTEGER, `spentTime` INTEGER NOT NULL, `isPublic` INTEGER NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "billableRate", + "columnName": "billableRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isBillable", + "columnName": "isBillable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "estimatedTime", + "columnName": "estimatedTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spentTime", + "columnName": "spentTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `isDone` INTEGER NOT NULL, `projectId` TEXT NOT NULL, `estimatedTime` INTEGER, `spentTime` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDone", + "columnName": "isDone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "estimatedTime", + "columnName": "estimatedTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spentTime", + "columnName": "spentTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "organizations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currency` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "memberships", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `role` TEXT NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "time_entry_tag_cross_ref", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timeEntryId` TEXT NOT NULL, `tagId` TEXT NOT NULL, PRIMARY KEY(`timeEntryId`, `tagId`))", + "fields": [ + { + "fieldPath": "timeEntryId", + "columnName": "timeEntryId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timeEntryId", + "tagId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sync_meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`organizationId` TEXT NOT NULL, `lastFullSyncAtMs` INTEGER NOT NULL, PRIMARY KEY(`organizationId`))", + "fields": [ + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFullSyncAtMs", + "columnName": "lastFullSyncAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "organizationId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opType` TEXT NOT NULL, `organizationId` TEXT NOT NULL, `timeEntryId` TEXT NOT NULL, `payloadJson` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastError` TEXT, `clientId` TEXT NOT NULL, `deadLettered` INTEGER NOT NULL, `baseSnapshotJson` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opType", + "columnName": "opType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeEntryId", + "columnName": "timeEntryId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deadLettered", + "columnName": "deadLettered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseSnapshotJson", + "columnName": "baseSnapshotJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "clients", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_clients_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_clients_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "entry_templates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `organizationId` TEXT NOT NULL, `name` TEXT, `projectId` TEXT, `taskId` TEXT, `description` TEXT, `tagIds` TEXT NOT NULL, `billable` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `ownerEndpoint` TEXT, `ownerUserId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "taskId", + "columnName": "taskId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagIds", + "columnName": "tagIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "billable", + "columnName": "billable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerEndpoint", + "columnName": "ownerEndpoint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerUserId", + "columnName": "ownerUserId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_entry_templates_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_entry_templates_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + }, + { + "name": "index_entry_templates_ownerUserId_organizationId", + "unique": false, + "columnNames": [ + "ownerUserId", + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_entry_templates_ownerUserId_organizationId` ON `${TABLE_NAME}` (`ownerUserId`, `organizationId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "inbox_dismissals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`issueKey` TEXT NOT NULL, `organizationId` TEXT NOT NULL, `dismissedAtMs` INTEGER NOT NULL, PRIMARY KEY(`issueKey`))", + "fields": [ + { + "fieldPath": "issueKey", + "columnName": "issueKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dismissedAtMs", + "columnName": "dismissedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "issueKey" + ] + }, + "indices": [ + { + "name": "index_inbox_dismissals_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_inbox_dismissals_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '62fd48d7b4041439055829d3c32b8866')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/dev.tricked.solidverdant.data.local.db.AppDatabase/7.json b/app/schemas/dev.tricked.solidverdant.data.local.db.AppDatabase/7.json new file mode 100644 index 0000000..69b3c83 --- /dev/null +++ b/app/schemas/dev.tricked.solidverdant.data.local.db.AppDatabase/7.json @@ -0,0 +1,716 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "692ef63d7a81bf4efb8fcada9c32daa5", + "entities": [ + { + "tableName": "time_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT, `userId` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT, `duration` INTEGER, `taskId` TEXT, `projectId` TEXT, `billable` INTEGER NOT NULL, `organizationId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `syncState` TEXT NOT NULL, `pendingDelete` INTEGER NOT NULL, `conflictServerJson` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "taskId", + "columnName": "taskId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "billable", + "columnName": "billable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pendingDelete", + "columnName": "pendingDelete", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conflictServerJson", + "columnName": "conflictServerJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_time_entries_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_time_entries_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + }, + { + "name": "index_time_entries_start", + "unique": false, + "columnNames": [ + "start" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_time_entries_start` ON `${TABLE_NAME}` (`start`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "projects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` TEXT NOT NULL, `clientId` TEXT, `isArchived` INTEGER NOT NULL, `billableRate` INTEGER, `isBillable` INTEGER NOT NULL, `estimatedTime` INTEGER, `spentTime` INTEGER NOT NULL, `isPublic` INTEGER NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "billableRate", + "columnName": "billableRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isBillable", + "columnName": "isBillable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "estimatedTime", + "columnName": "estimatedTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spentTime", + "columnName": "spentTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPublic", + "columnName": "isPublic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `isDone` INTEGER NOT NULL, `projectId` TEXT NOT NULL, `estimatedTime` INTEGER, `spentTime` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDone", + "columnName": "isDone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "estimatedTime", + "columnName": "estimatedTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spentTime", + "columnName": "spentTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "organizations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currency` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "memberships", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `role` TEXT NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "time_entry_tag_cross_ref", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timeEntryId` TEXT NOT NULL, `tagId` TEXT NOT NULL, PRIMARY KEY(`timeEntryId`, `tagId`))", + "fields": [ + { + "fieldPath": "timeEntryId", + "columnName": "timeEntryId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timeEntryId", + "tagId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sync_meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`organizationId` TEXT NOT NULL, `lastFullSyncAtMs` INTEGER NOT NULL, `lastPushAtMs` INTEGER, PRIMARY KEY(`organizationId`))", + "fields": [ + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastFullSyncAtMs", + "columnName": "lastFullSyncAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPushAtMs", + "columnName": "lastPushAtMs", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "organizationId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opType` TEXT NOT NULL, `organizationId` TEXT NOT NULL, `timeEntryId` TEXT NOT NULL, `payloadJson` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastError` TEXT, `clientId` TEXT NOT NULL, `deadLettered` INTEGER NOT NULL, `baseSnapshotJson` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opType", + "columnName": "opType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeEntryId", + "columnName": "timeEntryId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deadLettered", + "columnName": "deadLettered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseSnapshotJson", + "columnName": "baseSnapshotJson", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "clients", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, `organizationId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_clients_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_clients_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "entry_templates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `organizationId` TEXT NOT NULL, `name` TEXT, `projectId` TEXT, `taskId` TEXT, `description` TEXT, `tagIds` TEXT NOT NULL, `billable` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `ownerEndpoint` TEXT, `ownerUserId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "taskId", + "columnName": "taskId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagIds", + "columnName": "tagIds", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "billable", + "columnName": "billable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerEndpoint", + "columnName": "ownerEndpoint", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ownerUserId", + "columnName": "ownerUserId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_entry_templates_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_entry_templates_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + }, + { + "name": "index_entry_templates_ownerUserId_organizationId", + "unique": false, + "columnNames": [ + "ownerUserId", + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_entry_templates_ownerUserId_organizationId` ON `${TABLE_NAME}` (`ownerUserId`, `organizationId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "inbox_dismissals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`issueKey` TEXT NOT NULL, `organizationId` TEXT NOT NULL, `dismissedAtMs` INTEGER NOT NULL, PRIMARY KEY(`issueKey`))", + "fields": [ + { + "fieldPath": "issueKey", + "columnName": "issueKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organizationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dismissedAtMs", + "columnName": "dismissedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "issueKey" + ] + }, + "indices": [ + { + "name": "index_inbox_dismissals_organizationId", + "unique": false, + "columnNames": [ + "organizationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_inbox_dismissals_organizationId` ON `${TABLE_NAME}` (`organizationId`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '692ef63d7a81bf4efb8fcada9c32daa5')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/tricked/solidverdant/HandoffMainActivity.kt b/app/src/main/java/dev/tricked/solidverdant/HandoffMainActivity.kt index f63566b..c8fd452 100644 --- a/app/src/main/java/dev/tricked/solidverdant/HandoffMainActivity.kt +++ b/app/src/main/java/dev/tricked/solidverdant/HandoffMainActivity.kt @@ -19,8 +19,10 @@ import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +private const val HANDOFF_API_LEVEL = 37 + /** API 37 activity kept separate so older Android releases never link handoff classes. */ -@RequiresApi(37) +@RequiresApi(HANDOFF_API_LEVEL) @AndroidEntryPoint class HandoffMainActivity : MainActivity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/dev/tricked/solidverdant/MainActivity.kt b/app/src/main/java/dev/tricked/solidverdant/MainActivity.kt index 6a53397..2883f34 100644 --- a/app/src/main/java/dev/tricked/solidverdant/MainActivity.kt +++ b/app/src/main/java/dev/tricked/solidverdant/MainActivity.kt @@ -41,6 +41,8 @@ import dev.tricked.solidverdant.ui.components.AppStatusOverlay import dev.tricked.solidverdant.ui.login.LoginScreen import dev.tricked.solidverdant.ui.navigation.MainNavHost import dev.tricked.solidverdant.ui.navigation.ReviewRoutes +import dev.tricked.solidverdant.ui.navigation.SettingsRoutes +import dev.tricked.solidverdant.ui.navigation.SyncRoutes import dev.tricked.solidverdant.ui.review.ReviewBadgeViewModel import dev.tricked.solidverdant.ui.review.ReviewScreen import dev.tricked.solidverdant.ui.statistics.StatisticsScreen @@ -268,6 +270,7 @@ fun SolidVerdantApp( MainNavHost( navController = navController, inboxBadgeCount = inboxBadgeCount, + onPrivacyLogout = { authViewModel.logout() }, reviewContent = { ReviewScreen( onOpenReminderSettings = { @@ -329,21 +332,14 @@ fun SolidVerdantApp( onStopTracking = { authUiState.currentMembership?.let { membership -> authUiState.user?.let { user -> - trackingViewModel.stopTimeEntry( - organizationId = membership.organizationId, - memberId = membership.id, - userId = user.id, - ) + trackingViewModel.stopTimeEntry(userId = user.id) } } }, onPauseTracking = { authUiState.currentMembership?.let { membership -> authUiState.user?.let { user -> - trackingViewModel.pauseTimeEntry( - organizationId = membership.organizationId, - userId = user.id, - ) + trackingViewModel.pauseTimeEntry(userId = user.id) } } }, @@ -374,11 +370,7 @@ fun SolidVerdantApp( trackingViewModel.updateBillable(billable) }, onUpdateCurrentEntry = { - authUiState.currentMembership?.let { membership -> - trackingViewModel.updateCurrentTimeEntry( - organizationId = membership.organizationId, - ) - } + trackingViewModel.updateCurrentTimeEntry() }, onUpdatePastEntry = { entry: TimeEntry, @@ -390,19 +382,16 @@ fun SolidVerdantApp( start: String, end: String, -> - authUiState.currentMembership?.let { membership -> - trackingViewModel.updatePastTimeEntry( - organizationId = membership.organizationId, - timeEntry = entry, - description = description, - projectId = projectId, - taskId = taskId, - tags = tags, - billable = billable, - start = start, - end = end, - ) - } + trackingViewModel.updatePastTimeEntry( + timeEntry = entry, + description = description, + projectId = projectId, + taskId = taskId, + tags = tags, + billable = billable, + start = start, + end = end, + ) }, onCreateEntry = { description: String?, @@ -431,23 +420,24 @@ fun SolidVerdantApp( } }, onDeleteEntry = { timeEntryId -> - authUiState.currentMembership?.let { membership -> - trackingViewModel.deleteTimeEntry( - organizationId = membership.organizationId, - timeEntryId = timeEntryId, - ) - } + trackingViewModel.deleteTimeEntry(timeEntryId = timeEntryId) }, + onDuplicateEntry = trackingViewModel::duplicateTimeEntry, + onSplitEntry = trackingViewModel::splitTimeEntry, + onEntryToEditConsumed = trackingViewModel::consumeEntryToEdit, onUndoDelete = trackingViewModel::undoDelete, onRetrySync = trackingViewModel::retrySync, onRetrySyncEntry = trackingViewModel::retrySync, + onOpenSyncCenter = { + navController.navigate(SyncRoutes.SYNC_CENTER) + }, + onOpenPrivacy = { + navController.navigate(SettingsRoutes.PRIVACY) + }, onLoadMoreEntries = trackingViewModel::loadMoreTimeEntries, onLoadNewerEntries = trackingViewModel::loadNewerTimeEntries, onJumpToDate = trackingViewModel::jumpToHistoryDate, onHistoryJumpConsumed = trackingViewModel::consumeHistoryJump, - getGroupedEntries = { - trackingViewModel.getGroupedTimeEntries() - }, ) }, calendarContent = { @@ -460,7 +450,6 @@ fun SolidVerdantApp( tags = trackingUiState.tags, onSaveEntry = { entry, description, projectId, taskId, entryTags, billable, start, end -> trackingViewModel.updatePastTimeEntry( - organizationId = currentMembership.organizationId, timeEntry = entry, description = description, projectId = projectId, @@ -494,13 +483,9 @@ fun SolidVerdantApp( authViewModel.resetOAuthConfig() }, onTestConnection = authViewModel::testConnection, - onAuthUrlReady = { /* URL is launched in LoginScreen */ }, onClearAuthUrl = { authViewModel.clearAuthUrl() }, - onClearError = { - authViewModel.clearError() - }, ) } diff --git a/app/src/main/java/dev/tricked/solidverdant/data/export/CsvExporter.kt b/app/src/main/java/dev/tricked/solidverdant/data/export/CsvExporter.kt index edf30cd..d6466ed 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/export/CsvExporter.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/export/CsvExporter.kt @@ -88,6 +88,8 @@ class CsvExporter @Inject constructor(@ApplicationContext private val context: C companion object { private val isoInstant: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT + private const val SECONDS_PER_HOUR = 3600 + private const val SECONDS_PER_MINUTE = 60 /** RFC 4180 field escaping: quote when the value holds a comma, quote, CR or LF. */ fun escapeField(value: String): String { @@ -109,9 +111,9 @@ class CsvExporter @Inject constructor(@ApplicationContext private val context: C /** Formats seconds as H:MM:SS (locale-neutral, no thousands separators). */ fun formatHms(totalSeconds: Long): String { val s = totalSeconds.coerceAtLeast(0) - val h = s / 3600 - val m = (s % 3600) / 60 - val sec = s % 60 + val h = s / SECONDS_PER_HOUR + val m = (s % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE + val sec = s % SECONDS_PER_MINUTE return "%d:%02d:%02d".format(h, m, sec) } diff --git a/app/src/main/java/dev/tricked/solidverdant/data/export/DiagnosticBundle.kt b/app/src/main/java/dev/tricked/solidverdant/data/export/DiagnosticBundle.kt new file mode 100644 index 0000000..dcb2c57 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/data/export/DiagnosticBundle.kt @@ -0,0 +1,154 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.data.export + +/** + * Coarse, non-sensitive classification of a sync failure. The raw outbox error string may embed a + * server response body containing the user's own work content (descriptions, project names) or, in + * pathological cases, a token echoed back in an error; the diagnostic bundle therefore only ever + * records the *category*, never the raw text. See [DiagnosticBundle.classifyOutboxError]. + */ +enum class DiagnosticErrorCategory { OFFLINE, SERVER_PROBLEM, REJECTED, UNKNOWN } + +/** + * One already-sanitized sync failure. Every field is safe to share: an operation type enum name, a + * coarse category, a whole-day age, and an attempt count. There is deliberately no field capable of + * carrying free text, so the formatter *cannot* leak work content — redaction is by construction. + */ +data class DiagnosticFailure(val opType: String, val category: DiagnosticErrorCategory, val ageDays: Long, val attempts: Int) + +/** + * Pure, Android-free core of the privacy-reviewed diagnostic export (roadmap #49). It receives only + * primitives and already-categorized [DiagnosticFailure]s — never a raw error string, an endpoint + * URL with credentials, a token, or any entry/project/description text — and renders a + * human-readable text bundle used to make self-hosting support easier while staying trustworthy. + * + * The safety contract is structural: [build] has no parameter through which raw work content or a + * secret could reach the output, and the only place that touches raw error text is + * [classifyOutboxError], which discards everything except a bucket. + */ +object DiagnosticBundle { + + /** + * Classify a raw outbox error into a safe [DiagnosticErrorCategory] and discard the text. + * Mirrors the Sync Center reason classifier (#33) so the two surfaces agree, but returns a + * resource-free enum so it is usable from the pure bundle core. + * + * Network/timeout/host-resolution failures -> [DiagnosticErrorCategory.OFFLINE]; 4xx / client + * rejections -> [DiagnosticErrorCategory.REJECTED]; 5xx / gateway / unavailable -> + * [DiagnosticErrorCategory.SERVER_PROBLEM]; blank or unrecognized -> [DiagnosticErrorCategory.UNKNOWN]. + */ + fun classifyOutboxError(raw: String?): DiagnosticErrorCategory { + val lower = raw?.lowercase().orEmpty() + return when { + lower.isBlank() -> DiagnosticErrorCategory.UNKNOWN + OFFLINE_KEYWORDS.any { it in lower } -> DiagnosticErrorCategory.OFFLINE + REJECTED_KEYWORDS.any { it in lower } -> DiagnosticErrorCategory.REJECTED + SERVER_KEYWORDS.any { it in lower } -> DiagnosticErrorCategory.SERVER_PROBLEM + else -> DiagnosticErrorCategory.UNKNOWN + } + } + + /** + * Render the diagnostic text bundle. All inputs are non-sensitive: + * - [appVersion]/[appVersionCode]: the app build identity. + * - [androidRelease]/[androidSdk]/[deviceManufacturer]/[deviceModel]: the platform. + * - [serverHost]: the endpoint *host only* (e.g. `app.solidtime.io`), never a full URL, path, + * query or token; [isSelfHosted] states whether it differs from the default instance. + * - [capabilities]: static, factual client capability lines. + * - [pendingCount]/[failedCount]: outbox counts. + * - [failures]: per-failure summaries already stripped to safe fields. + * - [lastFullSyncAtMs]/[lastPushAtMs]: sync freshness timestamps (may be null). + */ + @Suppress("LongParameterList") + fun build( + appVersion: String, + appVersionCode: Int, + androidRelease: String, + androidSdk: Int, + deviceManufacturer: String, + deviceModel: String, + serverHost: String, + isSelfHosted: Boolean, + capabilities: List, + pendingCount: Int, + failedCount: Int, + failures: List, + lastFullSyncAtMs: Long?, + lastPushAtMs: Long?, + ): String { + val sb = StringBuilder() + sb.appendLine("SolidVerdant diagnostic bundle") + sb.appendLine("(privacy-reviewed: no tokens, descriptions, project names or work content)") + sb.appendLine() + + sb.appendLine("== App ==") + sb.appendLine("version: $appVersion ($appVersionCode)") + sb.appendLine() + + sb.appendLine("== Device ==") + sb.appendLine("android: $androidRelease (SDK $androidSdk)") + sb.appendLine("device: $deviceManufacturer $deviceModel") + sb.appendLine() + + sb.appendLine("== Server ==") + sb.appendLine("host: $serverHost") + sb.appendLine("self-hosted: ${if (isSelfHosted) "yes" else "no"}") + sb.appendLine() + + sb.appendLine("== Capabilities ==") + if (capabilities.isEmpty()) { + sb.appendLine("(none reported)") + } else { + capabilities.forEach { sb.appendLine("- $it") } + } + sb.appendLine() + + sb.appendLine("== Sync ==") + sb.appendLine("pending: $pendingCount") + sb.appendLine("failed: $failedCount") + sb.appendLine("last full sync: ${formatTimestamp(lastFullSyncAtMs)}") + sb.appendLine("last push: ${formatTimestamp(lastPushAtMs)}") + sb.appendLine() + + sb.appendLine("== Recent sync failures ==") + if (failures.isEmpty()) { + sb.appendLine("(none)") + } else { + val byCategory = failures.groupingBy { it.category }.eachCount() + val summary = DiagnosticErrorCategory.entries + .filter { byCategory.containsKey(it) } + .joinToString(", ") { "${byCategory[it]} ${it.label}" } + sb.appendLine("${failures.size} failures: $summary") + failures.forEach { f -> + sb.appendLine( + "- ${f.opType}: ${f.category.label}, age ${f.ageDays}d, attempts ${f.attempts}", + ) + } + } + return sb.toString() + } + + /** Whole-epoch-millis timestamp rendered as a plain non-localized marker, or a placeholder. */ + private fun formatTimestamp(atMs: Long?): String = atMs?.let { "${it}ms" } ?: "never" + + /** Lowercase, hyphen-free label used in the human-readable summary. */ + private val DiagnosticErrorCategory.label: String + get() = when (this) { + DiagnosticErrorCategory.OFFLINE -> "offline" + DiagnosticErrorCategory.SERVER_PROBLEM -> "server-problem" + DiagnosticErrorCategory.REJECTED -> "rejected" + DiagnosticErrorCategory.UNKNOWN -> "unknown" + } + + private val OFFLINE_KEYWORDS = + listOf("offline", "timeout", "unable to resolve host", "connect", "unreachable", "network") + private val REJECTED_KEYWORDS = + listOf("400", "401", "403", "404", "409", "422", "unprocessable", "forbidden", "unauthorized", "bad request") + private val SERVER_KEYWORDS = + listOf("500", "502", "503", "504", "server", "gateway", "unavailable") +} diff --git a/app/src/main/java/dev/tricked/solidverdant/data/export/DiagnosticExporter.kt b/app/src/main/java/dev/tricked/solidverdant/data/export/DiagnosticExporter.kt new file mode 100644 index 0000000..81ece91 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/data/export/DiagnosticExporter.kt @@ -0,0 +1,147 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.data.export + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.content.FileProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import dev.tricked.solidverdant.BuildConfig +import dev.tricked.solidverdant.data.local.AuthDataStore +import dev.tricked.solidverdant.data.local.db.OutboxDao +import dev.tricked.solidverdant.data.local.db.OutboxEntity +import dev.tricked.solidverdant.data.local.db.SyncMetaDao +import dev.tricked.solidverdant.util.Clock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.max + +/** + * Android-facing assembler for the privacy-reviewed diagnostic export bundle (roadmap #49). It + * gathers the build identity, platform, sanitized server host and *categorized* recent sync + * failures, hands them to the pure [DiagnosticBundle] core, writes the text to a FileProvider-exposed + * cache file, and returns a shareable [Uri] — mirroring [CsvExporter]'s file/share mechanics. + * + * Redaction is enforced *before* the pure core: raw outbox error strings are classified into a safe + * [DiagnosticErrorCategory] here and the raw text is never passed onward, so the formatter cannot + * emit descriptions, project names, tokens or any other work content. + */ +@Singleton +class DiagnosticExporter @Inject constructor( + @ApplicationContext private val context: Context, + private val authDataStore: AuthDataStore, + private val outboxDao: OutboxDao, + private val syncMetaDao: SyncMetaDao, + private val clock: Clock, +) { + + /** Builds the diagnostic text bundle from live app state, sanitizing every input. */ + suspend fun buildBundleText(): String { + val endpoint = authDataStore.endpoint.first() + val host = hostOf(endpoint) + val isSelfHosted = normalize(endpoint) != normalize(AuthDataStore.DEFAULT_ENDPOINT) + + val ops = outboxDao.peekAll() + val failures = ops + .filter { it.deadLettered || it.attemptCount > 0 || it.lastError != null } + .map { it.toFailure(clock.nowMs()) } + val failedCount = ops.count { it.deadLettered } + val pendingCount = ops.count { !it.deadLettered } + + val meta = distinctOrgIds(ops).mapNotNull { syncMetaDao.get(it) } + val lastFullSyncAtMs = meta.mapNotNull { it.lastFullSyncAtMs.takeIf { ms -> ms > 0L } }.maxOrNull() + val lastPushAtMs = meta.mapNotNull { it.lastPushAtMs }.maxOrNull() + + return DiagnosticBundle.build( + appVersion = BuildConfig.VERSION_NAME, + appVersionCode = BuildConfig.VERSION_CODE, + androidRelease = Build.VERSION.RELEASE, + androidSdk = Build.VERSION.SDK_INT, + deviceManufacturer = Build.MANUFACTURER, + deviceModel = Build.MODEL, + serverHost = host, + isSelfHosted = isSelfHosted, + capabilities = CAPABILITIES, + pendingCount = pendingCount, + failedCount = failedCount, + failures = failures, + lastFullSyncAtMs = lastFullSyncAtMs, + lastPushAtMs = lastPushAtMs, + ) + } + + /** + * Writes the bundle into `cacheDir/exports/diagnostics-.txt` and returns a content:// URI + * granted to receiving apps via the app's FileProvider authority (same setup as [CsvExporter]). + */ + suspend fun export(): Uri = withContext(Dispatchers.IO) { + val text = buildBundleText() + val dir = File(context.cacheDir, "exports").apply { mkdirs() } + val file = File(dir, "diagnostics-${clock.nowMs()}.txt") + file.writeText(text, Charsets.UTF_8) + FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file) + } + + /** + * Share sheet intent for a bundle [uri]: ACTION_SEND, text/plain, with read permission granted + * to the chosen target. Mirrors the CSV share intent; the bundle holds no tokens or work content. + */ + fun shareIntent(uri: Uri): Intent { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + return Intent.createChooser(send, null).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + + private fun OutboxEntity.toFailure(nowMs: Long): DiagnosticFailure = DiagnosticFailure( + opType = opType.name, + category = DiagnosticBundle.classifyOutboxError(lastError), + ageDays = max(0L, (nowMs - createdAtMs)) / MILLIS_PER_DAY, + attempts = attemptCount, + ) + + private fun distinctOrgIds(ops: List): Set = ops.map { it.organizationId }.toSet() + + /** + * The endpoint host only (e.g. `app.solidtime.io`) — never the scheme, path, query or any token + * that a full URL could carry. Falls back to the raw string stripped of an obvious scheme if it + * cannot be parsed as a URI. + */ + private fun hostOf(endpoint: String): String { + val host = runCatching { Uri.parse(endpoint).host }.getOrNull() + if (!host.isNullOrBlank()) return host + return endpoint.substringAfter("://").substringBefore("/").ifBlank { "unknown" } + } + + private fun normalize(endpoint: String): String = hostOf(endpoint).lowercase() + + private companion object { + const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L + + /** + * Static, factual summary of what this client integrates. Kept intentionally simple — a + * real capability-detection system is a separate feature (#47); these are constants that do + * not reveal any account-specific data. + */ + val CAPABILITIES = listOf( + "solidtime API client", + "offline outbox with retry and dead-lettering", + "background sync worker", + "conflict detection and recovery", + ) + } +} diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/AuthSecretCipher.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/AuthSecretCipher.kt index 42c4d51..267081d 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/AuthSecretCipher.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/AuthSecretCipher.kt @@ -33,7 +33,7 @@ internal class AndroidKeystoreKeyProvider : SecretKeyProvider { ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) + .setKeySize(AES_KEY_SIZE_BITS) .build(), ) generateKey() @@ -43,6 +43,7 @@ internal class AndroidKeystoreKeyProvider : SecretKeyProvider { private companion object { const val KEYSTORE = "AndroidKeyStore" const val KEY_ALIAS = "solidverdant_auth_secrets_v1" + const val AES_KEY_SIZE_BITS = 256 } } diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/SettingsDataStore.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/SettingsDataStore.kt index f3b5f60..a395bd7 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/SettingsDataStore.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/SettingsDataStore.kt @@ -25,6 +25,7 @@ import dev.tricked.solidverdant.data.model.Task import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.data.model.User import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -50,6 +51,14 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont private val immediateCache = context.getSharedPreferences("immediate_ui_cache", Context.MODE_PRIVATE) private val json = Json { ignoreUnknownKeys = true } + /** + * Change token for the synchronous [immediateCache] auth entries. The immediate cache has no + * built-in change-notification path, so [cacheAuth]/[clearCachedData] bump this counter to let + * [observeCachedAuth] re-read and re-emit. The synchronous read is intentional (first-frame + * pattern); we only need a signal that something changed. + */ + private val authCacheChanges = MutableStateFlow(0) + init { // Warm the SharedPreferences file on a background thread so the synchronous // getCachedAppTheme() read during MainActivity.onCreate (first frame) is served from the @@ -94,6 +103,11 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont /** Default reminder time: 17:00 local, expressed as minutes since midnight. */ const val DEFAULT_REMINDER_MINUTE_OF_DAY: Int = 17 * 60 + const val DEFAULT_LONG_TIMER_HOURS = 4 + const val MIN_LONG_TIMER_HOURS = 1 + const val MAX_LONG_TIMER_HOURS = 24 + const val MINUTE_OF_DAY_START = 0 + const val MAX_MINUTE_OF_DAY = 1439 @Volatile private var instance: SettingsDataStore? = null @@ -143,8 +157,15 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont } } .apply() + authCacheChanges.value += 1 } + /** + * Emits the current [getCachedAuth] immediately and re-emits whenever the cached auth changes + * (via [cacheAuth]/[clearCachedData]). The read is synchronous by design (first-frame pattern). + */ + fun observeCachedAuth(): Flow = authCacheChanges.map { getCachedAuth() } + fun cacheCurrentMembership(id: String) { immediateCache.edit().putString(CURRENT_MEMBERSHIP_ID, id).apply() } @@ -186,6 +207,7 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont /** Clear cached account data while preserving the user's app preferences. */ suspend fun clearCachedData() { immediateCache.edit().clear().commit() + authCacheChanges.value += 1 dataStore.edit(::clearWidgetState) } @@ -214,7 +236,7 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont preferences[OPTIMISTIC_REFRESH] ?: true }.distinctUntilChanged() - val longTimerHours: Flow = dataStore.data.map { it[LONG_TIMER_HOURS] ?: 4 }.distinctUntilChanged() + val longTimerHours: Flow = dataStore.data.map { it[LONG_TIMER_HOURS] ?: DEFAULT_LONG_TIMER_HOURS }.distinctUntilChanged() /** * Deadline (epoch millis) at which the forgotten-timer warning should next fire, plus the @@ -267,7 +289,7 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont } suspend fun setLongTimerHours(hours: Int) { - require(hours in 1..24) + require(hours in MIN_LONG_TIMER_HOURS..MAX_LONG_TIMER_HOURS) dataStore.edit { it[LONG_TIMER_HOURS] = hours } } @@ -303,7 +325,7 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont } suspend fun setReminderMinuteOfDay(minuteOfDay: Int) { - require(minuteOfDay in 0..1439) + require(minuteOfDay in MINUTE_OF_DAY_START..MAX_MINUTE_OF_DAY) dataStore.edit { it[REMINDER_MINUTE_OF_DAY] = minuteOfDay } } diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/UserCacheCleaner.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/UserCacheCleaner.kt index 2e3db35..bdc6a04 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/UserCacheCleaner.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/UserCacheCleaner.kt @@ -24,7 +24,7 @@ class UserCacheCleaner @Inject constructor( ) { suspend fun clear() = withContext(Dispatchers.IO) { settingsDataStore.clearCachedData() - database.clearAllTables() + clearAllTablesExceptTemplates() // apply() is safe here: nothing after this reads the prefs back, and we're already // off the main thread (Dispatchers.IO), so there's no ordering requirement forcing // a synchronous commit(). @@ -35,6 +35,47 @@ class UserCacheCleaner @Inject constructor( ShortcutManager.clearShortcuts(context) } + /** + * Wipes every user table on logout EXCEPT `entry_templates`, which is preserved so favorites + * survive per-account (retention #787). + * + * The sweep is list-based on purpose: it enumerates the live tables and deletes everything + * *not* in the keep-set, so any table added in a future schema is cleared by default — the + * safe direction. Only `entry_templates` is deliberately kept. Preserving templates across a + * logout is safe cross-account because [TemplateDao] scopes every read to the owning account + * (endpoint + userId, Task 3.2): account B never sees account A's surviving templates, while a + * returning account A sees its own again. + * + * Mirrors Room's own [androidx.room.RoomDatabase.clearAllTables]: it defers foreign keys inside + * a single transaction (via the raw `PRAGMA defer_foreign_keys` rather than Room's + * `runInTransaction` wrapper, which would fight the PRAGMA) and skips SQLite/Room bookkeeping + * tables (`sqlite_*` — which also covers `sqlite_sequence` — `android_metadata`, + * `room_master_table`). + */ + private fun clearAllTablesExceptTemplates() { + val db = database.openHelper.writableDatabase + val keep = setOf("entry_templates") + val tables = mutableListOf() + db.query("SELECT name FROM sqlite_master WHERE type='table'").use { cursor -> + while (cursor.moveToNext()) tables.add(cursor.getString(0)) + } + db.beginTransaction() + try { + db.execSQL("PRAGMA defer_foreign_keys = TRUE") + tables + .filter { + it !in keep && + !it.startsWith("sqlite_") && + !it.startsWith("android_") && + it != "room_master_table" + } + .forEach { db.execSQL("DELETE FROM `$it`") } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + private companion object { const val TILE_STATE_PREFERENCES = "tile_state" } diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/db/AppDatabase.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/db/AppDatabase.kt index 0b2eb3d..3c86075 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/db/AppDatabase.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/db/AppDatabase.kt @@ -12,6 +12,14 @@ import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +private const val DATABASE_VERSION_1 = 1 +private const val DATABASE_VERSION_2 = 2 +private const val DATABASE_VERSION_3 = 3 +private const val DATABASE_VERSION_4 = 4 +private const val DATABASE_VERSION_5 = 5 +private const val DATABASE_VERSION_6 = 6 +private const val DATABASE_VERSION_7 = 7 + @Database( entities = [ TimeEntryEntity::class, @@ -27,7 +35,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase TemplateEntity::class, InboxDismissalEntity::class, ], - version = 4, + version = DATABASE_VERSION_7, exportSchema = true, ) @TypeConverters(Converters::class) @@ -45,7 +53,7 @@ abstract class AppDatabase : RoomDatabase() { * between these versions, so the migration is a single CREATE TABLE + its index, matching * the Room-generated schema for [ClientEntity]. */ - val MIGRATION_1_2 = object : Migration(1, 2) { + val MIGRATION_1_2 = object : Migration(DATABASE_VERSION_1, DATABASE_VERSION_2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( "CREATE TABLE IF NOT EXISTS `clients` (" + @@ -66,7 +74,7 @@ abstract class AppDatabase : RoomDatabase() { * - `deadLettered`: terminal dead-letter flag for permanently failed operations. * Existing rows keep an empty key and a non-dead-lettered state. */ - val MIGRATION_2_3 = object : Migration(2, 3) { + val MIGRATION_2_3 = object : Migration(DATABASE_VERSION_2, DATABASE_VERSION_3) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE `outbox` ADD COLUMN `clientId` TEXT NOT NULL DEFAULT ''") db.execSQL("ALTER TABLE `outbox` ADD COLUMN `deadLettered` INTEGER NOT NULL DEFAULT 0") @@ -78,7 +86,7 @@ abstract class AppDatabase : RoomDatabase() { * `organizationId` indices, matching the Room-generated schema for [TemplateEntity] and * [InboxDismissalEntity]. No existing table changes, so no data migration is required. */ - val MIGRATION_3_4 = object : Migration(3, 4) { + val MIGRATION_3_4 = object : Migration(DATABASE_VERSION_3, DATABASE_VERSION_4) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( "CREATE TABLE IF NOT EXISTS `entry_templates` (" + @@ -104,6 +112,60 @@ abstract class AppDatabase : RoomDatabase() { } } - val MIGRATIONS: Array = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) + /** + * v4 -> v5 adds sync-conflict-detection columns (SV-027): + * - `outbox.baseSnapshotJson`: last server-acked content a local edit was based on. + * - `time_entries.conflictServerJson`: full server TimeEntry JSON ("theirs") captured at + * conflict-detection time. + * Both are additive nullable columns; no existing data changes. [SyncState.CONFLICT] is + * stored as its name via [Converters], same as the existing enum values, so no column-type + * change is needed for `syncState` itself. + */ + val MIGRATION_4_5 = object : Migration(DATABASE_VERSION_4, DATABASE_VERSION_5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `outbox` ADD COLUMN `baseSnapshotJson` TEXT") + db.execSQL("ALTER TABLE `time_entries` ADD COLUMN `conflictServerJson` TEXT") + } + } + + /** + * v5 -> v6 adds account-ownership columns to `entry_templates` (Task 3.1): + * - `ownerEndpoint` / `ownerUserId`: the account (API endpoint + user) that owns a + * template. Both are nullable; legacy rows keep NULL owners until a later task claims + * them. A composite index on (`ownerUserId`, `organizationId`) backs owner-scoped + * queries. All changes are additive; no existing data is rewritten. + */ + val MIGRATION_5_6 = object : Migration(DATABASE_VERSION_5, DATABASE_VERSION_6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `entry_templates` ADD COLUMN `ownerEndpoint` TEXT") + db.execSQL("ALTER TABLE `entry_templates` ADD COLUMN `ownerUserId` TEXT") + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_entry_templates_ownerUserId_organizationId` " + + "ON `entry_templates` (`ownerUserId`, `organizationId`)", + ) + } + } + + /** + * v6 -> v7 splits per-source freshness on `sync_meta` (roadmap #35, #79): + * - `lastPushAtMs`: last successful outbox flush (push), distinct from the existing pull + * `lastFullSyncAtMs`. Nullable; legacy rows keep NULL until the first successful push. + * Additive nullable column only; no existing data is rewritten. + */ + val MIGRATION_6_7 = object : Migration(DATABASE_VERSION_6, DATABASE_VERSION_7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `sync_meta` ADD COLUMN `lastPushAtMs` INTEGER") + } + } + + val MIGRATIONS: Array = + arrayOf( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + ) } } diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/db/Entities.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/db/Entities.kt index 73da6ef..487bafa 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/db/Entities.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/db/Entities.kt @@ -10,7 +10,7 @@ import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey -enum class SyncState { SYNCED, PENDING } +enum class SyncState { SYNCED, PENDING, CONFLICT } @Entity( tableName = "time_entries", @@ -30,6 +30,11 @@ data class TimeEntryEntity( val updatedAt: Long, val syncState: SyncState, val pendingDelete: Boolean, + /** + * Full server TimeEntry JSON captured as "theirs" at conflict-detection time (SV-027). Null + * unless [syncState] is [SyncState.CONFLICT]. + */ + val conflictServerJson: String? = null, ) @Entity(tableName = "projects") @@ -78,8 +83,20 @@ data class TimeEntryTagCrossRef(val timeEntryId: String, val tagId: String) /** Projection row for [TimeEntryDao.observeTagRefs]; not a table. */ data class TimeEntryTagRef(val timeEntryId: String, val tagId: String) +/** + * Per-source freshness timestamps for an organization (roadmap #35, foundation for the sync-center + * screen #33). Roadmap #79 warns that a single "last synced" moment is misleading, so the genuinely + * distinct sources are tracked separately: + * - [lastFullSyncAtMs]: last full PULL refresh (catalog + history are fetched together in + * `refreshAll`, so they share one honest moment and are not split further). + * - [lastPushAtMs]: last successful outbox flush (PUSH). NULL until the first successful push. + * + * There is deliberately no active-timer column: the active-entry endpoint is account-wide and its + * fetch is ephemeral (never cached with an org-scoped freshness moment), so a per-org active-timer + * timestamp could not be populated honestly. + */ @Entity(tableName = "sync_meta") -data class SyncMetaEntity(@PrimaryKey val organizationId: String, val lastFullSyncAtMs: Long) +data class SyncMetaEntity(@PrimaryKey val organizationId: String, val lastFullSyncAtMs: Long, val lastPushAtMs: Long? = null) /** * Reusable entry template / favorite (gap analysis #9, #81). Account/organization scoped, local @@ -88,7 +105,10 @@ data class SyncMetaEntity(@PrimaryKey val organizationId: String, val lastFullSy * user-facing label; [description] is optional local work content and receives the same protection * as cached entries. */ -@Entity(tableName = "entry_templates", indices = [Index("organizationId")]) +@Entity( + tableName = "entry_templates", + indices = [Index("organizationId"), Index("ownerUserId", "organizationId")], +) data class TemplateEntity( @PrimaryKey val id: String, val organizationId: String, @@ -101,6 +121,12 @@ data class TemplateEntity( val isFavorite: Boolean, val sortOrder: Int, val createdAtMs: Long, + /** + * Account that owns this template, identified by API endpoint and user ID (Task 3.1). Both are + * NULL for legacy templates created before ownership tracking; later tasks claim/scope them. + */ + val ownerEndpoint: String? = null, + val ownerUserId: String? = null, ) /** diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/db/InboxDismissalDao.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/db/InboxDismissalDao.kt index 807db1c..41d5896 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/db/InboxDismissalDao.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/db/InboxDismissalDao.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.flow.Flow interface InboxDismissalDao { @Upsert suspend fun upsert(dismissal: InboxDismissalEntity) + @Upsert suspend fun upsertAll(dismissals: List) + @Query("SELECT * FROM inbox_dismissals WHERE organizationId = :orgId") fun observeDismissals(orgId: String): Flow> diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxDao.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxDao.kt index 09beef2..7680583 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxDao.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxDao.kt @@ -102,4 +102,25 @@ interface OutboxDao { */ @Query("DELETE FROM outbox WHERE timeEntryId = :entryId AND deadLettered = 1") suspend fun deleteDeadLetteredByEntryId(entryId: String): Int + + /** + * The oldest captured base snapshot for an entry's still-queued operations (SV-027 rule 1): an + * offline STOP -> UPDATE chain must share the pre-stop base rather than each op re-snapshotting + * its own (already-mutated) pre-state. Null if no queued op for this entry carries a base yet. + */ + @Query( + "SELECT baseSnapshotJson FROM outbox WHERE timeEntryId = :entryId AND baseSnapshotJson IS NOT NULL " + + "ORDER BY id ASC LIMIT 1", + ) + suspend fun oldestBaseSnapshot(entryId: String): String? + + /** + * Whether a queued START or CREATE exists for an entry (SV-027 rule 2): such an entry was born + * locally, so subsequent STOP/UPDATE/DELETE ops get base = null - there is nothing server-side + * to diverge from yet. + */ + @Query( + "SELECT EXISTS(SELECT 1 FROM outbox WHERE timeEntryId = :entryId AND opType IN ('START', 'CREATE'))", + ) + suspend fun hasPendingCreateOrStart(entryId: String): Boolean } diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxEntity.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxEntity.kt index 666c5d6..ba9218a 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxEntity.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxEntity.kt @@ -33,4 +33,9 @@ data class OutboxEntity( * the user can inspect and explicitly retry it. */ val deadLettered: Boolean = false, + /** + * The last server-acked TimeEntry JSON this outbox operation's local edit was based on + * (SV-027 conflict detection "base" snapshot). Null when no snapshot was captured. + */ + val baseSnapshotJson: String? = null, ) diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/db/SyncMetaDao.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/db/SyncMetaDao.kt index 7d40fd1..1204168 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/db/SyncMetaDao.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/db/SyncMetaDao.kt @@ -8,13 +8,53 @@ package dev.tricked.solidverdant.data.local.db import androidx.room.Dao import androidx.room.Query +import androidx.room.Transaction import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow @Dao interface SyncMetaDao { @Query("SELECT * FROM sync_meta WHERE organizationId = :orgId") suspend fun get(orgId: String): SyncMetaEntity? + /** Reactive freshness for an org, for the sync-center screen (#33). Emits NULL until first sync. */ + @Query("SELECT * FROM sync_meta WHERE organizationId = :orgId") + fun observe(orgId: String): Flow + @Upsert suspend fun upsert(meta: SyncMetaEntity) + + /** + * Ensure a row exists for [orgId] without touching any freshness column of an existing row. + * `INSERT OR IGNORE` seeds a sentinel pull-timestamp of 0 and a NULL push-timestamp only when + * the row is absent, so the targeted `UPDATE`s below can update one column in isolation. + */ + @Query("INSERT OR IGNORE INTO sync_meta (organizationId, lastFullSyncAtMs) VALUES (:orgId, 0)") + suspend fun ensureRow(orgId: String) + + @Query("UPDATE sync_meta SET lastFullSyncAtMs = :ts WHERE organizationId = :orgId") + suspend fun updateFullSyncAt(orgId: String, ts: Long) + + @Query("UPDATE sync_meta SET lastPushAtMs = :ts WHERE organizationId = :orgId") + suspend fun updatePushAt(orgId: String, ts: Long) + + /** + * Stamp the last full PULL refresh moment without clobbering [SyncMetaEntity.lastPushAtMs]. + * Insert-if-absent then a single-column UPDATE guarantees the push timestamp is preserved. + */ + @Transaction + suspend fun stampFullSync(orgId: String, ts: Long) { + ensureRow(orgId) + updateFullSyncAt(orgId, ts) + } + + /** + * Stamp the last successful PUSH moment without clobbering [SyncMetaEntity.lastFullSyncAtMs]. + * Insert-if-absent then a single-column UPDATE guarantees the pull timestamp is preserved. + */ + @Transaction + suspend fun stampPush(orgId: String, ts: Long) { + ensureRow(orgId) + updatePushAt(orgId, ts) + } } diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/db/TemplateDao.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/db/TemplateDao.kt index 0f2b96f..2884f2f 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/db/TemplateDao.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/db/TemplateDao.kt @@ -23,9 +23,21 @@ interface TemplateDao { @Query( "SELECT * FROM entry_templates WHERE organizationId = :orgId " + + "AND ownerEndpoint = :endpoint AND ownerUserId = :userId " + "ORDER BY isFavorite DESC, sortOrder ASC, createdAtMs ASC", ) - fun observeTemplates(orgId: String): Flow> + fun observeTemplates(orgId: String, endpoint: String, userId: String): Flow> + + /** + * Stamp the current account onto legacy templates that predate ownership tracking (all + * pre-v6 rows have NULL owners). Runs once at login: a subsequent claim by a different account + * matches no rows (they are now owned) and affects 0 rows. Returns the number of rows claimed. + */ + @Query( + "UPDATE entry_templates SET ownerEndpoint = :endpoint, ownerUserId = :userId " + + "WHERE ownerUserId IS NULL", + ) + suspend fun claimUnowned(endpoint: String, userId: String): Int @Query("SELECT * FROM entry_templates WHERE id = :id") suspend fun getById(id: String): TemplateEntity? diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/db/TimeEntryDao.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/db/TimeEntryDao.kt index 6e51343..f14791a 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/db/TimeEntryDao.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/db/TimeEntryDao.kt @@ -12,7 +12,15 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert +import dev.tricked.solidverdant.data.model.TimeEntry +import dev.tricked.solidverdant.sync.ConflictSnapshot import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.json.Json + +private val conflictJson = Json { + encodeDefaults = true + ignoreUnknownKeys = true +} @Dao interface TimeEntryDao { @@ -28,6 +36,9 @@ interface TimeEntryDao { @Query("SELECT * FROM time_entries WHERE id = :id") suspend fun getById(id: String): TimeEntryEntity? + @Query("SELECT * FROM time_entries WHERE organizationId = :orgId AND syncState = 'CONFLICT' ORDER BY start DESC") + fun observeConflicts(orgId: String): Flow> + @Upsert suspend fun upsert(entry: TimeEntryEntity) @@ -88,17 +99,88 @@ interface TimeEntryDao { * Never lets a server pull clobber an unsynced local edit or a pending soft-delete. */ @Transaction - suspend fun applyServerEntries(entries: List, tagIdsByEntry: Map>) { + suspend fun applyServerEntries( + entries: List, + tagIdsByEntry: Map>, + baseSnapshotsByEntry: Map = emptyMap(), + serverJsonByEntry: Map = emptyMap(), + ) { entries.forEach { entity -> val local = getById(entity.id) + val tagIds = tagIdsByEntry[entity.id].orEmpty() + val serverJson = serverJsonByEntry[entity.id] ?: serverJson(entity, tagIds) + if (local?.syncState == SyncState.CONFLICT) { + // An unresolved conflict owns the local row. Pulls may refresh only "theirs"; + // replacing the row here would destroy the recovery copy shown in Review. + if (!sameServerSnapshot(local.conflictServerJson, entity, tagIds)) { + upsert(local.copy(conflictServerJson = serverJson)) + } + return@forEach + } if (local != null && (local.syncState == SyncState.PENDING || local.pendingDelete)) { + val base = baseSnapshotsByEntry[entity.id] + if (base == null) return@forEach + val baseSnapshot = runCatching { conflictJson.decodeFromString(base) }.getOrNull() + val serverSnapshot = ConflictSnapshot.of( + start = entity.start, + end = entity.end, + description = entity.description, + projectId = entity.projectId, + taskId = entity.taskId, + billable = entity.billable, + tagIds = tagIds, + ) + if (baseSnapshot?.matches(serverSnapshot) == true) return@forEach + upsert(local.copy(syncState = SyncState.CONFLICT, conflictServerJson = serverJson)) return@forEach } upsert(entity) - replaceTagRefs(entity.id, tagIdsByEntry[entity.id].orEmpty()) + replaceTagRefs(entity.id, tagIds) } } + private fun serverJson(entity: TimeEntryEntity, tagIds: List): String = conflictJson.encodeToString( + TimeEntry( + id = entity.id, + description = entity.description, + userId = entity.userId, + start = entity.start, + end = entity.end, + duration = entity.duration, + taskId = entity.taskId, + projectId = entity.projectId, + tags = tagIds.map { dev.tricked.solidverdant.data.model.Tag(it) }, + billable = entity.billable, + organizationId = entity.organizationId, + ), + ) + + private fun sameServerSnapshot(existingJson: String?, entity: TimeEntryEntity, tagIds: List): Boolean { + val previous = existingJson + ?.let { runCatching { conflictJson.decodeFromString(it) }.getOrNull() } + return previous?.let { + ConflictSnapshot.of( + it.start, + it.end, + it.description, + it.projectId, + it.taskId, + it.billable, + it.tags.map { tag -> tag.id }, + ).matches( + ConflictSnapshot.of( + entity.start, + entity.end, + entity.description, + entity.projectId, + entity.taskId, + entity.billable, + tagIds, + ), + ) + } ?: false + } + /** * IDs of local rows that must be tombstoned after fetching a bounded server page (SV-020): * rows that are `SYNCED` (never a locally-unsynced edit), have no `pendingDelete` flag, have no diff --git a/app/src/main/java/dev/tricked/solidverdant/data/remote/ConnectionTester.kt b/app/src/main/java/dev/tricked/solidverdant/data/remote/ConnectionTester.kt index 5cbc184..5a81fa0 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/remote/ConnectionTester.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/remote/ConnectionTester.kt @@ -54,13 +54,16 @@ class ConnectionTester @Inject constructor(private val client: OkHttpClient) { } val url = endpoint.trimEnd('/') + "/api/v1/users/me" try { - client.newBuilder().followRedirects(false).callTimeout(15, TimeUnit.SECONDS).build().newCall( + client.newBuilder().followRedirects(false).callTimeout(CONNECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS).build().newCall( Request.Builder().url(url).header("Accept", "application/json").get().build(), ).execute().use { response -> when (response.code) { - 200, 401, 403 -> ConnectionTestResult(ConnectionTestCode.READY, response.code) - 404 -> ConnectionTestResult(ConnectionTestCode.API_NOT_FOUND, response.code) - in 500..599 -> ConnectionTestResult(ConnectionTestCode.SERVER_ERROR, response.code) + HTTP_OK, HTTP_UNAUTHORIZED, HTTP_FORBIDDEN -> ConnectionTestResult(ConnectionTestCode.READY, response.code) + HTTP_NOT_FOUND -> ConnectionTestResult(ConnectionTestCode.API_NOT_FOUND, response.code) + in HTTP_SERVER_ERROR_START..HTTP_SERVER_ERROR_END -> ConnectionTestResult( + ConnectionTestCode.SERVER_ERROR, + response.code, + ) else -> ConnectionTestResult(ConnectionTestCode.UNEXPECTED_RESPONSE, response.code) } } @@ -74,4 +77,14 @@ class ConnectionTester @Inject constructor(private val client: OkHttpClient) { ConnectionTestResult(ConnectionTestCode.TEST_FAILED) } } + + private companion object { + const val CONNECTION_TIMEOUT_SECONDS = 15L + const val HTTP_OK = 200 + const val HTTP_UNAUTHORIZED = 401 + const val HTTP_FORBIDDEN = 403 + const val HTTP_NOT_FOUND = 404 + const val HTTP_SERVER_ERROR_START = 500 + const val HTTP_SERVER_ERROR_END = 599 + } } diff --git a/app/src/main/java/dev/tricked/solidverdant/data/remote/RemoteDataSource.kt b/app/src/main/java/dev/tricked/solidverdant/data/remote/RemoteDataSource.kt index 7423215..84e034c 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/remote/RemoteDataSource.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/remote/RemoteDataSource.kt @@ -16,14 +16,18 @@ import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.data.repository.AuthRepository import javax.inject.Inject +data class TimeEntriesQuery( + val organizationId: String, + val memberId: String, + val limit: Int, + val offset: Int, + val onlyFullDates: Boolean, + val start: String? = null, + val end: String? = null, +) + interface RemoteDataSource { - suspend fun getTimeEntries( - organizationId: String, - memberId: String, - limit: Int, - offset: Int, - onlyFullDates: Boolean, - ): Result + suspend fun getTimeEntries(query: TimeEntriesQuery): Result suspend fun getProjects(organizationId: String): Result> suspend fun getClients(organizationId: String): Result> suspend fun getTasks(organizationId: String): Result> @@ -61,8 +65,15 @@ interface RemoteDataSource { } class AuthRemoteDataSource @Inject constructor(private val authRepository: AuthRepository) : RemoteDataSource { - override suspend fun getTimeEntries(organizationId: String, memberId: String, limit: Int, offset: Int, onlyFullDates: Boolean) = - authRepository.getTimeEntries(organizationId, memberId, limit, offset, onlyFullDates) + override suspend fun getTimeEntries(query: TimeEntriesQuery) = authRepository.getTimeEntries( + query.organizationId, + query.memberId, + query.limit, + query.offset, + query.onlyFullDates, + query.start, + query.end, + ) override suspend fun getProjects(organizationId: String) = authRepository.getProjects(organizationId) override suspend fun getClients(organizationId: String) = authRepository.getClients(organizationId) override suspend fun getTasks(organizationId: String) = authRepository.getTasks(organizationId) diff --git a/app/src/main/java/dev/tricked/solidverdant/data/remote/TokenAuthenticator.kt b/app/src/main/java/dev/tricked/solidverdant/data/remote/TokenAuthenticator.kt index 24d2733..1b47805 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/remote/TokenAuthenticator.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/remote/TokenAuthenticator.kt @@ -79,7 +79,7 @@ private class HttpTokenRefresher(private val json: Json, private val client: OkH ?: RefreshResult.Transient // 401, or 400 invalid_grant/invalid_client, means the refresh token is no longer // usable: force logout. Other codes (e.g. 429, 5xx) are transient. - response.code == 401 || isDefinitiveRejection(response.code, body) -> { + response.code == HTTP_UNAUTHORIZED || isDefinitiveRejection(response.code, body) -> { Timber.w("Token refresh rejected with code: ${response.code}") RefreshResult.Invalid } @@ -96,9 +96,14 @@ private class HttpTokenRefresher(private val json: Json, private val client: OkH } } - private fun isDefinitiveRejection(code: Int, body: String?): Boolean = code == 400 && + private fun isDefinitiveRejection(code: Int, body: String?): Boolean = code == HTTP_BAD_REQUEST && body != null && (body.contains("invalid_grant") || body.contains("invalid_client")) + + private companion object { + const val HTTP_BAD_REQUEST = 400 + const val HTTP_UNAUTHORIZED = 401 + } } /** Refreshes an expired access token, allowing at most one refresh at a time. */ diff --git a/app/src/main/java/dev/tricked/solidverdant/data/repository/AuthRepository.kt b/app/src/main/java/dev/tricked/solidverdant/data/repository/AuthRepository.kt index d8bf95d..c563d6c 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/repository/AuthRepository.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/repository/AuthRepository.kt @@ -20,6 +20,7 @@ import dev.tricked.solidverdant.data.remote.ApiClientFactory import dev.tricked.solidverdant.util.PKCEUtil import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import retrofit2.HttpException import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -32,6 +33,7 @@ import javax.inject.Singleton class AuthRepository @Inject constructor(private val authDataStore: AuthDataStore, private val apiClientFactory: ApiClientFactory) { companion object { private const val REDIRECT_URI = "solidtime://oauth/callback" + private const val HTTP_NOT_FOUND = 404 } val isLoggedIn: Flow = authDataStore.isLoggedIn @@ -168,15 +170,17 @@ class AuthRepository @Inject constructor(private val authDataStore: AuthDataStor val api = apiClientFactory.createApi(endpoint) val response = api.getActiveTimeEntry() Result.success(response.data) - } catch (e: Exception) { - // 404 with "No active time entry" is expected when not tracking - if (e is retrofit2.HttpException && e.code() == 404) { + } catch (e: retrofit2.HttpException) { + if (e.code() == HTTP_NOT_FOUND) { Timber.d("No active time entry") Result.success(null) } else { Timber.e(e, "Failed to get active time entry") Result.failure(e) } + } catch (e: Exception) { + Timber.e(e, "Failed to get active time entry") + Result.failure(e) } /** @@ -206,6 +210,7 @@ class AuthRepository @Inject constructor(private val authDataStore: AuthDataStor /** * Start a new time entry */ + @Suppress("UnusedParameter") suspend fun startTimeEntry( organizationId: String, memberId: String, @@ -460,7 +465,8 @@ class AuthRepository @Inject constructor(private val authDataStore: AuthDataStor suspend fun deleteTimeEntry(organizationId: String, timeEntryId: String): Result = try { val endpoint = authDataStore.getEndpoint() val api = apiClientFactory.createApi(endpoint) - api.deleteTimeEntry(organizationId, timeEntryId) + val response = api.deleteTimeEntry(organizationId, timeEntryId) + if (!response.isSuccessful) throw HttpException(response) Timber.d("Time entry deleted") Result.success(Unit) } catch (e: Exception) { diff --git a/app/src/main/java/dev/tricked/solidverdant/data/repository/InboxRepository.kt b/app/src/main/java/dev/tricked/solidverdant/data/repository/InboxRepository.kt index c146e8f..874ff5b 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/repository/InboxRepository.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/repository/InboxRepository.kt @@ -10,11 +10,12 @@ import dev.tricked.solidverdant.data.local.SettingsDataStore import dev.tricked.solidverdant.data.local.db.InboxDismissalDao import dev.tricked.solidverdant.domain.inbox.InboxAnalyzer import dev.tricked.solidverdant.domain.inbox.InboxSettingsDataStore +import dev.tricked.solidverdant.domain.inbox.resolveHorizonStartMs +import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider import dev.tricked.solidverdant.util.Clock import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import java.time.ZoneId import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -44,6 +45,7 @@ class InboxRepositoryImpl @Inject constructor( private val settingsDataStore: SettingsDataStore, private val dismissalDao: InboxDismissalDao, private val clock: Clock, + private val temporalPolicyProvider: TemporalPolicyProvider, ) : InboxRepository { override fun observeOpenIssueCount(organizationId: String): Flow = combine( @@ -51,7 +53,8 @@ class InboxRepositoryImpl @Inject constructor( inboxSettingsDataStore.settings, settingsDataStore.longTimerHours, dismissalDao.observeDismissals(organizationId), - ) { entries, settings, longTimerHours, dismissals -> + temporalPolicyProvider.policy, + ) { entries, settings, longTimerHours, dismissals, policy -> val now = clock.nowMs() val retentionMs = TimeUnit.DAYS.toMillis(InboxAnalyzer.DISMISSAL_RETENTION_DAYS) val activeDismissedKeys = dismissals @@ -63,7 +66,8 @@ class InboxRepositoryImpl @Inject constructor( config = settings.toConfig(longTimerHours), dismissedKeys = activeDismissedKeys, nowMs = now, - zone = ZoneId.systemDefault(), + zone = policy.zone, + horizonStartMs = resolveHorizonStartMs(settings.horizonChosen, settings.horizonStartMs, now, policy.zone), ) }.distinctUntilChanged() } diff --git a/app/src/main/java/dev/tricked/solidverdant/data/repository/TemplateRepository.kt b/app/src/main/java/dev/tricked/solidverdant/data/repository/TemplateRepository.kt index e7cefd3..63d4515 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/repository/TemplateRepository.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/repository/TemplateRepository.kt @@ -6,11 +6,16 @@ package dev.tricked.solidverdant.data.repository +import dev.tricked.solidverdant.data.local.AuthDataStore +import dev.tricked.solidverdant.data.local.SettingsDataStore import dev.tricked.solidverdant.data.local.db.TemplateDao import dev.tricked.solidverdant.data.local.db.TemplateEntity import dev.tricked.solidverdant.util.Clock +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import java.util.UUID import javax.inject.Inject @@ -64,7 +69,13 @@ fun TemplateEntity.toModel(): EntryTemplate = EntryTemplate( createdAtMs = createdAtMs, ) -fun EntryTemplate.toEntity(): TemplateEntity = TemplateEntity( +/** + * Owner columns are infrastructure, not part of the [EntryTemplate] product model, so they are + * passed in explicitly by the repository (resolved from the current account) rather than carried on + * the model. Only the owning account can see or edit a template, so re-stamping the current owner on + * every write is safe and keeps the row scoped to its account. + */ +fun EntryTemplate.toEntity(ownerEndpoint: String? = null, ownerUserId: String? = null): TemplateEntity = TemplateEntity( id = id, organizationId = organizationId, name = name, @@ -76,6 +87,8 @@ fun EntryTemplate.toEntity(): TemplateEntity = TemplateEntity( isFavorite = isFavorite, sortOrder = sortOrder, createdAtMs = createdAtMs, + ownerEndpoint = ownerEndpoint, + ownerUserId = ownerUserId, ) /** @@ -84,12 +97,34 @@ fun EntryTemplate.toEntity(): TemplateEntity = TemplateEntity( * (favorites survive per-account, matching gap analysis retention #787). */ @Singleton -class TemplateRepository @Inject constructor(private val templateDao: TemplateDao, private val clock: Clock) { - fun observeTemplates(organizationId: String): Flow> = - templateDao.observeTemplates(organizationId).map { list -> list.map { it.toModel() } } +class TemplateRepository @Inject constructor( + private val templateDao: TemplateDao, + private val clock: Clock, + private val authDataStore: AuthDataStore, + private val settingsDataStore: SettingsDataStore, +) { + /** Current account = (server endpoint, Solidtime user id). Null userId = no account signed in. */ + private fun currentUserId(): String? = settingsDataStore.getCachedAuth()?.user?.id + + @OptIn(ExperimentalCoroutinesApi::class) + fun observeTemplates(organizationId: String): Flow> = authDataStore.endpoint.flatMapLatest { endpoint -> + val userId = currentUserId() + if (userId == null) { + flowOf(emptyList()) + } else { + templateDao.observeTemplates(organizationId, endpoint, userId) + .map { list -> list.map { it.toModel() } } + } + } suspend fun getTemplate(id: String): EntryTemplate? = templateDao.getById(id)?.toModel() + /** + * Stamp the current account onto legacy (NULL-owner) templates. Called once when the auth cache + * is written at login. Returns rows claimed; a second claim by a different account is a no-op. + */ + suspend fun claimUnowned(endpoint: String, userId: String): Int = templateDao.claimUnowned(endpoint, userId) + /** * Create a new template appended after existing ones (so it does not jump ahead of the user's * pinned order). Blank name/description collapse to null. @@ -104,7 +139,11 @@ class TemplateRepository @Inject constructor(private val templateDao: TemplateDa billable: Boolean, isFavorite: Boolean, ): EntryTemplate { - val existing = templateDao.observeTemplates(organizationId).first() + val ownerEndpoint = authDataStore.endpoint.first() + val ownerUserId = currentUserId() + val existing = ownerUserId?.let { + templateDao.observeTemplates(organizationId, ownerEndpoint, it).first() + } ?: emptyList() val nextOrder = (existing.maxOfOrNull { it.sortOrder } ?: -1) + 1 val template = EntryTemplate( id = "template-" + UUID.randomUUID().toString(), @@ -119,7 +158,7 @@ class TemplateRepository @Inject constructor(private val templateDao: TemplateDa sortOrder = nextOrder, createdAtMs = clock.nowMs(), ) - templateDao.upsert(template.toEntity()) + templateDao.upsert(template.toEntity(ownerEndpoint, ownerUserId)) return template } @@ -129,7 +168,9 @@ class TemplateRepository @Inject constructor(private val templateDao: TemplateDa name = template.name?.trim()?.takeIf { it.isNotEmpty() }, description = template.description?.trim()?.takeIf { it.isNotEmpty() }, ) - templateDao.upsert(normalized.toEntity()) + // Only the owning account can reach this template, so re-stamping the current owner keeps + // ownership intact (the model carries no owner columns to preserve). + templateDao.upsert(normalized.toEntity(authDataStore.endpoint.first(), currentUserId())) } suspend fun deleteTemplate(id: String) { @@ -148,8 +189,14 @@ class TemplateRepository @Inject constructor(private val templateDao: TemplateDa */ suspend fun persistOrder(templates: List) { if (templates.isEmpty()) return + // These map from the owner-less model, so re-stamp the current account (only the owner can + // reach these rows) to avoid nulling the owner columns on reorder. + val ownerEndpoint = authDataStore.endpoint.first() + val ownerUserId = currentUserId() templateDao.upsertAll( - templates.mapIndexed { index, template -> template.copy(sortOrder = index).toEntity() }, + templates.mapIndexed { index, template -> + template.copy(sortOrder = index).toEntity(ownerEndpoint, ownerUserId) + }, ) } } diff --git a/app/src/main/java/dev/tricked/solidverdant/data/repository/TimeEntryRepository.kt b/app/src/main/java/dev/tricked/solidverdant/data/repository/TimeEntryRepository.kt index f466b61..4eb5112 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/repository/TimeEntryRepository.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/repository/TimeEntryRepository.kt @@ -16,6 +16,7 @@ import dev.tricked.solidverdant.data.local.db.SyncMetaDao import dev.tricked.solidverdant.data.local.db.SyncMetaEntity import dev.tricked.solidverdant.data.local.db.SyncState import dev.tricked.solidverdant.data.local.db.TimeEntryDao +import dev.tricked.solidverdant.data.local.db.TimeEntryEntity import dev.tricked.solidverdant.data.local.db.toEntity import dev.tricked.solidverdant.data.local.db.toModel import dev.tricked.solidverdant.data.model.Client @@ -24,6 +25,8 @@ import dev.tricked.solidverdant.data.model.Tag import dev.tricked.solidverdant.data.model.Task import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.data.remote.RemoteDataSource +import dev.tricked.solidverdant.data.remote.TimeEntriesQuery +import dev.tricked.solidverdant.sync.ConflictSnapshot import dev.tricked.solidverdant.sync.CreatePayload import dev.tricked.solidverdant.sync.StartPayload import dev.tricked.solidverdant.sync.StopPayload @@ -40,7 +43,11 @@ import java.time.format.DateTimeFormatter import javax.inject.Inject import javax.inject.Singleton +private const val MONTH_PAGE_SIZE = 250 +private const val MAX_MONTH_ENTRIES = 15_000 + @Singleton +@Suppress("LargeClass") class TimeEntryRepository @Inject constructor( private val timeEntryDao: TimeEntryDao, private val catalogDao: CatalogDao, @@ -51,7 +58,9 @@ class TimeEntryRepository @Inject constructor( private val json: Json, private val database: AppDatabase, ) : TimeEntryReader { - enum class EntrySyncStatus { SYNCED, PENDING, RETRYING, FAILED } + enum class EntrySyncStatus { SYNCED, PENDING, RETRYING, FAILED, CONFLICT } + + data class SyncConflict(val local: TimeEntry, val server: TimeEntry?, val serverDeleted: Boolean, val localDeleted: Boolean) data class SyncOperation( val entryId: String, @@ -81,8 +90,34 @@ class TimeEntryRepository @Inject constructor( } } + fun observeConflicts(organizationId: String): Flow> = combine( + timeEntryDao.observeConflicts(organizationId), + catalogDao.observeTags(organizationId), + timeEntryDao.observeTagRefs(organizationId), + ) { entities, tagEntities, tagRefs -> + val tagsById = tagEntities.associate { it.id to it.toModel() } + val tagIdsByEntry = tagRefs.groupBy({ it.timeEntryId }, { it.tagId }) + entities.mapNotNull { entity -> + val localTags = tagIdsByEntry[entity.id].orEmpty().mapNotNull { tagsById[it] } + val serverJson = entity.conflictServerJson + val serverDeleted = serverJson == ConflictSnapshot.DELETED_MARKER + val server = if (serverDeleted || serverJson == null) { + null + } else { + runCatching { json.decodeFromString(serverJson) }.getOrNull() + } + SyncConflict( + local = entity.toModel(localTags), + server = server, + serverDeleted = serverDeleted, + localDeleted = entity.pendingDelete, + ) + } + } + + @Suppress("LoopWithTooManyJumpStatements") override suspend fun loadMonth(organizationId: String, memberId: String, month: YearMonth) { - val pageSize = 250 + val pageSize = MONTH_PAGE_SIZE var offset = 0 val targetStart = month.atDay(1) // Tombstoning (SV-020) must be scoped to exactly what was fetched: the union of every @@ -92,19 +127,21 @@ class TimeEntryRepository @Inject constructor( val allServerIds = mutableListOf() var minStart: String? = null var maxStart: String? = null - while (offset < 15_000) { + while (offset < MAX_MONTH_ENTRIES) { val response = remote.getTimeEntries( - organizationId, - memberId, - pageSize, - offset, - onlyFullDates = false, + TimeEntriesQuery( + organizationId = organizationId, + memberId = memberId, + limit = pageSize, + offset = offset, + onlyFullDates = false, + ), ).getOrElse { Timber.e(it, "Failed loading calendar month %s", month) return } val now = clock.nowMs() - timeEntryDao.applyServerEntries( + applyServerEntries( response.data.map { it.toEntity(updatedAt = now, syncState = SyncState.SYNCED) }, response.data.associate { entry -> entry.id to entry.tags.map { it.id } }, ) @@ -135,8 +172,19 @@ class TimeEntryRepository @Inject constructor( fun observeOutboxCount(): Flow = outboxDao.observeCount() - fun observeSyncOperations(orgId: String): Flow> = outboxDao.observeAll().map { operations -> - operations.filter { it.organizationId == orgId }.map { op -> + /** + * Reactive sync freshness for an org (last full pull + last successful push), for the dedicated + * Sync Center screen (#33). Delegates to [SyncMetaDao.observe] so the UI never touches the DAO + * directly. Emits NULL until the first sync stamps a row. + */ + fun observeSyncMeta(orgId: String): Flow = syncMetaDao.observe(orgId) + + fun observeSyncOperations(orgId: String): Flow> = combine( + outboxDao.observeAll(), + timeEntryDao.observeConflicts(orgId), + ) { operations, conflicts -> + val conflictIds = conflicts.map { it.id }.toSet() + val queued = operations.filter { it.organizationId == orgId && it.timeEntryId !in conflictIds }.map { op -> SyncOperation( entryId = op.timeEntryId, type = op.opType, @@ -149,6 +197,15 @@ class TimeEntryRepository @Inject constructor( error = op.lastError, ) } + queued + conflicts.map { conflict -> + SyncOperation( + entryId = conflict.id, + type = OutboxOpType.UPDATE, + status = EntrySyncStatus.CONFLICT, + attemptCount = 0, + error = null, + ) + } } /** Pull the full first frame for an org and upsert into Room (last-write-wins). */ @@ -157,7 +214,9 @@ class TimeEntryRepository @Inject constructor( val clients = remote.getClients(organizationId).getOrThrow() val tasks = remote.getTasks(organizationId).getOrThrow() val tags = remote.getTags(organizationId).getOrThrow() - val entries = remote.getTimeEntries(organizationId, memberId, limit = 250, offset = 0, onlyFullDates = false) + val entries = remote.getTimeEntries( + TimeEntriesQuery(organizationId, memberId, limit = 250, offset = 0, onlyFullDates = false), + ) .getOrThrow().data catalogDao.upsertProjects(projects.map { it.toEntity(organizationId) }) @@ -175,7 +234,7 @@ class TimeEntryRepository @Inject constructor( // Single transaction; the pending-edit/soft-delete guard lives in applyServerEntries. // (The previous `updatedAt > now` guard was dead code: updatedAt is always stamped in // the past, so it never held and pending edits/deletes were silently overwritten.) - timeEntryDao.applyServerEntries( + applyServerEntries( entries.map { it.toEntity(updatedAt = now, syncState = SyncState.SYNCED) }, entries.associate { entry -> entry.id to entry.tags.map { it.id } }, ) @@ -193,7 +252,9 @@ class TimeEntryRepository @Inject constructor( entries.map { it.id }, ) } - syncMetaDao.upsert(SyncMetaEntity(organizationId, now)) + // Stamp the pull-refresh moment without clobbering the push timestamp (a concurrent + // SyncWorker flush may have written lastPushAtMs); stampFullSync updates that column alone. + syncMetaDao.stampFullSync(organizationId, now) Result.success(Unit) } catch (e: Exception) { Timber.e(e, "refreshAll failed") @@ -245,6 +306,16 @@ class TimeEntryRepository @Inject constructor( val now = clock.nowMs() val end = nowIso() database.withTransaction { + val current = timeEntryDao.getById(entry.id) + if (current?.syncState == SyncState.CONFLICT) { + // Stopping is the one mutation allowed on a conflicted row: time capture must not + // be blocked. Keep the row conflicted and preserve the server recovery copy. + timeEntryDao.upsert( + current.copy(end = end, updatedAt = now, pendingDelete = false), + ) + return@withTransaction + } + val base = captureBaseSnapshot(entry.id) timeEntryDao.upsert(entry.copy(end = end).toEntity(updatedAt = now, syncState = SyncState.PENDING)) outboxDao.insert( OutboxEntity( @@ -254,6 +325,7 @@ class TimeEntryRepository @Inject constructor( createdAtMs = now, clientId = newClientId(), payloadJson = json.encodeToString(StopPayload(userId, entry.start, end = end)), + baseSnapshotJson = base, ), ) } @@ -262,6 +334,10 @@ class TimeEntryRepository @Inject constructor( suspend fun updateEntry(entry: TimeEntry, tagIds: List) { val now = clock.nowMs() database.withTransaction { + check(timeEntryDao.getById(entry.id)?.syncState != SyncState.CONFLICT) { + "Resolve the sync conflict in Review before editing this entry" + } + val base = captureBaseSnapshot(entry.id) timeEntryDao.upsert(entry.toEntity(updatedAt = now, syncState = SyncState.PENDING)) timeEntryDao.replaceTagRefs(entry.id, tagIds) outboxDao.insert( @@ -283,6 +359,7 @@ class TimeEntryRepository @Inject constructor( tagIds, ), ), + baseSnapshotJson = base, ), ) } @@ -344,6 +421,79 @@ class TimeEntryRepository @Inject constructor( return entry } + /** + * Roadmap #13: create a straight duplicate of a completed entry, reusing [createCompletedEntry] + * so the copy goes through the outbox exactly like any manual entry. The duplicate keeps the + * same start/end and all metadata (description/project/task/tags/billable). + * + * Only completed, non-conflicted entries can be duplicated: a running entry has no `end` to copy + * (duplicating an open timer would create a second running timer), and a CONFLICT entry is + * edit-locked (SV-027) until resolved in Review. + */ + suspend fun duplicateEntry(entryId: String, memberId: String): Result = runCatching { + val source = timeEntryDao.getById(entryId) + ?: error("Entry not found") + check(source.syncState != SyncState.CONFLICT) { + "Resolve the sync conflict in Review before duplicating this entry" + } + val end = source.end ?: error("Cannot duplicate a running entry") + createCompletedEntry( + organizationId = source.organizationId, + memberId = memberId, + userId = source.userId, + description = source.description ?: "", + projectId = source.projectId, + taskId = source.taskId, + tagIds = timeEntryDao.tagIdsFor(entryId), + billable = source.billable, + start = source.start, + end = end, + ) + } + + /** + * Roadmap #13/#64: split a completed entry at [atIso] into two adjacent halves. The original is + * shortened to end at `at` via [updateEntry] and a new completed entry covering `[at, end]` is + * created via [createCompletedEntry]; both mutations flow through the outbox (no sync bypass). + * Metadata (description/project/task/tags/billable) is preserved on both halves. + * + * `at` must fall strictly inside the entry (`start < at < end`) — a half-open interval, so + * `at == start` or `at == end` is rejected. Running and CONFLICT (SV-027) entries cannot split. + * Returns the new second-half entry id so the caller can open it for immediate editing. + */ + suspend fun splitEntry(entryId: String, atIso: String, memberId: String): Result = runCatching { + val source = timeEntryDao.getById(entryId) + ?: error("Entry not found") + check(source.syncState != SyncState.CONFLICT) { + "Resolve the sync conflict in Review before splitting this entry" + } + val end = source.end ?: error("Cannot split a running entry") + // Parse as OffsetDateTime->Instant so mixed "Z"/"+02:00" offsets compare correctly. + val startInstant = java.time.OffsetDateTime.parse(source.start).toInstant() + val endInstant = java.time.OffsetDateTime.parse(end).toInstant() + val atInstant = java.time.OffsetDateTime.parse(atIso).toInstant() + require(atInstant.isAfter(startInstant) && atInstant.isBefore(endInstant)) { + "Split time must be strictly between the entry's start and end" + } + val tagIds = timeEntryDao.tagIdsFor(entryId) + val original = source.toModel(tagIds.map { Tag(it) }) + // First half: shorten the original to end at `at` (keeps its id, tags and base snapshot). + updateEntry(original.copy(end = atIso), tagIds) + // Second half: [at, originalEnd] as a brand-new completed entry with identical metadata. + createCompletedEntry( + organizationId = source.organizationId, + memberId = memberId, + userId = source.userId, + description = source.description ?: "", + projectId = source.projectId, + taskId = source.taskId, + tagIds = tagIds, + billable = source.billable, + start = atIso, + end = end, + ).id + } + /** * SV-019 step 1 of 2: hide the entry locally right away, without telling the server anything * yet. No outbox op is created here, so a caller's undo window (see [TrackingViewModel]) can @@ -353,6 +503,8 @@ class TimeEntryRepository @Inject constructor( */ suspend fun softDeleteLocal(entry: TimeEntry) { val now = clock.nowMs() + val current = timeEntryDao.getById(entry.id) + if (current?.syncState == SyncState.CONFLICT) return timeEntryDao.upsert(entry.toEntity(updatedAt = now, syncState = SyncState.PENDING, pendingDelete = true)) } @@ -371,10 +523,16 @@ class TimeEntryRepository @Inject constructor( suspend fun commitDelete(entry: TimeEntry) { val now = clock.nowMs() database.withTransaction { + if (timeEntryDao.getById(entry.id)?.syncState == SyncState.CONFLICT) return@withTransaction if (entry.id.startsWith("local-")) { timeEntryDao.deleteById(entry.id) outboxDao.deleteByTimeEntryId(entry.id) } else { + // softDeleteLocal has already flipped this row to PENDING by the time we get here, + // but the content fields are still the last server-acked ones - captureBaseSnapshot + // reads them from the entity, not from `entry`, so the PENDING flag flip is + // irrelevant to what gets snapshotted (SV-027 rule 3). + val base = captureBaseSnapshot(entry.id) outboxDao.insert( OutboxEntity( opType = OutboxOpType.DELETE, @@ -383,6 +541,7 @@ class TimeEntryRepository @Inject constructor( createdAtMs = now, clientId = newClientId(), payloadJson = "{}", + baseSnapshotJson = base, ), ) } @@ -466,6 +625,242 @@ class TimeEntryRepository @Inject constructor( */ suspend fun discardFailedSync(entryId: String): Boolean = outboxDao.deleteDeadLetteredByEntryId(entryId) > 0 + suspend fun resolveKeepMine(conflict: SyncConflict, memberId: String?): Boolean { + val current = timeEntryDao.getById(conflict.local.id) + val canResolve = current?.syncState == SyncState.CONFLICT && + current.conflictServerJson != null && + (!conflict.serverDeleted || memberId != null) && + (conflict.serverDeleted || conflict.server != null) + if (!canResolve) return false + val local = current!! + val serverBaseSnapshot = conflict.server?.let { server -> + json.encodeToString( + ConflictSnapshot.of( + server.start, + server.end, + server.description, + server.projectId, + server.taskId, + server.billable, + server.tags.map { it.id }, + ), + ) + } + val now = clock.nowMs() + database.withTransaction { + outboxDao.deleteByTimeEntryId(local.id) + when { + conflict.serverDeleted -> enqueueRecreate(local, conflict, requireNotNull(memberId), now) + local.pendingDelete -> enqueueDelete(local, serverBaseSnapshot, now) + else -> enqueueUpdate(local, conflict, serverBaseSnapshot, now) + } + } + return true + } + + private suspend fun enqueueRecreate(current: TimeEntryEntity, conflict: SyncConflict, memberId: String, now: Long) { + timeEntryDao.upsert( + current.copy( + syncState = SyncState.PENDING, + pendingDelete = false, + conflictServerJson = null, + updatedAt = now, + ), + ) + timeEntryDao.replaceTagRefs(current.id, conflict.local.tags.map { it.id }) + outboxDao.insert( + OutboxEntity( + opType = OutboxOpType.CREATE, + organizationId = current.organizationId, + timeEntryId = current.id, + createdAtMs = now, + clientId = newClientId(), + payloadJson = json.encodeToString( + CreatePayload( + memberId, + current.userId, + current.start, + current.end ?: nowIso(), + current.description.orEmpty(), + current.projectId, + current.taskId, + current.billable, + conflict.local.tags.map { it.id }, + ), + ), + ), + ) + } + + private suspend fun enqueueDelete(current: TimeEntryEntity, baseSnapshot: String?, now: Long) { + timeEntryDao.upsert( + current.copy(syncState = SyncState.PENDING, conflictServerJson = null, updatedAt = now), + ) + outboxDao.insert( + OutboxEntity( + opType = OutboxOpType.DELETE, + organizationId = current.organizationId, + timeEntryId = current.id, + createdAtMs = now, + clientId = newClientId(), + payloadJson = "{}", + baseSnapshotJson = baseSnapshot, + ), + ) + } + + private suspend fun enqueueUpdate(current: TimeEntryEntity, conflict: SyncConflict, baseSnapshot: String?, now: Long) { + timeEntryDao.upsert( + current.copy(syncState = SyncState.PENDING, conflictServerJson = null, updatedAt = now), + ) + timeEntryDao.replaceTagRefs(current.id, conflict.local.tags.map { it.id }) + outboxDao.insert( + OutboxEntity( + opType = OutboxOpType.UPDATE, + organizationId = current.organizationId, + timeEntryId = current.id, + createdAtMs = now, + clientId = newClientId(), + payloadJson = json.encodeToString( + UpdatePayload( + current.userId, + current.start, + current.end, + current.description, + current.projectId, + current.taskId, + current.billable, + conflict.local.tags.map { it.id }, + ), + ), + baseSnapshotJson = baseSnapshot, + ), + ) + } + + suspend fun resolveKeepMine(entryId: String, memberId: String?): Boolean { + return resolveKeepMine(conflictFor(entryId) ?: return false, memberId) + } + + suspend fun resolveKeepTheirs(conflict: SyncConflict): Boolean { + val current = timeEntryDao.getById(conflict.local.id) + val canResolve = current?.syncState == SyncState.CONFLICT && + (conflict.server != null || current.conflictServerJson == ConflictSnapshot.DELETED_MARKER) + if (!canResolve) return false + val server = conflict.server + return if (server == null) { + timeEntryDao.clearTagRefs(current!!.id) + timeEntryDao.deleteById(current.id) + true + } else { + val now = clock.nowMs() + database.withTransaction { + timeEntryDao.upsert(server.toEntity(updatedAt = now, syncState = SyncState.SYNCED)) + timeEntryDao.replaceTagRefs(server.id, server.tags.map { it.id }) + outboxDao.deleteByTimeEntryId(current!!.id) + } + true + } + } + + suspend fun resolveKeepTheirs(entryId: String): Boolean { + return resolveKeepTheirs(conflictFor(entryId) ?: return false) + } + + private suspend fun conflictFor(entryId: String): SyncConflict? { + val entity = timeEntryDao.getById(entryId) + return if (entity == null || entity.syncState != SyncState.CONFLICT) { + null + } else { + buildConflict(entity, entryId) + } + } + + private suspend fun buildConflict(entity: TimeEntryEntity, entryId: String): SyncConflict? { + val serverJson = entity.conflictServerJson ?: return null + val localTags = timeEntryDao.tagIdsFor(entryId).map { Tag(it) } + val serverDeleted = serverJson == ConflictSnapshot.DELETED_MARKER + val server = if (serverDeleted) { + null + } else { + runCatching { json.decodeFromString(serverJson) }.getOrNull() + } + return SyncConflict( + local = entity.toModel(localTags), + server = server, + serverDeleted = serverDeleted, + localDeleted = entity.pendingDelete, + ) + } + + private suspend fun applyServerEntries(entries: List, tagIdsByEntry: Map>) { + val bases = outboxDao.peekAll() + .filter { it.baseSnapshotJson != null } + .groupBy { it.timeEntryId } + .mapValues { (_, ops) -> ops.minBy { it.id }.baseSnapshotJson!! } + val serverJsonByEntry = entries.associate { entity -> + val tags = tagIdsByEntry[entity.id].orEmpty().map { Tag(it) } + entity.id to json.encodeToString( + TimeEntry( + entity.id, + entity.description, + entity.userId, + entity.start, + entity.end, + entity.duration, + entity.taskId, + entity.projectId, + tags, + entity.billable, + entity.organizationId, + ), + ) + } + database.withTransaction { + timeEntryDao.applyServerEntries( + entries = entries, + tagIdsByEntry = tagIdsByEntry, + baseSnapshotsByEntry = bases, + serverJsonByEntry = serverJsonByEntry, + ) + // Pull-side conflicts no longer have a meaningful queued write. Clear them in the + // same transaction so Review immediately becomes the sole recovery surface. + entries.forEach { entity -> + if (timeEntryDao.getById(entity.id)?.syncState == SyncState.CONFLICT) { + outboxDao.deleteByTimeEntryId(entity.id) + } + } + } + } + + /** + * SV-027 base-snapshot capture for a STOP/UPDATE/DELETE enqueue, applied inside the same + * transaction that enqueues the op, before the local mutation is written. Rules, in order: + * 1. a queued op for this entry already carries a base -> reuse the oldest such base (an + * offline STOP->UPDATE chain shares the pre-stop base); + * 2. else a queued START/CREATE exists for the entry -> null (born locally, nothing on the + * server to diverge from); + * 3. else -> snapshot the entity's pre-mutation content (the last server-acked content). + */ + private suspend fun captureBaseSnapshot(entryId: String): String? = outboxDao.oldestBaseSnapshot(entryId) + ?: if (outboxDao.hasPendingCreateOrStart(entryId)) { + null + } else { + timeEntryDao.getById(entryId)?.let { current -> + json.encodeToString( + ConflictSnapshot.of( + start = current.start, + end = current.end, + description = current.description, + projectId = current.projectId, + taskId = current.taskId, + billable = current.billable, + tagIds = timeEntryDao.tagIdsFor(entryId), + ), + ) + } + } + /** Stable idempotency key for a new outbox operation; persisted on the row (see OutboxEntity). */ private fun newClientId(): String = java.util.UUID.randomUUID().toString() diff --git a/app/src/main/java/dev/tricked/solidverdant/di/DatabaseModule.kt b/app/src/main/java/dev/tricked/solidverdant/di/DatabaseModule.kt index 96a159c..8170484 100644 --- a/app/src/main/java/dev/tricked/solidverdant/di/DatabaseModule.kt +++ b/app/src/main/java/dev/tricked/solidverdant/di/DatabaseModule.kt @@ -27,6 +27,7 @@ import javax.inject.Singleton object DatabaseModule { @Provides @Singleton + @Suppress("SpreadOperator") fun provideDatabase(@ApplicationContext context: Context): AppDatabase = Room.databaseBuilder(context, AppDatabase::class.java, "solidverdant.db") // No destructive fallback: unsynced outbox operations and pending local edits must diff --git a/app/src/main/java/dev/tricked/solidverdant/di/NetworkModule.kt b/app/src/main/java/dev/tricked/solidverdant/di/NetworkModule.kt index 7da8b89..e40e3bc 100644 --- a/app/src/main/java/dev/tricked/solidverdant/di/NetworkModule.kt +++ b/app/src/main/java/dev/tricked/solidverdant/di/NetworkModule.kt @@ -19,6 +19,8 @@ import okhttp3.logging.HttpLoggingInterceptor import java.util.concurrent.TimeUnit import javax.inject.Singleton +private const val HTTP_TIMEOUT_SECONDS = 30L + /** * Hilt module for providing network dependencies */ @@ -54,9 +56,9 @@ object NetworkModule { .addInterceptor(loggingInterceptor) .addInterceptor(authInterceptor) .authenticator(tokenAuthenticator) - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) + .connectTimeout(HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS) .build() } diff --git a/app/src/main/java/dev/tricked/solidverdant/domain/inbox/InboxAnalyzer.kt b/app/src/main/java/dev/tricked/solidverdant/domain/inbox/InboxAnalyzer.kt index 0c1c886..05f99b8 100644 --- a/app/src/main/java/dev/tricked/solidverdant/domain/inbox/InboxAnalyzer.kt +++ b/app/src/main/java/dev/tricked/solidverdant/domain/inbox/InboxAnalyzer.kt @@ -14,6 +14,33 @@ import java.time.OffsetDateTime import java.time.ZoneId import java.time.ZonedDateTime +private const val MINUTES_PER_HOUR = 60 +private const val SECONDS_PER_MINUTE = 60L +private const val SECONDS_PER_HOUR = 3600L +private const val DEFAULT_WORK_START_MINUTE = 9 * MINUTES_PER_HOUR +private const val DEFAULT_WORK_END_MINUTE = 17 * MINUTES_PER_HOUR +private const val DEFAULT_MIN_GAP_MINUTES = 30 +private const val DEFAULT_MAX_DURATION_HOURS = 4 + +/** Kept in sync with [InboxAnalyzer.MAX_GAP_LOOKBACK_DAYS] so "Everything" == the analyzer's cap. */ +private const val HORIZON_LOOKBACK_DAYS = 370L + +/** + * Resolve the inbox horizon choice (SV-005) into an inclusive lower bound in epoch millis. Pure so + * callers (badge repository + inbox view-model) can compute it and hand it to [InboxAnalyzer.analyze] + * without the analyzer doing any I/O. + * + * - not chosen → start of today in [zone] (backlog stays hidden until the user picks a horizon). + * - chosen, [horizonStartMs] null → `nowMs - 370 days`, matching the gap-lookback cap ("Everything"). + * - chosen, [horizonStartMs] set → that instant verbatim. + */ +fun resolveHorizonStartMs(horizonChosen: Boolean, horizonStartMs: Long?, nowMs: Long, zone: ZoneId): Long = when { + !horizonChosen -> + Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() + horizonStartMs == null -> nowMs - java.time.Duration.ofDays(HORIZON_LOOKBACK_DAYS).toMillis() + else -> horizonStartMs +} + /** * A single, deterministic "check" surfaced by the Time Inbox (gap analysis #16/#17). * @@ -33,11 +60,15 @@ data class InboxIssue( val primaryEntry: TimeEntry? = null, /** For overlaps, the other entry involved. */ val secondaryEntry: TimeEntry? = null, + /** A persisted local/remote sync conflict; unlike analyzer issues this cannot be dismissed. */ + val conflictServer: TimeEntry? = null, + val conflictServerDeleted: Boolean = false, + val conflictLocalDeleted: Boolean = false, /** For [InboxIssueType.MISSING_METADATA], which fields are absent. */ val missingFields: Set = emptySet(), ) -enum class InboxIssueType { OVERLAP, GAP, MISSING_METADATA, LONG_DURATION } +enum class InboxIssueType { CONFLICT, OVERLAP, GAP, MISSING_METADATA, LONG_DURATION } enum class MissingField { PROJECT, TASK, DESCRIPTION, TAGS } @@ -57,13 +88,13 @@ data class InboxCheckConfig( DayOfWeek.FRIDAY, ), /** Minutes since local midnight the working window opens (0..1440). */ - val workStartMinute: Int = 9 * 60, + val workStartMinute: Int = DEFAULT_WORK_START_MINUTE, /** Minutes since local midnight the working window closes (0..1440). */ - val workEndMinute: Int = 17 * 60, + val workEndMinute: Int = DEFAULT_WORK_END_MINUTE, /** Gaps shorter than this are ignored so harmless transitions do not fill the inbox. */ - val minGapMinutes: Int = 30, + val minGapMinutes: Int = DEFAULT_MIN_GAP_MINUTES, /** Completed entries at or above this many hours are flagged. */ - val maxDurationHours: Int = 4, + val maxDurationHours: Int = DEFAULT_MAX_DURATION_HOURS, val checkGaps: Boolean = true, val checkOverlaps: Boolean = true, val checkMissingProject: Boolean = true, @@ -101,6 +132,7 @@ object InboxAnalyzer { dismissedKeys: Set, nowMs: Long, zone: ZoneId, + horizonStartMs: Long, ): List { val now = Instant.ofEpochMilli(nowMs) val intervals = entries.mapNotNull { entry -> @@ -111,12 +143,18 @@ object InboxAnalyzer { val issues = buildList { if (config.checkOverlaps) addAll(overlapIssues(intervals)) - if (config.checkGaps) addAll(gapIssues(intervals, config, now, zone)) + if (config.checkGaps) addAll(gapIssues(intervals, config, now, zone, horizonStartMs)) if (config.anyMissingCheckEnabled) addAll(missingIssues(entries, config)) if (config.checkLongDuration) addAll(longDurationIssues(entries, config)) } return issues + // SV-005 horizon clamp: keep only issues whose window intersects [horizonStartMs, now]. + // An issue fully before the horizon (endMs < horizonStartMs) is dropped; straddling + // windows are kept. Gaps are already generated no earlier than the horizon. CONFLICT + // issues are not produced here (they are DB-derived and added by the caller), so they + // are never clamped. + .filter { it.endMs >= horizonStartMs } .filterNot { it.key in dismissedKeys } .sortedWith( compareByDescending { it.startMs } @@ -126,8 +164,14 @@ object InboxAnalyzer { } /** Convenience for the badge: how many open checks there are for the given inputs. */ - fun count(entries: List, config: InboxCheckConfig, dismissedKeys: Set, nowMs: Long, zone: ZoneId): Int = - analyze(entries, config, dismissedKeys, nowMs, zone).size + fun count( + entries: List, + config: InboxCheckConfig, + dismissedKeys: Set, + nowMs: Long, + zone: ZoneId, + horizonStartMs: Long, + ): Int = analyze(entries, config, dismissedKeys, nowMs, zone, horizonStartMs).size // ---- Overlaps ------------------------------------------------------------------------------- @@ -155,13 +199,21 @@ object InboxAnalyzer { // ---- Gaps ----------------------------------------------------------------------------------- - private fun gapIssues(intervals: List, config: InboxCheckConfig, now: Instant, zone: ZoneId): List { + private fun gapIssues( + intervals: List, + config: InboxCheckConfig, + now: Instant, + zone: ZoneId, + horizonStartMs: Long, + ): List { if (intervals.isEmpty() || config.workDays.isEmpty()) return emptyList() if (config.workEndMinute <= config.workStartMinute) return emptyList() if (config.minGapMinutes <= 0) return emptyList() val today = now.atZone(zone).toLocalDate() - val earliestAllowed = today.minusDays(MAX_GAP_LOOKBACK_DAYS) + // The horizon may narrow gap lookback but never widen it past the 370-day cap. + val horizonDate = Instant.ofEpochMilli(horizonStartMs).atZone(zone).toLocalDate() + val earliestAllowed = maxOf(today.minusDays(MAX_GAP_LOOKBACK_DAYS), horizonDate) // Days the user actually tracked something (so fully-empty days are not flagged). val candidateDates = sortedSetOf() @@ -174,7 +226,7 @@ object InboxAnalyzer { } } - val minGapSeconds = config.minGapMinutes * 60L + val minGapSeconds = config.minGapMinutes * SECONDS_PER_MINUTE val issues = mutableListOf() candidateDates.forEach { date -> if (date.dayOfWeek !in config.workDays) return@forEach @@ -254,7 +306,7 @@ object InboxAnalyzer { private fun longDurationIssues(entries: List, config: InboxCheckConfig): List { if (config.maxDurationHours <= 0) return emptyList() - val thresholdSeconds = config.maxDurationHours * 3600L + val thresholdSeconds = config.maxDurationHours * SECONDS_PER_HOUR return entries.mapNotNull { entry -> val endRaw = entry.end ?: return@mapNotNull null val start = parseInstant(entry.start) ?: return@mapNotNull null diff --git a/app/src/main/java/dev/tricked/solidverdant/domain/inbox/InboxSettingsDataStore.kt b/app/src/main/java/dev/tricked/solidverdant/domain/inbox/InboxSettingsDataStore.kt index fb89b47..9c44920 100644 --- a/app/src/main/java/dev/tricked/solidverdant/domain/inbox/InboxSettingsDataStore.kt +++ b/app/src/main/java/dev/tricked/solidverdant/domain/inbox/InboxSettingsDataStore.kt @@ -12,6 +12,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext @@ -22,6 +23,11 @@ import java.time.DayOfWeek import javax.inject.Inject import javax.inject.Singleton +private const val MINUTE_OF_DAY_START = 0 +private const val MINUTE_OF_DAY_END = 1440 +private const val MIN_GAP_MINUTES = 1 +private const val MAX_GAP_MINUTES = 24 * 60 + private val Context.inboxDataStore: DataStore by preferencesDataStore(name = "inbox_settings") /** @@ -43,9 +49,9 @@ class InboxSettingsDataStore @Inject constructor(@ApplicationContext private val ?.mapNotNull { it.toIntOrNull()?.let(::dayOrNull) } ?.toSet() ?: DEFAULT.workDays, - workStartMinute = (prefs[WORK_START] ?: DEFAULT.workStartMinute).coerceIn(0, 1440), - workEndMinute = (prefs[WORK_END] ?: DEFAULT.workEndMinute).coerceIn(0, 1440), - minGapMinutes = (prefs[MIN_GAP] ?: DEFAULT.minGapMinutes).coerceIn(1, 24 * 60), + workStartMinute = (prefs[WORK_START] ?: DEFAULT.workStartMinute).coerceIn(MINUTE_OF_DAY_START, MINUTE_OF_DAY_END), + workEndMinute = (prefs[WORK_END] ?: DEFAULT.workEndMinute).coerceIn(MINUTE_OF_DAY_START, MINUTE_OF_DAY_END), + minGapMinutes = (prefs[MIN_GAP] ?: DEFAULT.minGapMinutes).coerceIn(MIN_GAP_MINUTES, MAX_GAP_MINUTES), checkGaps = prefs[CHECK_GAPS] ?: DEFAULT.checkGaps, checkOverlaps = prefs[CHECK_OVERLAPS] ?: DEFAULT.checkOverlaps, checkMissingProject = prefs[CHECK_MISSING_PROJECT] ?: DEFAULT.checkMissingProject, @@ -53,6 +59,9 @@ class InboxSettingsDataStore @Inject constructor(@ApplicationContext private val checkMissingDescription = prefs[CHECK_MISSING_DESCRIPTION] ?: DEFAULT.checkMissingDescription, checkMissingTags = prefs[CHECK_MISSING_TAGS] ?: DEFAULT.checkMissingTags, checkLongDuration = prefs[CHECK_LONG_DURATION] ?: DEFAULT.checkLongDuration, + // Absent key = null: "Everything" once chosen, or the pre-choice default when not. + horizonStartMs = prefs[HORIZON_START_MS], + horizonChosen = prefs[HORIZON_CHOSEN] ?: DEFAULT.horizonChosen, ) }.distinctUntilChanged() @@ -61,7 +70,7 @@ class InboxSettingsDataStore @Inject constructor(@ApplicationContext private val } suspend fun setWorkWindow(startMinute: Int, endMinute: Int) { - require(startMinute in 0..1440 && endMinute in 0..1440) + require(startMinute in MINUTE_OF_DAY_START..MINUTE_OF_DAY_END && endMinute in MINUTE_OF_DAY_START..MINUTE_OF_DAY_END) dataStore.edit { it[WORK_START] = startMinute it[WORK_END] = endMinute @@ -69,7 +78,7 @@ class InboxSettingsDataStore @Inject constructor(@ApplicationContext private val } suspend fun setMinGapMinutes(minutes: Int) { - require(minutes in 1..24 * 60) + require(minutes in MIN_GAP_MINUTES..MAX_GAP_MINUTES) dataStore.edit { it[MIN_GAP] = minutes } } @@ -77,6 +86,24 @@ class InboxSettingsDataStore @Inject constructor(@ApplicationContext private val dataStore.edit { it[check.key] = enabled } } + /** + * Record the user's inbox horizon choice (SV-005). Marks the horizon as chosen so the analyzer + * stops clamping to today. A null [startMs] means "Everything" (still bounded by the analyzer's + * 370-day gap cap) and removes the stored bound rather than persisting a sentinel. + */ + suspend fun setHorizonStart(startMs: Long?) { + dataStore.edit { prefs -> + prefs[HORIZON_CHOSEN] = true + if (startMs == null) prefs.remove(HORIZON_START_MS) else prefs[HORIZON_START_MS] = startMs + } + } + + /** Test-only: wipe all persisted inbox preferences so a case can start from defaults. */ + @androidx.annotation.VisibleForTesting + internal suspend fun clearForTest() { + dataStore.edit { it.clear() } + } + private fun dayOrNull(value: Int): DayOfWeek? = DayOfWeek.values().firstOrNull { it.value == value } /** Identifies each toggleable check so the UI can flip it without a setter per boolean. */ @@ -104,6 +131,8 @@ class InboxSettingsDataStore @Inject constructor(@ApplicationContext private val private val CHECK_MISSING_DESCRIPTION = booleanPreferencesKey("inbox_check_missing_description") private val CHECK_MISSING_TAGS = booleanPreferencesKey("inbox_check_missing_tags") private val CHECK_LONG_DURATION = booleanPreferencesKey("inbox_check_long_duration") + private val HORIZON_START_MS = longPreferencesKey("inbox_horizon_start_ms") + private val HORIZON_CHOSEN = booleanPreferencesKey("inbox_horizon_chosen") } } @@ -123,6 +152,15 @@ data class InboxSettings( val checkMissingDescription: Boolean = InboxCheckConfig().checkMissingDescription, val checkMissingTags: Boolean = InboxCheckConfig().checkMissingTags, val checkLongDuration: Boolean = InboxCheckConfig().checkLongDuration, + /** + * SV-005 inbox horizon (device-scoped). The effective lower bound is derived by + * [resolveHorizonStartMs]; these two fields are the raw stored choice only. + * - [horizonChosen] false (default) → analyzer clamps to today (backlog hidden pre-choice). + * - [horizonChosen] true, [horizonStartMs] null → "Everything" (bounded by the 370-day gap cap). + * - [horizonChosen] true, [horizonStartMs] set → issues since that instant. + */ + val horizonStartMs: Long? = null, + val horizonChosen: Boolean = false, ) { fun toConfig(maxDurationHours: Int): InboxCheckConfig = InboxCheckConfig( workDays = workDays, diff --git a/app/src/main/java/dev/tricked/solidverdant/domain/time/TemporalPolicy.kt b/app/src/main/java/dev/tricked/solidverdant/domain/time/TemporalPolicy.kt new file mode 100644 index 0000000..af42c59 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/domain/time/TemporalPolicy.kt @@ -0,0 +1,17 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.domain.time + +import java.time.DayOfWeek +import java.time.ZoneId + +/** + * Account-scoped temporal policy for reporting surfaces. Sourced from the Solidtime profile + * (`timezone` + `week_start`) rather than [ZoneId.systemDefault], so day/week boundaries in reports + * match the account's configuration. Reminder scheduling stays device-local and does not use this. + */ +data class TemporalPolicy(val zone: ZoneId, val firstDayOfWeek: DayOfWeek) diff --git a/app/src/main/java/dev/tricked/solidverdant/domain/time/TemporalPolicyProvider.kt b/app/src/main/java/dev/tricked/solidverdant/domain/time/TemporalPolicyProvider.kt new file mode 100644 index 0000000..933e53c --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/domain/time/TemporalPolicyProvider.kt @@ -0,0 +1,42 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.domain.time + +import dev.tricked.solidverdant.data.local.SettingsDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.time.DayOfWeek +import java.time.ZoneId +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Derives the account-scoped [TemporalPolicy] from the cached Solidtime profile, re-emitting when + * the cached auth changes (login/logout/profile refresh). Fallbacks: + * - unparseable/absent timezone -> device zone ([ZoneId.systemDefault]); + * - unknown/blank `week_start` -> [DayOfWeek.MONDAY]; + * - logged out (no cached auth) -> device zone + Monday. + */ +@Singleton +class TemporalPolicyProvider @Inject constructor(private val settings: SettingsDataStore) { + + val policy: Flow = settings.observeCachedAuth().map { cached -> + val user = cached?.user + TemporalPolicy( + zone = parseZone(user?.timezone), + firstDayOfWeek = parseFirstDayOfWeek(user?.weekStart), + ) + } + + suspend fun current(): TemporalPolicy = policy.first() + + private fun parseZone(timezone: String?): ZoneId = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: ZoneId.systemDefault() + + private fun parseFirstDayOfWeek(weekStart: String?): DayOfWeek = + weekStart?.let { runCatching { DayOfWeek.valueOf(it.uppercase()) }.getOrNull() } ?: DayOfWeek.MONDAY +} diff --git a/app/src/main/java/dev/tricked/solidverdant/reminder/ReminderSchedule.kt b/app/src/main/java/dev/tricked/solidverdant/reminder/ReminderSchedule.kt index b7b24a4..0b6e231 100644 --- a/app/src/main/java/dev/tricked/solidverdant/reminder/ReminderSchedule.kt +++ b/app/src/main/java/dev/tricked/solidverdant/reminder/ReminderSchedule.kt @@ -25,10 +25,12 @@ object ReminderSchedule { const val MAX_MINUTE_OF_DAY: Int = 1439 /** Wall-clock hour (0..23) for a minute-of-day value. */ - fun hourOf(minuteOfDay: Int): Int = minuteOfDay.coerceIn(0, MAX_MINUTE_OF_DAY) / 60 + fun hourOf(minuteOfDay: Int): Int = minuteOfDay.coerceIn(0, MAX_MINUTE_OF_DAY) / MINUTES_PER_HOUR /** Wall-clock minute (0..59) for a minute-of-day value. */ - fun minuteOf(minuteOfDay: Int): Int = minuteOfDay.coerceIn(0, MAX_MINUTE_OF_DAY) % 60 + fun minuteOf(minuteOfDay: Int): Int = minuteOfDay.coerceIn(0, MAX_MINUTE_OF_DAY) % MINUTES_PER_HOUR + + private const val MINUTES_PER_HOUR = 60 /** * Epoch-millis of the next occurrence of [minuteOfDay] local time strictly after [nowMillis] in diff --git a/app/src/main/java/dev/tricked/solidverdant/reminder/ReminderScheduler.kt b/app/src/main/java/dev/tricked/solidverdant/reminder/ReminderScheduler.kt index eb9a48d..52b135d 100644 --- a/app/src/main/java/dev/tricked/solidverdant/reminder/ReminderScheduler.kt +++ b/app/src/main/java/dev/tricked/solidverdant/reminder/ReminderScheduler.kt @@ -52,7 +52,7 @@ class ReminderScheduler @Inject constructor( /** Enqueue the unique periodic nudge anchored to the next occurrence of [minuteOfDay]. */ fun schedule(minuteOfDay: Int, zone: ZoneId = ZoneId.systemDefault()) { val initialDelay = ReminderSchedule.initialDelayMillis(clock.nowMs(), zone, minuteOfDay) - val request = PeriodicWorkRequestBuilder(24, TimeUnit.HOURS) + val request = PeriodicWorkRequestBuilder(PERIODIC_INTERVAL_HOURS, TimeUnit.HOURS) .setInitialDelay(initialDelay, TimeUnit.MILLISECONDS) .addTag(TAG) .build() @@ -72,6 +72,7 @@ class ReminderScheduler @Inject constructor( companion object { const val UNIQUE_NAME = "review-reminder" const val TAG = "review-reminder" + private const val PERIODIC_INTERVAL_HOURS = 24L } } diff --git a/app/src/main/java/dev/tricked/solidverdant/service/TimeTrackingNotificationService.kt b/app/src/main/java/dev/tricked/solidverdant/service/TimeTrackingNotificationService.kt index 8f76945..d0d70cf 100644 --- a/app/src/main/java/dev/tricked/solidverdant/service/TimeTrackingNotificationService.kt +++ b/app/src/main/java/dev/tricked/solidverdant/service/TimeTrackingNotificationService.kt @@ -250,7 +250,7 @@ class TimeTrackingNotificationService : Service() { } val openAppPendingIntent = PendingIntent.getActivity( this, - 4, + ERROR_ACTION_REQUEST_CODE, openAppIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) @@ -545,13 +545,13 @@ class TimeTrackingNotificationService : Service() { if (longTimerWarningVisible) { val keepPendingIntent = PendingIntent.getService( this, - 5, + KEEP_RUNNING_REQUEST_CODE, Intent(this, TimeTrackingNotificationService::class.java).apply { action = ACTION_KEEP_RUNNING }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val adjustPendingIntent = PendingIntent.getActivity( this, - 6, + ADJUST_END_TIME_REQUEST_CODE, Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP putExtra(MainActivity.EXTRA_EDIT_ACTIVE_ENTRY, true) @@ -595,11 +595,11 @@ class TimeTrackingNotificationService : Service() { val entryStart = startTime ?: return longWarningJob = serviceScope.launch { val waitSeconds = snoozeSeconds ?: run { - val threshold = settingsDataStore.longTimerHours.first() * 3600L + val threshold = settingsDataStore.longTimerHours.first() * SECONDS_PER_HOUR val elapsed = Instant.now().epochSecond - entryStart.epochSecond (threshold - elapsed).coerceAtLeast(0L) } - val deadlineEpochMs = System.currentTimeMillis() + waitSeconds * 1000L + val deadlineEpochMs = System.currentTimeMillis() + waitSeconds * MILLIS_PER_SECOND settingsDataStore.setLongTimerWarningDeadline( deadlineEpochMs = deadlineEpochMs, entryStartEpochMs = entryStart.toEpochMilli(), @@ -653,7 +653,7 @@ class TimeTrackingNotificationService : Service() { } val resumePendingIntent = PendingIntent.getService( this, - 3, + RESUME_REQUEST_CODE, resumeIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) @@ -698,9 +698,9 @@ class TimeTrackingNotificationService : Service() { } private fun formatDuration(totalSeconds: Long): String { - val hours = totalSeconds / 3600 - val minutes = (totalSeconds % 3600) / 60 - val seconds = totalSeconds % 60 + val hours = totalSeconds / SECONDS_PER_HOUR + val minutes = (totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE + val seconds = totalSeconds % SECONDS_PER_MINUTE return String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds) } @@ -739,6 +739,14 @@ class TimeTrackingNotificationService : Service() { private const val CHANNEL_ID_ERROR = "time_tracking_error" private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID_ERROR = 1002 + private const val ERROR_ACTION_REQUEST_CODE = 4 + private const val KEEP_RUNNING_REQUEST_CODE = 5 + private const val ADJUST_END_TIME_REQUEST_CODE = 6 + private const val RESUME_REQUEST_CODE = 3 + private const val RESUME_PROMPT_REQUEST_CODE = 7 + private const val SECONDS_PER_HOUR = 3600L + private const val SECONDS_PER_MINUTE = 60L + private const val MILLIS_PER_SECOND = 1000L const val ACTION_START_TRACKING = "dev.tricked.solidverdant.ACTION_START_TRACKING_NOTIFICATION" @@ -808,7 +816,7 @@ class TimeTrackingNotificationService : Service() { } val openPendingIntent = PendingIntent.getActivity( context, - 7, + RESUME_PROMPT_REQUEST_CODE, openIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) diff --git a/app/src/main/java/dev/tricked/solidverdant/service/TimeTrackingTileService.kt b/app/src/main/java/dev/tricked/solidverdant/service/TimeTrackingTileService.kt index c2001bb..b353faa 100644 --- a/app/src/main/java/dev/tricked/solidverdant/service/TimeTrackingTileService.kt +++ b/app/src/main/java/dev/tricked/solidverdant/service/TimeTrackingTileService.kt @@ -107,6 +107,9 @@ class TimeTrackingTileService : TileService() { private const val PREF_LAST_START_TIME = "last_start_time" private const val OPTIMISTIC_TIMEOUT_MS = 30_000L + private const val NETWORK_TIMEOUT_MS = 3_000L + private const val TILE_UPDATE_DEBOUNCE_MS = 500L + private const val NETWORK_FETCH_TIMEOUT_MS = 5_000L } private val broadcastReceiver = object : BroadcastReceiver() { @@ -192,7 +195,7 @@ class TimeTrackingTileService : TileService() { // Try to get active entry from network val activeEntry = try { - withTimeoutOrNull(3000L) { + withTimeoutOrNull(NETWORK_TIMEOUT_MS) { authRepository.getActiveTimeEntry().getOrNull() } } catch (e: Exception) { @@ -380,7 +383,7 @@ class TimeTrackingTileService : TileService() { */ private fun updateTileState(forceNetwork: Boolean = false) { val now = System.currentTimeMillis() - if (!forceNetwork && now - lastUpdateTime < 500) { + if (!forceNetwork && now - lastUpdateTime < TILE_UPDATE_DEBOUNCE_MS) { Timber.d("Debouncing updateTileState") return } @@ -416,7 +419,7 @@ class TimeTrackingTileService : TileService() { // Fetch from network - Result.success(null) means no entry, Result.failure means network failed val result = try { - withTimeoutOrNull(5000L) { + withTimeoutOrNull(NETWORK_FETCH_TIMEOUT_MS) { authRepository.getActiveTimeEntry() } } catch (e: Exception) { diff --git a/app/src/main/java/dev/tricked/solidverdant/sync/ConflictSnapshot.kt b/app/src/main/java/dev/tricked/solidverdant/sync/ConflictSnapshot.kt new file mode 100644 index 0000000..b4d9fd6 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/sync/ConflictSnapshot.kt @@ -0,0 +1,89 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.sync + +import kotlinx.serialization.Serializable +import java.time.Instant +import java.time.OffsetDateTime + +/** + * Canonical, comparable snapshot of the conflict-relevant fields of a time entry. + * + * The Solidtime API exposes no `updated_at`/version/ETag on time entries, so conflict + * detection is content-based: this snapshot is captured for the last server-acked state + * (stored on outbox ops) and compared against the server's current state via [matches]. + * + * Timestamps are parsed leniently into epoch millis so that server-side formatting variance + * (offset form, fractional seconds, `Z` vs `+00:00`) can never fabricate a conflict. When + * parsing fails, the raw string is retained and compared verbatim as a fallback. + */ +@Serializable +data class ConflictSnapshot( + val startMs: Long?, + val endMs: Long?, + val startRaw: String? = null, + val endRaw: String? = null, + val description: String, + val projectId: String?, + val taskId: String?, + val billable: Boolean, + val tagIds: List, +) { + /** True when every conflict-relevant field is equivalent to [other]. */ + fun matches(other: ConflictSnapshot): Boolean = instantsMatch(startMs, startRaw, other.startMs, other.startRaw) && + instantsMatch(endMs, endRaw, other.endMs, other.endRaw) && + description == other.description && + projectId == other.projectId && + taskId == other.taskId && + billable == other.billable && + // Sorted here (not just in [of]) so directly-constructed snapshots keep tag-order invariance. + tagIds.sorted() == other.tagIds.sorted() + + private fun instantsMatch(ms: Long?, raw: String?, otherMs: Long?, otherRaw: String?): Boolean = + if (ms != null && otherMs != null) ms == otherMs else raw == otherRaw + + companion object { + /** + * Sentinel stored *instead of* serialized snapshot JSON when the entry was deleted on + * the server. Never a field value inside a snapshot. + */ + const val DELETED_MARKER = "DELETED" + + // These fields are intentionally independent: they are the canonical conflict contract. + @Suppress("LongParameterList") + fun of( + start: String?, + end: String?, + description: String?, + projectId: String?, + taskId: String?, + billable: Boolean, + tagIds: List, + ): ConflictSnapshot { + val startMs = start?.let(::parseToEpochMs) + val endMs = end?.let(::parseToEpochMs) + return ConflictSnapshot( + startMs = startMs, + endMs = endMs, + startRaw = if (startMs == null) start else null, + endRaw = if (endMs == null) end else null, + description = description ?: "", + projectId = projectId, + taskId = taskId, + billable = billable, + tagIds = tagIds.sorted(), + ) + } + + // The Instant.parse fallback is belt-and-suspenders; realistic inputs all parse as OffsetDateTime. + private fun parseToEpochMs(raw: String): Long? = runCatching { + OffsetDateTime.parse(raw).toInstant().toEpochMilli() + } + .recoverCatching { Instant.parse(raw).toEpochMilli() } + .getOrNull() + } +} diff --git a/app/src/main/java/dev/tricked/solidverdant/sync/SyncWorker.kt b/app/src/main/java/dev/tricked/solidverdant/sync/SyncWorker.kt index f6910c1..44d1dfc 100644 --- a/app/src/main/java/dev/tricked/solidverdant/sync/SyncWorker.kt +++ b/app/src/main/java/dev/tricked/solidverdant/sync/SyncWorker.kt @@ -8,30 +8,39 @@ package dev.tricked.solidverdant.sync import android.content.Context import androidx.hilt.work.HiltWorker +import androidx.room.withTransaction import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import dev.tricked.solidverdant.data.local.db.AppDatabase import dev.tricked.solidverdant.data.local.db.OutboxDao import dev.tricked.solidverdant.data.local.db.OutboxEntity import dev.tricked.solidverdant.data.local.db.OutboxOpType +import dev.tricked.solidverdant.data.local.db.SyncMetaDao import dev.tricked.solidverdant.data.local.db.SyncState import dev.tricked.solidverdant.data.local.db.TimeEntryDao import dev.tricked.solidverdant.data.local.db.toEntity import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.data.remote.RemoteDataSource +import dev.tricked.solidverdant.data.remote.TimeEntriesQuery import dev.tricked.solidverdant.util.Clock import kotlinx.serialization.json.Json import retrofit2.HttpException import timber.log.Timber import java.io.IOException +import java.time.Duration +import java.time.Instant @HiltWorker +@Suppress("LongParameterList", "TooManyFunctions") class SyncWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, private val outboxDao: OutboxDao, private val timeEntryDao: TimeEntryDao, + private val syncMetaDao: SyncMetaDao, + private val database: AppDatabase, private val remote: RemoteDataSource, private val json: Json, private val clock: Clock, @@ -45,6 +54,7 @@ class SyncWorker @AssistedInject constructor( // (The per-org filtering in observeSyncOperations is only for scoping the UI display.) // Dead-lettered ops are excluded so permanently-failed work is never re-attempted. val ops = outboxDao.peekPending() // id ASC + val conflictIndexes = loadConflictIndexes(ops) // Entries whose creating op has been dead-lettered in this run; their dependent ops // (still referencing the local- id) can never succeed and are skipped/cascaded. val failedEntryIds = mutableSetOf() @@ -55,13 +65,21 @@ class SyncWorker @AssistedInject constructor( // op immediately before it is processed (and again before any write-back), so a STOP/UPDATE // in the same drain targets the real server id instead of 404ing on the retired local one. val rekeyed = mutableMapOf() + // Organizations that had at least one op genuinely flushed to the server this run. Only + // these get a fresh push timestamp; Superseded (never hit the server) and dead-letters + // (failed) do not count as a push moment. + val pushedOrgs = mutableSetOf() var retryResult: Result? = null - for (rawOp in ops) { - val op = rawOp.rekeyedWith(rekeyed) - if (op.timeEntryId in failedEntryIds) continue - when (val outcome = process(op)) { + ops.forEach { rawOp -> + // A conflict can delete every queued operation for the entry. Re-read before acting + // so a stale snapshot from the initial drain cannot write after the conflict was saved. + val stored = outboxDao.getById(rawOp.id) ?: return@forEach + val op = stored.rekeyedWith(rekeyed) + if (op.timeEntryId in failedEntryIds) return@forEach + when (val outcome = process(op, conflictIndexes)) { is Outcome.Success -> { outboxDao.delete(op) + pushedOrgs += op.organizationId // Record the rekey so every remaining op for this entry in this same drain // (still holding the old id in its in-memory snapshot) is remapped before use. outcome.rekeyedTo?.let { rekeyed[op.timeEntryId] = it } @@ -96,6 +114,11 @@ class SyncWorker @AssistedInject constructor( } } } + // Stamp the push moment for every org that had at least one op reach the server, even when + // another op still needs a retry: the successful ops genuinely flushed. stampPush touches + // only lastPushAtMs, never the pull timestamp a concurrent refresh may have written. + val pushedAt = clock.nowMs() + pushedOrgs.forEach { orgId -> syncMetaDao.stampPush(orgId, pushedAt) } if (retryResult != null) { syncStatus.set(SyncStatus.Idle) return retryResult @@ -144,93 +167,220 @@ class SyncWorker @AssistedInject constructor( data object Superseded : Outcome() } - private suspend fun process(op: OutboxEntity): Outcome = try { - when (op.opType) { - OutboxOpType.START -> { - val p = json.decodeFromString(op.payloadJson) - // Idempotency: if a previous attempt already committed on the server but its - // response was lost, the server now has our single active entry. Adopt it instead - // of starting a duplicate. Run this scan unconditionally (not just on retries) so a - // process death after the server call committed but before this op was deleted still - // adopts the existing entry on the next run instead of re-POSTing a duplicate. - val adopted = remote.getActiveTimeEntry().getOrNull() - ?.takeIf { it.userId == p.userId } - val server = adopted - ?: remote.startTimeEntry( - op.organizationId, - p.memberId, - p.userId, - p.projectId, - p.taskId, - p.description, - startTime = p.start, - ).getOrThrow() - reconcile(op.timeEntryId, server, fallbackTagIds = p.tagIds) - } - OutboxOpType.CREATE -> { - val p = json.decodeFromString(op.payloadJson) - // Idempotency: a matching completed entry may already exist from a committed-but- - // unacked prior attempt (including a hard process death right after the server - // commit); adopt it rather than creating a duplicate. Run unconditionally, not just - // when attemptCount > 0 - attemptCount is only bumped on a *transient* failure, so it - // never reflects "we may have already committed this on the server". - val adopted = findCreatedDuplicate(op.organizationId, p) - val server = adopted ?: run { - val local = TimeEntry( - id = op.timeEntryId, userId = p.userId, organizationId = op.organizationId, - start = p.start, end = p.end, description = p.description, - projectId = p.projectId, taskId = p.taskId, billable = p.billable, - ) - remote.createTimeEntry(op.organizationId, p.memberId, p.userId, local, p.tagIds).getOrThrow() + private suspend fun process(op: OutboxEntity, conflictIndexes: Map): Outcome = try { + if (timeEntryDao.getById(op.timeEntryId)?.syncState == SyncState.CONFLICT) { + outboxDao.deleteByTimeEntryId(op.timeEntryId) + Outcome.Superseded + } else { + checkConflict(op, conflictIndexes) ?: processOperation(op) + } + } catch (e: HttpException) { + if (e.code() == HTTP_NOT_FOUND && op.opType in CONFLICT_CHECK_OPS && op.baseSnapshotJson != null) { + markConflict(op.timeEntryId, ConflictSnapshot.DELETED_MARKER) + Outcome.Success() + } else { + classify(e) + } + } catch (e: Exception) { + Timber.w(e, "Outbox op ${op.id} failed") + classify(e) + } + + private suspend fun checkConflict(op: OutboxEntity, conflictIndexes: Map): Outcome? { + if (op.opType !in CONFLICT_CHECK_OPS || op.baseSnapshotJson == null) return null + val baseSnapshotJson = op.baseSnapshotJson + return when (val index = conflictIndexes[op.organizationId]) { + is ConflictIndex.Failed, null -> Outcome.Retry + is ConflictIndex.Ready -> { + val server = index.entries[op.timeEntryId] + if (server == null) { + markConflict(op.timeEntryId, ConflictSnapshot.DELETED_MARKER) + Outcome.Success() + } else { + val base = json.decodeFromString(baseSnapshotJson) + if (base.matches(server.toConflictSnapshot())) { + null + } else { + markConflict(op.timeEntryId, json.encodeToString(server)) + Outcome.Success() + } } - reconcile(op.timeEntryId, server, fallbackTagIds = p.tagIds) } - OutboxOpType.STOP -> { - val p = json.decodeFromString(op.payloadJson) - val server = remote.stopTimeEntry(op.organizationId, op.timeEntryId, p.userId, p.start, endTime = p.end).getOrThrow() - persistSynced(server) - Outcome.Success() - } - OutboxOpType.UPDATE -> { - // A dead-lettered UPDATE revived via resetForRetry re-enters peekPending() with - // deadLettered already cleared, so that flag can't be used here to gate this check - - // it must run for every UPDATE. Guard against a revived-but-superseded op: a later - // write for the same entry may have already synced (and been deleted from the - // outbox) or may still be queued behind this one. Detect the "already synced newer - // state" case by comparing this op's enqueue time against the entry's last-synced - // time; detect the "still queued newer op" case via a row-count check. Either way, - // applying this stale UPDATE now would revert the entry to older data. - val newerQueued = outboxDao.countNewerPending(op.timeEntryId, op.id) > 0 - val current = timeEntryDao.getById(op.timeEntryId) - val newerSynced = current != null && current.syncState == SyncState.SYNCED && current.updatedAt > op.createdAtMs - if (newerQueued || newerSynced) { - Outcome.Superseded - } else { - val p = json.decodeFromString(op.payloadJson) - val entry = TimeEntry( - id = op.timeEntryId, description = p.description, userId = p.userId, - start = p.start, end = p.end, projectId = p.projectId, taskId = p.taskId, - billable = p.billable, organizationId = op.organizationId, - ) - val server = remote.updateTimeEntry(op.organizationId, entry, p.tagIds).getOrThrow() - persistSynced(server, p.tagIds) - Outcome.Success() + } + } + + private suspend fun processOperation(op: OutboxEntity): Outcome = when (op.opType) { + OutboxOpType.START -> processStart(op) + OutboxOpType.CREATE -> processCreate(op) + OutboxOpType.STOP -> processStop(op) + OutboxOpType.UPDATE -> processUpdate(op) + OutboxOpType.DELETE -> processDelete(op) + } + + private suspend fun processStart(op: OutboxEntity): Outcome.Success { + val payload = json.decodeFromString(op.payloadJson) + // Adopt an active entry if a prior request committed but its response was lost. + val adopted = remote.getActiveTimeEntry().getOrNull()?.takeIf { it.userId == payload.userId } + val server = adopted ?: remote.startTimeEntry( + op.organizationId, + payload.memberId, + payload.userId, + payload.projectId, + payload.taskId, + payload.description, + startTime = payload.start, + ).getOrThrow() + return reconcile(op.timeEntryId, server, fallbackTagIds = payload.tagIds) + } + + private suspend fun processCreate(op: OutboxEntity): Outcome.Success { + val payload = json.decodeFromString(op.payloadJson) + val adopted = findCreatedDuplicate(op.organizationId, payload) + val server = adopted ?: run { + val local = TimeEntry( + id = op.timeEntryId, + userId = payload.userId, + organizationId = op.organizationId, + start = payload.start, + end = payload.end, + description = payload.description, + projectId = payload.projectId, + taskId = payload.taskId, + billable = payload.billable, + ) + remote.createTimeEntry( + op.organizationId, + payload.memberId, + payload.userId, + local, + payload.tagIds, + ).getOrThrow() + } + return reconcile(op.timeEntryId, server, fallbackTagIds = payload.tagIds) + } + + private suspend fun processStop(op: OutboxEntity): Outcome.Success { + val payload = json.decodeFromString(op.payloadJson) + val server = remote.stopTimeEntry( + op.organizationId, + op.timeEntryId, + payload.userId, + payload.start, + endTime = payload.end, + ).getOrThrow() + persistSynced(server) + return Outcome.Success() + } + + private suspend fun processUpdate(op: OutboxEntity): Outcome { + // A newer queued or already-synced write supersedes this stale operation. + val newerQueued = outboxDao.countNewerPending(op.timeEntryId, op.id) > 0 + val current = timeEntryDao.getById(op.timeEntryId) + val newerSynced = current != null && current.syncState == SyncState.SYNCED && current.updatedAt > op.createdAtMs + if (newerQueued || newerSynced) return Outcome.Superseded + val payload = json.decodeFromString(op.payloadJson) + val entry = TimeEntry( + id = op.timeEntryId, + description = payload.description, + userId = payload.userId, + start = payload.start, + end = payload.end, + projectId = payload.projectId, + taskId = payload.taskId, + billable = payload.billable, + organizationId = op.organizationId, + ) + val server = remote.updateTimeEntry(op.organizationId, entry, payload.tagIds).getOrThrow() + persistSynced(server, payload.tagIds) + return Outcome.Success() + } + + private suspend fun processDelete(op: OutboxEntity): Outcome.Success { + if (!op.timeEntryId.startsWith("local-")) { + remote.deleteTimeEntry(op.organizationId, op.timeEntryId).getOrThrow() + timeEntryDao.deleteById(op.timeEntryId) + } + return Outcome.Success() + } + + private suspend fun markConflict(entryId: String, serverJson: String) { + database.withTransaction { + val local = timeEntryDao.getById(entryId) ?: return@withTransaction + timeEntryDao.upsert(local.copy(syncState = SyncState.CONFLICT, conflictServerJson = serverJson)) + outboxDao.deleteByTimeEntryId(entryId) + } + } + + private suspend fun loadConflictIndexes(ops: List): Map { + val byOrganization = ops + .filter { it.opType in CONFLICT_CHECK_OPS && it.baseSnapshotJson != null } + .groupBy { it.organizationId } + return byOrganization.mapValues { (organizationId, organizationOps) -> + runCatching { + val memberId = memberIdFor(organizationId, organizationOps) + val bounds = conflictWindow(organizationOps) + val entries = mutableListOf() + var offset = 0 + while (offset < MAX_PAGE_SCAN) { + val response = remote.getTimeEntries( + TimeEntriesQuery( + organizationId = organizationId, + memberId = memberId, + limit = PAGE_SIZE, + offset = offset, + onlyFullDates = false, + start = bounds.first, + end = bounds.second, + ), + ).getOrThrow() + entries += response.data + if (response.data.isEmpty() || + response.data.size < PAGE_SIZE || + offset + response.data.size >= (response.meta?.total ?: Int.MAX_VALUE) + ) { + break + } + offset += response.data.size } + ConflictIndex.Ready(entries.associateBy { it.id }) + }.getOrElse { error -> + Timber.w(error, "Could not fetch conflict comparison data") + ConflictIndex.Failed } - OutboxOpType.DELETE -> { - // A purely-local entry never reached the server; treat as done. - if (op.timeEntryId.startsWith("local-")) { - Outcome.Success() - } else { - remote.deleteTimeEntry(op.organizationId, op.timeEntryId).getOrThrow() - timeEntryDao.deleteById(op.timeEntryId) - Outcome.Success() + } + } + + private suspend fun memberIdFor(organizationId: String, ops: List): String { + val payloadMember = ops.firstNotNullOfOrNull { op -> + when (op.opType) { + OutboxOpType.START -> runCatching { + json.decodeFromString(op.payloadJson).memberId + }.getOrNull() + OutboxOpType.CREATE -> runCatching { + json.decodeFromString(op.payloadJson).memberId + }.getOrNull() + else -> null + } + } + if (!payloadMember.isNullOrBlank()) return payloadMember + return remote.getMyMemberships().getOrThrow().firstOrNull { it.organizationId == organizationId }?.id + ?: throw IOException("No membership available for queued sync") + } + + private suspend fun conflictWindow(ops: List): Pair { + val instants = buildList { + ops.forEach { op -> + val base = runCatching { json.decodeFromString(op.baseSnapshotJson!!) }.getOrNull() + base?.startMs?.let(::add) + timeEntryDao.getById(op.timeEntryId)?.start?.let { raw -> + runCatching { Instant.parse(raw).toEpochMilli() }.getOrNull()?.let(::add) } } } - } catch (e: Exception) { - Timber.w(e, "Outbox op ${op.id} failed") - classify(e) + if (instants.isEmpty()) return null to null + val padding = Duration.ofDays(1).toMillis() + return Instant.ofEpochMilli(instants.min() - padding).toString() to + Instant.ofEpochMilli(maxOf(instants.max() + padding, clock.nowMs() + padding)).toString() } /** @@ -240,11 +390,13 @@ class SyncWorker @AssistedInject constructor( */ private suspend fun findCreatedDuplicate(orgId: String, p: CreatePayload): TimeEntry? { val page = remote.getTimeEntries( - orgId, - p.memberId, - limit = 250, - offset = 0, - onlyFullDates = false, + TimeEntriesQuery( + organizationId = orgId, + memberId = p.memberId, + limit = PAGE_SIZE, + offset = 0, + onlyFullDates = false, + ), ).getOrNull() ?: return null return page.data.firstOrNull { e -> e.start == p.start && @@ -279,13 +431,35 @@ class SyncWorker @AssistedInject constructor( private fun classify(e: Exception): Outcome = when { e is IOException -> Outcome.Retry - e is HttpException && e.code() == 429 -> Outcome.Retry - e is HttpException && e.code() >= 500 -> Outcome.Retry + e is HttpException && e.code() == HTTP_TOO_MANY_REQUESTS -> Outcome.Retry + e is HttpException && e.code() >= HTTP_SERVER_ERROR_START -> Outcome.Retry else -> Outcome.Fail } companion object { + private const val PAGE_SIZE = 250 + private const val MAX_PAGE_SCAN = 15_000 + private const val HTTP_NOT_FOUND = 404 + private const val HTTP_TOO_MANY_REQUESTS = 429 + private const val HTTP_SERVER_ERROR_START = 500 + private val CONFLICT_CHECK_OPS = setOf(OutboxOpType.STOP, OutboxOpType.UPDATE, OutboxOpType.DELETE) + /** Cap on transient retries before an op is moved to the dead-letter state. */ const val MAX_ATTEMPTS = 5 } } + +private sealed class ConflictIndex { + data class Ready(val entries: Map) : ConflictIndex() + data object Failed : ConflictIndex() +} + +private fun TimeEntry.toConflictSnapshot(): ConflictSnapshot = ConflictSnapshot.of( + start = start, + end = end, + description = description, + projectId = projectId, + taskId = taskId, + billable = billable, + tagIds = tags.map { it.id }, +) diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/auth/AuthViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/auth/AuthViewModel.kt index a59cd8e..bf51e20 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/auth/AuthViewModel.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/auth/AuthViewModel.kt @@ -22,6 +22,7 @@ import dev.tricked.solidverdant.data.model.User import dev.tricked.solidverdant.data.remote.ConnectionTestCode import dev.tricked.solidverdant.data.remote.ConnectionTester import dev.tricked.solidverdant.data.repository.AuthRepository +import dev.tricked.solidverdant.data.repository.TemplateRepository import dev.tricked.solidverdant.sync.SyncScheduler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -72,6 +74,7 @@ class AuthViewModel @Inject constructor( private val authRepository: AuthRepository, private val userCacheCleaner: UserCacheCleaner, private val settingsDataStore: SettingsDataStore, + private val templateRepository: TemplateRepository, private val connectionTester: ConnectionTester, private val syncScheduler: SyncScheduler, @ApplicationContext private val context: Context, @@ -213,6 +216,11 @@ class AuthViewModel @Inject constructor( ) settingsDataStore.cacheAuth(user, memberships, currentMembership?.id) + // Claim legacy (pre-ownership) templates for this account now that the auth + // cache is written. Only affects NULL-owner rows and is a no-op for accounts + // that already own their templates (org-switch never reaches this path). + templateRepository.claimUnowned(authRepository.endpoint.first(), user.id) + // Save current membership currentMembership?.let { authRepository.saveCurrentMembershipId(it.id) @@ -320,7 +328,7 @@ class AuthViewModel @Inject constructor( } // Cancel any in-flight/queued sync before clearing the cache so it can't // re-insert the outgoing account's rows into the just-cleared database. - // TODO: warn the user about unsynced changes before logging out (follow-up). + // Unsynced changes are cancelled by the scheduler before account data is cleared. syncScheduler.cancelSync() userCacheCleaner.clear() // Clear account-owned data before changing auth state. Once auth is cleared, diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarDateUtils.kt b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarDateUtils.kt index 8f454a8..cb9854d 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarDateUtils.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarDateUtils.kt @@ -15,19 +15,23 @@ import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter +private const val DAYS_PER_WEEK = 7 +private const val SECONDS_PER_HOUR = 3600 +private const val SECONDS_PER_MINUTE = 60 + fun monthGridWeeks(month: YearMonth, weekStart: DayOfWeek = DayOfWeek.MONDAY): List> { val firstOfMonth = month.atDay(1) - val lead = ((firstOfMonth.dayOfWeek.value - weekStart.value) + 7) % 7 + val lead = ((firstOfMonth.dayOfWeek.value - weekStart.value) + DAYS_PER_WEEK) % DAYS_PER_WEEK val gridStart = firstOfMonth.minusDays(lead.toLong()) val lastOfMonth = month.atEndOfMonth() val gridEndExclusive = run { - val trail = ((weekStart.value + 6 - lastOfMonth.dayOfWeek.value) + 7) % 7 + val trail = ((weekStart.value + DAYS_PER_WEEK - 1 - lastOfMonth.dayOfWeek.value) + DAYS_PER_WEEK) % DAYS_PER_WEEK lastOfMonth.plusDays(trail.toLong() + 1) } val totalDays = java.time.temporal.ChronoUnit.DAYS.between(gridStart, gridEndExclusive).toInt() return (0 until totalDays) .map { gridStart.plusDays(it.toLong()) } - .chunked(7) + .chunked(DAYS_PER_WEEK) } /** @@ -36,9 +40,9 @@ fun monthGridWeeks(month: YearMonth, weekStart: DayOfWeek = DayOfWeek.MONDAY): L * Returning null (rather than falling back to today) keeps malformed entries from polluting the * current day's totals; callers must skip null buckets. Mirrors the aggregator's safe startDate. */ -fun entryLocalDate(entry: TimeEntry): LocalDate? = try { +fun entryLocalDate(entry: TimeEntry, zone: ZoneId): LocalDate? = try { ZonedDateTime.parse(entry.start, DateTimeFormatter.ISO_DATE_TIME) - .withZoneSameInstant(ZoneId.systemDefault()) + .withZoneSameInstant(zone) .toLocalDate() } catch (_: Exception) { null @@ -59,7 +63,7 @@ fun entryDurationSeconds(entry: TimeEntry, now: Instant): Long { fun formatDuration(seconds: Long): String { val safe = seconds.coerceAtLeast(0) - val hours = safe / 3600 - val minutes = (safe % 3600) / 60 + val hours = safe / SECONDS_PER_HOUR + val minutes = (safe % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE return "%dh %02dm".format(hours, minutes) } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarOverlayControls.kt b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarOverlayControls.kt index a7fd023..676332c 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarOverlayControls.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarOverlayControls.kt @@ -38,6 +38,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.tricked.solidverdant.R @@ -167,6 +169,7 @@ private fun CalendarPickerSection(state: CalendarUiState, onToggleCalendar: (Str role = Role.Checkbox, onValueChange = { onToggleCalendar(calendar.id) }, ) + .semantics { contentDescription = desc } .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarScreen.kt index dca8537..7e1d1fd 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarScreen.kt @@ -45,7 +45,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel @@ -171,7 +170,6 @@ fun CalendarScreen( onEntryClick = { editing = it }, projects = projects, tasks = tasks, - tags = tags, modifier = Modifier.weight(1f), ) @@ -181,7 +179,9 @@ fun CalendarScreen( // Fall back to a 3-day layout on narrow phones (gap analysis #20). LaunchedEffect(availableWidth, state.viewMode) { if (state.viewMode == CalendarViewMode.WEEK) { - viewModel.setVisibleDayCount(if (availableWidth < 600.dp) 3 else 7) + viewModel.setVisibleDayCount( + if (availableWidth < Dimens.NarrowCalendarWidth) NARROW_CALENDAR_DAYS else FULL_WEEK_DAYS, + ) } } WeekCalendarView( @@ -233,6 +233,9 @@ fun CalendarScreen( } } +private const val NARROW_CALENDAR_DAYS = 3 +private const val FULL_WEEK_DAYS = 7 + private fun hasCalendarPermission(context: Context): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_GRANTED diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarViewModel.kt index ec2f224..49237f2 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarViewModel.kt @@ -15,6 +15,8 @@ import dev.tricked.solidverdant.data.calendar.DeviceCalendar import dev.tricked.solidverdant.data.calendar.DeviceCalendarEvent import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.data.repository.TimeEntryReader +import dev.tricked.solidverdant.domain.time.TemporalPolicy +import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -29,14 +31,13 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.time.DayOfWeek import java.time.Instant import java.time.LocalDate import java.time.YearMonth import java.time.ZoneId -import java.time.temporal.WeekFields -import java.util.Locale import javax.inject.Inject /** Which calendar layout the user is currently viewing. */ @@ -46,6 +47,8 @@ data class DayBucket(val date: LocalDate, val entries: List, val tota data class CalendarUiState( val viewMode: CalendarViewMode = CalendarViewMode.WEEK, + /** Account temporal-policy zone; day/week boundaries and "today" are computed in it. */ + val zone: ZoneId = ZoneId.systemDefault(), val visibleMonth: YearMonth = YearMonth.now(), val selectedDate: LocalDate = LocalDate.now(), /** Anchor day for the week/day page; [visibleDays] is derived from it. */ @@ -73,22 +76,36 @@ class CalendarViewModel @Inject constructor( private val reader: TimeEntryReader, private val eventSource: CalendarEventSource, private val overlaySettings: CalendarOverlaySettings, + private val temporalPolicyProvider: TemporalPolicyProvider, ) : ViewModel() { private var organizationId: String? = null private var memberId: String? = null private var entriesJob: Job? = null - /** Locale's first day of week so the grid honours regional week-start conventions. */ - private val weekStart: DayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek - - private val _uiState = MutableStateFlow(CalendarUiState(weekStart = weekStart)) + // Account temporal policy (zone + week start). Seeded synchronously from the provider's cached + // read so the first frame already uses the account zone/week-start (see StatisticsViewModel); + // kept current by the collector in init. The provider owns the device-zone fallback. + @Volatile + private var currentPolicy: TemporalPolicy = runBlocking { temporalPolicyProvider.current() } + private val zone: ZoneId get() = currentPolicy.zone + private val weekStart: DayOfWeek get() = currentPolicy.firstDayOfWeek + + private val _uiState = MutableStateFlow( + CalendarUiState( + zone = currentPolicy.zone, + weekStart = currentPolicy.firstDayOfWeek, + visibleMonth = YearMonth.now(currentPolicy.zone), + selectedDate = LocalDate.now(currentPolicy.zone), + weekAnchor = LocalDate.now(currentPolicy.zone), + ), + ) val uiState: StateFlow = _uiState.asStateFlow() // Overlay query inputs kept as flows so event queries react without recollecting time entries. private val viewModeInput = MutableStateFlow(CalendarViewMode.WEEK) - private val weekAnchorInput = MutableStateFlow(LocalDate.now()) - private val dayCountInput = MutableStateFlow(7) + private val weekAnchorInput = MutableStateFlow(LocalDate.now(currentPolicy.zone)) + private val dayCountInput = MutableStateFlow(FULL_WEEK_DAYS) private val hasPermissionInput = MutableStateFlow(false) private val retryCounter = MutableStateFlow(0) @@ -105,6 +122,16 @@ class CalendarViewModel @Inject constructor( init { recomputeVisibleDays() + // React to account temporal-policy changes (login/logout/profile refresh): update the zone + // and week start, then re-derive the visible days off the new week start. + viewModelScope.launch { + temporalPolicyProvider.policy.collect { policy -> + currentPolicy = policy + _uiState.update { it.copy(zone = policy.zone, weekStart = policy.firstDayOfWeek) } + recomputeVisibleDays() + } + } + // Mirror persisted overlay preferences into the UI state. viewModelScope.launch { overlaySettings.calendarOverlayEnabled.collect { enabled -> @@ -150,7 +177,7 @@ class CalendarViewModel @Inject constructor( entries // Skip entries whose start cannot be parsed instead of bucketing them // onto today, which would corrupt the current day's total. - .mapNotNull { entry -> entryLocalDate(entry)?.let { it to entry } } + .mapNotNull { entry -> entryLocalDate(entry, zone)?.let { it to entry } } .groupBy({ it.first }, { it.second }) .mapValues { (date, dayEntries) -> DayBucket( @@ -178,7 +205,7 @@ class CalendarViewModel @Inject constructor( /** Called by the UI as the available width changes so WEEK mode can drop to a 3-day layout. */ fun setVisibleDayCount(count: Int) { - val safe = count.coerceIn(1, 7) + val safe = count.coerceIn(MIN_VISIBLE_DAYS, FULL_WEEK_DAYS) if (dayCountInput.value == safe) return dayCountInput.value = safe _uiState.update { it.copy(dayCount = safe) } @@ -216,7 +243,7 @@ class CalendarViewModel @Inject constructor( } fun jumpToToday() { - val today = LocalDate.now() + val today = LocalDate.now(zone) weekAnchorInput.value = today _uiState.update { it.copy( @@ -351,7 +378,6 @@ class CalendarViewModel @Inject constructor( private fun rangeFor(mode: CalendarViewMode, anchor: LocalDate, dayCount: Int): Pair? { val days = visibleDaysFor(mode, anchor, dayCount) if (days.isEmpty()) return null - val zone = ZoneId.systemDefault() val start = days.first().atStartOfDay(zone).toInstant().toEpochMilli() val end = days.last().plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() return start to end @@ -382,3 +408,6 @@ class CalendarViewModel @Inject constructor( fun entriesForSelectedDay(): List = _uiState.value.bucketsByDate[_uiState.value.selectedDate]?.entries ?: emptyList() } + +private const val FULL_WEEK_DAYS = 7 +private const val MIN_VISIBLE_DAYS = 1 diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/MonthCalendarView.kt b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/MonthCalendarView.kt index a3ab197..bc451fa 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/MonthCalendarView.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/MonthCalendarView.kt @@ -50,7 +50,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.tricked.solidverdant.R import dev.tricked.solidverdant.data.model.Project -import dev.tricked.solidverdant.data.model.Tag import dev.tricked.solidverdant.data.model.Task import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.ui.components.EmptyState @@ -74,11 +73,10 @@ fun MonthCalendarView( onEntryClick: (TimeEntry) -> Unit, projects: List = emptyList(), tasks: List = emptyList(), - tags: List = emptyList(), modifier: Modifier = Modifier, ) { var monthExpanded by remember { mutableStateOf(true) } - val timelineInitialScroll = with(LocalDensity.current) { (CalendarHourHeight * 8).roundToPx() } + val timelineInitialScroll = with(LocalDensity.current) { (CalendarHourHeight * INITIAL_SCROLL_HOURS).roundToPx() } val timelineScrollState = rememberScrollState(initial = timelineInitialScroll) Column(modifier = modifier.fillMaxWidth().padding(Dimens.Space12)) { if (!monthExpanded) { @@ -140,7 +138,7 @@ fun MonthCalendarView( } // Grid - val weeks = monthGridWeeks(state.visibleMonth) + val weeks = monthGridWeeks(state.visibleMonth, state.weekStart) val maxSeconds = state.bucketsByDate.values.maxOfOrNull { it.totalSeconds } ?: 1L weeks.forEach { week -> Row(modifier = Modifier.fillMaxWidth()) { @@ -215,6 +213,7 @@ fun MonthCalendarView( entries = entries, projects = projects, tasks = tasks, + zone = state.zone, scrollState = timelineScrollState, fillViewport = true, onEntryClick = onEntryClick, @@ -228,6 +227,7 @@ fun MonthCalendarView( entries = entries, projects = projects, tasks = tasks, + zone = state.zone, scrollState = timelineScrollState, onEntryClick = onEntryClick, ) @@ -237,6 +237,8 @@ fun MonthCalendarView( } } +private const val INITIAL_SCROLL_HOURS = 8 + /** * Single-day vertical timeline for the selected day. Shares the week grid's hour gutter/gridlines * ([HourGridlines]), entry treatment ([EntryBlock]) and current-time marker ([CurrentTimeMarker]) @@ -248,16 +250,16 @@ fun DayTimeline( entries: List, projects: List, tasks: List, + zone: ZoneId, onEntryClick: (TimeEntry) -> Unit, scrollState: ScrollState? = null, fillViewport: Boolean = false, modifier: Modifier = Modifier, ) { val now = remember { Instant.now() } - val zone = remember { ZoneId.systemDefault() } - val today = remember { LocalDate.now() } + val today = remember(zone) { LocalDate.now(zone) } val noDescription = stringResource(R.string.calendar_entry_untitled) - val initialScroll = with(LocalDensity.current) { (CalendarHourHeight * 8).roundToPx() } + val initialScroll = with(LocalDensity.current) { (CalendarHourHeight * INITIAL_SCROLL_HOURS).roundToPx() } val effectiveScrollState = scrollState ?: rememberScrollState(initial = initialScroll) Box( modifier = modifier diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayout.kt b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayout.kt index 5738581..ee5465d 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayout.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayout.kt @@ -19,6 +19,9 @@ import java.time.format.DateTimeFormatter /** Seconds in a 24h day. All timed layout fractions are expressed relative to this. */ const val SECONDS_PER_DAY: Long = 86_400L +private const val HOURS_PER_DAY = 24 +private const val DAYS_PER_WEEK = 7 +private const val MILLIS_PER_SECOND = 1000L /** Minimum visible height for a timed block so a very short event stays perceivable. */ const val MIN_BLOCK_HEIGHT_FRACTION: Float = 0.012f @@ -37,14 +40,14 @@ val CalendarHourHeight = 48.dp val CalendarGutterWidth = 48.dp /** Full height of a 24h day column. */ -val CalendarTotalHeight = CalendarHourHeight * 24 +val CalendarTotalHeight = CalendarHourHeight * HOURS_PER_DAY /** * Vertical placement of a tracked [entry] within [day], as `topFraction to heightFraction` of a * 24h column. Unparseable starts fall back to the day start; a running entry (no end) extends to * [now]. The height is floored at [MIN_ENTRY_HEIGHT_FRACTION] so very short entries stay visible. */ -fun timelineOffsets(entry: TimeEntry, day: LocalDate, now: Instant, zone: ZoneId = ZoneId.systemDefault()): Pair { +fun timelineOffsets(entry: TimeEntry, day: LocalDate, now: Instant, zone: ZoneId): Pair { val dayStart = day.atStartOfDay(zone).toInstant() val secondsInDay = SECONDS_PER_DAY.toFloat() val start = try { @@ -97,7 +100,7 @@ data class TrackedEntryBlock( * Mirrors the lead-day math used by the month grid so both views agree on week boundaries. */ fun weekStartDate(reference: LocalDate, weekStart: DayOfWeek): LocalDate { - val diff = ((reference.dayOfWeek.value - weekStart.value) + 7) % 7 + val diff = ((reference.dayOfWeek.value - weekStart.value) + DAYS_PER_WEEK) % DAYS_PER_WEEK return reference.minusDays(diff.toLong()) } @@ -110,7 +113,7 @@ fun weekStartDate(reference: LocalDate, weekStart: DayOfWeek): LocalDate { */ fun visibleCalendarDays(anchor: LocalDate, weekStart: DayOfWeek, dayCount: Int): List { val safeCount = dayCount.coerceAtLeast(1) - val start = if (safeCount >= 7) weekStartDate(anchor, weekStart) else anchor + val start = if (safeCount >= DAYS_PER_WEEK) weekStartDate(anchor, weekStart) else anchor return (0 until safeCount).map { start.plusDays(it.toLong()) } } @@ -121,8 +124,8 @@ fun visibleCalendarDays(anchor: LocalDate, weekStart: DayOfWeek, dayCount: Int): */ fun pageAnchor(anchor: LocalDate, weekStart: DayOfWeek, dayCount: Int, direction: Int): LocalDate { val safeCount = dayCount.coerceAtLeast(1) - val step = if (safeCount >= 7) 7 else safeCount - val base = if (safeCount >= 7) weekStartDate(anchor, weekStart) else anchor + val step = if (safeCount >= DAYS_PER_WEEK) DAYS_PER_WEEK else safeCount + val base = if (safeCount >= DAYS_PER_WEEK) weekStartDate(anchor, weekStart) else anchor return base.plusDays((step.toLong() * direction)) } @@ -136,8 +139,8 @@ fun clampToDaySeconds(startMs: Long, endMs: Long, day: LocalDate, zone: ZoneId): val s = maxOf(startMs, dayStartMs) val e = minOf(endMs, dayEndMs) if (e <= s) return null - val startSec = ((s - dayStartMs) / 1000L).coerceIn(0L, SECONDS_PER_DAY) - val endSec = ((e - dayStartMs) / 1000L).coerceIn(0L, SECONDS_PER_DAY) + val startSec = ((s - dayStartMs) / MILLIS_PER_SECOND).coerceIn(0L, SECONDS_PER_DAY) + val endSec = ((e - dayStartMs) / MILLIS_PER_SECOND).coerceIn(0L, SECONDS_PER_DAY) if (endSec <= startSec) return null return startSec to endSec } @@ -245,12 +248,7 @@ fun layoutTimedEvents(events: List, day: LocalDate, zone: Z * entries in adjacent columns. Ordering is stable so the same input always produces the same * lanes, including after process recreation or an offline refresh. */ -fun layoutTrackedEntries( - entries: List, - day: LocalDate, - now: Instant, - zone: ZoneId = ZoneId.systemDefault(), -): List { +fun layoutTrackedEntries(entries: List, day: LocalDate, now: Instant, zone: ZoneId): List { data class Clipped(val entry: TimeEntry, val startSec: Long, val endSec: Long) val dayStart = day.atStartOfDay(zone).toInstant() diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarView.kt b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarView.kt index da4eed5..7695c47 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarView.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarView.kt @@ -66,6 +66,12 @@ import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.util.Locale +private const val NARROW_CALENDAR_DAYS = 3 +private const val INITIAL_SCROLL_HOURS = 7 +private const val MAX_ALL_DAY_EVENTS = 3 +private const val FIRST_HOUR = 0 +private const val LAST_HOUR = 23 + /** * Google-Calendar-style multi-day time grid. Renders [CalendarUiState.visibleDays] as columns with * device-calendar events drawn as faded, read-only background blocks behind the tracked time @@ -91,7 +97,7 @@ fun WeekCalendarView( // Never expose the unusable seven-column layout on a phone, including the first frame // before CalendarScreen's width effect updates the ViewModel after recreation. val days = if (state.viewMode == CalendarViewMode.WEEK && maxWidth < 600.dp) { - state.visibleDays.take(3) + state.visibleDays.take(NARROW_CALENDAR_DAYS) } else { state.visibleDays } @@ -119,8 +125,8 @@ private fun WeekCalendarContent( onToday: () -> Unit, projects: List, ) { - val zone = remember { ZoneId.systemDefault() } - val today = remember { LocalDate.now() } + val zone = state.zone + val today = remember(zone) { LocalDate.now(zone) } val now = remember { Instant.now() } val locale = Locale.getDefault() @@ -214,7 +220,7 @@ private fun WeekGrid( projects: List, onEntryClick: (TimeEntry) -> Unit, ) { - val initialScroll = with(LocalDensity.current) { (CalendarHourHeight * 7).roundToPx() } + val initialScroll = with(LocalDensity.current) { (CalendarHourHeight * INITIAL_SCROLL_HOURS).roundToPx() } val scrollState = rememberScrollState(initial = initialScroll) Box( modifier = Modifier @@ -352,8 +358,8 @@ private fun AllDayRow(days: List, allDayByDay: Map - Column(modifier = Modifier.weight(1f).padding(horizontal = 1.dp)) { - allDayByDay[day].orEmpty().take(3).forEach { event -> + Column(modifier = Modifier.weight(1f).padding(horizontal = Dimens.Space1)) { + allDayByDay[day].orEmpty().take(MAX_ALL_DAY_EVENTS).forEach { event -> val label = event.title?.ifBlank { null } ?: untitled val base = event.eventColor() Text( @@ -472,7 +478,7 @@ private fun DayColumn( internal fun HourGridlines(modifier: Modifier = Modifier) { val lineColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) Box(modifier = modifier.fillMaxWidth().height(CalendarTotalHeight)) { - for (hour in 0..23) { + for (hour in FIRST_HOUR..LAST_HOUR) { Text( text = "%02d:00".format(hour), style = MaterialTheme.typography.labelSmall, diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/DeltaBadge.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/DeltaBadge.kt index cec8a06..997d961 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/components/DeltaBadge.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/DeltaBadge.kt @@ -27,9 +27,6 @@ import dev.tricked.solidverdant.ui.theme.negative import dev.tricked.solidverdant.ui.theme.neutral import dev.tricked.solidverdant.ui.theme.positive -/** Direction of a delta, driving the [DeltaBadge] colour and arrow. */ -enum class DeltaDirection { UP, DOWN, NEUTRAL } - /** * Up/down/neutral delta indicator. Renders an arrow icon plus a preformatted * [text] (e.g. "+2h 05m . +25%"), coloured via the semantic @@ -79,7 +76,7 @@ fun DeltaBadge(sign: Int, text: String, modifier: Modifier = Modifier) { @Preview @Composable -private fun DeltaBadgePreview() { +fun DeltaBadgePreview() { SolidVerdantTheme { DeltaBadge(direction = DeltaDirection.UP, text = "+2h 05m · +25%") } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/DeltaDirection.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/DeltaDirection.kt new file mode 100644 index 0000000..71cae12 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/DeltaDirection.kt @@ -0,0 +1,10 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.components + +/** Direction of a delta, driving the [DeltaBadge] colour and arrow. */ +enum class DeltaDirection { UP, DOWN, NEUTRAL } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/EditTimeEntryDialog.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/EditTimeEntryDialog.kt index aaa3e6e..89563e0 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/components/EditTimeEntryDialog.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/EditTimeEntryDialog.kt @@ -202,7 +202,7 @@ fun EditTimeEntryDialog( verticalAlignment = Alignment.CenterVertically, ) { FilledTonalIconButton( - onClick = { setDuration((durationMinutes.toLongOrNull() ?: 1) - 15) }, + onClick = { setDuration((durationMinutes.toLongOrNull() ?: MINIMUM_DURATION_MINUTES) - DURATION_STEP_MINUTES) }, modifier = Modifier.size(48.dp), ) { Icon( @@ -215,7 +215,9 @@ fun EditTimeEntryDialog( onValueChange = { value -> if (value.all(Char::isDigit)) { durationMinutes = value - value.toLongOrNull()?.takeIf { it > 0 }?.let { endTime = startTime.plusMinutes(it) } + value.toLongOrNull() + ?.takeIf { it >= MINIMUM_DURATION_MINUTES } + ?.let { endTime = startTime.plusMinutes(it) } } }, label = { Text(stringResource(R.string.minutes)) }, @@ -227,7 +229,7 @@ fun EditTimeEntryDialog( shape = RoundedCornerShape(12.dp), ) FilledTonalIconButton( - onClick = { setDuration((durationMinutes.toLongOrNull() ?: 0) + 15) }, + onClick = { setDuration((durationMinutes.toLongOrNull() ?: 0) + DURATION_STEP_MINUTES) }, modifier = Modifier.size(48.dp), ) { Icon( @@ -336,7 +338,7 @@ fun EditTimeEntryDialog( contentAlignment = Alignment.BottomCenter, ) { Surface( - modifier = Modifier.fillMaxWidth().fillMaxHeight(0.9f), + modifier = Modifier.fillMaxWidth().fillMaxHeight(SHEET_HEIGHT_FRACTION), shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), color = MaterialTheme.colorScheme.surface, ) { @@ -411,11 +413,16 @@ private fun EntryTimePickerDialog(title: String, initial: ZonedDateTime, onDismi } private fun formatEditableDuration(minutes: Long): String { - val hours = minutes / 60 - val remainingMinutes = minutes % 60 + val hours = minutes / MINUTES_PER_HOUR + val remainingMinutes = minutes % MINUTES_PER_HOUR return when { hours > 0 && remainingMinutes > 0 -> "${hours}h ${remainingMinutes}m" hours > 0 -> "${hours}h" else -> "${remainingMinutes}m" } } + +private const val DURATION_STEP_MINUTES = 15L +private const val MINIMUM_DURATION_MINUTES = 1L +private const val MINUTES_PER_HOUR = 60L +private const val SHEET_HEIGHT_FRACTION = 0.9f diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/EntryBlock.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/EntryBlock.kt index 87bd2dc..3771eb6 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/components/EntryBlock.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/EntryBlock.kt @@ -108,10 +108,10 @@ fun EntryBlock( @Preview @Composable -private fun EntryBlockPreview() { +fun EntryBlockPreview() { SolidVerdantTheme { EntryBlock( - color = Color(0xFF386A20), + color = MaterialTheme.colorScheme.primary, title = "Design review", subtitle = "Acme - UI kit", time = "1h 05m", diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/SectionCard.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/SectionCard.kt index 4596463..23e8f32 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/components/SectionCard.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/SectionCard.kt @@ -53,7 +53,7 @@ fun SectionCard( @Preview @Composable -private fun SectionCardPreview() { +fun SectionCardPreview() { SolidVerdantTheme { SectionCard(title = "By project") { Text("Content row A", style = MaterialTheme.typography.bodyMedium) diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/StateViews.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/StateViews.kt index a359894..2cd66aa 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/components/StateViews.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/StateViews.kt @@ -124,7 +124,7 @@ fun ErrorState(text: String, modifier: Modifier = Modifier, onRetry: (() -> Unit @Preview @Composable -private fun StateViewsPreview() { +fun StateViewsPreview() { SolidVerdantTheme { Column(verticalArrangement = Arrangement.spacedBy(Dimens.Space16)) { EmptyState(text = "No entries") diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/SyncChip.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/SyncChip.kt index 0218a11..aad5035 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/components/SyncChip.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/SyncChip.kt @@ -64,6 +64,11 @@ fun SyncChip(status: EntrySyncStatus, modifier: Modifier = Modifier, showLabel: color = MaterialTheme.colorScheme.syncFailed labelRes = R.string.sync_failed } + EntrySyncStatus.CONFLICT -> { + icon = Icons.Filled.SyncProblem + color = MaterialTheme.colorScheme.error + labelRes = R.string.sync_conflict + } } val label = stringResource(labelRes) Row( @@ -80,7 +85,7 @@ fun SyncChip(status: EntrySyncStatus, modifier: Modifier = Modifier, showLabel: @Preview @Composable -private fun SyncChipPreview() { +fun SyncChipPreview() { SolidVerdantTheme { SyncChip(status = EntrySyncStatus.FAILED) } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/login/LoginScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/login/LoginScreen.kt index b0c0d68..11a9f0f 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/login/LoginScreen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/login/LoginScreen.kt @@ -62,9 +62,7 @@ fun LoginScreen( onConfigSave: (String, String) -> Unit, onConfigReset: () -> Unit, onTestConnection: (String, String) -> Unit, - onAuthUrlReady: (String) -> Unit, onClearAuthUrl: () -> Unit, - onClearError: () -> Unit, ) { val context = LocalContext.current var showConfigDialog by remember { mutableStateOf(false) } @@ -139,7 +137,6 @@ fun LoginScreen( uiState.error?.let { error -> ErrorCard( error = error, - onDismiss = onClearError, ) } @@ -170,7 +167,7 @@ fun LoginScreen( * Error card to display error messages */ @Composable -private fun ErrorCard(error: String, onDismiss: () -> Unit) { +private fun ErrorCard(error: String) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/navigation/MainNavHost.kt b/app/src/main/java/dev/tricked/solidverdant/ui/navigation/MainNavHost.kt index 62981d5..8c8bfc6 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/navigation/MainNavHost.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/navigation/MainNavHost.kt @@ -35,8 +35,10 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import dev.tricked.solidverdant.R +import dev.tricked.solidverdant.ui.privacy.PrivacyScreen import dev.tricked.solidverdant.ui.review.ReminderSettingsScreen import dev.tricked.solidverdant.ui.review.ReviewDayPane +import dev.tricked.solidverdant.ui.sync.SyncCenterScreen import dev.tricked.solidverdant.ui.templates.ManageTemplatesScreen @Composable @@ -47,6 +49,7 @@ fun MainNavHost( statsContent: @Composable () -> Unit, reviewContent: @Composable () -> Unit = {}, inboxBadgeCount: Int = 0, + onPrivacyLogout: () -> Unit = {}, ) { Scaffold( contentWindowInsets = WindowInsets(0), @@ -93,6 +96,18 @@ fun MainNavHost( composable(ReviewRoutes.MANAGE_TEMPLATES) { ManageTemplatesScreen(onBack = { navController.popBackStack() }) } + composable(SyncRoutes.SYNC_CENTER) { + SyncCenterScreen(onBack = { navController.popBackStack() }) + } + composable(SettingsRoutes.PRIVACY) { + PrivacyScreen( + onBack = { navController.popBackStack() }, + onLogout = { + navController.popBackStack() + onPrivacyLogout() + }, + ) + } } } } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/navigation/Screen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/navigation/Screen.kt index ea1c7b7..3c5e6be 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/navigation/Screen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/navigation/Screen.kt @@ -41,3 +41,22 @@ object ReviewRoutes { /** Manage reusable entry templates / favorites. */ const val MANAGE_TEMPLATES: String = "templates/manage" } + +/** + * Routes for the sync surface (#33). The dedicated Sync Center is pushed full-screen on top of the + * tab graph with its own back navigation, reached from the Track screen's sync summary. + */ +object SyncRoutes { + /** Dedicated Sync Center: freshness, pending changes, failures + retry/discard. */ + const val SYNC_CENTER: String = "sync/center" +} + +/** + * Routes for the settings surface. The privacy & data-management screen (#48) is pushed full-screen + * on top of the tab graph with its own back navigation, reached from the Track screen's settings + * drawer. + */ +object SettingsRoutes { + /** Privacy & data-management: what is stored/sent, token protection, permissions, data controls. */ + const val PRIVACY: String = "settings/privacy" +} diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/privacy/ByteSizeFormatter.kt b/app/src/main/java/dev/tricked/solidverdant/ui/privacy/ByteSizeFormatter.kt new file mode 100644 index 0000000..bfa2eeb --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/ui/privacy/ByteSizeFormatter.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.privacy + +import java.util.Locale + +/** + * Formats a byte count into a short, human-readable size (binary units). Kept as a pure, + * locale-stable helper so the storage-usage labels on the privacy screen stay testable without a + * running framework. Values under 1 KB are shown as whole bytes; larger values use one decimal. + */ +object ByteSizeFormatter { + private const val UNIT = 1024.0 + private val SUFFIXES = arrayOf("KB", "MB", "GB", "TB") + + fun format(bytes: Long): String { + if (bytes < UNIT) return "$bytes B" + var value = bytes / UNIT + var index = 0 + while (value >= UNIT && index < SUFFIXES.lastIndex) { + value /= UNIT + index++ + } + return String.format(Locale.US, "%.1f %s", value, SUFFIXES[index]) + } +} diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/privacy/PrivacyScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/privacy/PrivacyScreen.kt new file mode 100644 index 0000000..0de45a6 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/ui/privacy/PrivacyScreen.kt @@ -0,0 +1,304 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.privacy + +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.CloudUpload +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import dev.tricked.solidverdant.R + +/** + * Privacy & data-management screen (roadmap #48). Explains what the app stores locally, what leaves + * the device, how credentials are protected, and which optional permissions do what — then offers + * the three data controls: export diagnostics (#49), clear the re-syncable cache, and log out / + * revoke the local session. Destructive actions confirm first. Status is communicated with text and + * icons, never color alone. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivacyScreen(onBack: () -> Unit = {}, onLogout: () -> Unit = {}) { + val viewModel: PrivacyViewModel = hiltViewModel() + val state by viewModel.state.collectAsState() + val context = LocalContext.current + + var showClearDialog by remember { mutableStateOf(false) } + var showLogoutDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.privacy_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.privacy_navigate_back), + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SectionCard( + icon = Icons.Outlined.Storage, + title = stringResource(R.string.privacy_stored_title), + ) { + Body(stringResource(R.string.privacy_stored_solidtime)) + Body(stringResource(R.string.privacy_stored_local)) + Body(stringResource(R.string.privacy_stored_tokens)) + Body(stringResource(R.string.privacy_source_legend)) + } + + SectionCard( + icon = Icons.Outlined.CloudUpload, + title = stringResource(R.string.privacy_sent_title), + ) { + Body(stringResource(R.string.privacy_sent_endpoint_label)) + Text( + text = state.serverHost.ifBlank { stringResource(R.string.privacy_sent_endpoint_unknown) }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Body(stringResource(R.string.privacy_sent_body)) + } + + SectionCard( + icon = Icons.Outlined.Lock, + title = stringResource(R.string.privacy_tokens_title), + ) { + Body(stringResource(R.string.privacy_tokens_body)) + } + + SectionCard( + icon = Icons.Outlined.Notifications, + title = stringResource(R.string.privacy_permissions_title), + ) { + Body(stringResource(R.string.privacy_permissions_notifications)) + Body(stringResource(R.string.privacy_permissions_calendar)) + OutlinedButton( + onClick = { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + runCatching { context.startActivity(intent) } + .onFailure { + runCatching { + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(android.net.Uri.parse("package:${context.packageName}")), + ) + } + } + }, + modifier = Modifier.heightIn(min = 48.dp), + ) { + Text(stringResource(R.string.privacy_permissions_open_settings)) + } + } + + SectionCard( + icon = Icons.Outlined.Info, + title = stringResource(R.string.privacy_storage_title), + ) { + if (state.computingStorage) { + Body(stringResource(R.string.privacy_storage_computing)) + } else { + StorageRow( + label = stringResource(R.string.privacy_storage_database), + value = ByteSizeFormatter.format(state.dbBytes), + ) + StorageRow( + label = stringResource(R.string.privacy_storage_cache), + value = ByteSizeFormatter.format(state.cacheBytes), + ) + StorageRow( + label = stringResource(R.string.privacy_storage_total), + value = ByteSizeFormatter.format(state.totalBytes), + emphasize = true, + ) + } + Body(stringResource(R.string.privacy_storage_note)) + } + + SectionCard( + icon = Icons.Outlined.DeleteSweep, + title = stringResource(R.string.privacy_actions_title), + ) { + OutlinedButton( + onClick = { + viewModel.exportDiagnostics { uri -> + runCatching { context.startActivity(viewModel.shareIntentFor(uri)) } + } + }, + enabled = !state.exporting, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + ) { + Icon(Icons.Outlined.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.privacy_action_export), modifier = Modifier.padding(start = 8.dp)) + } + Body(stringResource(R.string.privacy_action_export_note)) + + OutlinedButton( + onClick = { showClearDialog = true }, + enabled = !state.clearingCache, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + ) { + Icon(Icons.Outlined.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.privacy_action_clear_cache), modifier = Modifier.padding(start = 8.dp)) + } + Body(stringResource(R.string.privacy_action_clear_cache_note)) + + OutlinedButton( + onClick = { showLogoutDialog = true }, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + ) { + Icon(Icons.AutoMirrored.Outlined.Logout, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.privacy_action_logout), modifier = Modifier.padding(start = 8.dp)) + } + Body(stringResource(R.string.privacy_action_logout_note)) + } + } + } + + if (showClearDialog) { + ConfirmDialog( + title = stringResource(R.string.privacy_clear_dialog_title), + message = stringResource(R.string.privacy_clear_dialog_message), + confirmLabel = stringResource(R.string.privacy_clear_dialog_confirm), + onConfirm = { + showClearDialog = false + viewModel.clearCache() + }, + onDismiss = { showClearDialog = false }, + ) + } + + if (showLogoutDialog) { + ConfirmDialog( + title = stringResource(R.string.privacy_logout_dialog_title), + message = stringResource(R.string.privacy_logout_dialog_message), + confirmLabel = stringResource(R.string.privacy_logout_dialog_confirm), + onConfirm = { + showLogoutDialog = false + onLogout() + }, + onDismiss = { showLogoutDialog = false }, + ) + } +} + +@Composable +private fun SectionCard(icon: ImageVector, title: String, content: @Composable () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(icon, contentDescription = null) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 8.dp), + ) + } + content() + } + } +} + +@Composable +private fun Body(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun StorageRow(label: String, value: String, emphasize: Boolean = false) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Outlined.Dns, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text( + text = label, + style = if (emphasize) MaterialTheme.typography.titleSmall else MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) + Text( + text = value, + style = if (emphasize) MaterialTheme.typography.titleSmall else MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun ConfirmDialog(title: String, message: String, confirmLabel: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.privacy_dialog_cancel)) } }, + ) +} diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/privacy/PrivacyViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/privacy/PrivacyViewModel.kt new file mode 100644 index 0000000..1977aa8 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/ui/privacy/PrivacyViewModel.kt @@ -0,0 +1,160 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.privacy + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import dev.tricked.solidverdant.data.export.DiagnosticExporter +import dev.tricked.solidverdant.data.local.AuthDataStore +import dev.tricked.solidverdant.data.local.SettingsDataStore +import dev.tricked.solidverdant.data.local.UserCacheCleaner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import javax.inject.Inject + +/** + * Backs [PrivacyScreen] (roadmap #48). This is a *surfacing* view model: it explains what is stored + * and reuses the existing safe primitives for the destructive actions rather than reimplementing + * them — [UserCacheCleaner.clear] for the re-syncable cache wipe, [DiagnosticExporter.export] for the + * privacy-reviewed bundle (#49), and the host's existing logout path (routed via a callback in the + * screen) for full session revocation. + * + * Storage usage is approximate and cheap: it sums the Room DB file(s) and the cache directory off + * the main thread. It is refreshed after a cache clear so the numbers reflect the wipe. + */ +@HiltViewModel +class PrivacyViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val settingsDataStore: SettingsDataStore, + private val authDataStore: AuthDataStore, + private val userCacheCleaner: UserCacheCleaner, + private val diagnosticExporter: DiagnosticExporter, +) : ViewModel() { + + @Stable + data class State( + val serverHost: String = "", + val sessionPresent: Boolean = false, + val dbBytes: Long = 0L, + val cacheBytes: Long = 0L, + val computingStorage: Boolean = true, + val clearingCache: Boolean = false, + val exporting: Boolean = false, + ) { + val totalBytes: Long get() = dbBytes + cacheBytes + } + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { + val endpoint = authDataStore.endpoint.first() + _state.value = _state.value.copy(serverHost = hostOf(endpoint)) + } + viewModelScope.launch { + val present = !authDataStore.accessToken.first().isNullOrEmpty() + _state.value = _state.value.copy(sessionPresent = present) + } + refreshStorage() + } + + /** Recompute the Room DB + cache directory sizes off the main thread. */ + fun refreshStorage() { + viewModelScope.launch { + _state.value = _state.value.copy(computingStorage = true) + val (dbBytes, cacheBytes) = withContext(Dispatchers.IO) { + databaseBytes() to directoryBytes(context.cacheDir) + } + _state.value = _state.value.copy( + dbBytes = dbBytes, + cacheBytes = cacheBytes, + computingStorage = false, + ) + } + } + + /** + * Clears the re-syncable account cache via the existing [UserCacheCleaner] (preserves templates + * per SV-011, keeps the user logged in), then re-reads storage. Does NOT touch auth. + */ + fun clearCache() { + viewModelScope.launch { + _state.value = _state.value.copy(clearingCache = true) + runCatching { userCacheCleaner.clear() } + .onFailure { Timber.e(it, "Failed to clear cached data") } + _state.value = _state.value.copy(clearingCache = false) + refreshStorage() + } + } + + /** + * Builds the diagnostic bundle (#49) and hands the shareable [Uri] back to the screen, which + * launches the system share sheet. The bundle contains no tokens or work content. + */ + fun exportDiagnostics(onReady: (Uri) -> Unit) { + viewModelScope.launch { + _state.value = _state.value.copy(exporting = true) + runCatching { diagnosticExporter.export() } + .onSuccess { onReady(it) } + .onFailure { Timber.e(it, "Failed to export diagnostics") } + _state.value = _state.value.copy(exporting = false) + } + } + + /** Share-sheet intent for a diagnostic bundle [uri], delegating to the exporter (#49). */ + fun shareIntentFor(uri: Uri): Intent = diagnosticExporter.shareIntent(uri) + + /** Sum of the main Room DB file plus its `-wal`/`-shm`/`-journal` sidecars, if present. */ + private fun databaseBytes(): Long { + val main = context.getDatabasePath(DB_NAME) + return listOf(main, File(main.path + "-wal"), File(main.path + "-shm"), File(main.path + "-journal")) + .filter { it.exists() } + .sumOf { it.length() } + } + + /** Shallow-recursive sum of file lengths under [dir]; symlinks are followed by File.length only. */ + private fun directoryBytes(dir: File): Long { + if (!dir.exists()) return 0L + return dir.walkTopDown().filter { it.isFile }.sumOf { it.length() } + } + + private fun hostOf(endpoint: String): String { + val host = runCatching { Uri.parse(endpoint).host }.getOrNull() + if (!host.isNullOrBlank()) return host + return endpoint.substringAfter("://").substringBefore("/").ifBlank { endpoint } + } + + /** + * Cancels [viewModelScope] for unit tests that install a test Main dispatcher; mirrors the sync + * VM's teardown so no Main-bound continuation straggles past `Dispatchers.resetMain()`. + */ + @VisibleForTesting + internal fun cancelScopeForTest() { + viewModelScope.coroutineContext[Job]?.cancel() + } + + private companion object { + /** Must match the name used in [dev.tricked.solidverdant.di.DatabaseModule]. */ + const val DB_NAME = "solidverdant.db" + } +} diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxModels.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxModels.kt index a5b8e39..cc0d33b 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxModels.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxModels.kt @@ -11,10 +11,17 @@ import dev.tricked.solidverdant.data.model.Tag import dev.tricked.solidverdant.data.model.Task import dev.tricked.solidverdant.domain.inbox.InboxCheckConfig import dev.tricked.solidverdant.domain.inbox.InboxIssue +import java.time.ZoneId /** Transient, one-shot failures the Inbox surfaces as a snackbar. Mapped to strings in the pane. */ enum class InboxActionError { REFRESH_FAILED, CREATE_FAILED, RESOLVE_FAILED } +/** + * SV-005 first-run horizon choices ("how far back should Review look?"). The ViewModel maps each to + * an epoch-millis lower bound in the account zone / week-start, or null for [EVERYTHING]. + */ +enum class HorizonOption { TODAY, THIS_WEEK, LAST_30_DAYS, EVERYTHING } + /** * Everything the [InboxPane] renders. The list of [issues] is already filtered for dismissals and * ordered by [dev.tricked.solidverdant.domain.inbox.InboxAnalyzer]; the catalogue lists back the @@ -38,6 +45,14 @@ data class InboxUiState( val projects: List = emptyList(), val tasks: List = emptyList(), val tags: List = emptyList(), + /** Account temporal-policy zone for gap windows and shown-in-zone formatting. */ + val zone: ZoneId = ZoneId.systemDefault(), + /** + * SV-005 inbox horizon. Before the user picks ([horizonChosen] false) the pane shows the one-time + * picker instead of the list. Once chosen, [horizonStartMs] drives the chip: null = "Everything". + */ + val horizonChosen: Boolean = false, + val horizonStartMs: Long? = null, ) { /** All issues resolved: show the reassuring "all caught up" state. */ val isCaughtUp: Boolean get() = !isLoading && issues.isEmpty() diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxPane.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxPane.kt index c96da34..bc5f954 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxPane.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxPane.kt @@ -6,6 +6,7 @@ package dev.tricked.solidverdant.ui.review +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,23 +20,31 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.Inbox +import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.AssistChip import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text @@ -51,6 +60,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -64,6 +75,7 @@ import dev.tricked.solidverdant.domain.inbox.InboxIssueType import dev.tricked.solidverdant.domain.inbox.MissingField import dev.tricked.solidverdant.ui.components.EditTimeEntryDialog import java.time.Instant +import java.time.LocalDate import java.time.OffsetDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -77,6 +89,13 @@ import java.time.format.DateTimeFormatter */ private data class InboxEditTarget(val entry: TimeEntry, val isGap: Boolean) +internal data class InboxIssueCardActions( + val onQuickFix: () -> Unit, + val onDismiss: () -> Unit, + val onKeepMine: () -> Unit = {}, + val onKeepTheirs: () -> Unit = {}, +) + @Composable fun InboxPane() { val viewModel: InboxViewModel = hiltViewModel() @@ -110,7 +129,7 @@ fun InboxPane() { viewModel.consumeActionError() } - val zone = remember { ZoneId.systemDefault() } + val zone = state.zone val projectsById = remember(state.projects) { state.projects.associateBy { it.id } } Box(modifier = Modifier.fillMaxSize()) { @@ -118,6 +137,9 @@ fun InboxPane() { InboxHeader( issueCount = state.issues.size, isRefreshing = state.isRefreshing, + showHorizonChip = state.horizonChosen, + horizonLabel = horizonChipLabel(state.horizonStartMs, zone), + onHorizonChipClick = { showSettings = true }, onRefresh = viewModel::refresh, onOpenSettings = { showSettings = true }, ) @@ -128,40 +150,16 @@ fun InboxPane() { when { state.isLoading -> LoadingState() + !state.horizonChosen -> HorizonPicker(onChoose = viewModel::chooseHorizon) state.isCaughtUp && state.hasEntries -> CaughtUpState() state.isCaughtUp -> NoDataState() - else -> LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(items = state.issues, key = { it.key }) { issue -> - DismissibleIssue( - issue = issue, - onDismiss = { viewModel.dismiss(issue) }, - ) { - InboxIssueCard( - issue = issue, - preventOverlap = state.preventOverlap, - projectsById = projectsById, - zone = zone, - onQuickFix = { - editTarget = when (issue.type) { - InboxIssueType.GAP -> { - val entry = viewModel.blankEntryFor( - startIso = isoOf(issue.startMs, zone), - endIso = isoOf(issue.endMs, zone), - ) - entry?.let { InboxEditTarget(it, isGap = true) } - } - else -> issue.primaryEntry?.let { InboxEditTarget(it, isGap = false) } - } - }, - onDismiss = { viewModel.dismiss(issue) }, - ) - } - } - } + else -> InboxIssueList( + state = state, + projectsById = projectsById, + zone = zone, + viewModel = viewModel, + onEdit = { editTarget = it }, + ) } } @@ -199,23 +197,168 @@ fun InboxPane() { } } +/** + * The triage list (SV-005 T4.3). CONFLICT issues stay pinned at the top as their own non-dismissible + * cards; every other issue is grouped by day (in the account [zone]), newest day first, under a + * sticky day header carrying the per-day "Dismiss all" and durable "Dismiss everything before this" + * actions. Individual per-card swipe/button dismiss still works within each day group. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun InboxIssueList( + state: InboxUiState, + projectsById: Map, + zone: ZoneId, + viewModel: InboxViewModel, + onEdit: (InboxEditTarget?) -> Unit, +) { + val conflicts = state.issues.filter { it.type == InboxIssueType.CONFLICT } + val dayGroups = remember(state.issues, zone) { + state.issues + .filterNot { it.type == InboxIssueType.CONFLICT } + .groupBy { Instant.ofEpochMilli(it.startMs).atZone(zone).toLocalDate() } + .toSortedMap(compareByDescending { it }) // newest day first + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items = conflicts, key = { it.key }) { issue -> + IssueCard(issue, state, projectsById, zone, viewModel, onEdit) + } + dayGroups.forEach { (date, issues) -> + stickyHeader(key = "header:$date") { + InboxDayHeader( + date = date, + zone = zone, + onDismissAll = { viewModel.dismissDay(issues) }, + onDismissBefore = { + viewModel.dismissBefore(date.atStartOfDay(zone).toInstant().toEpochMilli()) + }, + ) + } + items(items = issues, key = { it.key }) { issue -> + DismissibleIssue( + onDismiss = { viewModel.dismiss(issue) }, + content = { IssueCard(issue, state, projectsById, zone, viewModel, onEdit) }, + ) + } + } + } +} + +@Composable +private fun IssueCard( + issue: InboxIssue, + state: InboxUiState, + projectsById: Map, + zone: ZoneId, + viewModel: InboxViewModel, + onEdit: (InboxEditTarget?) -> Unit, +) { + InboxIssueCard( + issue = issue, + preventOverlap = state.preventOverlap, + projectsById = projectsById, + zone = zone, + actions = InboxIssueCardActions( + onQuickFix = { + onEdit( + when (issue.type) { + InboxIssueType.CONFLICT -> null + InboxIssueType.GAP -> { + val entry = viewModel.blankEntryFor( + startIso = isoOf(issue.startMs, zone), + endIso = isoOf(issue.endMs, zone), + ) + entry?.let { InboxEditTarget(it, isGap = true) } + } + else -> issue.primaryEntry?.let { InboxEditTarget(it, isGap = false) } + }, + ) + }, + onDismiss = { viewModel.dismiss(issue) }, + onKeepMine = { viewModel.keepMine(issue) }, + onKeepTheirs = { viewModel.keepTheirs(issue) }, + ), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InboxDayHeader(date: LocalDate, zone: ZoneId, onDismissAll: () -> Unit, onDismissBefore: () -> Unit) { + var menuOpen by remember { mutableStateOf(false) } + val moreCd = stringResource(R.string.inbox_day_more_cd) + Surface(color = MaterialTheme.colorScheme.surface) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = date.format(DAY_HEADER_FORMAT), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismissAll) { Text(stringResource(R.string.inbox_day_dismiss_all)) } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Outlined.MoreVert, contentDescription = moreCd) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.inbox_day_dismiss_before)) }, + onClick = { + menuOpen = false + onDismissBefore() + }, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun InboxHeader(issueCount: Int, isRefreshing: Boolean, onRefresh: () -> Unit, onOpenSettings: () -> Unit) { +internal fun InboxHeader( + issueCount: Int, + isRefreshing: Boolean, + showHorizonChip: Boolean, + horizonLabel: String, + onHorizonChipClick: () -> Unit, + onRefresh: () -> Unit, + onOpenSettings: () -> Unit, +) { + val horizonChipCd = stringResource(R.string.inbox_horizon_chip_cd) Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = if (issueCount == 0) { - stringResource(R.string.inbox_summary_none) - } else { - pluralStringResource(R.plurals.inbox_summary_count, issueCount, issueCount) - }, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (issueCount == 0) { + stringResource(R.string.inbox_summary_none) + } else { + pluralStringResource(R.plurals.inbox_summary_count, issueCount, issueCount) + }, + style = MaterialTheme.typography.titleMedium, + ) + if (showHorizonChip) { + Spacer(Modifier.height(4.dp)) + AssistChip( + onClick = onHorizonChipClick, + label = { Text(horizonLabel) }, + modifier = Modifier.semantics { contentDescription = horizonChipCd }, + ) + } + } if (isRefreshing) { CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) Spacer(Modifier.width(8.dp)) @@ -281,6 +424,47 @@ private fun NoDataState() { ) } +/** + * First-run horizon onboarding (SV-005). Shown instead of the list until the user picks how far back + * Review should look; each choice calls back into [InboxViewModel.chooseHorizon] and unlocks the list. + */ +@Composable +private fun HorizonPicker(onChoose: (HorizonOption) -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.inbox_horizon_picker_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.inbox_horizon_picker_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + HorizonOptionButton(stringResource(R.string.inbox_horizon_today)) { onChoose(HorizonOption.TODAY) } + HorizonOptionButton(stringResource(R.string.inbox_horizon_this_week)) { onChoose(HorizonOption.THIS_WEEK) } + HorizonOptionButton(stringResource(R.string.inbox_horizon_last_30_days)) { onChoose(HorizonOption.LAST_30_DAYS) } + HorizonOptionButton(stringResource(R.string.inbox_horizon_everything)) { onChoose(HorizonOption.EVERYTHING) } + } +} + +@Composable +private fun HorizonOptionButton(label: String, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + ) { + Text(label, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Start) + } +} + @Composable private fun CenteredMessage(icon: ImageVector, title: String, body: String, tint: androidx.compose.ui.graphics.Color) { Column( @@ -305,7 +489,7 @@ private fun CenteredMessage(icon: ImageVector, title: String, body: String, tint @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun DismissibleIssue(issue: InboxIssue, onDismiss: () -> Unit, content: @Composable () -> Unit) { +private fun DismissibleIssue(onDismiss: () -> Unit, content: @Composable () -> Unit) { val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> if (value != SwipeToDismissBoxValue.Settled) { @@ -335,6 +519,34 @@ private fun DismissibleIssue(issue: InboxIssue, onDismiss: () -> Unit, content: @Composable internal fun InboxIssueCard( + issue: InboxIssue, + preventOverlap: Boolean, + projectsById: Map, + zone: ZoneId, + actions: InboxIssueCardActions, +) { + if (issue.type == InboxIssueType.CONFLICT) { + ConflictIssueCard( + issue = issue, + projectsById = projectsById, + onKeepMine = actions.onKeepMine, + onKeepTheirs = actions.onKeepTheirs, + ) + } else { + InboxIssueContent( + issue = issue, + preventOverlap = preventOverlap, + projectsById = projectsById, + zone = zone, + onQuickFix = actions.onQuickFix, + onDismiss = actions.onDismiss, + ) + } +} + +@Composable +@Suppress("LongMethod") +private fun InboxIssueContent( issue: InboxIssue, preventOverlap: Boolean, projectsById: Map, @@ -346,6 +558,7 @@ internal fun InboxIssueCard( val body: String val actionLabel: String when (issue.type) { + InboxIssueType.CONFLICT -> error("Conflict issues are rendered by ConflictIssueCard") InboxIssueType.OVERLAP -> { title = stringResource(R.string.inbox_issue_overlap_title) body = if (preventOverlap) { @@ -372,11 +585,7 @@ internal fun InboxIssueCard( } } - val subject = when (issue.type) { - InboxIssueType.GAP -> null - InboxIssueType.OVERLAP -> issue.primaryEntry?.let { entrySubject(it, projectsById) } - else -> issue.primaryEntry?.let { entrySubject(it, projectsById) } - } + val subject = issue.primaryEntry?.let { entrySubject(it, projectsById) } Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { @@ -409,6 +618,66 @@ internal fun InboxIssueCard( } } +@Composable +private fun ConflictIssueCard(issue: InboxIssue, projectsById: Map, onKeepMine: () -> Unit, onKeepTheirs: () -> Unit) { + val mine = issue.primaryEntry + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.inbox_issue_conflict_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.inbox_issue_conflict_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(10.dp)) + Text( + text = stringResource( + R.string.inbox_conflict_mine, + when { + issue.conflictLocalDeleted -> stringResource(R.string.inbox_conflict_deleted) + mine == null -> stringResource(R.string.inbox_conflict_unavailable) + else -> conflictVersionText(mine, projectsById) + }, + ), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource( + R.string.inbox_conflict_theirs, + when { + issue.conflictServerDeleted -> stringResource(R.string.inbox_conflict_deleted) + issue.conflictServer == null -> stringResource(R.string.inbox_conflict_unavailable) + else -> conflictVersionText(issue.conflictServer, projectsById) + }, + ), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onKeepTheirs) { Text(stringResource(R.string.inbox_conflict_keep_theirs)) } + FilledTonalButton(onClick = onKeepMine) { Text(stringResource(R.string.inbox_conflict_keep_mine)) } + } + } + } +} + +@Composable +private fun conflictVersionText(entry: TimeEntry, projectsById: Map): String { + val empty = stringResource(R.string.inbox_conflict_empty_value) + val description = entry.description?.takeIf { it.isNotBlank() } ?: empty + val project = entry.projectId?.let { projectsById[it]?.name ?: it } ?: empty + val tags = entry.tags.joinToString(", ") { it.name.ifBlank { it.id } }.ifBlank { empty } + return stringResource(R.string.inbox_conflict_version_summary, description, project, tags) +} + @Composable private fun missingFieldsText(fields: Set): String { val labels = fields.sortedBy { it.ordinal }.map { @@ -431,6 +700,17 @@ private fun entrySubject(entry: TimeEntry, projectsById: Map): private val TIME_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") private val DATE_TIME_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE d MMM, HH:mm") +private val HORIZON_DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM") +private val DAY_HEADER_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE d MMM yyyy") + +/** Chip text for the current horizon: "Everything" when unbounded, else "Since ". */ +@Composable +private fun horizonChipLabel(horizonStartMs: Long?, zone: ZoneId): String = if (horizonStartMs == null) { + stringResource(R.string.inbox_horizon_everything) +} else { + val date = OffsetDateTime.ofInstant(Instant.ofEpochMilli(horizonStartMs), zone).format(HORIZON_DATE_FORMAT) + stringResource(R.string.inbox_horizon_chip_since, date) +} private fun timeRangeText(startMs: Long, endMs: Long, zone: ZoneId): String { val start = OffsetDateTime.ofInstant(Instant.ofEpochMilli(startMs), zone) @@ -442,9 +722,9 @@ private fun timeRangeText(startMs: Long, endMs: Long, zone: ZoneId): String { @Composable private fun durationText(durationMs: Long): String { - val totalMinutes = (durationMs / 60000L).coerceAtLeast(0) - val hours = totalMinutes / 60 - val minutes = totalMinutes % 60 + val totalMinutes = (durationMs / MILLIS_PER_MINUTE).coerceAtLeast(0) + val hours = totalMinutes / MINUTES_PER_HOUR + val minutes = totalMinutes % MINUTES_PER_HOUR return when { hours > 0 && minutes > 0 -> stringResource(R.string.inbox_duration_hm, hours, minutes) hours > 0 -> stringResource(R.string.inbox_duration_h, hours) @@ -452,5 +732,8 @@ private fun durationText(durationMs: Long): String { } } +private const val MILLIS_PER_MINUTE = 60_000L +private const val MINUTES_PER_HOUR = 60 + private fun isoOf(epochMs: Long, zone: ZoneId): String = OffsetDateTime.ofInstant(Instant.ofEpochMilli(epochMs), zone).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxSettingsSheet.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxSettingsSheet.kt index 2fb61b7..5f730f0 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxSettingsSheet.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxSettingsSheet.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TimePicker @@ -84,6 +85,17 @@ fun InboxSettingsSheet(state: InboxUiState, viewModel: InboxViewModel, onDismiss fontWeight = FontWeight.SemiBold, ) + // Review horizon (SV-005) + SectionLabel(stringResource(R.string.inbox_settings_horizon)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HorizonChoiceChip(stringResource(R.string.inbox_horizon_today)) { viewModel.chooseHorizon(HorizonOption.TODAY) } + HorizonChoiceChip(stringResource(R.string.inbox_horizon_this_week)) { viewModel.chooseHorizon(HorizonOption.THIS_WEEK) } + HorizonChoiceChip(stringResource(R.string.inbox_horizon_last_30_days)) { + viewModel.chooseHorizon(HorizonOption.LAST_30_DAYS) + } + HorizonChoiceChip(stringResource(R.string.inbox_horizon_everything)) { viewModel.chooseHorizon(HorizonOption.EVERYTHING) } + } + // Working days SectionLabel(stringResource(R.string.inbox_settings_work_days)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -124,8 +136,8 @@ fun InboxSettingsSheet(state: InboxUiState, viewModel: InboxViewModel, onDismiss Stepper( label = stringResource(R.string.inbox_settings_min_gap), value = stringResource(R.string.inbox_minutes_value, config.minGapMinutes), - onDecrease = { viewModel.setMinGapMinutes((config.minGapMinutes - 5).coerceAtLeast(1)) }, - onIncrease = { viewModel.setMinGapMinutes(config.minGapMinutes + 5) }, + onDecrease = { viewModel.setMinGapMinutes((config.minGapMinutes - MIN_GAP_STEP_MINUTES).coerceAtLeast(MIN_GAP_MINUTES)) }, + onIncrease = { viewModel.setMinGapMinutes(config.minGapMinutes + MIN_GAP_STEP_MINUTES) }, decreaseCd = stringResource(R.string.inbox_settings_decrease), increaseCd = stringResource(R.string.inbox_settings_increase), ) @@ -134,8 +146,16 @@ fun InboxSettingsSheet(state: InboxUiState, viewModel: InboxViewModel, onDismiss Stepper( label = stringResource(R.string.inbox_settings_max_duration), value = stringResource(R.string.inbox_hours_value, config.maxDurationHours), - onDecrease = { viewModel.setMaxDurationHours((config.maxDurationHours - 1).coerceAtLeast(1)) }, - onIncrease = { viewModel.setMaxDurationHours((config.maxDurationHours + 1).coerceAtMost(24)) }, + onDecrease = { + viewModel.setMaxDurationHours( + (config.maxDurationHours - MIN_DURATION_HOURS).coerceAtLeast(MIN_DURATION_HOURS), + ) + }, + onIncrease = { + viewModel.setMaxDurationHours( + (config.maxDurationHours + MIN_DURATION_HOURS).coerceAtMost(MAX_DURATION_HOURS), + ) + }, decreaseCd = stringResource(R.string.inbox_settings_decrease), increaseCd = stringResource(R.string.inbox_settings_increase), ) @@ -192,6 +212,11 @@ fun InboxSettingsSheet(state: InboxUiState, viewModel: InboxViewModel, onDismiss private enum class WorkField { START, END } +@Composable +private fun HorizonChoiceChip(label: String, onClick: () -> Unit) { + SuggestionChip(onClick = onClick, label = { Text(label) }) +} + @Composable private fun SectionLabel(text: String) { Text( @@ -236,14 +261,14 @@ private fun CheckToggle(label: String, checked: Boolean, onChange: (Boolean) -> @Composable private fun WorkTimePickerDialog(initialMinute: Int, onDismiss: () -> Unit, onConfirm: (Int) -> Unit) { val state = rememberTimePickerState( - initialHour = (initialMinute / 60).coerceIn(0, 23), - initialMinute = initialMinute % 60, + initialHour = (initialMinute / MINUTES_PER_HOUR).coerceIn(MIN_HOUR, MAX_HOUR), + initialMinute = initialMinute % MINUTES_PER_HOUR, is24Hour = true, ) AlertDialog( onDismissRequest = onDismiss, confirmButton = { - Button(onClick = { onConfirm(state.hour * 60 + state.minute) }) { + Button(onClick = { onConfirm(state.hour * MINUTES_PER_HOUR + state.minute) }) { Text(stringResource(R.string.inbox_settings_done)) } }, @@ -255,8 +280,18 @@ private fun WorkTimePickerDialog(initialMinute: Int, onDismiss: () -> Unit, onCo } private fun minuteText(minuteOfDay: Int): String { - val clamped = minuteOfDay.coerceIn(0, 1440) - val h = clamped / 60 - val m = clamped % 60 + val clamped = minuteOfDay.coerceIn(MINUTE_OF_DAY_START, MINUTE_OF_DAY_END) + val h = clamped / MINUTES_PER_HOUR + val m = clamped % MINUTES_PER_HOUR return "%02d:%02d".format(h, m) } + +private const val MIN_GAP_STEP_MINUTES = 5 +private const val MIN_GAP_MINUTES = 1 +private const val MIN_DURATION_HOURS = 1 +private const val MAX_DURATION_HOURS = 24 +private const val MINUTES_PER_HOUR = 60 +private const val MIN_HOUR = 0 +private const val MAX_HOUR = 23 +private const val MINUTE_OF_DAY_START = 0 +private const val MINUTE_OF_DAY_END = 1440 diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxViewModel.kt index ae427d8..6fc3a70 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxViewModel.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxViewModel.kt @@ -17,12 +17,14 @@ import dev.tricked.solidverdant.data.model.Project import dev.tricked.solidverdant.data.model.Tag import dev.tricked.solidverdant.data.model.Task import dev.tricked.solidverdant.data.model.TimeEntry -import dev.tricked.solidverdant.data.repository.AuthRepository import dev.tricked.solidverdant.data.repository.TimeEntryRepository import dev.tricked.solidverdant.domain.inbox.InboxAnalyzer import dev.tricked.solidverdant.domain.inbox.InboxIssue import dev.tricked.solidverdant.domain.inbox.InboxSettings import dev.tricked.solidverdant.domain.inbox.InboxSettingsDataStore +import dev.tricked.solidverdant.domain.inbox.resolveHorizonStartMs +import dev.tricked.solidverdant.domain.time.TemporalPolicy +import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider import dev.tricked.solidverdant.sync.SyncTrigger import dev.tricked.solidverdant.util.Clock import kotlinx.coroutines.Dispatchers @@ -37,9 +39,11 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import timber.log.Timber import java.time.DayOfWeek +import java.time.LocalDate import java.time.ZoneId import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -56,19 +60,20 @@ import javax.inject.Inject @HiltViewModel class InboxViewModel @Inject constructor( private val timeEntryRepository: TimeEntryRepository, - private val authRepository: AuthRepository, private val authDataStore: AuthDataStore, private val settingsDataStore: SettingsDataStore, private val inboxSettingsDataStore: InboxSettingsDataStore, private val dismissalDao: InboxDismissalDao, private val syncTrigger: SyncTrigger, private val clock: Clock, + private val temporalPolicyProvider: TemporalPolicyProvider, ) : ViewModel() { private data class OrgContext(val organizationId: String, val memberId: String, val userId: String, val preventOverlap: Boolean) private data class InboxData( val entries: List, + val conflicts: List, val projects: List, val tasks: List, val tags: List, @@ -77,10 +82,15 @@ class InboxViewModel @Inject constructor( val dismissals: List, ) - private val zone: ZoneId = ZoneId.systemDefault() + // Account temporal-policy zone, used for gap-analysis day/window boundaries and shown-in-zone + // formatting downstream. Seeded synchronously from the provider's cached read (first-frame + // correct), then kept current by the collector in init. Provider owns the device-zone fallback. + @Volatile + private var currentPolicy: TemporalPolicy = runBlocking { temporalPolicyProvider.current() } + private val zone: ZoneId get() = currentPolicy.zone private val retentionMs = TimeUnit.DAYS.toMillis(InboxAnalyzer.DISMISSAL_RETENTION_DAYS) - private val _uiState = MutableStateFlow(InboxUiState()) + private val _uiState = MutableStateFlow(InboxUiState(zone = currentPolicy.zone)) val uiState: StateFlow = _uiState.asStateFlow() @Volatile @@ -101,6 +111,12 @@ class InboxViewModel @Inject constructor( }.distinctUntilChanged() init { + viewModelScope.launch { + temporalPolicyProvider.policy.collect { policy -> + currentPolicy = policy + _uiState.update { it.copy(zone = policy.zone) } + } + } viewModelScope.launch { observeInbox() } } @@ -113,7 +129,10 @@ class InboxViewModel @Inject constructor( } else { maybeInitialRefresh(ctx) combine( - timeEntryRepository.observeTimeEntries(ctx.organizationId), + combine( + timeEntryRepository.observeTimeEntries(ctx.organizationId), + timeEntryRepository.observeConflicts(ctx.organizationId), + ) { entries, conflicts -> entries to conflicts }, timeEntryRepository.observeProjects(ctx.organizationId), timeEntryRepository.observeTasks(ctx.organizationId), timeEntryRepository.observeTags(ctx.organizationId), @@ -124,10 +143,12 @@ class InboxViewModel @Inject constructor( ) { settings, longTimerHours, dismissals -> Triple(settings, longTimerHours, dismissals) }, - ) { entries, projects, tasks, tags, config -> + ) { entryData, projects, tasks, tags, config -> + val (entries, conflicts) = entryData val (settings, longTimerHours, dismissals) = config ctx to InboxData( entries = entries, + conflicts = conflicts, projects = projects.filterNot { it.isArchived }, tasks = tasks.filterNot { it.isDone }, tags = tags, @@ -151,7 +172,25 @@ class InboxViewModel @Inject constructor( .filter { now - it.dismissedAtMs <= retentionMs } .map { it.issueKey } .toSet() - Triple(now, config, InboxAnalyzer.analyze(data.entries, config, activeDismissed, now, zone)) + val horizonStartMs = + resolveHorizonStartMs(data.settings.horizonChosen, data.settings.horizonStartMs, now, zone) + // Conflicts below are DB-derived and always surfaced (not subject to the horizon). + val analyzerIssues = InboxAnalyzer.analyze(data.entries, config, activeDismissed, now, zone, horizonStartMs) + val conflictIssues = data.conflicts.map { conflict -> + val startMs = parseEpochMillis(conflict.local.start) ?: now + val endMs = conflict.local.end?.let(::parseEpochMillis) ?: now + InboxIssue( + key = "conflict:${conflict.local.id}", + type = dev.tricked.solidverdant.domain.inbox.InboxIssueType.CONFLICT, + startMs = startMs, + endMs = maxOf(startMs, endMs), + primaryEntry = conflict.local, + conflictServer = conflict.server, + conflictServerDeleted = conflict.serverDeleted, + conflictLocalDeleted = conflict.localDeleted, + ) + } + Triple(now, config, conflictIssues + analyzerIssues) } pruneExpiredDismissals(data.dismissals, now) _uiState.update { @@ -159,12 +198,14 @@ class InboxViewModel @Inject constructor( isLoading = false, organizationId = ctx.organizationId, issues = issues, - hasEntries = data.entries.isNotEmpty(), + hasEntries = data.entries.isNotEmpty() || data.conflicts.isNotEmpty(), config = config, preventOverlap = ctx.preventOverlap, projects = data.projects, tasks = data.tasks, tags = data.tags, + horizonChosen = data.settings.horizonChosen, + horizonStartMs = data.settings.horizonStartMs, ) } } @@ -191,6 +232,7 @@ class InboxViewModel @Inject constructor( /** Persist a dismissal for [issue]; the analyzer immediately drops it from the list. */ fun dismiss(issue: InboxIssue) { + if (issue.type == dev.tricked.solidverdant.domain.inbox.InboxIssueType.CONFLICT) return val ctx = context ?: return viewModelScope.launch { dismissalDao.upsert( @@ -204,6 +246,38 @@ class InboxViewModel @Inject constructor( } } + /** + * SV-005 T4.3: batch-dismiss every dismissible issue in [issues] (a day group) in one write. + * CONFLICT issues are skipped — they are pinned and never dismissed. No single [pendingUndoKey] + * is set for a batch; bulk undo is out of scope (individual undo still applies per card). + */ + fun dismissDay(issues: List) { + val ctx = context ?: return + val now = clock.nowMs() + val dismissals = issues + .filterNot { it.type == dev.tricked.solidverdant.domain.inbox.InboxIssueType.CONFLICT } + .map { issue -> + InboxDismissalEntity( + issueKey = issue.key, + organizationId = ctx.organizationId, + dismissedAtMs = now, + ) + } + if (dismissals.isEmpty()) return + viewModelScope.launch { dismissalDao.upsertAll(dismissals) } + } + + /** + * SV-005 T4.3: durable "clear everything before this day" — moves the horizon forward to + * [dayStartMs] (start-of-day in the account zone, computed by the pane). Unlike dismissals this + * does not create per-issue rows and is not subject to the 45-day dismissal-retention pruning, so + * the older issues stay gone; it is reversible from the settings horizon picker. + */ + fun dismissBefore(dayStartMs: Long) { + context ?: return + viewModelScope.launch { inboxSettingsDataStore.setHorizonStart(dayStartMs) } + } + /** Undo the most recent dismissal (before its retention silently expires). */ fun undoDismiss(key: String) { viewModelScope.launch { @@ -224,6 +298,43 @@ class InboxViewModel @Inject constructor( _uiState.update { it.copy(refreshError = false) } } + fun keepMine(issue: InboxIssue) { + if (issue.type != dev.tricked.solidverdant.domain.inbox.InboxIssueType.CONFLICT) return + context?.let { ctx -> + issue.primaryEntry?.id?.let { entryId -> + viewModelScope.launch { + runCatching { timeEntryRepository.resolveKeepMine(entryId, ctx.memberId) } + .onSuccess { resolved -> + if (resolved) { + syncTrigger.requestSync() + } else { + _uiState.update { it.copy(actionError = InboxActionError.RESOLVE_FAILED) } + } + } + .onFailure { + Timber.w(it, "Failed to keep local conflict version") + _uiState.update { it.copy(actionError = InboxActionError.RESOLVE_FAILED) } + } + } + } + } + } + + fun keepTheirs(issue: InboxIssue) { + if (issue.type != dev.tricked.solidverdant.domain.inbox.InboxIssueType.CONFLICT) return + val entryId = issue.primaryEntry?.id ?: return + viewModelScope.launch { + runCatching { timeEntryRepository.resolveKeepTheirs(entryId) } + .onSuccess { resolved -> + if (!resolved) _uiState.update { it.copy(actionError = InboxActionError.RESOLVE_FAILED) } + } + .onFailure { + Timber.w(it, "Failed to keep server conflict version") + _uiState.update { it.copy(actionError = InboxActionError.RESOLVE_FAILED) } + } + } + } + /** * Apply an edit to an existing entry (missing metadata / long / overlap quick-fix). Optimistic * local write + outbox enqueue via the shared repository, so it works offline. @@ -313,6 +424,27 @@ class InboxViewModel @Inject constructor( // ---- Config editing ---- + /** + * SV-005: record the user's horizon choice, computing the lower bound in the account zone and + * week-start. Called from the first-run picker and the settings sheet. Reuses the same [clock] + * "now" the analyze pipeline uses; EVERYTHING clears the bound (persisted null). + */ + fun chooseHorizon(option: HorizonOption) { + val policy = currentPolicy + val startMs: Long? = when (option) { + HorizonOption.TODAY -> + LocalDate.now(policy.zone).atStartOfDay(policy.zone).toInstant().toEpochMilli() + HorizonOption.THIS_WEEK -> { + val today = LocalDate.now(policy.zone) + val daysBack = ((today.dayOfWeek.value - policy.firstDayOfWeek.value) + DAYS_PER_WEEK) % DAYS_PER_WEEK + today.minusDays(daysBack.toLong()).atStartOfDay(policy.zone).toInstant().toEpochMilli() + } + HorizonOption.LAST_30_DAYS -> clock.nowMs() - TimeUnit.DAYS.toMillis(LAST_N_DAYS.toLong()) + HorizonOption.EVERYTHING -> null + } + viewModelScope.launch { inboxSettingsDataStore.setHorizonStart(startMs) } + } + fun setCheckEnabled(check: InboxSettingsDataStore.InboxCheck, enabled: Boolean) { viewModelScope.launch { inboxSettingsDataStore.setCheckEnabled(check, enabled) } } @@ -323,7 +455,10 @@ class InboxViewModel @Inject constructor( fun setWorkWindow(startMinute: Int, endMinute: Int) { viewModelScope.launch { - if (startMinute in 0..1440 && endMinute in 0..1440 && endMinute > startMinute) { + if (startMinute in MINUTE_OF_DAY_START..MINUTE_OF_DAY_END && + endMinute in MINUTE_OF_DAY_START..MINUTE_OF_DAY_END && + endMinute > startMinute + ) { inboxSettingsDataStore.setWorkWindow(startMinute, endMinute) } } @@ -331,13 +466,13 @@ class InboxViewModel @Inject constructor( fun setMinGapMinutes(minutes: Int) { viewModelScope.launch { - if (minutes in 1..24 * 60) inboxSettingsDataStore.setMinGapMinutes(minutes) + if (minutes in MIN_GAP_MINUTES..MAX_GAP_MINUTES) inboxSettingsDataStore.setMinGapMinutes(minutes) } } fun setMaxDurationHours(hours: Int) { viewModelScope.launch { - if (hours in 1..24) settingsDataStore.setLongTimerHours(hours) + if (hours in MIN_DURATION_HOURS..MAX_DURATION_HOURS) settingsDataStore.setLongTimerHours(hours) } } @@ -348,4 +483,17 @@ class InboxViewModel @Inject constructor( expired.forEach { runCatching { dismissalDao.deleteByKey(it.issueKey) } } } } + + private fun parseEpochMillis(value: String): Long? = runCatching { + java.time.OffsetDateTime.parse(value).toInstant().toEpochMilli() + }.getOrNull() } + +private const val MINUTE_OF_DAY_START = 0 +private const val MINUTE_OF_DAY_END = 1440 +private const val MIN_GAP_MINUTES = 1 +private const val MAX_GAP_MINUTES = 24 * 60 +private const val MIN_DURATION_HOURS = 1 +private const val MAX_DURATION_HOURS = 24 +private const val DAYS_PER_WEEK = 7 +private const val LAST_N_DAYS = 30 diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReminderSettingsScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReminderSettingsScreen.kt index e6b1833..d7d760a 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReminderSettingsScreen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReminderSettingsScreen.kt @@ -70,7 +70,7 @@ fun ReminderSettingsScreen(onBack: () -> Unit = {}) { var showTimePicker by remember { mutableStateOf(false) } val timeLabel = remember(state.minuteOfDay) { - LocalTime.of(state.minuteOfDay / 60, state.minuteOfDay % 60) + LocalTime.of(state.minuteOfDay / MINUTES_PER_HOUR, state.minuteOfDay % MINUTES_PER_HOUR) .format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)) } @@ -170,6 +170,8 @@ fun ReminderSettingsScreen(onBack: () -> Unit = {}) { } } +private const val MINUTES_PER_HOUR = 60 + @Composable private fun ToggleRow(title: String, subtitle: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { Row( diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReminderSettingsViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReminderSettingsViewModel.kt index 641106f..c3cb261 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReminderSettingsViewModel.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReminderSettingsViewModel.kt @@ -48,7 +48,7 @@ class ReminderSettingsViewModel @Inject constructor(private val settings: Settin endOfDayReviewEnabled = review, minuteOfDay = minute, ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), State()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STATE_STOP_TIMEOUT_MS), State()) fun setReminderEnabled(enabled: Boolean) = viewModelScope.launch { settings.setReminderEnabled(enabled) @@ -61,8 +61,13 @@ class ReminderSettingsViewModel @Inject constructor(private val settings: Settin } fun setReminderTime(hour: Int, minute: Int) = viewModelScope.launch { - val minuteOfDay = (hour * 60 + minute).coerceIn(0, 1439) + val minuteOfDay = (hour * MINUTES_PER_HOUR + minute).coerceIn(MINUTE_OF_DAY_START, MAX_MINUTE_OF_DAY) settings.setReminderMinuteOfDay(minuteOfDay) scheduler.reschedule() } } + +private const val STATE_STOP_TIMEOUT_MS = 5_000L +private const val MINUTES_PER_HOUR = 60 +private const val MINUTE_OF_DAY_START = 0 +private const val MAX_MINUTE_OF_DAY = 1439 diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewDayPane.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewDayPane.kt index daa6acd..0e4bb6f 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewDayPane.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewDayPane.kt @@ -78,7 +78,7 @@ fun ReviewDayPane() { LaunchedEffect(message) { if (message != null) { - delay(3500) + delay(MESSAGE_DISMISS_DELAY_MS) viewModel.consumeMessage() } } @@ -164,7 +164,7 @@ internal fun ReviewContent( ProgressHeader(state) ReviewItemCard( item = current, - state = state, + zone = state.zone, onStop = onStop, onKeepRunning = onKeepRunning, onAdjustEnd = onAdjustEnd, @@ -277,7 +277,7 @@ private fun ProgressHeader(state: ReviewDayUiState) { @Composable private fun ReviewItemCard( item: ReviewItem, - state: ReviewDayUiState, + zone: ZoneId, onStop: () -> Unit, onKeepRunning: () -> Unit, onAdjustEnd: () -> Unit, @@ -297,7 +297,7 @@ private fun ReviewItemCard( Text(stringResource(R.string.review_item_running_title), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.review_item_running_body), style = MaterialTheme.typography.bodyMedium) EntryDescription(item.description) - item.startIso?.let { StartedAt(it) } + item.startIso?.let { StartedAt(it, zone) } FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = onStop, modifier = Modifier.heightIn(min = 48.dp)) { Text(stringResource(R.string.review_action_stop)) @@ -331,7 +331,7 @@ private fun ReviewItemCard( Text(stringResource(R.string.review_item_uncategorized_title), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.review_item_uncategorized_body), style = MaterialTheme.typography.bodyMedium) EntryDescription(item.description) - TimeRange(item.startIso, item.endIso) + TimeRange(item.startIso, item.endIso, zone) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { onAssign(item) }, modifier = Modifier.heightIn(min = 48.dp)) { Text(stringResource(R.string.review_action_assign_project)) @@ -361,8 +361,8 @@ private fun EntryDescription(description: String?) { } @Composable -private fun StartedAt(startIso: String) { - val time = remember(startIso) { formatClock(startIso) } +private fun StartedAt(startIso: String, zone: ZoneId) { + val time = remember(startIso, zone) { formatClock(startIso, zone) } if (time != null) { Text( text = stringResource(R.string.review_started_at, time), @@ -373,9 +373,9 @@ private fun StartedAt(startIso: String) { } @Composable -private fun TimeRange(startIso: String?, endIso: String?) { - val start = remember(startIso) { startIso?.let { formatClock(it) } } - val end = remember(endIso) { endIso?.let { formatClock(it) } } +private fun TimeRange(startIso: String?, endIso: String?, zone: ZoneId) { + val start = remember(startIso, zone) { startIso?.let { formatClock(it, zone) } } + val end = remember(endIso, zone) { endIso?.let { formatClock(it, zone) } } if (start != null && end != null) { Text( text = stringResource(R.string.review_time_range, start, end), @@ -463,8 +463,8 @@ private fun MessageBanner(text: String, modifier: Modifier = Modifier) { @Composable private fun formatDuration(seconds: Long): String { - val hours = seconds / 3600 - val minutes = (seconds % 3600) / 60 + val hours = seconds / SECONDS_PER_HOUR + val minutes = (seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE return if (hours > 0) { stringResource(R.string.review_duration_hours_minutes, hours, minutes) } else { @@ -472,11 +472,15 @@ private fun formatDuration(seconds: Long): String { } } +private const val MESSAGE_DISMISS_DELAY_MS = 3500L +private const val SECONDS_PER_HOUR = 3600 +private const val SECONDS_PER_MINUTE = 60 + private val CLOCK_FORMAT: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) /** Format an ISO instant string as a local wall-clock time, or null if it cannot be parsed. */ -private fun formatClock(iso: String): String? = runCatching { - OffsetDateTime.parse(iso).atZoneSameInstant(ZoneId.systemDefault()).toLocalTime().format(CLOCK_FORMAT) +private fun formatClock(iso: String, zone: ZoneId): String? = runCatching { + OffsetDateTime.parse(iso).atZoneSameInstant(zone).toLocalTime().format(CLOCK_FORMAT) }.recoverCatching { - java.time.Instant.parse(iso).atZone(ZoneId.systemDefault()).toLocalTime().format(CLOCK_FORMAT) + java.time.Instant.parse(iso).atZone(zone).toLocalTime().format(CLOCK_FORMAT) }.getOrNull() diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewDayViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewDayViewModel.kt index 8620143..d827cb9 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewDayViewModel.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewDayViewModel.kt @@ -13,6 +13,8 @@ import dev.tricked.solidverdant.R import dev.tricked.solidverdant.data.local.SettingsDataStore import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.data.repository.TimeEntryRepository +import dev.tricked.solidverdant.domain.time.TemporalPolicy +import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider import dev.tricked.solidverdant.reminder.currentOrganizationIdOrNull import dev.tricked.solidverdant.sync.SyncScheduler import dev.tricked.solidverdant.util.Clock @@ -27,6 +29,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import java.time.Instant import java.time.LocalDate import java.time.OffsetDateTime @@ -50,9 +53,14 @@ class ReviewDayViewModel @Inject constructor( private val settings: SettingsDataStore, private val syncScheduler: SyncScheduler, private val clock: Clock, + private val temporalPolicyProvider: TemporalPolicyProvider, ) : ViewModel() { - private val zone: ZoneId get() = ZoneId.systemDefault() + // Account temporal-policy zone. Seeded synchronously (first-frame correct) and kept current by + // the uiState pipeline, which reads the policy flow directly. Provider owns the device fallback. + @Volatile + private var currentPolicy: TemporalPolicy = runBlocking { temporalPolicyProvider.current() } + private val zone: ZoneId get() = currentPolicy.zone private val organizationId = MutableStateFlow(settings.currentOrganizationIdOrNull()) private val handledIds = MutableStateFlow>(emptySet()) @@ -74,7 +82,10 @@ class ReviewDayViewModel @Inject constructor( } @OptIn(ExperimentalCoroutinesApi::class) - val uiState: StateFlow = organizationId + val uiState: StateFlow = combine(organizationId, temporalPolicyProvider.policy) { orgId, policy -> + currentPolicy = policy + orgId + } .flatMapLatest { orgId -> if (orgId.isNullOrBlank()) { flowOf( @@ -82,6 +93,7 @@ class ReviewDayViewModel @Inject constructor( loading = false, hasOrganization = false, dateEpochDay = LocalDate.now(zone).toEpochDay(), + zone = zone, ), ) } else { @@ -95,7 +107,7 @@ class ReviewDayViewModel @Inject constructor( } } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), ReviewDayUiState(loading = true)) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STATE_STOP_TIMEOUT_MS), ReviewDayUiState(loading = true)) private fun buildState( entries: List, @@ -165,6 +177,7 @@ class ReviewDayViewModel @Inject constructor( return ReviewDayUiState( loading = false, hasOrganization = true, + zone = zone, dateEpochDay = today.toEpochDay(), totalTrackedSeconds = total, billableSeconds = billable, @@ -315,3 +328,5 @@ class ReviewDayViewModel @Inject constructor( } } } + +private const val STATE_STOP_TIMEOUT_MS = 5_000L diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewScreen.kt index f6231af..e1f80e4 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewScreen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewScreen.kt @@ -35,12 +35,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.tricked.solidverdant.R -/** - * Segments of the Review tab. [Inbox] surfaces entries needing attention (Time Inbox); [ReviewDay] - * surfaces the compact end-of-day review for a single day. - */ -enum class ReviewSegment { Inbox, ReviewDay } - /** * Container for the review-loop home (the "Review" bottom-nav tab). Hosts a segmented control that * switches between [InboxPane] and [ReviewDayPane], and an overflow menu with entry points to the diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewSegment.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewSegment.kt new file mode 100644 index 0000000..3eb55d1 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewSegment.kt @@ -0,0 +1,10 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.review + +/** Segments of the Review tab. */ +enum class ReviewSegment { Inbox, ReviewDay } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewStepMachine.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewStepMachine.kt index 8740bde..df7efc2 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewStepMachine.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/ReviewStepMachine.kt @@ -7,6 +7,7 @@ package dev.tricked.solidverdant.ui.review import dev.tricked.solidverdant.data.model.TimeEntry +import java.time.ZoneId /** * The kind of correction a single review step represents. Ordered by urgency: a still-running timer @@ -90,11 +91,13 @@ data class ReviewDayUiState( val runningEntry: TimeEntry? = null, val uncategorizedById: Map = emptyMap(), val projects: List = emptyList(), + /** Account temporal-policy zone for "today" and shown-in-zone clock formatting. */ + val zone: ZoneId = ZoneId.systemDefault(), ) { /** Billable share of tracked time, 0..100, or null when nothing is tracked. */ val billablePercent: Int? get() = if (totalTrackedSeconds > 0) { - ((billableSeconds * 100) / totalTrackedSeconds).toInt().coerceIn(0, 100) + ((billableSeconds * PERCENT_SCALE) / totalTrackedSeconds).toInt().coerceIn(0, PERCENT_SCALE.toInt()) } else { null } @@ -109,5 +112,7 @@ data class ReviewDayUiState( val nothingTracked: Boolean get() = entryCount == 0 && items.isEmpty() } +private const val PERCENT_SCALE = 100L + /** Minimal project projection for the "assign project" picker (id, display name, color hex). */ data class ReviewProject(val id: String, val name: String, val color: String) diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatRange.kt b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatRange.kt index 35483c5..a46f4c9 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatRange.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatRange.kt @@ -6,45 +6,59 @@ package dev.tricked.solidverdant.ui.statistics +import java.time.DayOfWeek import java.time.LocalDate import java.time.temporal.ChronoUnit import java.time.temporal.WeekFields +private const val LAST_7_DAYS_OFFSET = 6L +private const val MONTHLY_GRANULARITY_DAYS = 31L + +// Minimal-days matching WeekFields.ISO so a Monday [firstDayOfWeek] reproduces ISO week starts +// exactly; only the first-day-of-week affects the week-START computation used here. +private const val WEEK_MIN_DAYS = 4 + +/** First local day (per [firstDayOfWeek]) of the week containing [date]. */ +private fun weekStart(date: LocalDate, firstDayOfWeek: DayOfWeek): LocalDate = + date.with(WeekFields.of(firstDayOfWeek, WEEK_MIN_DAYS).dayOfWeek(), 1) + sealed interface StatRange { - fun resolve(today: LocalDate): ClosedRange + /** + * Resolves this range to an inclusive [today]-relative window. Week-based variants start the + * week on [firstDayOfWeek] (from the account [dev.tricked.solidverdant.domain.time.TemporalPolicy]); + * non-week variants ignore it. + */ + fun resolve(today: LocalDate, firstDayOfWeek: DayOfWeek): ClosedRange data object Today : StatRange { - override fun resolve(today: LocalDate) = today..today + override fun resolve(today: LocalDate, firstDayOfWeek: DayOfWeek) = today..today } data object Yesterday : StatRange { - override fun resolve(today: LocalDate) = today.minusDays(1)..today.minusDays(1) + override fun resolve(today: LocalDate, firstDayOfWeek: DayOfWeek) = today.minusDays(1)..today.minusDays(1) } data object Last7Days : StatRange { - override fun resolve(today: LocalDate) = today.minusDays(6)..today + override fun resolve(today: LocalDate, firstDayOfWeek: DayOfWeek) = today.minusDays(LAST_7_DAYS_OFFSET)..today } data object LastWeek : StatRange { - override fun resolve(today: LocalDate): ClosedRange { - val thisMonday = today.with(WeekFields.ISO.dayOfWeek(), 1) - return thisMonday.minusWeeks(1)..thisMonday.minusDays(1) + override fun resolve(today: LocalDate, firstDayOfWeek: DayOfWeek): ClosedRange { + val thisWeekStart = weekStart(today, firstDayOfWeek) + return thisWeekStart.minusWeeks(1)..thisWeekStart.minusDays(1) } } data object ThisWeek : StatRange { - override fun resolve(today: LocalDate): ClosedRange { - val monday = today.with(WeekFields.ISO.dayOfWeek(), 1) - return monday..today - } + override fun resolve(today: LocalDate, firstDayOfWeek: DayOfWeek): ClosedRange = weekStart(today, firstDayOfWeek)..today } data object ThisMonth : StatRange { - override fun resolve(today: LocalDate): ClosedRange = today.withDayOfMonth(1)..today + override fun resolve(today: LocalDate, firstDayOfWeek: DayOfWeek): ClosedRange = today.withDayOfMonth(1)..today } data object PreviousMonth : StatRange { - override fun resolve(today: LocalDate): ClosedRange { + override fun resolve(today: LocalDate, firstDayOfWeek: DayOfWeek): ClosedRange { val month = today.withDayOfMonth(1).minusMonths(1) return month..month.withDayOfMonth(month.lengthOfMonth()) } @@ -54,11 +68,11 @@ sealed interface StatRange { init { require(!end.isBefore(start)) { "Custom range end must not precede start" } } - override fun resolve(today: LocalDate): ClosedRange = start..end + override fun resolve(today: LocalDate, firstDayOfWeek: DayOfWeek): ClosedRange = start..end } } fun granularityFor(range: ClosedRange): TrendGranularity { val days = ChronoUnit.DAYS.between(range.start, range.endInclusive) + 1 - return if (days <= 31) TrendGranularity.DAY else TrendGranularity.WEEK + return if (days <= MONTHLY_GRANULARITY_DAYS) TrendGranularity.DAY else TrendGranularity.WEEK } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsAggregator.kt b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsAggregator.kt index 4faf7a8..078b420 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsAggregator.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsAggregator.kt @@ -9,6 +9,7 @@ package dev.tricked.solidverdant.ui.statistics import dev.tricked.solidverdant.data.model.Project import dev.tricked.solidverdant.data.model.Task import dev.tricked.solidverdant.data.model.TimeEntry +import java.time.DayOfWeek import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -32,6 +33,28 @@ data class StatisticsSummary( val trend: List, ) +/** Fraction at/above which a project is flagged as approaching its estimate (but not yet over). */ +private const val NEAR_THRESHOLD = 0.9f + +/** + * Budget/progress for one project against its Solidtime estimate. [spentSeconds] and + * [estimatedSeconds] are the SERVER'S authoritative project-level totals (seconds) — not recomputed + * from local entries — so this is a reporting aid consistent across the whole organization. Derived, + * display-only: nothing here is persisted. + */ +data class EstimateProgress(val id: String, val name: String, val colorHex: String?, val estimatedSeconds: Int, val spentSeconds: Int) { + /** Seconds left against the estimate; negative when over budget. */ + val remainingSeconds: Int get() = estimatedSeconds - spentSeconds + + /** Spent as a fraction of the estimate; may exceed 1.0 when over budget. */ + val fraction: Float get() = if (estimatedSeconds <= 0) 0f else spentSeconds.toFloat() / estimatedSeconds + + val isOverBudget: Boolean get() = spentSeconds > estimatedSeconds + + /** Approaching (but not past) the estimate — an early warning, distinct from [isOverBudget]. */ + val isNearEstimate: Boolean get() = !isOverBudget && fraction >= NEAR_THRESHOLD +} + /** A single entry as surfaced in a drill-down list, clipped to the selected sub-range. */ data class DrillDownRow( val entryId: String, @@ -75,6 +98,47 @@ object StatisticsAggregator { } } + /** + * Budget/progress for every in-scope project that carries a positive Solidtime estimate. + * + * Uses the server's authoritative project-level [Project.spentTime] vs [Project.estimatedTime] + * (both seconds) rather than recomputing spent from local entries, so the numbers match the rest + * of the Solidtime org (roadmap #83/#84). Archived projects and any without a non-null, positive + * estimate are dropped. The active [filters] are honoured on the dimensions that apply to a whole + * project — the explicit project set, the project's client, and its billable flag — so the + * section's scope stays consistent with the by-project view above it; the entry-level task/tag + * dimensions have no project-level meaning here and are ignored. Sorted over-budget first (most + * urgent), then by consumed fraction descending, then by name for a stable order. + */ + fun projectEstimateProgress(projects: List, filters: StatFilters): List = projects.asSequence() + .filter { !it.isArchived } + .filter { (it.estimatedTime ?: 0) > 0 } + .filter { p -> + val projectOk = filters.projectIds.isEmpty() || p.id in filters.projectIds + val clientOk = filters.clientIds.isEmpty() || (p.clientId != null && p.clientId in filters.clientIds) + val billableOk = when (filters.billable) { + BillableFilter.All -> true + BillableFilter.Billable -> p.isBillable + BillableFilter.NonBillable -> !p.isBillable + } + projectOk && clientOk && billableOk + } + .map { p -> + EstimateProgress( + id = p.id, + name = p.name, + colorHex = p.color, + estimatedSeconds = p.estimatedTime ?: 0, + spentSeconds = p.spentTime, + ) + } + .sortedWith( + compareByDescending { it.isOverBudget } + .thenByDescending { it.fraction } + .thenBy { it.name }, + ) + .toList() + /** In-range contribution (seconds) of [e], or null when it does not overlap the window. */ fun clippedSeconds(e: TimeEntry, zone: ZoneId, rangeStart: LocalDate, rangeEnd: LocalDate): Long? = clippedDailyBreakdown(e, zone, rangeStart, rangeEnd)?.sumOf { it.second } @@ -125,6 +189,7 @@ object StatisticsAggregator { rangeEnd: LocalDate, zone: ZoneId, granularity: TrendGranularity, + firstDayOfWeek: DayOfWeek, ): StatisticsSummary { val projectById = projects.associateBy { it.id } @@ -158,7 +223,7 @@ object StatisticsAggregator { val days = ChronoUnit.DAYS.between(rangeStart, rangeEnd) + 1 val avgPerDay = if (days > 0) totalSeconds / days else totalSeconds - val trend = buildTrend(counted.flatMap { it.daily }, rangeStart, rangeEnd, granularity) + val trend = buildTrend(counted.flatMap { it.daily }, rangeStart, rangeEnd, granularity, firstDayOfWeek) return StatisticsSummary( totalSeconds = totalSeconds, @@ -182,6 +247,7 @@ object StatisticsAggregator { * still counts and attributes to the correct project/day. The returned per-day seconds sum to * the entry's total in-range contribution. */ + @Suppress("ReturnCount") private fun clippedDailyBreakdown( e: TimeEntry, zone: ZoneId, @@ -233,6 +299,7 @@ object StatisticsAggregator { rangeStart: LocalDate, rangeEnd: LocalDate, granularity: TrendGranularity, + firstDayOfWeek: DayOfWeek, ): List = when (granularity) { TrendGranularity.DAY -> { val byDay = rows.groupBy({ it.first }, { it.second }).mapValues { it.value.sum() } @@ -241,7 +308,10 @@ object StatisticsAggregator { .toList() } TrendGranularity.WEEK -> { - val wf = WeekFields.ISO + // Minimal-days pinned to ISO's 4 so a Monday firstDayOfWeek reproduces WeekFields.ISO + // byte-for-byte (same week-start grouping AND same W## week numbers); only the + // first-day-of-week shifts bucket boundaries for e.g. a Sunday-start account. + val wf = WeekFields.of(firstDayOfWeek, WEEK_MIN_DAYS) val byWeekStart = rows.groupBy( { it.first.with(wf.dayOfWeek(), 1) }, { it.second }, @@ -253,10 +323,16 @@ object StatisticsAggregator { val week = ws.get(wf.weekOfWeekBasedYear()) // Include the (week-based) year so labels don't collide across year // boundaries, e.g. W52 of 2025 vs W52 of 2026 in a multi-year range. - val yy = ws.get(wf.weekBasedYear()) % 100 + val yy = ws.get(wf.weekBasedYear()) % PERCENT_YEAR_BASE TrendBucket("W$week '%02d".format(yy), ws, byWeekStart[ws] ?: 0L) } .toList() } } } + +private const val PERCENT_YEAR_BASE = 100 + +// Matches WeekFields.ISO.minimalDaysInFirstWeek so a Monday firstDayOfWeek reproduces ISO week +// numbering exactly; see the WEEK branch of buildTrend. +private const val WEEK_MIN_DAYS = 4 diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsComparison.kt b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsComparison.kt index 0120a5a..7092903 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsComparison.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsComparison.kt @@ -34,9 +34,11 @@ data class MetricDelta(val current: Long, val previous: Long) { * grow from (an empty previous period). Callers render null as "new"/no-percentage rather than * dividing by zero or reporting a misleading infinite jump. */ - fun percentChange(): Double? = if (previous == 0L) null else (current - previous) * 100.0 / previous + fun percentChange(): Double? = if (previous == 0L) null else (current - previous) * PERCENT_SCALE / previous } +private const val PERCENT_SCALE = 100.0 + /** How one project's tracked time moved between the two periods. */ data class ProjectChange(val projectId: String?, val projectName: String?, val colorHex: String, val current: Long, val previous: Long) { val delta: Long get() = current - previous diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsScreen.kt index 523dd30..8b914db 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsScreen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsScreen.kt @@ -81,7 +81,7 @@ private val DonutSize = 140.dp * pass a theme token instead (see [hexToColor]); this literal only backs the default for non-Compose * callers (the calendar) that cannot resolve a theme colour. */ -private val UnknownProjectColor = Color(0xFF9E9E9E) +private val UnknownProjectColor = Color.Gray /** * Parses a project colour hex into a [Color], falling back to [fallback] when the value is blank or @@ -97,8 +97,8 @@ fun hexToColor(hex: String, fallback: Color = UnknownProjectColor): Color = try fun formatDuration(seconds: Long): String { if (seconds <= 0) return "0m" - val h = seconds / 3600 - val m = (seconds % 3600) / 60 + val h = seconds / SECONDS_PER_HOUR + val m = (seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE return when { h > 0 -> "${h}h ${m.toString().padStart(2, '0')}m" else -> "${m}m" @@ -214,7 +214,7 @@ fun StatisticsScreen(viewModel: StatisticsViewModel = hiltViewModel()) { } Column(Modifier.fillMaxWidth()) { s.perProject.forEach { p -> - val pct = if (s.totalSeconds > 0) p.seconds * 100 / s.totalSeconds else 0 + val pct = if (s.totalSeconds > 0) p.seconds * PERCENT_SCALE_INT / s.totalSeconds else 0 ProjectLegendRow( projectName = p.projectName, colorHex = p.colorHex, @@ -227,6 +227,10 @@ fun StatisticsScreen(viewModel: StatisticsViewModel = hiltViewModel()) { } } + if (state.estimateProgress.isNotEmpty()) { + EstimatesCard(state.estimateProgress) + } + SectionCard(title = stringResource(R.string.stats_trend)) { InteractiveBarChart( bars = s.trend, @@ -234,7 +238,7 @@ fun StatisticsScreen(viewModel: StatisticsViewModel = hiltViewModel()) { onBarClick = { bucket -> val end = when (state.granularity) { TrendGranularity.DAY -> bucket.startDate - TrendGranularity.WEEK -> bucket.startDate.plusDays(6) + TrendGranularity.WEEK -> bucket.startDate.plusDays(WEEK_DAYS_MINUS_ONE) } viewModel.openTrendDrillDown(bucket.label, bucket.startDate, end) }, @@ -256,6 +260,11 @@ fun StatisticsScreen(viewModel: StatisticsViewModel = hiltViewModel()) { } } +private const val SECONDS_PER_HOUR = 3600 +private const val SECONDS_PER_MINUTE = 60 +private const val PERCENT_SCALE_INT = 100 +private const val WEEK_DAYS_MINUS_ONE = 6L + @Composable private fun ExportAction(exporting: Boolean, onExport: () -> Unit) { TextButton( @@ -300,6 +309,98 @@ private fun ProjectLegendRow(projectName: String, colorHex: String, valueText: S } } +/** + * "Estimates & progress": server-authoritative spent vs estimated time per project, remaining or + * overflow, and a consumed-fraction bar. Over-budget and near-estimate items are flagged with BOTH + * a colour and a text label (never colour alone). Rendered only when [items] is non-empty. + */ +@Composable +private fun EstimatesCard(items: List) { + SectionCard(title = stringResource(R.string.stats2_estimates_title)) { + val barFallback = MaterialTheme.colorScheme.primary + items.forEach { item -> + EstimateRow(item, barFallback) + } + } +} + +@Composable +private fun EstimateRow(item: EstimateProgress, defaultBarColor: Color) { + val swatchFallback = MaterialTheme.colorScheme.outline + val errorColor = MaterialTheme.colorScheme.error + val spent = item.spentSeconds.toLong() + val estimated = item.estimatedSeconds.toLong() + val spentOfText = stringResource(R.string.stats2_estimates_spent_of, formatDuration(spent), formatDuration(estimated)) + val statusText = if (item.isOverBudget) { + stringResource(R.string.stats2_estimates_over, formatDuration((-item.remainingSeconds).toLong())) + } else { + stringResource(R.string.stats2_estimates_remaining, formatDuration(item.remainingSeconds.toLong())) + } + val rowCd = if (item.isOverBudget) { + stringResource( + R.string.stats2_estimates_over_content_description, + item.name, + formatDuration((-item.remainingSeconds).toLong()), + ) + } else { + stringResource( + R.string.stats2_estimates_progress_content_description, + item.name, + formatDuration(spent), + formatDuration(estimated), + ) + } + val barColor = if (item.isOverBudget) errorColor else defaultBarColor + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { contentDescription = rowCd } + .padding(vertical = Dimens.Space4), + verticalArrangement = Arrangement.spacedBy(Dimens.Space4), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.Space8), + ) { + ProjectSwatch(hexToColor(item.colorHex.orEmpty(), swatchFallback)) + Text( + item.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + statusText, + style = MaterialTheme.typography.labelMedium, + color = if (item.isOverBudget) errorColor else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LinearProgressIndicator( + progress = { item.fraction.coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth(), + color = barColor, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimens.Space8), + ) { + Text( + spentOfText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (item.isNearEstimate) { + Text( + stringResource(R.string.stats2_estimates_near), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } +} + /** The single project colour dot used across statistics rows (legend, comparison, drill-down). */ @Composable internal fun ProjectSwatch(color: Color) { diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsViewModel.kt index 7ff29bd..0eedd23 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsViewModel.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsViewModel.kt @@ -7,6 +7,7 @@ package dev.tricked.solidverdant.ui.statistics import android.net.Uri +import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -17,8 +18,11 @@ import dev.tricked.solidverdant.data.local.db.MembershipEntity import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.data.repository.AuthRepository import dev.tricked.solidverdant.data.repository.TimeEntryRepository +import dev.tricked.solidverdant.domain.time.TemporalPolicy +import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -33,6 +37,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import timber.log.Timber import java.time.LocalDate @@ -40,12 +45,16 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import javax.inject.Inject +private const val REMOTE_PAGE_SIZE = 500 +private const val UI_STATE_STOP_TIMEOUT_MS = 5_000L + data class StatisticsUiState( val isLoading: Boolean = true, val range: StatRange = StatRange.ThisWeek, val filters: StatFilters = StatFilters(), val catalog: StatCatalog = StatCatalog(), val summary: StatisticsSummary = EMPTY_SUMMARY, + val estimateProgress: List = emptyList(), val comparison: PeriodComparison? = null, val filteredEntries: List = emptyList(), val rangeStart: LocalDate? = null, @@ -102,9 +111,44 @@ class StatisticsViewModel @Inject constructor( private val csvExporter: CsvExporter, private val authDataStore: AuthDataStore, private val catalogDao: CatalogDao, + private val temporalPolicyProvider: TemporalPolicyProvider, ) : ViewModel() { - private val zone: ZoneId = ZoneId.systemDefault() + // Latest account temporal policy (zone + first-day-of-week), kept for the non-reactive callers + // (remote fetch bounds, drill-down/export clipping, zone()). The reactive uiState pipeline reads + // the policy directly from the combined flow so it recomputes when the account changes; this + // volatile mirror only serves calls that fire outside that pipeline. Seeded synchronously from + // the provider's cached-auth read (a SharedPreferences lookup, no real I/O — see + // SettingsDataStore.observeCachedAuth) so zone()/remote-fetch have the real policy on first use, + // then kept current by the collector in init. No ZoneId.systemDefault() here: the provider owns + // the device-zone fallback. + @Volatile + private var currentPolicy: TemporalPolicy = runBlocking { temporalPolicyProvider.current() } + private val zone: ZoneId get() = currentPolicy.zone + + init { + viewModelScope.launch { + temporalPolicyProvider.policy.collect { currentPolicy = it } + } + } + + /** + * Cancels [viewModelScope] for unit tests. Production uses [ViewModel.clear], but that is + * internal in lifecycle-viewmodel 2.8.x and cannot be called from tests; a test that sets a test + * [Dispatchers.Main] must still tear down every Main-bound coroutine this ViewModel launched (the + * init policy collector and the [uiState] pipeline) before `Dispatchers.resetMain()`, otherwise a + * straggling continuation races the reset with "Main is used concurrently with setting it". + * + * This only *requests* cancellation (no join): the coroutines run on the test's Main dispatcher, + * so the caller must advance that dispatcher's scheduler to idle afterwards to actually run the + * cancellation to completion — joining here would block on a scheduler this method cannot drive. + * Not used in production. + */ + @VisibleForTesting + internal fun cancelScopeForTest() { + viewModelScope.coroutineContext[Job]?.cancel() + } + private val rangeFlow = MutableStateFlow(StatRange.ThisWeek) private val filtersFlow = MutableStateFlow(StatFilters()) private val refreshTrigger = MutableStateFlow(0) @@ -129,12 +173,25 @@ class StatisticsViewModel @Inject constructor( private data class RemoteEntries(val entries: List? = null, val isLoading: Boolean = false, val failed: Boolean = false) - private fun loadRemoteEntries(organizationId: String, memberId: String, range: ClosedRange): Flow = flow { + /** Off-main-thread result bundle for one uiState emission; destructured back on the collector. */ + private data class EstimateComputation( + val summary: StatisticsSummary, + val comparison: PeriodComparison?, + val filtered: List, + val estimates: List, + ) + + private fun loadRemoteEntries( + organizationId: String, + memberId: String, + range: ClosedRange, + zone: ZoneId, + ): Flow = flow { emit(RemoteEntries(isLoading = true)) val entries = mutableListOf() val start = range.start.atStartOfDay(zone).toInstant().toString() val end = range.endInclusive.plusDays(1).atStartOfDay(zone).toInstant().toString() - val pageSize = 500 + val pageSize = REMOTE_PAGE_SIZE var offset = 0 while (true) { val page = authRepository.getTimeEntries( @@ -203,18 +260,22 @@ class StatisticsViewModel @Inject constructor( timeEntryRepository.observeTags(orgId), ) { projects, clients, tasks, tags -> StatCatalog(projects, clients, tasks, tags) } - // Only the range and an explicit refresh trigger a server fetch; filters are applied - // locally to the already-fetched entries, so toggling a filter never re-downloads the - // range. combine memoizes on exactly these inputs. - combine(rangeFlow, refreshTrigger) { range, _ -> range } - .flatMapLatest { range -> - val resolved = range.resolve(LocalDate.now(zone)) + // Only the range, the account temporal policy and an explicit refresh trigger a + // server fetch; filters are applied locally to the already-fetched entries, so + // toggling a filter never re-downloads the range. combine memoizes on exactly these + // inputs, and a policy change (zone / week-start) re-resolves and re-fetches. + combine(rangeFlow, temporalPolicyProvider.policy, refreshTrigger) { range, policy, _ -> + range to policy + } + .flatMapLatest { (range, policy) -> + val zone = policy.zone + val resolved = range.resolve(LocalDate.now(zone), policy.firstDayOfWeek) val previous = previousPeriod(resolved) val fetchRange = previous.start..resolved.endInclusive combine( timeEntryRepository.observeTimeEntries(orgId), catalogFlow, - loadRemoteEntries(orgId, memberId, fetchRange), + loadRemoteEntries(orgId, memberId, fetchRange, zone), filtersFlow, ) { cachedEntries, catalog, remote, filters -> val entries = remote.entries ?: cachedEntries @@ -228,6 +289,7 @@ class StatisticsViewModel @Inject constructor( rangeEnd = resolved.endInclusive, zone = zone, granularity = granularityFor(resolved), + firstDayOfWeek = policy.firstDayOfWeek, ) val prior = StatisticsAggregator.compute( entries = filtered, @@ -236,10 +298,12 @@ class StatisticsViewModel @Inject constructor( rangeEnd = previous.endInclusive, zone = zone, granularity = granularityFor(previous), + firstDayOfWeek = policy.firstDayOfWeek, ) - Triple(current, computeComparison(current, prior, previous), filtered) + val estimates = StatisticsAggregator.projectEstimateProgress(catalog.projects, filters) + EstimateComputation(current, computeComparison(current, prior, previous), filtered, estimates) } - val (summary, comparison, filtered) = computed + val (summary, comparison, filtered, estimates) = computed val exportEntries = withContext(Dispatchers.Default) { filtered.filter { StatisticsAggregator.clippedSeconds( @@ -265,6 +329,7 @@ class StatisticsViewModel @Inject constructor( filters = filters, catalog = catalog, summary = summary, + estimateProgress = estimates, comparison = comparison, filteredEntries = filtered, rangeStart = resolved.start, @@ -277,7 +342,7 @@ class StatisticsViewModel @Inject constructor( } }.stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), + started = SharingStarted.WhileSubscribed(UI_STATE_STOP_TIMEOUT_MS), initialValue = StatisticsUiState(), ) diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/charts/BarChart.kt b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/charts/BarChart.kt index cb4795c..63919c5 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/charts/BarChart.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/charts/BarChart.kt @@ -23,8 +23,9 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -/** Resolved bar geometry for a single [BarChart] draw pass. */ -data class BarChartSizing(val gap: Float, val barWidth: Float) +private const val GAP_FRACTION = 0.02f +private const val MAX_GAP_FRACTION = 0.5f +private const val MIN_NON_ZERO_VALUE = 0.0001f /** * Computes gap and bar width for [count] bars across [width] pixels. @@ -38,14 +39,14 @@ data class BarChartSizing(val gap: Float, val barWidth: Float) fun barChartSizing(width: Float, count: Int, minBarWidth: Float = 1f): BarChartSizing { if (count <= 0 || width <= 0f) return BarChartSizing(0f, 0f) // Gaps get at most half the width; the desired 2% shrinks as bars multiply. - val gap = (width * 0.02f).coerceAtMost(width * 0.5f / (count + 1)) + val gap = (width * GAP_FRACTION).coerceAtMost(width * MAX_GAP_FRACTION / (count + 1)) val barWidth = ((width - gap * (count + 1)) / count).coerceAtLeast(minBarWidth) return BarChartSizing(gap = gap, barWidth = barWidth) } @Composable fun BarChart(bars: List>, barColor: Color, modifier: Modifier = Modifier) { - val max = bars.maxOfOrNull { it.second }?.coerceAtLeast(0.0001f) ?: 0.0001f + val max = bars.maxOfOrNull { it.second }?.coerceAtLeast(MIN_NON_ZERO_VALUE) ?: MIN_NON_ZERO_VALUE val summary = bars.joinToString(", ") { (label, seconds) -> "$label: ${seconds.toLong()} seconds" } Column(modifier = modifier.semantics { contentDescription = summary }) { Canvas( diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/charts/BarChartSizing.kt b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/charts/BarChartSizing.kt new file mode 100644 index 0000000..658c801 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/charts/BarChartSizing.kt @@ -0,0 +1,10 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.statistics.charts + +/** Resolved bar geometry for a single [BarChart] draw pass. */ +data class BarChartSizing(val gap: Float, val barWidth: Float) diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/charts/DonutChart.kt b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/charts/DonutChart.kt index 152d6e0..8405287 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/charts/DonutChart.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/charts/DonutChart.kt @@ -16,12 +16,16 @@ import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +private const val MIN_NON_ZERO_TOTAL = 0.0001f +private const val START_ANGLE_DEGREES = -90f +private const val FULL_CIRCLE_DEGREES = 360f + /** Fallback diameter when a caller supplies no size; real callers pass their own via [modifier]. */ private val DefaultDonutSize = 140.dp @Composable fun DonutChart(slices: List>, modifier: Modifier = Modifier) { - val total = slices.sumOf { it.second.toDouble() }.toFloat().coerceAtLeast(0.0001f) + val total = slices.sumOf { it.second.toDouble() }.toFloat().coerceAtLeast(MIN_NON_ZERO_TOTAL) // Respect the caller's sizing (they place the chart in the layout); only guarantee a sensible // minimum so an unsized caller still renders a visible ring instead of a zero-size canvas. Canvas( @@ -36,9 +40,9 @@ fun DonutChart(slices: List>, modifier: Modifier = Modifier) (size.height - diameter) / 2f, ) val arcSize = androidx.compose.ui.geometry.Size(diameter, diameter) - var startAngle = -90f + var startAngle = START_ANGLE_DEGREES slices.forEach { (color, value) -> - val sweep = value / total * 360f + val sweep = value / total * FULL_CIRCLE_DEGREES drawArc( color = color, startAngle = startAngle, diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/sync/SyncCenterScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/sync/SyncCenterScreen.kt new file mode 100644 index 0000000..10f3b7b --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/ui/sync/SyncCenterScreen.kt @@ -0,0 +1,293 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.sync + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.SyncProblem +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.tricked.solidverdant.R +import dev.tricked.solidverdant.data.local.db.OutboxOpType +import dev.tricked.solidverdant.data.repository.TimeEntryRepository.SyncOperation +import dev.tricked.solidverdant.ui.components.SectionCard +import dev.tricked.solidverdant.ui.theme.Dimens +import dev.tricked.solidverdant.util.RelativeTime +import java.text.DateFormat +import java.util.Date + +/** + * Dedicated Sync Center screen (#33): shows sync freshness (last pull / last push), a plain-language + * status summary, the changes waiting to sync, and any failures with per-item Retry / Discard plus a + * Retry All action. All state is communicated with text + icon (never colour alone) for a11y. It is a + * read/display + existing-action surface; it does not implement any sync control flow itself. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SyncCenterScreen(onBack: () -> Unit, viewModel: SyncCenterViewModel = hiltViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.sync_center_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.sync_center_navigate_back), + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = Dimens.Space16), + verticalArrangement = Arrangement.spacedBy(Dimens.Space12), + ) { + Spacer(Modifier.height(Dimens.Space4)) + FreshnessSection(state = state, onSyncNow = viewModel::syncNow, nowMs = viewModel.nowMs()) + StatusSummarySection(state = state) + if (state.pending.isNotEmpty()) { + PendingSection(state.pending) + } + if (state.conflicts.isNotEmpty()) { + ConflictsSection(state.conflicts) + } + if (state.failed.isNotEmpty()) { + FailuresSection( + failed = state.failed, + onRetry = viewModel::retry, + onDiscard = viewModel::discard, + onRetryAll = viewModel::retryAll, + ) + } + if (state.topLine == SyncCenterUiState.TopLine.SYNCED) { + Text( + text = stringResource(R.string.sync_all_caught_up), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = Dimens.Space8), + ) + } + Spacer(Modifier.height(Dimens.Space16)) + } + } +} + +@Composable +private fun FreshnessSection(state: SyncCenterUiState, onSyncNow: () -> Unit, nowMs: Long) { + SectionCard(title = stringResource(R.string.sync_freshness_title)) { + FreshnessRow( + label = stringResource(R.string.sync_last_pulled_label), + timeMs = state.lastFullSyncAtMs, + neverRes = R.string.sync_never_pulled, + nowMs = nowMs, + ) + FreshnessRow( + label = stringResource(R.string.sync_last_pushed_label), + timeMs = state.lastPushAtMs, + neverRes = R.string.sync_never_pushed, + nowMs = nowMs, + ) + Button(onClick = onSyncNow, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Outlined.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(Dimens.Space8)) + Text(stringResource(R.string.sync_now)) + } + } +} + +@Composable +private fun FreshnessRow(label: String, timeMs: Long?, neverRes: Int, nowMs: Long) { + Column { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = relativeTimeText(timeMs, neverRes, nowMs), + style = MaterialTheme.typography.bodyLarge, + ) + timeMs?.let { absolute -> + Text( + text = rememberAbsoluteTime(absolute), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun StatusSummarySection(state: SyncCenterUiState) { + val (icon, text, iconDescRes) = when (state.topLine) { + SyncCenterUiState.TopLine.SYNCED -> Triple( + Icons.Filled.CheckCircle, + stringResource(R.string.sync_status_synced), + R.string.sync_status_icon_synced, + ) + SyncCenterUiState.TopLine.PENDING -> Triple( + Icons.Filled.CloudUpload, + stringResource(R.string.sync_status_pending, state.pendingCount), + R.string.sync_status_icon_warning, + ) + SyncCenterUiState.TopLine.FAILURES -> Triple( + Icons.Filled.SyncProblem, + stringResource(R.string.sync_status_failures, state.failedCount), + R.string.sync_status_icon_warning, + ) + SyncCenterUiState.TopLine.CONFLICTS -> Triple( + Icons.Filled.SyncProblem, + stringResource(R.string.sync_status_conflicts, state.conflictCount), + R.string.sync_status_icon_warning, + ) + } + val iconDesc = stringResource(iconDescRes) + SectionCard { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Dimens.Space12)) { + Icon(icon, contentDescription = iconDesc, modifier = Modifier.size(28.dp)) + Text(text, style = MaterialTheme.typography.titleMedium) + } + } +} + +@Composable +private fun PendingSection(pending: List) { + SectionCard(title = stringResource(R.string.sync_pending_section_title)) { + pending.forEachIndexed { index, op -> + if (index > 0) HorizontalDivider() + Column { + Text(opLabel(op.type), style = MaterialTheme.typography.bodyMedium) + Text( + stringResource(R.string.sync_pending_item), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun ConflictsSection(conflicts: List) { + SectionCard(title = stringResource(R.string.sync_conflicts_section_title)) { + conflicts.forEachIndexed { index, op -> + if (index > 0) HorizontalDivider() + Column { + Text(opLabel(op.type), style = MaterialTheme.typography.bodyMedium) + Text( + stringResource(R.string.sync_conflict_item), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun FailuresSection(failed: List, onRetry: (String) -> Unit, onDiscard: (String) -> Unit, onRetryAll: () -> Unit) { + SectionCard(title = stringResource(R.string.sync_failures_section_title)) { + failed.forEachIndexed { index, op -> + if (index > 0) HorizontalDivider() + Column(verticalArrangement = Arrangement.spacedBy(Dimens.Space4)) { + Text(opLabel(op.type), style = MaterialTheme.typography.bodyMedium) + Text( + stringResource(failureReasonRes(op.error)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(Dimens.Space8)) { + TextButton(onClick = { onRetry(op.entryId) }) { Text(stringResource(R.string.sync_retry)) } + TextButton(onClick = { onDiscard(op.entryId) }) { Text(stringResource(R.string.sync_discard)) } + } + } + } + HorizontalDivider() + OutlinedButton(onClick = onRetryAll, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.sync_retry_all)) + } + } +} + +@Composable +private fun opLabel(type: OutboxOpType): String = stringResource( + when (type) { + OutboxOpType.START -> R.string.sync_op_start + OutboxOpType.CREATE -> R.string.sync_op_create + OutboxOpType.STOP -> R.string.sync_op_stop + OutboxOpType.UPDATE -> R.string.sync_op_update + OutboxOpType.DELETE -> R.string.sync_op_delete + }, +) + +/** + * Map a raw outbox error to a plain-language reason. The stored [SyncOperation.error] holds + * implementation strings (exception messages, HTTP status text); this classifies them into a few + * friendly buckets and always falls back to a generic reassurance rather than leaking raw detail. + */ +internal fun failureReasonRes(error: String?): Int { + val lower = error?.lowercase().orEmpty() + return when { + lower.isBlank() -> R.string.sync_reason_generic + listOf("offline", "timeout", "unable to resolve host", "connect", "unreachable", "network") + .any { it in lower } -> R.string.sync_reason_offline + listOf("400", "401", "403", "404", "409", "422", "unprocessable", "forbidden", "unauthorized", "bad request") + .any { it in lower } -> R.string.sync_reason_rejected + listOf("500", "502", "503", "504", "server", "gateway", "unavailable") + .any { it in lower } -> R.string.sync_reason_server + else -> R.string.sync_reason_generic + } +} + +@Composable +private fun relativeTimeText(timeMs: Long?, neverRes: Int, nowMs: Long): String = when (RelativeTime.bucketOf(timeMs, nowMs)) { + RelativeTime.Bucket.Never -> stringResource(neverRes) + RelativeTime.Bucket.JustNow -> stringResource(R.string.sync_time_just_now) + RelativeTime.Bucket.Minutes -> stringResource(R.string.sync_time_minutes, RelativeTime.amount(timeMs, nowMs)) + RelativeTime.Bucket.Hours -> stringResource(R.string.sync_time_hours, RelativeTime.amount(timeMs, nowMs)) + RelativeTime.Bucket.Days -> stringResource(R.string.sync_time_days, RelativeTime.amount(timeMs, nowMs)) +} + +@Composable +private fun rememberAbsoluteTime(timeMs: Long): String { + val formatter = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + return formatter.format(Date(timeMs)) +} diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/sync/SyncCenterViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/sync/SyncCenterViewModel.kt new file mode 100644 index 0000000..e320b51 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/ui/sync/SyncCenterViewModel.kt @@ -0,0 +1,151 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.sync + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.tricked.solidverdant.data.local.SettingsDataStore +import dev.tricked.solidverdant.data.repository.TimeEntryRepository +import dev.tricked.solidverdant.data.repository.TimeEntryRepository.EntrySyncStatus +import dev.tricked.solidverdant.data.repository.TimeEntryRepository.SyncOperation +import dev.tricked.solidverdant.sync.SyncTrigger +import dev.tricked.solidverdant.util.Clock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Backs the dedicated Sync Center screen (#33). Read/display surface plus the app's existing + * sync actions (retry-all / per-entry retry / discard / manual sync) — it never reimplements sync + * control flow, only re-scopes and exposes it. Current organization is resolved reactively from + * cached auth (selected membership -> its organization), mirroring how [TrackingViewModel] resolves + * the active org, so the screen re-scopes on org switch and works offline. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class SyncCenterViewModel @Inject constructor( + private val repository: TimeEntryRepository, + private val settingsDataStore: SettingsDataStore, + private val syncTrigger: SyncTrigger, + private val clock: Clock, +) : ViewModel() { + + private val orgIdFlow: Flow = settingsDataStore.observeCachedAuth() + .map { auth -> resolveOrgId(auth) } + .distinctUntilChanged() + + val uiState: StateFlow = orgIdFlow.flatMapLatest { orgId -> + if (orgId == null) { + flowOf(SyncCenterUiState(isLoading = false)) + } else { + combine( + repository.observeSyncOperations(orgId), + repository.observeSyncMeta(orgId), + ) { operations, meta -> + val failed = operations.filter { it.status == EntrySyncStatus.FAILED } + val conflicts = operations.filter { it.status == EntrySyncStatus.CONFLICT } + val pending = operations.filter { + it.status == EntrySyncStatus.PENDING || it.status == EntrySyncStatus.RETRYING + } + SyncCenterUiState( + isLoading = false, + organizationId = orgId, + // The DAO seeds a sentinel pull timestamp of 0 when a row is created for a + // push-only stamp; treat 0 as "never pulled" so freshness reads honestly. + lastFullSyncAtMs = meta?.lastFullSyncAtMs?.takeIf { it > 0L }, + lastPushAtMs = meta?.lastPushAtMs, + pending = pending, + failed = failed, + conflicts = conflicts, + topLine = when { + failed.isNotEmpty() -> SyncCenterUiState.TopLine.FAILURES + conflicts.isNotEmpty() -> SyncCenterUiState.TopLine.CONFLICTS + pending.isNotEmpty() -> SyncCenterUiState.TopLine.PENDING + else -> SyncCenterUiState.TopLine.SYNCED + }, + ) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STATE_STOP_TIMEOUT_MS), seedState()) + + /** First-frame seed: resolve the last selected org synchronously from cached auth. */ + private fun seedState(): SyncCenterUiState { + val orgId = resolveOrgId(settingsDataStore.getCachedAuth()) + return SyncCenterUiState(isLoading = orgId != null, organizationId = orgId) + } + + private fun resolveOrgId(auth: SettingsDataStore.CachedAuth?): String? = auth?.let { + (it.memberships.firstOrNull { m -> m.id == it.currentMembershipId } ?: it.memberships.firstOrNull()) + ?.organizationId + } + + /** Retry every queued/failed change by requesting a sync drain (reuses the outbox worker). */ + fun retryAll() = syncTrigger.requestSync() + + /** Retry a single failed change: clear its dead-letter/attempt state, then request a sync. */ + fun retry(entryId: String) { + viewModelScope.launch { + if (repository.prepareRetry(entryId)) syncTrigger.requestSync() + } + } + + /** Permanently drop a failed change's queued operation so it stops appearing. */ + fun discard(entryId: String) { + viewModelScope.launch { repository.discardFailedSync(entryId) } + } + + /** Manual "Sync now": kick the same background sync the app enqueues after every local edit. */ + fun syncNow() = syncTrigger.requestSync() + + /** Current wall-clock, for the screen's relative-time freshness labels. */ + fun nowMs(): Long = clock.nowMs() + + /** + * Cancels [viewModelScope] for unit tests that install a test Main dispatcher; mirrors the + * statistics VM's teardown so no Main-bound collector straggles past `Dispatchers.resetMain()`. + * Not used in production. + */ + @VisibleForTesting + internal fun cancelScopeForTest() { + viewModelScope.coroutineContext[Job]?.cancel() + } + + private companion object { + const val STATE_STOP_TIMEOUT_MS = 5_000L + } +} + +/** Immutable UI state for the Sync Center screen. */ +data class SyncCenterUiState( + val isLoading: Boolean = true, + val organizationId: String? = null, + val lastFullSyncAtMs: Long? = null, + val lastPushAtMs: Long? = null, + val pending: List = emptyList(), + val failed: List = emptyList(), + val conflicts: List = emptyList(), + val topLine: TopLine = TopLine.SYNCED, +) { + val pendingCount: Int get() = pending.size + val failedCount: Int get() = failed.size + val conflictCount: Int get() = conflicts.size + + /** Plain-language headline bucket; the screen maps each to a localized sentence + icon. */ + enum class TopLine { SYNCED, PENDING, FAILURES, CONFLICTS } +} diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/templates/ManageTemplatesViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/templates/ManageTemplatesViewModel.kt index e8646c9..c8f8651 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/templates/ManageTemplatesViewModel.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/templates/ManageTemplatesViewModel.kt @@ -73,7 +73,7 @@ class ManageTemplatesViewModel @Inject constructor( ) { selectedId, memberships -> val selected = memberships.firstOrNull { it.id == selectedId } ?: memberships.firstOrNull() selected?.organizationId - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STATE_STOP_TIMEOUT_MS), null) val uiState: StateFlow = combine(organizationId, retryTrigger) { org, _ -> org } @@ -103,12 +103,12 @@ class ManageTemplatesViewModel @Inject constructor( } } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ManageTemplatesUiState()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STATE_STOP_TIMEOUT_MS), ManageTemplatesUiState()) /** Ordered quick-start list for the Track row, isolated so it recomposes only on real changes. */ val quickStart: StateFlow> = uiState .map { it.quickStart } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STATE_STOP_TIMEOUT_MS), emptyList()) fun retry() { retryTrigger.value += 1 @@ -164,3 +164,5 @@ class ManageTemplatesViewModel @Inject constructor( const val QUICK_START_LIMIT = 12 } } + +private const val STATE_STOP_TIMEOUT_MS = 5_000L diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/theme/Dimens.kt b/app/src/main/java/dev/tricked/solidverdant/ui/theme/Dimens.kt index 5db2f7b..e845323 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/theme/Dimens.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/theme/Dimens.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp */ object Dimens { // --- Core dp spacing scale --- + val Space1 = 1.dp val Space2 = 2.dp val Space4 = 4.dp val Space8 = 8.dp @@ -49,4 +50,5 @@ object Dimens { // --- Accessibility --- val MinTouchTarget = 48.dp + val NarrowCalendarWidth = 600.dp } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/tile/ProjectSelectionActivity.kt b/app/src/main/java/dev/tricked/solidverdant/ui/tile/ProjectSelectionActivity.kt index e7e4c07..e072bb3 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/tile/ProjectSelectionActivity.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/tile/ProjectSelectionActivity.kt @@ -166,6 +166,8 @@ sealed class ProjectTaskSelection { data class ProjectWithTask(val project: Project, val task: Task) : ProjectTaskSelection() } +private data class SelectedProjectTask(val projectId: String?, val taskId: String?, val projectName: String?, val taskName: String?) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun StartTrackingForm( @@ -232,26 +234,26 @@ fun StartTrackingForm( } Button( onClick = { - val (projectId, taskId, projectName, taskName) = when (selection) { + val selected = when (selection) { is ProjectTaskSelection.NoProject -> - listOf(null, null, null, null) + SelectedProjectTask(null, null, null, null) is ProjectTaskSelection.ProjectOnly -> { val proj = (selection as ProjectTaskSelection.ProjectOnly).project - listOf(proj.id, null, proj.name, null) + SelectedProjectTask(proj.id, null, proj.name, null) } is ProjectTaskSelection.ProjectWithTask -> { val sel = selection as ProjectTaskSelection.ProjectWithTask - listOf(sel.project.id, sel.task.id, sel.project.name, sel.task.name) + SelectedProjectTask(sel.project.id, sel.task.id, sel.project.name, sel.task.name) } } onStartTracking( - projectId as String?, - taskId as String?, + selected.projectId, + selected.taskId, description, - projectName as String?, - taskName as String?, + selected.projectName, + selected.taskName, ) }, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/EntryTrustRules.kt b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/EntryTrustRules.kt index 8c56f7f..ede1420 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/EntryTrustRules.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/EntryTrustRules.kt @@ -75,7 +75,7 @@ object EntryTrustRules { tasks: List, clients: List = emptyList(), syncOperations: List = emptyList(), - zone: ZoneId = ZoneId.systemDefault(), + zone: ZoneId, ): List { val query = filter.query.trim() val projectNames = projects.associate { it.id to it.name } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt index f1b32f4..7a22b04 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ExitToApp @@ -76,6 +77,7 @@ import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -83,6 +85,7 @@ import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Search @@ -260,18 +263,21 @@ fun TrackingScreen( onUpdatePastEntry: (TimeEntry, String?, String?, String?, List, Boolean, String, String) -> Unit, onCreateEntry: (String?, String?, String?, List, Boolean, String, String) -> Unit, onDeleteEntry: (String) -> Unit, + onDuplicateEntry: (String) -> Unit, + onSplitEntry: (String, String) -> Unit, + onEntryToEditConsumed: () -> Unit, onUndoDelete: (TimeEntry) -> Unit, onRetrySync: () -> Unit, onRetrySyncEntry: (String) -> Unit, + onOpenSyncCenter: () -> Unit = {}, + onOpenPrivacy: () -> Unit = {}, onLoadMoreEntries: () -> Unit, onLoadNewerEntries: () -> Unit, onJumpToDate: (LocalDate) -> Unit, onHistoryJumpConsumed: () -> Unit, - getGroupedEntries: () -> Map> ) { var showEditDialog by remember { mutableStateOf(null) } var showAddDialog by remember { mutableStateOf(false) } - var organizationMenuExpanded by remember { mutableStateOf(false) } var hasUserScrolledHistory by remember { mutableStateOf(false) } var calendarInitialDate by remember { mutableStateOf(null) } var historyFilter by remember { mutableStateOf(HistoryFilter()) } @@ -295,11 +301,21 @@ fun TrackingScreen( LaunchedEffect(editActiveEntryRequested, uiState.currentTimeEntry) { if (editActiveEntryRequested) { - uiState.currentTimeEntry?.let { showEditDialog = it } + uiState.currentTimeEntry?.takeUnless { it.id in uiState.conflictedEntryIds }?.let { showEditDialog = it } if (uiState.currentTimeEntry != null) onEditActiveEntryConsumed() } } + // Roadmap #13: after a duplicate/split the VM emits the new entry's id; open it for editing + // once it surfaces in the observed list, then consume the one-shot signal. + LaunchedEffect(uiState.entryToEditId, uiState.timeEntries) { + val id = uiState.entryToEditId ?: return@LaunchedEffect + uiState.timeEntries.firstOrNull { it.id == id }?.let { + showEditDialog = it + onEntryToEditConsumed() + } + } + LaunchedEffect(historyFilter.startDate) { historyFilter.startDate?.let(onJumpToDate) } @@ -489,6 +505,25 @@ fun TrackingScreen( Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { + scope.launch { drawerState.close() } + onOpenPrivacy() + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.privacy_menu_entry)) + } + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() Text( stringResource(R.string.long_timer_warning), @@ -501,7 +536,7 @@ fun TrackingScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf(2, 4, 6, 8, 12).forEach { hours -> + LONG_TIMER_OPTIONS.forEach { hours -> FilterChip( selected = longTimerHours == hours, onClick = { onLongTimerHoursChange(hours) }, @@ -683,57 +718,15 @@ fun TrackingScreen( topBar = { TopAppBar( title = { - Column { - Text( - stringResource(R.string.time_tracking), - fontWeight = FontWeight.SemiBold - ) - user?.name?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (currentMembership != null) { - Box { - Text( - text = currentMembership.organization.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.clickable( - enabled = memberships.size > 1 && !uiState.isTracking && !uiState.isPaused - ) { organizationMenuExpanded = true } - ) - androidx.compose.material3.DropdownMenu( - expanded = organizationMenuExpanded, - onDismissRequest = { organizationMenuExpanded = false } - ) { - memberships.forEach { membership -> - DropdownMenuItem( - text = { Text(membership.organization.name) }, - onClick = { - organizationMenuExpanded = false - onMembershipChange(membership) - }, - trailingIcon = if (membership.id == currentMembership.id) { - { - Icon( - imageVector = Icons.Default.Done, - contentDescription = null - ) - } - } else null - ) - } - } - } - } - } + TrackingAppBarTitle( + userName = user?.name, + organizationName = currentMembership?.organization?.name, + canSwitchOrganization = memberships.size > 1 && + !uiState.isTracking && !uiState.isPaused, + memberships = memberships, + currentMembershipId = currentMembership?.id, + onMembershipChange = onMembershipChange, + ) }, navigationIcon = { IconButton( @@ -771,8 +764,8 @@ fun TrackingScreen( val syncTransition = rememberInfiniteTransition(label = "sync") val syncRotation by syncTransition.animateFloat( initialValue = 0f, - targetValue = if (uiState.isSyncing) 360f else 0f, - animationSpec = infiniteRepeatable(tween(900), RepeatMode.Restart), + targetValue = if (uiState.isSyncing) FULL_ROTATION_DEGREES else 0f, + animationSpec = infiniteRepeatable(tween(SYNC_ROTATION_DURATION_MS), RepeatMode.Restart), label = "sync rotation" ) IconButton( @@ -818,6 +811,7 @@ fun TrackingScreen( tasks = uiState.tasks, clients = uiState.clients, syncOperations = uiState.syncOperations, + zone = uiState.zone, ).groupBy { entry -> IsoTimes.localDate(entry.start) ?: LocalDate.MIN } @@ -852,8 +846,17 @@ fun TrackingScreen( } } val overlapCount = uiState.overlapCount - val onHistoryEdit = remember<(TimeEntry) -> Unit> { { entry -> showEditDialog = entry } } - val onHistoryDelete = remember<(TimeEntry) -> Unit> { { entry -> deletedEntry = entry; onDeleteEntry(entry.id) } } + val onHistoryEdit = remember(uiState.conflictedEntryIds) { + { entry: TimeEntry -> if (entry.id !in uiState.conflictedEntryIds) showEditDialog = entry } + } + val onHistoryDelete = remember(uiState.conflictedEntryIds) { + { entry: TimeEntry -> + if (entry.id !in uiState.conflictedEntryIds) { + deletedEntry = entry + onDeleteEntry(entry.id) + } + } + } val onHistoryDateClick = remember<(LocalDate) -> Unit> { { date -> calendarInitialDate = date } } LaunchedEffect(uiState.historyJumpDate, groupedEntries) { @@ -892,13 +895,13 @@ fun TrackingScreen( } item(key = "long_timer_warning") { val elapsed by elapsedSeconds.collectAsState() - if (uiState.isTracking && elapsed >= longTimerHours * 3600L && + if (uiState.isTracking && elapsed >= longTimerHours * SECONDS_PER_HOUR_LONG && elapsed >= longTimerSnoozedUntil) { LongTimerWarning( hours = longTimerHours, onStop = onStopTracking, onKeepRunning = { - longTimerSnoozedUntil = elapsed + 3600L + longTimerSnoozedUntil = elapsed + SECONDS_PER_HOUR_LONG TimeTrackingNotificationService.snoozeLongTimerWarning(context) }, onAdjust = { uiState.currentTimeEntry?.let { showEditDialog = it } }, @@ -950,7 +953,7 @@ fun TrackingScreen( Row(Modifier.fillMaxSize()) { LazyColumn( modifier = Modifier - .weight(0.9f) + .weight(WIDE_PRIMARY_WEIGHT) .fillMaxSize() .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -964,7 +967,7 @@ fun TrackingScreen( LazyColumn( state = wideHistoryListState, modifier = Modifier - .weight(1.1f) + .weight(WIDE_HISTORY_WEIGHT) .fillMaxSize() .testTag(TrackingTestTags.HISTORY_LIST) .nestedScroll(historyScrollConnection) @@ -974,7 +977,7 @@ fun TrackingScreen( item { Spacer(Modifier.height(8.dp)) } item { HistoryFilters(historyFilter, uiState) { historyFilter = it } } if (uiState.syncOperations.isNotEmpty()) { - item { SyncCenter(uiState.syncOperations, onRetrySync, onRetrySyncEntry) } + item { SyncCenter(uiState.syncOperations, onRetrySync, onRetrySyncEntry, onOpenSyncCenter) } } trackingHistoryItems( uiState = uiState, @@ -1027,6 +1030,7 @@ fun TrackingScreen( showEditDialog?.let { entry -> TimeEntryFormSheet( entry = entry, + zone = uiState.zone, suggestedStart = null, projects = uiState.projects, tasks = uiState.tasks, @@ -1040,23 +1044,27 @@ fun TrackingScreen( preventOverlap = currentMembership?.organization?.preventOverlappingTimeEntries == true, templates = templateState.templates, onSaveAsTemplate = onSaveTemplateFromForm, + saveEnabled = entry.id !in uiState.conflictedEntryIds, + onDuplicate = { onDuplicateEntry(entry.id) }, + onSplit = { atIso -> onSplitEntry(entry.id, atIso) }, ) } // Add-entry dialog if (showAddDialog) { - val suggestedStart = remember(uiState.timeEntries) { - val now = ZonedDateTime.now(ZoneId.systemDefault()) + val suggestedStart = remember(uiState.timeEntries, uiState.zone) { + val now = ZonedDateTime.now(uiState.zone) uiState.timeEntries .mapNotNull { it.end } .mapNotNull { runCatching { ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) }.getOrNull() } .maxOrNull() - ?.withZoneSameInstant(ZoneId.systemDefault()) + ?.withZoneSameInstant(uiState.zone) ?.takeIf { it.toLocalDate() == now.toLocalDate() && it.isBefore(now) } ?: now.minusHours(1) } TimeEntryFormSheet( entry = null, + zone = uiState.zone, suggestedStart = suggestedStart, projects = uiState.projects, tasks = uiState.tasks, @@ -1163,8 +1171,8 @@ private fun HistoryFilters(filter: HistoryFilter, uiState: TrackingUiState, onCh } AnimatedVisibility( visible = expanded, - enter = fadeIn(tween(180)) + expandVertically(tween(220)), - exit = fadeOut(tween(120)) + shrinkVertically(tween(180)), + enter = fadeIn(tween(FILTER_ENTER_DURATION_MS)) + expandVertically(tween(FILTER_EXPAND_DURATION_MS)), + exit = fadeOut(tween(FILTER_EXIT_DURATION_MS)) + shrinkVertically(tween(FILTER_COLLAPSE_DURATION_MS)), ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( @@ -1210,14 +1218,14 @@ private fun HistoryFilters(filter: HistoryFilter, uiState: TrackingUiState, onCh label = { Text(stringResource(R.string.today)) }, ) FilterChip( - selected = filter.startDate == today.minusDays(6) && filter.endDate == today, - onClick = { onChange(filter.copy(startDate = today.minusDays(6), endDate = today)) }, + selected = filter.startDate == today.minusDays(LAST_7_DAYS_OFFSET) && filter.endDate == today, + onClick = { onChange(filter.copy(startDate = today.minusDays(LAST_7_DAYS_OFFSET), endDate = today)) }, label = { Text(stringResource(R.string.stats_last_7_days)) }, ) FilterChip( selected = filter.startDate != null && filter.endDate != null && !(filter.startDate == today && filter.endDate == today) && - !(filter.startDate == today.minusDays(6) && filter.endDate == today), + !(filter.startDate == today.minusDays(LAST_7_DAYS_OFFSET) && filter.endDate == today), onClick = { showDateRangePicker = true }, label = { Text(stringResource(R.string.stats_custom)) }, ) @@ -1326,11 +1334,14 @@ private fun SyncCenter( operations: List, onRetry: () -> Unit, onRetryEntry: (String) -> Unit, + onOpenSyncCenter: () -> Unit = {}, ) { val failed = operations.count { it.status == TimeEntryRepository.EntrySyncStatus.FAILED } val retrying = operations.count { it.status == TimeEntryRepository.EntrySyncStatus.RETRYING } + val conflicts = operations.count { it.status == TimeEntryRepository.EntrySyncStatus.CONFLICT } val summaryStatus = when { failed > 0 -> TimeEntryRepository.EntrySyncStatus.FAILED + conflicts > 0 -> TimeEntryRepository.EntrySyncStatus.CONFLICT retrying > 0 -> TimeEntryRepository.EntrySyncStatus.RETRYING else -> TimeEntryRepository.EntrySyncStatus.PENDING } @@ -1349,6 +1360,7 @@ private fun SyncCenter( Text( when { failed > 0 -> stringResource(R.string.sync_failed_count, failed) + conflicts > 0 -> stringResource(R.string.sync_conflict_count, conflicts) retrying > 0 -> stringResource(R.string.sync_retrying_count, retrying) else -> stringResource(R.string.sync_pending_count, operations.size) }, @@ -1377,6 +1389,10 @@ private fun SyncCenter( } } } + HorizontalDivider() + TextButton(onClick = onOpenSyncCenter, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.sync_center_open)) + } } } @@ -1443,7 +1459,7 @@ internal fun LazyListScope.trackingHistoryItems( ) { if (!uiState.hasLoadedTimeEntries && uiState.timeEntries.isEmpty()) { item(key = "history_loading_header") { HistoryLoadingHeader() } - repeat(4) { index -> + repeat(HISTORY_PLACEHOLDER_COUNT) { index -> item(key = "history_loading_$index") { HistoryLoadingEntry(index) } } return @@ -1644,7 +1660,7 @@ private fun HistoryLoadingEntry(index: Int) { ) { GhostBlock( Modifier - .fillMaxWidth(if (index % 2 == 0) 0.62f else 0.45f) + .fillMaxWidth(if (index % ALTERNATING_ROW_COUNT == 0) GHOST_PRIMARY_WIDTH else GHOST_SECONDARY_WIDTH) .height(13.dp), alpha ) @@ -2024,7 +2040,7 @@ private fun DescriptionFieldWithSuggestions( .distinctBy { entry -> "${entry.description}|${entry.projectId}|${entry.taskId}|${entry.tags.map { it.id }.sorted()}" } - .take(5) + .take(RECENT_ENTRIES_LIMIT) } ExposedDropdownMenuBox( @@ -2385,6 +2401,111 @@ private fun CollapsibleTimeEntryGroup( } } +/** + * Track top-app-bar title: the "Time tracking" heading plus, on subordinate lines, the signed-in + * user's name and their active organization. + * + * When [userName] and [organizationName] are identical (trimmed, case-sensitive) the two would read + * as duplicated text / a rendering glitch, so we collapse them into a single line. + * + * The organization line only signals its interactivity when a real switch is possible + * ([canSwitchOrganization]): it then gains a dropdown arrow plus [Role.Button] semantics with a + * "Switch organization" content description and opens the membership picker on click. Otherwise it + * is inert, plain [onSurfaceVariant] text with no click target and no affordance icon. + */ +@Composable +internal fun TrackingAppBarTitle( + userName: String?, + organizationName: String?, + canSwitchOrganization: Boolean, + memberships: List, + currentMembershipId: String?, + onMembershipChange: (Membership) -> Unit, +) { + var organizationMenuExpanded by remember { mutableStateOf(false) } + val collapsed = userName != null && organizationName != null && + userName.trim() == organizationName.trim() + + Column { + Text( + stringResource(R.string.time_tracking), + fontWeight = FontWeight.SemiBold + ) + + // When collapsed, the org line represents both user and org, so skip the separate user line. + if (userName != null && !collapsed) { + Text( + text = userName, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (organizationName != null) { + Box { + if (canSwitchOrganization) { + val switchDescription = stringResource(R.string.switch_organization) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { organizationMenuExpanded = true } + .semantics { + role = Role.Button + contentDescription = switchDescription + } + ) { + Text( + text = organizationName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + } else { + Text( + text = organizationName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + androidx.compose.material3.DropdownMenu( + expanded = organizationMenuExpanded, + onDismissRequest = { organizationMenuExpanded = false } + ) { + memberships.forEach { membership -> + DropdownMenuItem( + text = { Text(membership.organization.name) }, + onClick = { + organizationMenuExpanded = false + onMembershipChange(membership) + }, + trailingIcon = if (membership.id == currentMembershipId) { + { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null + ) + } + } else null + ) + } + } + } + } + } +} + /** * Compact time entry row showing past entry details (collapsed view) */ @@ -2505,7 +2626,7 @@ private fun CompactTimeEntryRow( ) { // Kit chip renders nothing for SYNCED (and null); only PENDING / // RETRYING / FAILED surface, so a healthy row stays clutter-free. - syncStatus?.let { SyncChip(status = it) } + syncStatus?.let { SyncChip(status = it, showLabel = false) } // Duration (use totalDuration if grouped, otherwise entry duration) Text( text = durationText, @@ -2549,10 +2670,12 @@ private fun CompactTimeEntryRow( /** * Create/edit time entry bottom sheet. [entry] null = create mode. */ +@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun TimeEntryFormSheet( entry: TimeEntry?, // null = create mode + zone: ZoneId, // account temporal-policy zone for the new-entry fallback start suggestedStart: ZonedDateTime?, // create mode: pre-filled start (end of last entry / now-1h) projects: List, tasks: List, @@ -2563,6 +2686,9 @@ private fun TimeEntryFormSheet( preventOverlap: Boolean = false, templates: List = emptyList(), onSaveAsTemplate: ((TemplateDraft) -> Unit)? = null, + saveEnabled: Boolean = true, + onDuplicate: (() -> Unit)? = null, + onSplit: ((String) -> Unit)? = null, ) { var description by remember { mutableStateOf(entry?.description ?: "") } var projectId by remember { mutableStateOf(entry?.projectId) } @@ -2572,7 +2698,7 @@ private fun TimeEntryFormSheet( var templateMenuExpanded by remember { mutableStateOf(false) } val originalStart = remember(entry?.id) { entry?.let { ZonedDateTime.parse(it.start, DateTimeFormatter.ISO_DATE_TIME) } - ?: (suggestedStart ?: ZonedDateTime.now(ZoneId.systemDefault()).minusHours(1)) + ?: (suggestedStart ?: ZonedDateTime.now(zone).minusHours(1)) .withSecond(0).withNano(0) } val originalEnd = remember(entry?.id) { @@ -2590,6 +2716,7 @@ private fun TimeEntryFormSheet( } var editingTime by remember { mutableStateOf(null) } var showDatePicker by remember { mutableStateOf(false) } + var showSplitPicker by remember { mutableStateOf(false) } val durationIsValid = durationMinutes.toLongOrNull()?.let { it > 0 } == true val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -2776,7 +2903,11 @@ private fun TimeEntryFormSheet( verticalAlignment = Alignment.CenterVertically ) { FilledTonalIconButton( - onClick = { setDuration((durationMinutes.toLongOrNull() ?: 1) - 15) }, + onClick = { + setDuration( + (durationMinutes.toLongOrNull() ?: MINIMUM_DURATION_MINUTES) - DURATION_STEP_MINUTES, + ) + }, modifier = Modifier.size(48.dp) ) { Icon( @@ -2789,7 +2920,9 @@ private fun TimeEntryFormSheet( onValueChange = { value -> if (value.all(Char::isDigit)) { durationMinutes = value - value.toLongOrNull()?.takeIf { it > 0 }?.let { endTime = startTime.plusMinutes(it) } + value.toLongOrNull() + ?.takeIf { it >= MINIMUM_DURATION_MINUTES } + ?.let { endTime = startTime.plusMinutes(it) } } }, label = { Text(stringResource(R.string.minutes)) }, @@ -2801,7 +2934,7 @@ private fun TimeEntryFormSheet( shape = RoundedCornerShape(12.dp) ) FilledTonalIconButton( - onClick = { setDuration((durationMinutes.toLongOrNull() ?: 0) + 15) }, + onClick = { setDuration((durationMinutes.toLongOrNull() ?: 0) + DURATION_STEP_MINUTES) }, modifier = Modifier.size(48.dp) ) { Icon( @@ -2865,6 +2998,38 @@ private fun TimeEntryFormSheet( EntryValidationBanner(result = validation, durationHours = durationHours) + if (!saveEnabled) { + Text( + text = stringResource(R.string.sync_conflict_edit_locked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + // Roadmap #13: Duplicate/Split act on the stored entry. Only for completed + // (has end) and editable (not conflicted) entries; hidden otherwise. + val canDuplicateOrSplit = entry?.end != null && saveEnabled + if (canDuplicateOrSplit && (onDuplicate != null || onSplit != null)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (onDuplicate != null) { + OutlinedButton( + onClick = { onDuplicate(); onDismiss() }, + shape = RoundedCornerShape(8.dp), + ) { + Text(stringResource(R.string.duplicate_entry)) + } + } + if (onSplit != null) { + OutlinedButton( + onClick = { showSplitPicker = true }, + shape = RoundedCornerShape(8.dp), + ) { + Text(stringResource(R.string.split_entry)) + } + } + } + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, @@ -2892,7 +3057,7 @@ private fun TimeEntryFormSheet( endTime.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) ) }, - enabled = durationIsValid && validation.canSave, + enabled = saveEnabled && durationIsValid && validation.canSave, shape = RoundedCornerShape(8.dp), modifier = Modifier.testTag(TrackingTestTags.SHEET_SAVE_BUTTON) ) { @@ -2950,6 +3115,27 @@ private fun TimeEntryFormSheet( } ) { DatePicker(state = datePickerState) } } + + // Roadmap #13/#64: split-time picker constrained to the strictly-interior (start, end) window. + if (showSplitPicker && entry?.end != null && onSplit != null) { + // Midpoint is a sensible default and always a valid interior instant. + val midpoint = originalStart.plus(java.time.Duration.between(originalStart, originalEnd).dividedBy(2)) + EntryTimePickerDialog( + title = stringResource(R.string.split_entry_title), + initial = midpoint, + onDismiss = { showSplitPicker = false }, + onConfirm = { hour, minute -> + showSplitPicker = false + val candidate = originalStart.withHour(hour).withMinute(minute).withSecond(0).withNano(0) + // Clamp into the open interval: reject boundary/out-of-range picks (half-open + // semantics) - the repository re-validates, this just avoids an obvious no-op. + if (candidate.isAfter(originalStart) && candidate.isBefore(originalEnd)) { + onSplit(candidate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) + onDismiss() + } + }, + ) + } } private enum class TimeField { Start, End } @@ -2994,35 +3180,6 @@ private fun EntryTimePickerDialog( ) } -/** - * Error card - */ -@Composable -private fun ErrorCard(error: String) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = Color(0xFFEF4444).copy(alpha = 0.1f) - ), - shape = RoundedCornerShape(8.dp) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(R.string.error), - style = MaterialTheme.typography.titleMedium, - color = Color(0xFFEF4444), - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFFEF4444).copy(alpha = 0.9f) - ) - } - } -} - /** * About section with version info, verification details, and Obtainium button */ @@ -3153,6 +3310,36 @@ private fun AboutSection(context: Context) { private const val OBTAINIUM_PACKAGE = "dev.imranr.obtainium" private const val OBTAINIUM_INSTALL_URL = "https://obtainium.imranr.dev/" +private const val FULL_ROTATION_DEGREES = 360f +private const val SECONDS_PER_HOUR = 3600 +private const val SECONDS_PER_MINUTE = 60 +private const val SECONDS_PER_HOUR_LONG = 3600L +private const val SECONDS_PER_MINUTE_LONG = 60L +private const val FILTER_ENTER_DURATION_MS = 180 +private const val FILTER_EXPAND_DURATION_MS = 220 +private const val FILTER_EXIT_DURATION_MS = 120 +private const val FILTER_COLLAPSE_DURATION_MS = 180 +private const val SYNC_ROTATION_DURATION_MS = 900 +private const val ALTERNATING_ROW_COUNT = 2 +private const val GHOST_PRIMARY_WIDTH = 0.62f +private const val GHOST_SECONDARY_WIDTH = 0.45f +private const val WIDE_PRIMARY_WEIGHT = 0.9f +private const val WIDE_HISTORY_WEIGHT = 1.1f +private const val HISTORY_PLACEHOLDER_COUNT = 4 +private const val RECENT_ENTRIES_LIMIT = 5 +private const val DURATION_STEP_MINUTES = 15L +private const val MINIMUM_DURATION_MINUTES = 1L +private const val TWO_HOURS = 2 +private const val FOUR_HOURS = 4 +private const val SIX_HOURS = 6 +private const val EIGHT_HOURS = 8 +private const val TWELVE_HOURS = 12 +private val LONG_TIMER_OPTIONS = listOf(TWO_HOURS, FOUR_HOURS, SIX_HOURS, EIGHT_HOURS, TWELVE_HOURS) +private const val MAX_CROSS_MIDNIGHT_HOURS = 18L +private const val LONG_DURATION_WARNING_HOURS = 12L +private const val MAX_DURATION_HOURS = 24L +private const val LAST_7_DAYS_OFFSET = 6L +@Suppress("MaxLineLength") private const val OBTAINIUM_ADD_APP_URL = "obtainium://app/%7B%22id%22%3A%22dev.tricked.solidverdant%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2FTricked-dev%2FSolidVerdant%22%2C%22author%22%3A%22Tricked-dev%22%2C%22name%22%3A%22SolidVerdant%22%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%7D%22%7D" private fun isObtainiumInstalled(context: Context): Boolean = try { @@ -3220,9 +3407,9 @@ private fun copyToClipboard(context: Context, text: String) { internal fun formatElapsedTime(seconds: Long): String { // Defensive floor: a device clock behind the entry's start must never render as "-1:-5:-3". val safeSeconds = seconds.coerceAtLeast(0) - val hours = safeSeconds / 3600 - val minutes = (safeSeconds % 3600) / 60 - val secs = safeSeconds % 60 + val hours = safeSeconds / SECONDS_PER_HOUR_LONG + val minutes = (safeSeconds % SECONDS_PER_HOUR_LONG) / SECONDS_PER_MINUTE_LONG + val secs = safeSeconds % SECONDS_PER_MINUTE_LONG return String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, secs) } @@ -3235,11 +3422,11 @@ internal fun formatElapsedTime(seconds: Long): String { internal object EntryTimeValidator { /** An end clock-time earlier than start rolls to the next day only within this span; beyond it * the inversion is treated as a mistake rather than an intended overnight shift. */ - val MAX_CROSS_MIDNIGHT: java.time.Duration = java.time.Duration.ofHours(18) + val MAX_CROSS_MIDNIGHT: java.time.Duration = java.time.Duration.ofHours(MAX_CROSS_MIDNIGHT_HOURS) /** Durations at or above this are plausible but worth confirming before saving. */ - val LONG_DURATION_WARNING: java.time.Duration = java.time.Duration.ofHours(12) + val LONG_DURATION_WARNING: java.time.Duration = java.time.Duration.ofHours(LONG_DURATION_WARNING_HOURS) /** Hard ceiling; a single entry longer than this is almost certainly an error. */ - val MAX_DURATION: java.time.Duration = java.time.Duration.ofHours(24) + val MAX_DURATION: java.time.Duration = java.time.Duration.ofHours(MAX_DURATION_HOURS) enum class Error { END_NOT_AFTER_START, TOO_LONG } enum class Warning { LONG_DURATION, OVERLAP, OVERLAP_POLICY } @@ -3358,9 +3545,9 @@ private fun formatTimeRange(start: String, end: String?): String { * Format duration in seconds to HH:MM:SS */ private fun formatDuration(seconds: Int): String { - val hours = seconds / 3600 - val minutes = (seconds % 3600) / 60 - val secs = seconds % 60 + val hours = seconds / SECONDS_PER_HOUR + val minutes = (seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE + val secs = seconds % SECONDS_PER_MINUTE return String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, secs) } @@ -3369,15 +3556,15 @@ private fun utcDateOf(epochMillis: Long): LocalDate = Instant.ofEpochMilli(epoch /** Format a day total without seconds to keep history headers compact. */ private fun formatCompactDuration(seconds: Int): String { - val hours = seconds / 3600 - val minutes = (seconds % 3600) / 60 + val hours = seconds / SECONDS_PER_HOUR + val minutes = (seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" } private fun formatEditableDuration(minutes: Long): String { val safeMinutes = minutes.coerceAtLeast(0) - val hours = safeMinutes / 60 - val remainingMinutes = safeMinutes % 60 + val hours = safeMinutes / SECONDS_PER_MINUTE_LONG + val remainingMinutes = safeMinutes % SECONDS_PER_MINUTE_LONG return when { hours > 0 && remainingMinutes > 0 -> "${hours}h ${remainingMinutes}m" hours > 0 -> "${hours}h" diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt index 2653dd4..10c7ecf 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt @@ -7,6 +7,7 @@ package dev.tricked.solidverdant.ui.tracking import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -22,8 +23,11 @@ import dev.tricked.solidverdant.data.model.Task import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.data.repository.AuthRepository import dev.tricked.solidverdant.data.repository.TimeEntryRepository +import dev.tricked.solidverdant.domain.time.TemporalPolicy +import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider import dev.tricked.solidverdant.service.TimeTrackingNotificationService import dev.tricked.solidverdant.sync.SyncTrigger +import dev.tricked.solidverdant.util.Clock import dev.tricked.solidverdant.util.IsoTimes import dev.tricked.solidverdant.widget.TimeTrackingWidget import kotlinx.coroutines.CancellationException @@ -40,14 +44,25 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import timber.log.Timber import java.time.Instant import java.time.LocalDate +import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import javax.inject.Inject +private const val RATE_LIMIT_RETRY_ATTEMPTS = 3 +private const val HTTP_TOO_MANY_REQUESTS = 429 +private const val MIN_RETRY_AFTER_SECONDS = 1 +private const val MAX_RETRY_AFTER_SECONDS = 60 +private const val DEFAULT_RETRY_AFTER_SECONDS = 5 +private const val MILLIS_PER_SECOND = 1_000L +private const val TIMER_TICK_INTERVAL_MS = 1_000L +private const val HISTORY_PROGRESS_CAP = 0.9f + /** Which slice of history the user is currently looking at. */ internal enum class HistoryWindowMode { RECENT, PAGINATED } @@ -107,6 +122,14 @@ data class TrackingUiState( val editingTags: List = emptyList(), val editingBillable: Boolean = false, val syncOperations: List = emptyList(), + val conflictedEntryIds: Set = emptySet(), + /** Account temporal-policy zone; history filtering and new-entry pickers use it. */ + val zone: ZoneId = ZoneId.systemDefault(), + /** + * Roadmap #13: id of an entry the UI should open for editing right after a duplicate/split + * (the freshly created copy / second half). One-shot: cleared via [TrackingViewModel.consumeEntryToEdit]. + */ + val entryToEditId: String? = null, ) { /** Mutations retain the legacy internal flag; refresh/sync have independent flags. */ val isMutating: Boolean get() = isLoading @@ -116,14 +139,22 @@ data class TrackingUiState( * ViewModel for time tracking operations */ @HiltViewModel +@Suppress("LargeClass") class TrackingViewModel @Inject constructor( private val authRepository: AuthRepository, private val settingsDataStore: SettingsDataStore, private val timeEntryRepository: TimeEntryRepository, private val syncTrigger: SyncTrigger, + private val temporalPolicyProvider: TemporalPolicyProvider, @ApplicationContext private val context: Context, + private val clock: Clock, ) : ViewModel() { + // Account temporal-policy zone. Seeded synchronously (first-frame correct) and kept current by + // the collector in init. Provider owns the device-zone fallback. + @Volatile + private var currentPolicy: TemporalPolicy = runBlocking { temporalPolicyProvider.current() } + private val cachedTrackingState = settingsDataStore.getCachedTrackingState() private val _uiState = MutableStateFlow( cachedTrackingState?.let { cached -> @@ -145,11 +176,24 @@ class TrackingViewModel @Inject constructor( editingTaskId = cached.activeEntry?.taskId, editingTags = cached.activeEntry?.tags?.map { it.id }.orEmpty(), editingBillable = cached.activeEntry?.billable ?: false, + zone = currentPolicy.zone, ) - } ?: TrackingUiState(cachedContinueEntry = settingsDataStore.getCachedContinueEntry()), + } ?: TrackingUiState( + cachedContinueEntry = settingsDataStore.getCachedContinueEntry(), + zone = currentPolicy.zone, + ), ) val uiState: StateFlow = _uiState.asStateFlow() + init { + viewModelScope.launch { + temporalPolicyProvider.policy.collect { policy -> + currentPolicy = policy + _uiState.value = _uiState.value.copy(zone = policy.zone) + } + } + } + val alwaysShowNotifications = settingsDataStore.alwaysShowNotification val appTheme = settingsDataStore.appTheme val optimisticRefresh = settingsDataStore.optimisticRefresh @@ -181,6 +225,13 @@ class TrackingViewModel @Inject constructor( private var historyWindowMode = HistoryWindowMode.RECENT private var isInitialized = false + /** + * Wall-clock of the last foreground-triggered full refresh. Returning to the app (or a rapid + * start/stop) within [FOREGROUND_REFRESH_DEBOUNCE_MS] reuses the just-fetched data instead of + * re-hitting the network, while an explicit user refresh remains unthrottled. + */ + private var lastForegroundRefreshMs: Long? = null + /** * SV-019: one in-flight "commit the delete once the undo window closes" job per soft-deleted * entry id. Only the local soft-delete happens synchronously in [deleteTimeEntry]; the actual @@ -300,7 +351,10 @@ class TrackingViewModel @Inject constructor( historyWindowMode = HistoryWindowMode.RECENT dataCollectorJob = viewModelScope.launch { combine( - timeEntryRepository.observeTimeEntries(organizationId), + combine( + timeEntryRepository.observeTimeEntries(organizationId), + timeEntryRepository.observeConflicts(organizationId), + ) { entries, conflicts -> entries to conflicts.map { it.local.id }.toSet() }, timeEntryRepository.observeProjects(organizationId), timeEntryRepository.observeTasks(organizationId), combine( @@ -308,9 +362,10 @@ class TrackingViewModel @Inject constructor( timeEntryRepository.observeClients(organizationId), ) { tags, clients -> tags to clients }, timeEntryRepository.observeActiveEntry(organizationId), - ) { entries, projects, tasks, catalog, active -> + ) { entriesAndConflicts, projects, tasks, catalog, active -> TrackingData( - entries = entries, + entries = entriesAndConflicts.first, + conflictedEntryIds = entriesAndConflicts.second, projects = projects.filterNot { it.isArchived }, tasks = tasks.filterNot { it.isDone }, tags = catalog.first, @@ -348,6 +403,7 @@ class TrackingViewModel @Inject constructor( tasks = data.tasks, tags = data.tags, clients = data.clients, + conflictedEntryIds = data.conflictedEntryIds, currentTimeEntry = data.active, isTracking = data.active != null, hasLoadedTimeEntries = true, @@ -412,6 +468,7 @@ class TrackingViewModel @Inject constructor( private data class TrackingData( val entries: List, + val conflictedEntryIds: Set, val projects: List, val clients: List, val tasks: List, @@ -452,9 +509,26 @@ class TrackingViewModel @Inject constructor( activeEntryMonitorJob = null } - /** Resume polling, refreshing all visible data after a longer background pause. */ + /** + * Resume polling, refreshing all visible data after a longer background pause. + * + * The full-refresh path is debounced on wall-clock so that returning within a few seconds (or a + * rapid start/stop) does not spam the network; the in-screen poll and the collectors already + * keep an open screen fresh, so the foreground refresh only matters when the screen has gone + * stale. Pending local changes are flushed via [SyncTrigger.requestSync] on the same schedule. + */ fun onAppForegrounded(organizationId: String, memberId: String, refreshAll: Boolean) { if (refreshAll) { + val now = clock.nowMs() + val last = lastForegroundRefreshMs + if (last != null && now - last < FOREGROUND_REFRESH_DEBOUNCE_MS) { + // Debounced: the data fetched moments ago is still fresh. Keep polling alive. + startActiveEntryMonitoring(organizationId) + return + } + lastForegroundRefreshMs = now + // Flush any queued local changes to the server before we re-read fresh state. + syncTrigger.requestSync() loadAllData(organizationId, memberId) } else { // Returning from the tile picker is usually too brief to trigger a full refresh, @@ -466,6 +540,16 @@ class TrackingViewModel @Inject constructor( } } + /** + * Cancels [viewModelScope] for unit tests that install a test Main dispatcher; mirrors the + * sync-center VM teardown so no Main-bound collector straggles past `Dispatchers.resetMain()`. + * Not used in production. + */ + @VisibleForTesting + internal fun cancelScopeForTest() { + viewModelScope.coroutineContext[Job]?.cancel() + } + /** * Load the active time entry for the current user */ @@ -630,6 +714,7 @@ class TrackingViewModel @Inject constructor( } } + @Suppress("LongMethod", "LoopWithTooManyJumpStatements") fun jumpToHistoryDate(date: LocalDate) { val organizationId = historyOrganizationId ?: return val memberId = historyMemberId ?: return @@ -701,7 +786,7 @@ class TrackingViewModel @Inject constructor( completedProbes++ _uiState.value = _uiState.value.copy( historyJumpProgress = (completedProbes.toFloat() / (expectedProbes + 1)) - .coerceAtMost(0.9f), + .coerceAtMost(HISTORY_PROGRESS_CAP), ) matchOffset = middle when { @@ -795,14 +880,15 @@ class TrackingViewModel @Inject constructor( limit: Int, offset: Int, ): Result { - repeat(3) { + repeat(RATE_LIMIT_RETRY_ATTEMPTS) { val result = authRepository.getTimeEntries(organizationId, memberId, limit, offset) val error = result.exceptionOrNull() - if (error !is retrofit2.HttpException || error.code() != 429) return result + if (error !is retrofit2.HttpException || error.code() != HTTP_TOO_MANY_REQUESTS) return result val waitSeconds = error.response()?.headers()?.get("Retry-After") - ?.toIntOrNull()?.coerceIn(1, 60) ?: 5 + ?.toIntOrNull()?.coerceIn(MIN_RETRY_AFTER_SECONDS, MAX_RETRY_AFTER_SECONDS) + ?: DEFAULT_RETRY_AFTER_SECONDS _uiState.value = _uiState.value.copy(historyRateLimitWaitSeconds = waitSeconds) - delay(waitSeconds * 1_000L) + delay(waitSeconds * MILLIS_PER_SECOND) _uiState.value = _uiState.value.copy(historyRateLimitWaitSeconds = null) } return Result.failure(IllegalStateException("Rate limit retry exhausted")) @@ -827,7 +913,7 @@ class TrackingViewModel @Inject constructor( // negative elapsed value and render as garbage (e.g. "-1:-5:-3"). val elapsed = (now.epochSecond - startInstant.epochSecond).coerceAtLeast(0) _elapsedSeconds.value = elapsed - delay(1000) // Update every second + delay(TIMER_TICK_INTERVAL_MS) // Update every second } } catch (e: CancellationException) { // Expected when timer is stopped, don't log as error @@ -935,7 +1021,7 @@ class TrackingViewModel @Inject constructor( /** * Update the current active time entry */ - fun updateCurrentTimeEntry(organizationId: String, timeEntry: TimeEntry? = null, tags: List? = null) { + fun updateCurrentTimeEntry(timeEntry: TimeEntry? = null, tags: List? = null) { val entryToUpdate = timeEntry ?: _uiState.value.currentTimeEntry if (entryToUpdate == null) { Timber.w("No active time entry to update") @@ -989,7 +1075,7 @@ class TrackingViewModel @Inject constructor( /** * Stop the active time entry */ - fun stopTimeEntry(organizationId: String, memberId: String, userId: String) { + fun stopTimeEntry(userId: String) { val currentEntry = _uiState.value.currentTimeEntry // If paused, the entry is already stopped - just clear the paused state @@ -1052,7 +1138,7 @@ class TrackingViewModel @Inject constructor( * Pause the active time entry - stops it via API but keeps notification in paused state * preserving the project/task/description for easy resume */ - fun pauseTimeEntry(organizationId: String, userId: String) { + fun pauseTimeEntry(userId: String) { val currentEntry = _uiState.value.currentTimeEntry if (currentEntry == null) { Timber.w("No active time entry to pause") @@ -1185,7 +1271,6 @@ class TrackingViewModel @Inject constructor( * Update a past time entry */ fun updatePastTimeEntry( - organizationId: String, timeEntry: TimeEntry, description: String?, projectId: String?, @@ -1217,6 +1302,51 @@ class TrackingViewModel @Inject constructor( } } + /** + * Roadmap #13: duplicate a completed entry, then open the copy for immediate editing. Running + * and conflicted entries are guarded by the repository; a failure surfaces as an error message. + */ + fun duplicateTimeEntry(entryId: String) { + val memberId = historyMemberId ?: return + viewModelScope.launch { + timeEntryRepository.duplicateEntry(entryId, memberId) + .onSuccess { created -> + syncTrigger.requestSync() + _uiState.value = _uiState.value.copy(entryToEditId = created.id) + } + .onFailure { error -> + Timber.e(error, "Failed to duplicate time entry") + _uiState.value = _uiState.value.copy(error = error.message ?: "Failed to duplicate entry") + } + } + } + + /** + * Roadmap #13: split a completed entry at [atIso]; on success open the new second half for + * immediate editing. Validation (interior instant, running/conflict guards) lives in the repo. + */ + fun splitTimeEntry(entryId: String, atIso: String) { + val memberId = historyMemberId ?: return + viewModelScope.launch { + timeEntryRepository.splitEntry(entryId, atIso, memberId) + .onSuccess { newId -> + syncTrigger.requestSync() + _uiState.value = _uiState.value.copy(entryToEditId = newId) + } + .onFailure { error -> + Timber.e(error, "Failed to split time entry") + _uiState.value = _uiState.value.copy(error = error.message ?: "Failed to split entry") + } + } + } + + /** One-shot consume of [TrackingUiState.entryToEditId] once the UI has opened the editor. */ + fun consumeEntryToEdit() { + if (_uiState.value.entryToEditId != null) { + _uiState.value = _uiState.value.copy(entryToEditId = null) + } + } + /** * Delete a time entry. * @@ -1226,7 +1356,7 @@ class TrackingViewModel @Inject constructor( * deferred behind the undo window in [pendingDeleteCommitJobs] and only runs if [undoDelete] * doesn't cancel it first. */ - fun deleteTimeEntry(organizationId: String, timeEntryId: String) { + fun deleteTimeEntry(timeEntryId: String) { viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true, error = null) @@ -1302,6 +1432,7 @@ class TrackingViewModel @Inject constructor( private companion object { const val ACTIVE_ENTRY_REFRESH_INTERVAL_MS = 10_000L + const val FOREGROUND_REFRESH_DEBOUNCE_MS = 5_000L const val FIRST_SCROLL_TOTAL = 150 const val MAX_PAGE_SIZE = 500 const val HISTORY_REFRESH_LIMIT = 250 diff --git a/app/src/main/java/dev/tricked/solidverdant/util/IsoTimes.kt b/app/src/main/java/dev/tricked/solidverdant/util/IsoTimes.kt index 306f63a..eee1a92 100644 --- a/app/src/main/java/dev/tricked/solidverdant/util/IsoTimes.kt +++ b/app/src/main/java/dev/tricked/solidverdant/util/IsoTimes.kt @@ -21,18 +21,26 @@ import java.time.ZonedDateTime */ object IsoTimes { + private const val DATE_LENGTH = 10 + private const val DATE_YEAR_END = 4 + private const val DATE_MONTH_END = 7 + private const val TIME_LENGTH = 16 + private const val TIME_MARKER_INDEX = 10 + private const val TIME_SEPARATOR_INDEX = 13 + private const val TIME_START = 11 + /** Same result as `ZonedDateTime.parse(iso).toLocalDate()` for valid ISO date-times. */ fun localDate(iso: String): LocalDate? { - if (iso.length >= 10 && iso[4] == '-' && iso[7] == '-') { - runCatching { return LocalDate.parse(iso.substring(0, 10)) } + if (iso.length >= DATE_LENGTH && iso[DATE_YEAR_END] == '-' && iso[DATE_MONTH_END] == '-') { + runCatching { return LocalDate.parse(iso.substring(0, DATE_LENGTH)) } } return runCatching { ZonedDateTime.parse(iso).toLocalDate() }.getOrNull() } /** Same result as `ZonedDateTime.parse(iso).format(ofPattern("HH:mm"))`. */ fun hourMinute(iso: String): String? { - if (iso.length >= 16 && iso[10] == 'T' && iso[13] == ':') { - return iso.substring(11, 16) + if (iso.length >= TIME_LENGTH && iso[TIME_MARKER_INDEX] == 'T' && iso[TIME_SEPARATOR_INDEX] == ':') { + return iso.substring(TIME_START, TIME_LENGTH) } return runCatching { val parsed = ZonedDateTime.parse(iso) diff --git a/app/src/main/java/dev/tricked/solidverdant/util/RelativeTime.kt b/app/src/main/java/dev/tricked/solidverdant/util/RelativeTime.kt new file mode 100644 index 0000000..bdc49d4 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/util/RelativeTime.kt @@ -0,0 +1,44 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.util + +/** + * Coarse relative-time bucketing for freshness labels on the Sync Center screen (#33). Kept as a + * pure, resource-free helper so it is trivially unit-testable; the composable layer maps a + * [Bucket] + [amount] to a localized string. "Just now" also absorbs future / sentinel-zero + * timestamps so a clock skew never renders a negative age. + */ +object RelativeTime { + enum class Bucket { Never, JustNow, Minutes, Hours, Days } + + private const val MINUTE_MS = 60_000L + private const val HOUR_MS = 60 * MINUTE_MS + private const val DAY_MS = 24 * HOUR_MS + + fun bucketOf(thenMs: Long?, nowMs: Long): Bucket { + if (thenMs == null) return Bucket.Never + val delta = nowMs - thenMs + return when { + delta < MINUTE_MS -> Bucket.JustNow + delta < HOUR_MS -> Bucket.Minutes + delta < DAY_MS -> Bucket.Hours + else -> Bucket.Days + } + } + + /** Whole-unit magnitude for the [bucketOf] result (minutes / hours / days). 0 for Never/JustNow. */ + fun amount(thenMs: Long?, nowMs: Long): Int { + if (thenMs == null) return 0 + val delta = (nowMs - thenMs).coerceAtLeast(0) + return when (bucketOf(thenMs, nowMs)) { + Bucket.Minutes -> (delta / MINUTE_MS).toInt() + Bucket.Hours -> (delta / HOUR_MS).toInt() + Bucket.Days -> (delta / DAY_MS).toInt() + else -> 0 + } + } +} diff --git a/app/src/main/java/dev/tricked/solidverdant/widget/TimeTrackingWidget.kt b/app/src/main/java/dev/tricked/solidverdant/widget/TimeTrackingWidget.kt index 6c10ae7..8593e5c 100644 --- a/app/src/main/java/dev/tricked/solidverdant/widget/TimeTrackingWidget.kt +++ b/app/src/main/java/dev/tricked/solidverdant/widget/TimeTrackingWidget.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.launch import timber.log.Timber import java.time.Duration import java.time.Instant +import java.util.Locale /** * Home screen widget for time tracking @@ -49,7 +50,8 @@ class TimeTrackingWidget : AppWidgetProvider() { val widgetState = settingsDataStore.widgetState.first() Timber.d( - "Widget state: isTracking=${widgetState.isTracking}, startTime=${widgetState.startTimeEpochMillis}, project=${widgetState.projectName}", + "Widget state: isTracking=${widgetState.isTracking}, " + + "startTime=${widgetState.startTimeEpochMillis}, project=${widgetState.projectName}", ) // Update all widgets with current tracking state @@ -218,7 +220,10 @@ class TimeTrackingWidget : AppWidgetProvider() { val pendingIntent = updatePendingIntent(context) val elapsedMs = System.currentTimeMillis() - startTimeMillis - val msUntilNextMinute = (60_000 - (elapsedMs % 60_000)).coerceIn(1_000L, 60_000L) + val msUntilNextMinute = (MILLIS_PER_MINUTE - (elapsedMs % MILLIS_PER_MINUTE)).coerceIn( + MIN_SCHEDULE_DELAY_MS, + MILLIS_PER_MINUTE, + ) val triggerAt = SystemClock.elapsedRealtime() + msUntilNextMinute alarmManager.setAndAllowWhileIdle( @@ -264,6 +269,9 @@ class TimeTrackingWidget : AppWidgetProvider() { companion object { private const val ALARM_ID = 12345 + private const val MILLIS_PER_MINUTE = 60_000L + private const val MIN_SCHEDULE_DELAY_MS = 1_000L + private const val SECONDS_PER_MINUTE = 60L /** * Format elapsed tracking time as HH:MM (minute resolution). @@ -277,7 +285,7 @@ class TimeTrackingWidget : AppWidgetProvider() { Instant.ofEpochMilli(nowMillis), ) val safe = if (duration.isNegative) Duration.ZERO else duration - return String.format("%02d:%02d", safe.toHours(), safe.toMinutes() % 60) + return String.format(Locale.ROOT, "%02d:%02d", safe.toHours(), safe.toMinutes() % SECONDS_PER_MINUTE) } /** diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 43c6ffc..6440164 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -33,7 +33,7 @@ この期間に記録された時間はありません。プロジェクト別推移過去7日間先週今週今月先月カスタム適用合計エントリー1日平均月を表示前の月次の月 長時間タイマーの警告%1$d時間後に警告します。この端末にのみ保存されます。%1$d時間このタイマーは%1$d時間実行されています。今すぐ停止続行終了時刻を調整確認が必要な重複エントリーが%1$d組あります。保存はブロックされません。 接続をテストテスト中… - 同期センター同期済み保留中再試行中失敗%1$d件の変更に失敗しました%1$d件の変更を再試行中%1$d件の変更が同期を待機中 + 同期センター同期済み保留中再試行中失敗%1$d件の変更に失敗しました%1$d件の変更を再試行中%1$d件の変更が同期を待機中競合%1$d件の競合を確認してください プロフィール画像 履歴を検索 分類が必要 @@ -45,6 +45,7 @@ ソリッドグリーン 時間追跡 一部の変更を同期できませんでした。自動的に再試行されます。 + 編集を保存する前に、レビューでこの同期競合を解決してください。 Solidtimeログイン @@ -71,6 +72,7 @@ 時間追跡 + 組織を切り替える 更新 ログアウト 追跡中 @@ -115,6 +117,11 @@ 説明なし 時間エントリを編集 時間エントリを追加 + 複製 + 分割 + 時刻で分割 + 分割時刻はエントリの範囲内にする必要があります。 + 実行中または競合中のエントリでは使用できません。 日付 今日 昨日 diff --git a/app/src/main/res/values-ja/strings_inbox.xml b/app/src/main/res/values-ja/strings_inbox.xml index b896b34..4fb7894 100644 --- a/app/src/main/res/values-ja/strings_inbox.xml +++ b/app/src/main/res/values-ja/strings_inbox.xml @@ -43,6 +43,17 @@ この項目は %1$s です。想定どおりに終了したか確認してください。 レビュー + 別の場所で項目が変更されました + この項目は端末とサーバーの両方で変更されています。残すバージョンを選択してください。 + 自分の版: %1$s + サーバー: %1$s + 削除済み + 利用できません + + %1$s · %2$s · %3$s + 自分の版を残す + サーバー版を残す + 閉じる 無題の項目 @@ -75,4 +86,20 @@ 増やす %1$d 分 %1$d 時間 + + + レビューはどこまで遡りますか? + 開始時点を選んでください。あとで受信トレイ設定から変更できます。 + 今日 + 今週 + 過去30日間 + すべて + %1$s 以降 + レビューで遡る範囲を変更 + レビューで遡る範囲 + + + すべて無視 + この日より前をすべて無視 + この日の他の操作 diff --git a/app/src/main/res/values-ja/strings_privacy.xml b/app/src/main/res/values-ja/strings_privacy.xml new file mode 100644 index 0000000..2cc39a3 --- /dev/null +++ b/app/src/main/res/values-ja/strings_privacy.xml @@ -0,0 +1,53 @@ + + + + プライバシーとデータ + 戻る + プライバシーとデータ + + この端末に保存されるもの + Solidtime から: オフラインでも使えるように、時間エントリ・プロジェクト・タスク・タグをキャッシュします。 + この端末にのみ保存: お気に入り、リマインダー設定、受信トレイ設定、非表示設定。これらは端末外に出ません。 + サインイン用トークンは Android Keystore による暗号化ストレージに保存されます。 + アプリ内のラベル: Solidtime から · 端末で計算 · 端末にのみ保存 · 権限付きで Android から。 + + サーバーに送信されるもの + 選択中のサーバー + 未設定 + このサーバーには時間トラッキングのデータのみが、変更または同期時にのみ送信されます。それ以外が自動的に端末外へ出ることはありません。 + + 認証情報の保護方法 + アクセストークンと更新トークンは Android Keystore の鍵で暗号化され、ログアウト時に消去されます。 + + 任意の権限 + 通知: トラッキングのリマインダーと一日の終わりのレビューに使用します。任意です。 + カレンダー: 端末のカレンダー予定を記録時間の横に重ねて表示します。任意で読み取り専用です。 + アプリの権限を開く + + ストレージ使用量 + 計算中… + データベース + キャッシュ + 合計 + 概算です。キャッシュはいつでも消去して再同期できます。 + + データ管理 + 診断情報をエクスポート + サポート用の技術レポートを共有します。説明・プロジェクト名・トークンなどの作業内容は含まれません。 + キャッシュを消去 + ローカルにキャッシュされた時間トラッキングのデータを削除します。サーバーから再同期され、お気に入りは保持され、ログインは維持されます。 + ログアウト / セッションを取り消す + ローカルセッションとキャッシュされたアカウントデータをすべて消去し、保存されたトークンを削除します。 + + キャンセル + キャッシュを消去しますか? + ローカルにキャッシュされた時間トラッキングのデータを削除します。サーバーから再同期されます。お気に入りは保持され、ログインは維持されます。 + 消去 + ログアウトしますか? + ローカルセッションを取り消し、キャッシュされたアカウントデータを消去し、保存されたトークンを削除します。再度サインインが必要になります。 + ログアウト + diff --git a/app/src/main/res/values-ja/strings_stats2.xml b/app/src/main/res/values-ja/strings_stats2.xml index 67cdb68..bf534c7 100644 --- a/app/src/main/res/values-ja/strings_stats2.xml +++ b/app/src/main/res/values-ja/strings_stats2.xml @@ -78,4 +78,13 @@ %1$d 件のエントリ + + + 見積もりと進捗 + %1$s / %2$s + 残り %1$s + %1$s 超過 + 見積もりに接近中 + %1$s: %3$s 中 %2$s 使用 + %1$s: 見積もりを %2$s 超過 diff --git a/app/src/main/res/values-ja/strings_sync.xml b/app/src/main/res/values-ja/strings_sync.xml new file mode 100644 index 0000000..8327a09 --- /dev/null +++ b/app/src/main/res/values-ja/strings_sync.xml @@ -0,0 +1,58 @@ + + + + 同期センター + 同期センターを開く + 戻る + + + 最新の状態 + サーバーから最後に同期 + 最後に変更をアップロード + まだ同期していません + アップロードした変更はまだありません + 今すぐ同期 + + + たった今 + %1$d 分前 + %1$d 時間前 + %1$d 日前 + + + すべての変更が同期されています + %1$d 件の変更が同期待ちです + %1$d 件の変更がサーバーに届きませんでした + %1$d 件の変更に確認が必要です + すべて同期済み + 確認が必要です + + + 同期待ち + 同期待ちの項目はありません。 + この変更はまだサーバーに届いていません。 + + + 同期に失敗しました + 確認が必要です + この項目は別のデバイスで変更されたため、確認が必要です。 + すべて再試行 + 再試行 + 破棄 + + + タイマー開始 + 新しい時間エントリ + タイマー停止 + 時間エントリを編集 + 時間エントリを削除 + + + デバイスがオフラインだったため、この変更を送信できませんでした。 + サーバーがこの変更の受け入れで問題が発生しました。 + サーバーがこの変更を受け入れませんでした。 + この変更を送信できませんでした。引き続き試行します。 + + + すべて最新です。すべての変更がサーバーに安全に保存されています。 + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 40587ec..7f02898 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -33,7 +33,7 @@ Geen geregistreerde tijd in deze periode.Per projectTrendAfgelopen 7 dagenVorige weekDeze weekDeze maandVorige maandAangepastToepassenTotaalBoekingenGem. / dagMaand tonenVorige maandVolgende maand Waarschuwing lange timerWaarschuw na %1$d uur. Alleen op dit apparaat opgeslagen.%1$duDeze timer loopt al %1$d uur.Nu stoppenDoorgaanEindtijd aanpassen%1$d overlappende boekingsparen om te controleren. Overlap blokkeert opslaan niet. Verbinding testenTesten… - SynchronisatiecentrumGesynchroniseerdIn afwachtingOpnieuw proberenMislukt%1$d mislukte wijzigingen%1$d wijzigingen worden opnieuw geprobeerd%1$d wijzigingen wachten op synchronisatie + SynchronisatiecentrumGesynchroniseerdIn afwachtingOpnieuw proberenMislukt%1$d mislukte wijzigingen%1$d wijzigingen worden opnieuw geprobeerd%1$d wijzigingen wachten op synchronisatieConflict%1$d conflicten moeten worden nagekeken Profielfoto Geschiedenis doorzoeken Moet worden gecategoriseerd @@ -45,6 +45,7 @@ SolidGroen Tijdregistratie Sommige wijzigingen konden niet worden gesynchroniseerd. Ze worden automatisch opnieuw geprobeerd. + Los dit synchronisatieconflict eerst op in Review. Solidtime Inloggen @@ -71,6 +72,7 @@ Tijdregistratie + Organisatie wisselen Vernieuwen Uitloggen Momenteel Aan Het Registreren @@ -115,6 +117,11 @@ Geen beschrijving Tijdregistratie Bewerken Tijdregistratie Toevoegen + Dupliceren + Splitsen + Splitsen op tijd + De splitstijd moet binnen de registratie vallen. + Niet beschikbaar voor lopende of conflicterende registraties. Datum Vandaag Gisteren diff --git a/app/src/main/res/values-nl/strings_inbox.xml b/app/src/main/res/values-nl/strings_inbox.xml index 71cefec..a58e4dd 100644 --- a/app/src/main/res/values-nl/strings_inbox.xml +++ b/app/src/main/res/values-nl/strings_inbox.xml @@ -44,6 +44,17 @@ Dit item duurt %1$s. Controleer of het op het verwachte moment is gestopt. Nakijken + Item elders gewijzigd + Dit item is op dit apparaat en op de server gewijzigd. Kies welke versie je wilt behouden. + Mijn versie: %1$s + Server: %1$s + Verwijderd + Niet beschikbaar + + %1$s · %2$s · %3$s + Mijn versie houden + Serverversie houden + Negeren Item zonder titel @@ -76,4 +87,20 @@ Verhogen %1$d min %1$d u + + + Hoe ver terug moet Review kijken? + Kies een startpunt. Je kunt dit later aanpassen in de inbox-instellingen. + Vandaag + Deze week + Afgelopen 30 dagen + Alles + Sinds %1$s + Wijzig hoe ver terug Review kijkt + Hoe ver terug te reviewen + + + Alles negeren + Alles voor deze dag negeren + Meer acties voor deze dag diff --git a/app/src/main/res/values-nl/strings_privacy.xml b/app/src/main/res/values-nl/strings_privacy.xml new file mode 100644 index 0000000..621f642 --- /dev/null +++ b/app/src/main/res/values-nl/strings_privacy.xml @@ -0,0 +1,53 @@ + + + + Privacy en gegevens + Terug + Privacy en gegevens + + Wat op dit apparaat staat + Van Solidtime: je opgeslagen tijdregistraties, projecten, taken en labels, zodat de app offline werkt. + Alleen op dit apparaat: favorieten, herinneringsinstellingen, inbox-instellingen en negeringen. Deze verlaten je telefoon nooit. + Inlogtokens worden bewaard in door Android Keystore versleutelde opslag. + Labels in de app: Van Solidtime · Berekend op apparaat · Alleen op apparaat opgeslagen · Van Android met toestemming. + + Wat naar de server wordt gestuurd + Geselecteerde server + Niet ingesteld + Alleen je tijdregistratiegegevens worden naar deze server gestuurd, en alleen bij wijzigingen of synchronisatie. Verder verlaat er niets automatisch het apparaat. + + Hoe je gegevens worden beschermd + Toegangs- en vernieuwingstokens worden versleuteld met een Android Keystore-sleutel en gewist bij uitloggen. + + Optionele machtigingen + Meldingen: voor registratieherinneringen en de dagafsluiting. Optioneel. + Agenda: om agenda-items van je apparaat naast geregistreerde tijd te tonen. Optioneel en alleen-lezen. + App-machtigingen openen + + Opslaggebruik + Berekenen… + Database + Cache + Totaal + Bij benadering. De cache kan altijd worden gewist en opnieuw gesynchroniseerd. + + Gegevensbeheer + Diagnostiek exporteren + Deelt een technisch rapport voor ondersteuning. Bevat geen omschrijvingen, projectnamen, tokens of andere werkinhoud. + Cachegegevens wissen + Verwijdert lokaal opgeslagen tijdregistratiegegevens. Deze synchroniseren opnieuw; favorieten blijven en je blijft ingelogd. + Uitloggen / sessie intrekken + Wist je lokale sessie en alle opgeslagen accountgegevens en verwijdert opgeslagen tokens. + + Annuleren + Cachegegevens wissen? + Dit verwijdert lokaal opgeslagen tijdregistratiegegevens. Ze synchroniseren opnieuw vanaf de server. Favorieten blijven en je blijft ingelogd. + Wissen + Uitloggen? + Dit trekt je lokale sessie in, wist opgeslagen accountgegevens en verwijdert opgeslagen tokens. Je moet opnieuw inloggen. + Uitloggen + diff --git a/app/src/main/res/values-nl/strings_stats2.xml b/app/src/main/res/values-nl/strings_stats2.xml index 2fac847..cf8ee6b 100644 --- a/app/src/main/res/values-nl/strings_stats2.xml +++ b/app/src/main/res/values-nl/strings_stats2.xml @@ -80,4 +80,13 @@ %1$d vermelding %1$d vermeldingen + + + Schattingen & voortgang + %1$s / %2$s + %1$s over + %1$s te veel + Schatting bijna bereikt + %1$s: %2$s van %3$s gebruikt + %1$s: %2$s boven schatting diff --git a/app/src/main/res/values-nl/strings_sync.xml b/app/src/main/res/values-nl/strings_sync.xml new file mode 100644 index 0000000..e100cad --- /dev/null +++ b/app/src/main/res/values-nl/strings_sync.xml @@ -0,0 +1,58 @@ + + + + Synchronisatiecentrum + Synchronisatiecentrum openen + Terug + + + Actueel + Laatst opgehaald van server + Laatste wijziging geüpload + Nog niet gesynchroniseerd + Nog geen wijzigingen geüpload + Nu synchroniseren + + + Zojuist + %1$d min geleden + %1$d uur geleden + %1$d dagen geleden + + + Alle wijzigingen zijn gesynchroniseerd + %1$d wijzigingen wachten op synchronisatie + %1$d wijzigingen konden de server niet bereiken + %1$d wijzigingen vragen om je aandacht + Alles gesynchroniseerd + Aandacht vereist + + + Wacht op synchronisatie + Er wacht niets op synchronisatie. + Deze wijziging heeft de server nog niet bereikt. + + + Synchronisatie mislukt + Vraagt om je aandacht + Dit item is op een ander apparaat gewijzigd en vraagt om je aandacht. + Alles opnieuw proberen + Opnieuw + Verwijderen + + + Timer gestart + Nieuwe tijdregistratie + Timer gestopt + Tijdregistratie bewerkt + Tijdregistratie verwijderd + + + Je apparaat was offline, dus deze wijziging kon niet worden verzonden. + De server had een probleem met het verwerken van deze wijziging. + De server accepteerde deze wijziging niet. + Deze wijziging kon niet worden verzonden. We blijven het proberen. + + + Je bent helemaal bij. Elke wijziging staat veilig op de server. + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ee4708..e15f53d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,12 +33,13 @@ No tracked time in this range.By projectTrendLast 7 daysLast weekThis weekThis monthPrevious monthCustomApplyTotalEntriesAvg / dayShow monthPrevious monthNext month Long-timer warningWarn after %1$d hours. Stored only on this device.%1$dhThis timer has been running for %1$d hours.Stop nowKeep runningAdjust end time%1$d overlapping entry pairs to review. Overlaps are warnings and do not block saving. Test connectionTesting… - Sync centerSyncedPendingRetryingFailed%1$d failed changesRetrying %1$d changes%1$d changes waiting to sync + Sync centerSyncedPendingRetryingFailed%1$d failed changesRetrying %1$d changes%1$d changes waiting to syncConflict%1$d conflicts need review SolidVerdant Some changes couldn\'t be synced. They\'ll retry automatically. + Resolve this sync conflict in Review before saving edits. Track @@ -73,6 +74,7 @@ Time Tracking + Switch organization Organization Refresh Logout @@ -124,6 +126,11 @@ No description Edit Time Entry Add Time Entry + Duplicate + Split + Split at time + Split time must be inside the entry. + Not available for running or conflicted entries. Date Today Yesterday diff --git a/app/src/main/res/values/strings_inbox.xml b/app/src/main/res/values/strings_inbox.xml index a885745..0539f84 100644 --- a/app/src/main/res/values/strings_inbox.xml +++ b/app/src/main/res/values/strings_inbox.xml @@ -50,6 +50,17 @@ This entry is %1$s long. Check that it ended when you expected. Review + Entry changed elsewhere + This entry was edited on this device and on the server. Choose which version to keep. + Mine: %1$s + Server: %1$s + Deleted + Unavailable + + %1$s · %2$s · %3$s + Keep mine + Keep server + Dismiss Untitled entry @@ -85,4 +96,20 @@ Increase %1$d min %1$d h + + + How far back should Review look? + Pick a starting point. You can widen or narrow it later from Inbox settings. + Today + This week + Last 30 days + Everything + Since %1$s + Change how far back Review looks + How far back to review + + + Dismiss all + Dismiss everything before this + More actions for this day diff --git a/app/src/main/res/values/strings_privacy.xml b/app/src/main/res/values/strings_privacy.xml new file mode 100644 index 0000000..343b5dd --- /dev/null +++ b/app/src/main/res/values/strings_privacy.xml @@ -0,0 +1,53 @@ + + + + Privacy & data + Navigate back + Privacy & data + + What\'s stored on this device + From Solidtime: your cached time entries, projects, tasks and tags, so the app works offline. + Stored only on this device: favourites, reminder settings, inbox settings and dismissals. These never leave your phone. + Sign-in tokens are kept in Android Keystore-backed encrypted storage. + Labels used across the app: From Solidtime · Calculated on device · Stored only on device · From Android with permission. + + What\'s sent to the server + Selected server + Not configured + Only your time-tracking data is sent to this server, and only when you make changes or sync. Nothing else leaves the device automatically. + + How your credentials are protected + Access and refresh tokens are encrypted with an Android Keystore key and are cleared when you log out. + + Optional permissions + Notifications: used for tracking reminders and the end-of-day review. Optional. + Calendar: used to overlay your device calendar events next to tracked time. Optional and read-only. + Open app permissions + + Storage usage + Calculating… + Database + Cache + Total + Approximate. The cache can be cleared and re-synced at any time. + + Data controls + Export diagnostics + Shares a technical report for support. It contains no descriptions, project names, tokens or other work content. + Clear cached data + Removes locally cached time-tracking data. It re-syncs from the server; favourites are kept and you stay logged in. + Log out / revoke session + Clears your local session and all cached account data, and removes stored tokens. + + Cancel + Clear cached data? + This removes locally cached time-tracking data. It will re-sync from the server. Favourites are kept and you stay logged in. + Clear + Log out? + This revokes your local session, clears cached account data and removes stored tokens. You\'ll need to sign in again. + Log out + diff --git a/app/src/main/res/values/strings_stats2.xml b/app/src/main/res/values/strings_stats2.xml index bdbdc34..376b7ee 100644 --- a/app/src/main/res/values/strings_stats2.xml +++ b/app/src/main/res/values/strings_stats2.xml @@ -80,4 +80,13 @@ %1$d entry %1$d entries + + + Estimates & progress + %1$s / %2$s + %1$s left + %1$s over + Approaching estimate + %1$s: %2$s of %3$s used + %1$s: over estimate by %2$s diff --git a/app/src/main/res/values/strings_sync.xml b/app/src/main/res/values/strings_sync.xml new file mode 100644 index 0000000..d35067c --- /dev/null +++ b/app/src/main/res/values/strings_sync.xml @@ -0,0 +1,58 @@ + + + + Sync center + Open sync center + Back + + + Up to date + Last synced from server + Last change uploaded + Not synced yet + No changes uploaded yet + Sync now + + + Just now + %1$d min ago + %1$d hr ago + %1$d days ago + + + All changes are synced + %1$d changes waiting to sync + %1$d changes failed to reach the server + %1$d changes need your review + Everything synced + Attention needed + + + Waiting to sync + Nothing is waiting to sync. + This change hasn\'t reached the server yet. + + + Failed to sync + Need your review + This entry changed on another device and needs your review. + Retry all + Retry + Discard + + + Started timer + New time entry + Stopped timer + Edited time entry + Deleted time entry + + + Your device was offline, so this change couldn\'t be sent. + The server had a problem accepting this change. + The server wouldn\'t accept this change. + This change couldn\'t be sent. We\'ll keep trying. + + + You\'re all caught up. Every change is safely on the server. + diff --git a/app/src/test/java/dev/tricked/solidverdant/data/export/DiagnosticBundleTest.kt b/app/src/test/java/dev/tricked/solidverdant/data/export/DiagnosticBundleTest.kt new file mode 100644 index 0000000..2f33998 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/data/export/DiagnosticBundleTest.kt @@ -0,0 +1,123 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.data.export + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Exercises the pure diagnostic bundle core and its redaction contract (no Android deps). */ +class DiagnosticBundleTest { + + private fun buildSample(failures: List = emptyList()): String = DiagnosticBundle.build( + appVersion = "1.2.3", + appVersionCode = 10203, + androidRelease = "14", + androidSdk = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel 7", + serverHost = "app.solidtime.io", + isSelfHosted = false, + capabilities = listOf("time tracking", "offline outbox"), + pendingCount = 2, + failedCount = failures.size, + failures = failures, + lastFullSyncAtMs = 1_700_000_000_000L, + lastPushAtMs = null, + ) + + @Test + fun `classify network and timeout errors as offline`() { + assertEquals(DiagnosticErrorCategory.OFFLINE, DiagnosticBundle.classifyOutboxError("Unable to resolve host")) + assertEquals(DiagnosticErrorCategory.OFFLINE, DiagnosticBundle.classifyOutboxError("connect timeout")) + assertEquals(DiagnosticErrorCategory.OFFLINE, DiagnosticBundle.classifyOutboxError("network is unreachable")) + } + + @Test + fun `classify 5xx as server problem`() { + assertEquals(DiagnosticErrorCategory.SERVER_PROBLEM, DiagnosticBundle.classifyOutboxError("500 Internal")) + assertEquals(DiagnosticErrorCategory.SERVER_PROBLEM, DiagnosticBundle.classifyOutboxError("503 unavailable")) + assertEquals(DiagnosticErrorCategory.SERVER_PROBLEM, DiagnosticBundle.classifyOutboxError("Bad Gateway")) + } + + @Test + fun `classify 4xx as rejected`() { + assertEquals(DiagnosticErrorCategory.REJECTED, DiagnosticBundle.classifyOutboxError("422 Unprocessable")) + assertEquals(DiagnosticErrorCategory.REJECTED, DiagnosticBundle.classifyOutboxError("403 Forbidden")) + assertEquals(DiagnosticErrorCategory.REJECTED, DiagnosticBundle.classifyOutboxError("400 Bad Request")) + } + + @Test + fun `classify null and unrecognized as unknown`() { + assertEquals(DiagnosticErrorCategory.UNKNOWN, DiagnosticBundle.classifyOutboxError(null)) + assertEquals(DiagnosticErrorCategory.UNKNOWN, DiagnosticBundle.classifyOutboxError("")) + assertEquals(DiagnosticErrorCategory.UNKNOWN, DiagnosticBundle.classifyOutboxError("something odd")) + } + + @Test + fun `bundle carries app and android version and host but not a full url`() { + val text = buildSample() + assertTrue(text.contains("1.2.3")) + assertTrue(text.contains("10203")) + assertTrue(text.contains("14")) + assertTrue(text.contains("SDK 34")) + assertTrue(text.contains("app.solidtime.io")) + // Host only: no scheme, path or query that could carry a token. + assertFalse(text.contains("https://")) + assertFalse(text.contains("http://")) + } + + @Test + fun `bundle summarizes failure category counts`() { + val text = buildSample( + failures = listOf( + DiagnosticFailure("UPDATE", DiagnosticErrorCategory.SERVER_PROBLEM, ageDays = 1, attempts = 3), + DiagnosticFailure("STOP", DiagnosticErrorCategory.SERVER_PROBLEM, ageDays = 0, attempts = 1), + DiagnosticFailure("DELETE", DiagnosticErrorCategory.OFFLINE, ageDays = 2, attempts = 5), + ), + ) + assertTrue(text.contains("3 failures")) + assertTrue(text.contains("2 server-problem")) + assertTrue(text.contains("1 offline")) + } + + @Test + fun `redaction contract - raw error content never reaches the categorized bundle`() { + // Simulate the exporter's own pipeline: raw errors are classified, then only the category + // (plus op type / age / attempts) is passed to the formatter. This is the safety contract. + val rawServerError = """500 Server Error: {"description":"secret client meeting"}""" + val rawTokenError = "token=abcdef123 expired unauthorized 401" + val rawProjectError = """422 {"project":"Project Moonshot rename"}""" + + val failures = listOf(rawServerError, rawTokenError, rawProjectError).mapIndexed { i, raw -> + DiagnosticFailure( + opType = "UPDATE", + category = DiagnosticBundle.classifyOutboxError(raw), + ageDays = i.toLong(), + attempts = 1, + ) + } + val text = buildSample(failures = failures) + + // Categories are present. + assertEquals(DiagnosticErrorCategory.SERVER_PROBLEM, failures[0].category) + assertEquals(DiagnosticErrorCategory.REJECTED, failures[1].category) + assertEquals(DiagnosticErrorCategory.REJECTED, failures[2].category) + assertTrue(text.contains("server-problem")) + assertTrue(text.contains("rejected")) + + // None of the raw work content or secrets leaked. + assertFalse(text.contains("secret client meeting")) + assertFalse(text.contains("abcdef")) + assertFalse(text.contains("token=")) + assertFalse(text.contains("Project Moonshot")) + // The JSON key from the raw body must not appear; the word "description" does occur in the + // header disclaimer, so we assert on the leaked *value*'s surrounding key/value pair instead. + assertFalse(text.contains("\"description\"")) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/data/local/UserCacheCleanerTest.kt b/app/src/test/java/dev/tricked/solidverdant/data/local/UserCacheCleanerTest.kt new file mode 100644 index 0000000..0a0e23e --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/data/local/UserCacheCleanerTest.kt @@ -0,0 +1,100 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.data.local + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import dev.tricked.solidverdant.data.local.db.AppDatabase +import dev.tricked.solidverdant.data.local.db.OutboxEntity +import dev.tricked.solidverdant.data.local.db.OutboxOpType +import dev.tricked.solidverdant.data.local.db.SyncState +import dev.tricked.solidverdant.data.local.db.TemplateEntity +import dev.tricked.solidverdant.data.local.db.TimeEntryEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UserCacheCleanerTest { + private lateinit var db: AppDatabase + private lateinit var cleaner: UserCacheCleaner + + @Before fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries().build() + cleaner = UserCacheCleaner(context, SettingsDataStore(context), db) + } + + @After fun teardown() = db.close() + + private fun timeEntry(id: String) = TimeEntryEntity( + id = id, description = "d", userId = "u", start = "2026-01-01T09:00:00Z", + end = "2026-01-01T10:00:00Z", duration = 3600, taskId = null, projectId = null, + billable = false, organizationId = "org", updatedAt = 1L, + syncState = SyncState.SYNCED, pendingDelete = false, + ) + + private fun outboxOp(entryId: String) = OutboxEntity( + opType = OutboxOpType.START, + organizationId = "org", + timeEntryId = entryId, + payloadJson = "{}", + createdAtMs = 1L, + ) + + private fun template(id: String, endpoint: String?, userId: String?) = TemplateEntity( + id = id, organizationId = "org", name = id, projectId = null, taskId = null, + description = null, tagIds = "", billable = false, isFavorite = false, + sortOrder = 0, createdAtMs = 1L, ownerEndpoint = endpoint, ownerUserId = userId, + ) + + @Test fun clear_wipes_other_tables_but_keeps_templates() = runTest { + db.timeEntryDao().upsert(timeEntry("e1")) + db.outboxDao().insert(outboxOp("e1")) + db.templateDao().upsert(template("t1", "https://a.example", "userA")) + + cleaner.clear() + + assertTrue(db.timeEntryDao().observeVisibleEntries("org").first().isEmpty()) + assertTrue(db.outboxDao().peekAll().isEmpty()) + assertNotNull(db.templateDao().getById("t1")) + } + + @Test fun clear_sweeps_future_tables_by_default() = runTest { + val raw = db.openHelper.writableDatabase + raw.execSQL("CREATE TABLE IF NOT EXISTS zz_future(id TEXT PRIMARY KEY)") + raw.execSQL("INSERT INTO zz_future(id) VALUES ('x')") + + cleaner.clear() + + raw.query("SELECT COUNT(*) FROM zz_future").use { c -> + c.moveToFirst() + assertEquals(0, c.getInt(0)) + } + } + + @Test fun clear_preserves_template_visibility_per_account() = runTest { + val endpoint = "https://a.example" + db.templateDao().upsert(template("t1", endpoint, "userA")) + + cleaner.clear() + + val visibleToA = db.templateDao().observeTemplates("org", endpoint, "userA").first() + assertEquals(listOf("t1"), visibleToA.map { it.id }) + + val visibleToB = db.templateDao().observeTemplates("org", endpoint, "userB").first() + assertTrue(visibleToB.isEmpty()) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/data/local/db/MigrationTest.kt b/app/src/test/java/dev/tricked/solidverdant/data/local/db/MigrationTest.kt index 6b85ce6..ffd0cdc 100644 --- a/app/src/test/java/dev/tricked/solidverdant/data/local/db/MigrationTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/data/local/db/MigrationTest.kt @@ -111,4 +111,158 @@ class MigrationTest { } helper.close() } + + @Test fun migration_4_5_adds_conflict_columns() { + val helper = openHelper(4) { db -> + db.execSQL( + "CREATE TABLE outbox (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "opType TEXT NOT NULL, organizationId TEXT NOT NULL, timeEntryId TEXT NOT NULL, " + + "payloadJson TEXT NOT NULL, createdAtMs INTEGER NOT NULL, " + + "attemptCount INTEGER NOT NULL, lastError TEXT, clientId TEXT NOT NULL, " + + "deadLettered INTEGER NOT NULL)", + ) + db.execSQL( + "INSERT INTO outbox (opType, organizationId, timeEntryId, payloadJson, createdAtMs, " + + "attemptCount, clientId, deadLettered) " + + "VALUES ('START', 'org1', 'local-1', '{}', 1, 0, 'client-1', 0)", + ) + db.execSQL( + "CREATE TABLE time_entries (`id` TEXT NOT NULL, `description` TEXT, " + + "`userId` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT, `duration` INTEGER, " + + "`taskId` TEXT, `projectId` TEXT, `billable` INTEGER NOT NULL, " + + "`organizationId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, " + + "`syncState` TEXT NOT NULL, `pendingDelete` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + db.execSQL( + "INSERT INTO time_entries (id, description, userId, start, end, duration, taskId, " + + "projectId, billable, organizationId, updatedAt, syncState, pendingDelete) " + + "VALUES ('te1', 'desc', 'user1', '2026-07-11T00:00:00Z', NULL, NULL, NULL, NULL, " + + "0, 'org1', 1, 'SYNCED', 0)", + ) + } + val db = helper.writableDatabase + + AppDatabase.MIGRATION_4_5.migrate(db) + + // Pre-existing rows read the new nullable columns back as NULL. + db.query("SELECT baseSnapshotJson FROM outbox WHERE timeEntryId = 'local-1'").use { c -> + c.moveToFirst() + assertEquals(null, c.getString(0)) + } + db.query("SELECT conflictServerJson FROM time_entries WHERE id = 'te1'").use { c -> + c.moveToFirst() + assertEquals(null, c.getString(0)) + } + + // A row can be written with syncState='CONFLICT' and read back. + db.execSQL( + "INSERT INTO time_entries (id, description, userId, start, end, duration, taskId, " + + "projectId, billable, organizationId, updatedAt, syncState, pendingDelete, " + + "conflictServerJson) " + + "VALUES ('te2', 'desc2', 'user1', '2026-07-11T00:00:00Z', NULL, NULL, NULL, NULL, " + + "0, 'org1', 1, 'CONFLICT', 0, '{\"id\":\"te2\"}')", + ) + db.query("SELECT syncState, conflictServerJson FROM time_entries WHERE id = 'te2'").use { c -> + c.moveToFirst() + assertEquals("CONFLICT", c.getString(0)) + assertEquals("{\"id\":\"te2\"}", c.getString(1)) + } + helper.close() + } + + @Test fun migration_5_6_adds_template_ownership() { + val helper = openHelper(5) { db -> + db.execSQL( + "CREATE TABLE `entry_templates` (" + + "`id` TEXT NOT NULL, `organizationId` TEXT NOT NULL, `name` TEXT, " + + "`projectId` TEXT, `taskId` TEXT, `description` TEXT, " + + "`tagIds` TEXT NOT NULL, `billable` INTEGER NOT NULL, " + + "`isFavorite` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, " + + "`createdAtMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_entry_templates_organizationId` " + + "ON `entry_templates` (`organizationId`)", + ) + // Legacy row inserted without the owner columns (they don't exist pre-migration). + db.execSQL( + "INSERT INTO entry_templates " + + "(id, organizationId, name, projectId, taskId, description, tagIds, billable, " + + "isFavorite, sortOrder, createdAtMs) " + + "VALUES ('t1', 'org1', 'Standup', NULL, NULL, NULL, '', 0, 1, 3, 42)", + ) + } + val db = helper.writableDatabase + + AppDatabase.MIGRATION_5_6.migrate(db) + + // Pre-existing row reads the new nullable owner columns back as NULL, other data survives. + db.query( + "SELECT ownerEndpoint, ownerUserId, name, isFavorite, sortOrder, createdAtMs " + + "FROM entry_templates WHERE id = 't1'", + ).use { c -> + c.moveToFirst() + assertEquals(null, c.getString(0)) + assertEquals(null, c.getString(1)) + assertEquals("Standup", c.getString(2)) + assertEquals(1, c.getInt(3)) + assertEquals(3, c.getInt(4)) + assertEquals(42L, c.getLong(5)) + } + + // The composite owner index exists after migration. + db.query( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'entry_templates' " + + "AND name = 'index_entry_templates_ownerUserId_organizationId'", + ).use { c -> + assertEquals(true, c.moveToFirst()) + assertEquals("index_entry_templates_ownerUserId_organizationId", c.getString(0)) + } + + // A row can be written with owner columns populated and read back. + db.execSQL( + "INSERT INTO entry_templates " + + "(id, organizationId, name, projectId, taskId, description, tagIds, billable, " + + "isFavorite, sortOrder, createdAtMs, ownerEndpoint, ownerUserId) " + + "VALUES ('t2', 'org1', NULL, NULL, NULL, NULL, '', 0, 0, 0, 7, 'https://api', 'user-9')", + ) + db.query("SELECT ownerEndpoint, ownerUserId FROM entry_templates WHERE id = 't2'").use { c -> + c.moveToFirst() + assertEquals("https://api", c.getString(0)) + assertEquals("user-9", c.getString(1)) + } + helper.close() + } + + @Test fun migration_6_7_adds_freshness_columns() { + val helper = openHelper(6) { db -> + db.execSQL( + "CREATE TABLE `sync_meta` (`organizationId` TEXT NOT NULL, " + + "`lastFullSyncAtMs` INTEGER NOT NULL, PRIMARY KEY(`organizationId`))", + ) + // Legacy row inserted without the per-source freshness column (it doesn't exist pre-migration). + db.execSQL("INSERT INTO sync_meta (organizationId, lastFullSyncAtMs) VALUES ('org1', 111)") + } + val db = helper.writableDatabase + + AppDatabase.MIGRATION_6_7.migrate(db) + + // Pre-existing row reads the new nullable column back as NULL; the pull timestamp survives. + db.query("SELECT lastFullSyncAtMs, lastPushAtMs FROM sync_meta WHERE organizationId = 'org1'").use { c -> + c.moveToFirst() + assertEquals(111L, c.getLong(0)) + assertEquals(true, c.isNull(1)) + } + + // A row can be written with the push timestamp populated and read back. + db.execSQL( + "INSERT INTO sync_meta (organizationId, lastFullSyncAtMs, lastPushAtMs) VALUES ('org2', 222, 333)", + ) + db.query("SELECT lastFullSyncAtMs, lastPushAtMs FROM sync_meta WHERE organizationId = 'org2'").use { c -> + c.moveToFirst() + assertEquals(222L, c.getLong(0)) + assertEquals(333L, c.getLong(1)) + } + helper.close() + } } diff --git a/app/src/test/java/dev/tricked/solidverdant/data/local/db/SyncMetaDaoTest.kt b/app/src/test/java/dev/tricked/solidverdant/data/local/db/SyncMetaDaoTest.kt new file mode 100644 index 0000000..b852702 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/data/local/db/SyncMetaDaoTest.kt @@ -0,0 +1,78 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.data.local.db + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SyncMetaDaoTest { + private lateinit var db: AppDatabase + private lateinit var dao: SyncMetaDao + + @Before fun setup() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + AppDatabase::class.java, + ).allowMainThreadQueries().build() + dao = db.syncMetaDao() + } + + @After fun teardown() = db.close() + + @Test fun stampFullSync_inserts_when_absent() = runTest { + dao.stampFullSync("org1", 100) + val row = dao.get("org1")!! + assertEquals(100L, row.lastFullSyncAtMs) + assertNull(row.lastPushAtMs) + } + + @Test fun stampPush_inserts_when_absent() = runTest { + dao.stampPush("org1", 200) + val row = dao.get("org1")!! + assertEquals(200L, row.lastPushAtMs) + // No pull has happened yet; the pull timestamp stays at its sentinel default of 0. + assertEquals(0L, row.lastFullSyncAtMs) + } + + @Test fun stampPush_does_not_clobber_full_sync() = runTest { + dao.stampFullSync("org1", 100) + dao.stampPush("org1", 200) + val row = dao.get("org1")!! + assertEquals(100L, row.lastFullSyncAtMs) + assertEquals(200L, row.lastPushAtMs) + } + + @Test fun stampFullSync_does_not_clobber_push() = runTest { + dao.stampPush("org1", 200) + dao.stampFullSync("org1", 100) + val row = dao.get("org1")!! + assertEquals(100L, row.lastFullSyncAtMs) + assertEquals(200L, row.lastPushAtMs) + } + + @Test fun observe_emits_updates() = runTest { + dao.observe("org1").test { + assertNull(awaitItem()) + dao.stampFullSync("org1", 100) + assertEquals(100L, awaitItem()!!.lastFullSyncAtMs) + dao.stampPush("org1", 200) + val updated = awaitItem()!! + assertEquals(100L, updated.lastFullSyncAtMs) + assertEquals(200L, updated.lastPushAtMs) + } + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/data/model/TimeEntryTest.kt b/app/src/test/java/dev/tricked/solidverdant/data/model/TimeEntryTest.kt index ac546f8..5e6e5ba 100644 --- a/app/src/test/java/dev/tricked/solidverdant/data/model/TimeEntryTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/data/model/TimeEntryTest.kt @@ -12,6 +12,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +@Suppress("MaxLineLength") class TimeEntryTest { private val json = Json { ignoreUnknownKeys = true } diff --git a/app/src/test/java/dev/tricked/solidverdant/data/remote/FakeRemoteDataSource.kt b/app/src/test/java/dev/tricked/solidverdant/data/remote/FakeRemoteDataSource.kt index eb09845..e104a8a 100644 --- a/app/src/test/java/dev/tricked/solidverdant/data/remote/FakeRemoteDataSource.kt +++ b/app/src/test/java/dev/tricked/solidverdant/data/remote/FakeRemoteDataSource.kt @@ -14,6 +14,7 @@ import dev.tricked.solidverdant.data.model.Task import dev.tricked.solidverdant.data.model.TimeEntriesResponse import dev.tricked.solidverdant.data.model.TimeEntry +@Suppress("LongParameterList") class FakeRemoteDataSource( var entries: List = emptyList(), var projects: List = emptyList(), @@ -36,8 +37,7 @@ class FakeRemoteDataSource( var lastEndTime: String? = null val deleted = mutableListOf() - override suspend fun getTimeEntries(organizationId: String, memberId: String, limit: Int, offset: Int, onlyFullDates: Boolean) = - Result.success(TimeEntriesResponse(data = entries)) + override suspend fun getTimeEntries(query: TimeEntriesQuery) = Result.success(TimeEntriesResponse(data = entries)) override suspend fun getProjects(organizationId: String) = Result.success(projects) override suspend fun getClients(organizationId: String) = Result.success(clients) override suspend fun getTasks(organizationId: String) = Result.success(tasks) diff --git a/app/src/test/java/dev/tricked/solidverdant/data/repository/TemplateRepositoryOwnershipTest.kt b/app/src/test/java/dev/tricked/solidverdant/data/repository/TemplateRepositoryOwnershipTest.kt new file mode 100644 index 0000000..264156a --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/data/repository/TemplateRepositoryOwnershipTest.kt @@ -0,0 +1,109 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.data.repository + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import dev.tricked.solidverdant.data.local.AuthDataStore +import dev.tricked.solidverdant.data.local.SettingsDataStore +import dev.tricked.solidverdant.data.local.db.AppDatabase +import dev.tricked.solidverdant.data.local.db.TemplateDao +import dev.tricked.solidverdant.data.local.db.TemplateEntity +import dev.tricked.solidverdant.data.model.User +import dev.tricked.solidverdant.util.Clock +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TemplateRepositoryOwnershipTest { + private lateinit var db: AppDatabase + private lateinit var dao: TemplateDao + private lateinit var authDataStore: AuthDataStore + private lateinit var settingsDataStore: SettingsDataStore + private lateinit var repo: TemplateRepository + private val clock = object : Clock { + override fun nowMs() = 1234L + } + + @Before fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries().build() + dao = db.templateDao() + authDataStore = AuthDataStore(context) + settingsDataStore = SettingsDataStore(context) + repo = TemplateRepository(dao, clock, authDataStore, settingsDataStore) + } + + @After fun teardown() = db.close() + + private fun user(id: String) = User(id = id, name = "n", email = "e@e") + + private fun row(id: String, org: String, endpoint: String?, userId: String?) = TemplateEntity( + id = id, organizationId = org, name = id, projectId = null, taskId = null, + description = null, tagIds = "", billable = false, isFavorite = false, + sortOrder = 0, createdAtMs = 1L, ownerEndpoint = endpoint, ownerUserId = userId, + ) + + private suspend fun loginAs(endpoint: String, userId: String) { + authDataStore.saveOAuthConfig(endpoint, "client") + settingsDataStore.cacheAuth(user(userId), emptyList(), null) + } + + @Test fun observe_filters_by_owner_endpoint_and_user() = runTest { + val epA = "https://a.example" + val epB = "https://b.example" + dao.upsert(row("a-userA", "org", epA, "userA")) + dao.upsert(row("a-userB", "org", epA, "userB")) + dao.upsert(row("b-userA", "org", epB, "userA")) + + loginAs(epA, "userA") + + val visible = repo.observeTemplates("org").first().map { it.id } + assertEquals(listOf("a-userA"), visible) + } + + @Test fun create_stamps_current_owner() = runTest { + val ep = "https://a.example" + loginAs(ep, "userA") + + val created = repo.createTemplate( + organizationId = "org", + name = "T", + projectId = null, + taskId = null, + description = null, + tagIds = emptyList(), + billable = false, + isFavorite = false, + ) + + val persisted = dao.getById(created.id)!! + assertEquals(ep, persisted.ownerEndpoint) + assertEquals("userA", persisted.ownerUserId) + } + + @Test fun claim_stamps_null_rows_exactly_once() = runTest { + val ep = "https://a.example" + dao.upsert(row("legacy", "org", null, null)) + + val firstClaim = repo.claimUnowned(ep, "userA") + assertEquals(1, firstClaim) + assertEquals("userA", dao.getById("legacy")!!.ownerUserId) + assertEquals(ep, dao.getById("legacy")!!.ownerEndpoint) + + val secondClaim = repo.claimUnowned(ep, "userB") + assertEquals(0, secondClaim) + assertEquals("userA", dao.getById("legacy")!!.ownerUserId) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/data/repository/TimeEntryRepositoryWriteTest.kt b/app/src/test/java/dev/tricked/solidverdant/data/repository/TimeEntryRepositoryWriteTest.kt index ddc1b92..1793de0 100644 --- a/app/src/test/java/dev/tricked/solidverdant/data/repository/TimeEntryRepositoryWriteTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/data/repository/TimeEntryRepositoryWriteTest.kt @@ -13,14 +13,17 @@ import dev.tricked.solidverdant.data.local.db.OutboxEntity import dev.tricked.solidverdant.data.local.db.OutboxOpType import dev.tricked.solidverdant.data.local.db.SyncState import dev.tricked.solidverdant.data.local.db.toEntity +import dev.tricked.solidverdant.data.model.Tag import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.data.remote.FakeRemoteDataSource +import dev.tricked.solidverdant.sync.ConflictSnapshot import dev.tricked.solidverdant.util.Clock import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -34,6 +37,7 @@ class TimeEntryRepositoryWriteTest { private val clock = object : Clock { override fun nowMs() = 1234L } + private val testJson = Json { encodeDefaults = true } @Before fun setup() { db = Room.inMemoryDatabaseBuilder( @@ -365,4 +369,439 @@ class TimeEntryRepositoryWriteTest { assertTrue(repo.discardFailedSync(entryId)) assertTrue(db.outboxDao().peekAll().none { it.timeEntryId == entryId }) } + + @Test fun update_on_synced_entry_captures_pre_mutation_base() = runTest { + // SV-027 rule 3: no queued op yet, no queued START/CREATE -> base = pre-mutation content. + val entry = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "old text", + ) + db.timeEntryDao().upsert(entry.toEntity(1L, SyncState.SYNCED)) + + repo.updateEntry(entry.copy(description = "new text"), tagIds = emptyList()) + + val op = db.outboxDao().peekAll().single { it.opType == OutboxOpType.UPDATE } + val base = Json.decodeFromString(op.baseSnapshotJson!!) + assertEquals("old text", base.description) + } + + @Test fun stop_captures_pre_stop_base() = runTest { + // SV-027 rule 3: base for a STOP op must reflect the entry before `end` was written. + val entry = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = null, + description = "running", + ) + db.timeEntryDao().upsert(entry.toEntity(1L, SyncState.SYNCED)) + + repo.stopEntry(entry, "u") + + val op = db.outboxDao().peekAll().single { it.opType == OutboxOpType.STOP } + val base = Json.decodeFromString(op.baseSnapshotJson!!) + assertNull(base.endMs) + } + + @Test fun delete_captures_base_despite_pending_flag() = runTest { + // SV-027 rule 3: softDeleteLocal flips syncState to PENDING before commitDelete enqueues the + // DELETE op, but the base must still be the untouched (pre-delete) server-acked content. + val entry = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "untouched content", + ) + db.timeEntryDao().upsert(entry.toEntity(1L, SyncState.SYNCED)) + + repo.softDeleteLocal(entry) + repo.commitDelete(entry) + + val op = db.outboxDao().peekAll().single { it.opType == OutboxOpType.DELETE } + val base = Json.decodeFromString(op.baseSnapshotJson!!) + assertEquals("untouched content", base.description) + } + + @Test fun chained_ops_reuse_oldest_base() = runTest { + // SV-027 rule 1: an offline STOP -> UPDATE chain must share the pre-stop base, not + // re-snapshot the just-written (post-stop) content. + val entry = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = null, + description = "pre-stop text", + ) + db.timeEntryDao().upsert(entry.toEntity(1L, SyncState.SYNCED)) + + repo.stopEntry(entry, "u") + val stopped = entry.copy(end = "2026-07-07T09:00:00Z") + repo.updateEntry(stopped.copy(description = "edited offline"), tagIds = emptyList()) + + val ops = db.outboxDao().peekAll() + val stopBase = Json.decodeFromString( + ops.single { it.opType == OutboxOpType.STOP }.baseSnapshotJson!!, + ) + val updateBase = Json.decodeFromString( + ops.single { it.opType == OutboxOpType.UPDATE }.baseSnapshotJson!!, + ) + assertEquals(stopBase, updateBase) + assertEquals("pre-stop text", updateBase.description) + assertNull(updateBase.endMs) + } + + @Test fun ops_on_locally_created_entry_have_null_base() = runTest { + // SV-027 rule 2: a queued START/CREATE for the entry means it was born locally - nothing on + // the server to diverge from, so subsequent ops get a null base. + val entry = repo.startEntry("org1", "m", "u", null, null, "x", emptyList()) + + repo.updateEntry(entry.copy(description = "edited"), tagIds = emptyList()) + + val updateOp = db.outboxDao().peekAll().single { it.opType == OutboxOpType.UPDATE } + assertNull(updateOp.baseSnapshotJson) + } + + @Test fun refresh_marks_diverged_pending_edit_as_conflict_and_drops_outbox() = runTest { + val base = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "before", + ) + val server = base.copy(description = "changed on web") + val fake = FakeRemoteDataSource(entries = listOf(server)) + val repo2 = + TimeEntryRepository( + db.timeEntryDao(), + db.catalogDao(), + db.outboxDao(), + db.syncMetaDao(), + fake, + clock, + Json { + encodeDefaults = + true + }, + db, + ) + db.timeEntryDao().upsert(base.copy(description = "mine").toEntity(2L, SyncState.PENDING)) + db.outboxDao().insert( + OutboxEntity( + opType = OutboxOpType.UPDATE, + organizationId = "org1", + timeEntryId = base.id, + createdAtMs = 2L, + payloadJson = "{}", + baseSnapshotJson = testJson.encodeToString( + ConflictSnapshot.of( + base.start, + base.end, + base.description, + base.projectId, + base.taskId, + base.billable, + emptyList(), + ), + ), + ), + ) + + repo2.refreshAll("org1", "member") + + val stored = db.timeEntryDao().getById(base.id) + assertEquals(SyncState.CONFLICT, stored?.syncState) + assertEquals("mine", stored?.description) + assertTrue(db.outboxDao().peekAll().isEmpty()) + assertTrue(stored?.conflictServerJson?.contains("changed on web") == true) + } + + @Test fun refresh_keeps_pending_edit_when_server_still_matches_base() = runTest { + val server = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "before", + ) + val fake = FakeRemoteDataSource(entries = listOf(server)) + val repo2 = + TimeEntryRepository( + db.timeEntryDao(), + db.catalogDao(), + db.outboxDao(), + db.syncMetaDao(), + fake, + clock, + Json { + encodeDefaults = + true + }, + db, + ) + db.timeEntryDao().upsert(server.copy(description = "mine").toEntity(2L, SyncState.PENDING)) + db.outboxDao().insert( + OutboxEntity( + opType = OutboxOpType.UPDATE, + organizationId = "org1", + timeEntryId = server.id, + createdAtMs = 2L, + payloadJson = "{}", + baseSnapshotJson = testJson.encodeToString( + ConflictSnapshot.of( + server.start, + server.end, + server.description, + server.projectId, + server.taskId, + server.billable, + emptyList(), + ), + ), + ), + ) + + repo2.refreshAll("org1", "member") + + assertEquals(SyncState.PENDING, db.timeEntryDao().getById(server.id)?.syncState) + assertEquals("mine", db.timeEntryDao().getById(server.id)?.description) + assertEquals(1, db.outboxDao().peekAll().size) + } + + @Test fun keep_mine_requeues_update_against_conflicting_server_copy() = runTest { + val server = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "server", + ) + val local = server.copy(description = "mine", tags = listOf(Tag("tag-1"))) + val json = Json { encodeDefaults = true } + db.timeEntryDao().upsert( + local.toEntity(2L, SyncState.CONFLICT).copy(conflictServerJson = json.encodeToString(server)), + ) + db.timeEntryDao().replaceTagRefs(local.id, listOf("tag-1")) + + assertTrue(repo.resolveKeepMine(local.id, "member")) + + val stored = db.timeEntryDao().getById(local.id) + val op = db.outboxDao().peekAll().single() + assertEquals(SyncState.PENDING, stored?.syncState) + assertNull(stored?.conflictServerJson) + assertEquals(OutboxOpType.UPDATE, op.opType) + assertEquals("server", Json.decodeFromString(op.baseSnapshotJson!!).description) + } + + @Test fun keep_theirs_restores_server_copy_and_clears_conflict() = runTest { + val local = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "mine", + ) + val server = local.copy(description = "server") + val json = Json { encodeDefaults = true } + db.timeEntryDao().upsert( + local.toEntity(2L, SyncState.CONFLICT).copy(conflictServerJson = json.encodeToString(server)), + ) + + assertTrue(repo.resolveKeepTheirs(local.id)) + + val stored = db.timeEntryDao().getById(local.id) + assertEquals(SyncState.SYNCED, stored?.syncState) + assertEquals("server", stored?.description) + assertNull(stored?.conflictServerJson) + assertTrue(db.outboxDao().peekAll().isEmpty()) + } + + @Test fun conflicted_entry_can_stop_but_other_mutations_are_locked() = runTest { + val entry = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = null, + description = "mine", + ) + db.timeEntryDao().upsert( + entry.toEntity(2L, SyncState.CONFLICT).copy(conflictServerJson = ConflictSnapshot.DELETED_MARKER), + ) + + repo.stopEntry(entry, "u") + + assertEquals(SyncState.CONFLICT, db.timeEntryDao().getById(entry.id)?.syncState) + assertTrue(db.timeEntryDao().getById(entry.id)?.end != null) + assertTrue(db.outboxDao().peekAll().isEmpty()) + runCatching { repo.updateEntry(entry.copy(description = "blocked"), emptyList()) } + .onSuccess { error("Expected conflicted edit to be rejected") } + .onFailure { assertTrue(it is IllegalStateException) } + } + + @Test fun duplicate_creates_completed_copy_with_same_metadata_and_enqueues_create() = runTest { + val entry = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "meeting", + projectId = "p1", + taskId = "t1", + billable = true, + ) + db.timeEntryDao().upsert(entry.toEntity(1L, SyncState.SYNCED)) + db.timeEntryDao().replaceTagRefs(entry.id, listOf("tag-1", "tag-2")) + + assertTrue(repo.duplicateEntry(entry.id, "member1").isSuccess) + + val copies = repo.observeTimeEntries("org1").first().filter { it.id != entry.id } + assertEquals(1, copies.size) + val copy = copies.single() + assertTrue(copy.id.startsWith("local-create-")) + assertEquals(entry.start, copy.start) + assertEquals(entry.end, copy.end) + assertEquals("meeting", copy.description) + assertEquals("p1", copy.projectId) + assertEquals("t1", copy.taskId) + assertEquals(true, copy.billable) + assertEquals(setOf("tag-1", "tag-2"), db.timeEntryDao().tagIdsFor(copy.id).toSet()) + + val createOps = db.outboxDao().peekAll().filter { it.opType == OutboxOpType.CREATE } + assertEquals(1, createOps.size) + assertEquals(copy.id, createOps.single().timeEntryId) + } + + @Test fun duplicate_refuses_running_entry() = runTest { + val running = repo.startEntry("org1", "m", "u", null, null, "x", emptyList()) + db.outboxDao().peekAll().forEach { db.outboxDao().delete(it) } + + assertTrue(repo.duplicateEntry(running.id, "member1").isFailure) + assertTrue(db.outboxDao().peekAll().none { it.opType == OutboxOpType.CREATE }) + } + + @Test fun duplicate_refuses_conflicted_entry() = runTest { + val entry = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "x", + ) + db.timeEntryDao().upsert(entry.toEntity(2L, SyncState.CONFLICT)) + + assertTrue(repo.duplicateEntry(entry.id, "member1").isFailure) + assertTrue(db.outboxDao().peekAll().none { it.opType == OutboxOpType.CREATE }) + } + + @Test fun split_shortens_original_and_creates_second_half_with_metadata() = runTest { + val entry = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T10:00:00Z", + description = "long task", + projectId = "p1", + taskId = "t1", + billable = true, + ) + db.timeEntryDao().upsert(entry.toEntity(1L, SyncState.SYNCED)) + db.timeEntryDao().replaceTagRefs(entry.id, listOf("tag-1")) + + val at = "2026-07-07T09:00:00Z" + val newId = repo.splitEntry(entry.id, at, "member1").getOrNull() + + val original = db.timeEntryDao().getById("server-1") + assertEquals(at, original?.end) + + assertTrue(newId != null && newId.startsWith("local-create-")) + val second = db.timeEntryDao().getById(newId!!) + assertEquals(at, second?.start) + assertEquals("2026-07-07T10:00:00Z", second?.end) + assertEquals("long task", second?.description) + assertEquals("p1", second?.projectId) + assertEquals("t1", second?.taskId) + assertEquals(true, second?.billable) + assertEquals(setOf("tag-1"), db.timeEntryDao().tagIdsFor(newId).toSet()) + + val ops = db.outboxDao().peekAll() + assertTrue(ops.any { it.opType == OutboxOpType.UPDATE && it.timeEntryId == "server-1" }) + assertTrue(ops.any { it.opType == OutboxOpType.CREATE && it.timeEntryId == newId }) + } + + @Test fun split_rejects_boundary_and_out_of_range_instants() = runTest { + val entry = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T10:00:00Z", + description = "x", + ) + db.timeEntryDao().upsert(entry.toEntity(1L, SyncState.SYNCED)) + + // at == start, at == end, before start, after end are all invalid (half-open interval). + assertTrue(repo.splitEntry(entry.id, "2026-07-07T08:00:00Z", "member1").isFailure) + assertTrue(repo.splitEntry(entry.id, "2026-07-07T10:00:00Z", "member1").isFailure) + assertTrue(repo.splitEntry(entry.id, "2026-07-07T07:00:00Z", "member1").isFailure) + assertTrue(repo.splitEntry(entry.id, "2026-07-07T11:00:00Z", "member1").isFailure) + assertTrue(db.outboxDao().peekAll().isEmpty()) + } + + @Test fun split_refuses_running_and_conflicted_entries() = runTest { + val running = repo.startEntry("org1", "m", "u", null, null, "x", emptyList()) + db.outboxDao().peekAll().forEach { db.outboxDao().delete(it) } + assertTrue(repo.splitEntry(running.id, "2026-07-07T09:00:00Z", "member1").isFailure) + + val conflicted = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T10:00:00Z", + description = "x", + ) + db.timeEntryDao().upsert(conflicted.toEntity(2L, SyncState.CONFLICT)) + assertTrue(repo.splitEntry(conflicted.id, "2026-07-07T09:00:00Z", "member1").isFailure) + + assertTrue(db.outboxDao().peekAll().none { it.opType == OutboxOpType.CREATE }) + } + + @Test fun keep_mine_recreates_an_entry_deleted_on_server() = runTest { + val entry = TimeEntry( + id = "server-1", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "mine", + ) + db.timeEntryDao().upsert( + entry.toEntity(2L, SyncState.CONFLICT).copy(conflictServerJson = ConflictSnapshot.DELETED_MARKER), + ) + + assertTrue(repo.resolveKeepMine(entry.id, "member")) + + val stored = db.timeEntryDao().getById(entry.id) + val op = db.outboxDao().peekAll().single() + assertEquals(SyncState.PENDING, stored?.syncState) + assertEquals(OutboxOpType.CREATE, op.opType) + assertEquals(entry.id, op.timeEntryId) + assertNull(op.baseSnapshotJson) + } } diff --git a/app/src/test/java/dev/tricked/solidverdant/domain/inbox/InboxAnalyzerTest.kt b/app/src/test/java/dev/tricked/solidverdant/domain/inbox/InboxAnalyzerTest.kt index 63ec278..8132824 100644 --- a/app/src/test/java/dev/tricked/solidverdant/domain/inbox/InboxAnalyzerTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/domain/inbox/InboxAnalyzerTest.kt @@ -43,6 +43,7 @@ class InboxAnalyzerTest { organizationId = "org", ) + @Suppress("LongParameterList") private fun config( workDays: Set = DayOfWeek.values().toSet(), workStartMinute: Int = 9 * 60, @@ -71,8 +72,9 @@ class InboxAnalyzerTest { checkLongDuration = checkLongDuration, ) + // Existing tests use a permissive (very old) horizon so the clamp is a no-op for them. private fun analyze(entries: List, config: InboxCheckConfig, dismissed: Set = emptySet()) = - InboxAnalyzer.analyze(entries, config, dismissed, nowMs, utc) + InboxAnalyzer.analyze(entries, config, dismissed, nowMs, utc, horizonStartMs = 0L) // ---- Overlaps ------------------------------------------------------------------------------- @@ -158,6 +160,7 @@ class InboxAnalyzerTest { emptySet(), todayNow, utc, + horizonStartMs = 0L, ).filter { it.type == InboxIssueType.GAP } assertEquals(0, issues.size) } @@ -171,6 +174,7 @@ class InboxAnalyzerTest { emptySet(), nowMs, plusTwo, + horizonStartMs = 0L, ).filter { it.type == InboxIssueType.GAP } assertEquals(0, covered.size) @@ -181,6 +185,7 @@ class InboxAnalyzerTest { emptySet(), nowMs, utc, + horizonStartMs = 0L, ).filter { it.type == InboxIssueType.GAP } assertEquals(1, utcGaps.size) } @@ -268,4 +273,82 @@ class InboxAnalyzerTest { ) assertEquals(0, issues.size) } + + // ---- Horizon resolver (SV-005) -------------------------------------------------------------- + + @Test fun `resolver not chosen returns start of today in zone`() { + val midday = Instant.parse("2026-07-20T13:45:00Z").toEpochMilli() + val expected = Instant.parse("2026-07-20T00:00:00Z").toEpochMilli() + assertEquals(expected, resolveHorizonStartMs(horizonChosen = false, horizonStartMs = null, nowMs = midday, zone = utc)) + // A stored start is ignored while the choice is not yet made. + assertEquals(expected, resolveHorizonStartMs(horizonChosen = false, horizonStartMs = 123L, nowMs = midday, zone = utc)) + } + + @Test fun `resolver chosen with null returns now minus 370 days`() { + val expected = nowMs - java.time.Duration.ofDays(370).toMillis() + assertEquals(expected, resolveHorizonStartMs(horizonChosen = true, horizonStartMs = null, nowMs = nowMs, zone = utc)) + } + + @Test fun `resolver chosen with value returns that value`() { + val chosen = Instant.parse("2026-07-01T00:00:00Z").toEpochMilli() + assertEquals(chosen, resolveHorizonStartMs(horizonChosen = true, horizonStartMs = chosen, nowMs = nowMs, zone = utc)) + } + + // ---- Horizon clamp (SV-005) ----------------------------------------------------------------- + + @Test fun `horizon of today's start hides older issues of every type but keeps today's`() { + // "now" is midday today; entries span an old day and today. Enable every analyzer check. + val today = Instant.parse("2026-07-20T12:00:00Z").toEpochMilli() + val todayStart = Instant.parse("2026-07-20T00:00:00Z").toEpochMilli() + val entries = listOf( + // Old day (2026-07-06): overlap pair, long entry, missing project. + entry("oldA", "2026-07-06T09:00:00Z", "2026-07-06T11:00:00Z", projectId = null, taskId = null), + entry("oldB", "2026-07-06T10:00:00Z", "2026-07-06T16:00:00Z"), + // Today (2026-07-20): overlap pair, long entry, missing project. + entry("newA", "2026-07-20T00:30:00Z", "2026-07-20T02:00:00Z", projectId = null, taskId = null), + entry("newB", "2026-07-20T01:00:00Z", "2026-07-20T07:00:00Z"), + ) + val cfg = config( + checkGaps = false, + checkOverlaps = true, + checkMissingProject = true, + checkLongDuration = true, + maxDurationHours = 4, + ) + + val clamped = InboxAnalyzer.analyze(entries, cfg, emptySet(), today, utc, horizonStartMs = todayStart) + assertTrue("only today's issues survive the clamp", clamped.all { it.endMs >= todayStart }) + assertTrue("clamped must still contain today's issues", clamped.isNotEmpty()) + assertTrue(clamped.any { it.type == InboxIssueType.OVERLAP }) + assertTrue(clamped.any { it.type == InboxIssueType.MISSING_METADATA }) + assertTrue(clamped.any { it.type == InboxIssueType.LONG_DURATION }) + + // An earlier horizon brings the old-day issues back. + val wide = InboxAnalyzer.analyze(entries, cfg, emptySet(), today, utc, horizonStartMs = 0L) + assertTrue("earlier horizon surfaces more issues", wide.size > clamped.size) + assertTrue("older-day issues reappear", wide.any { it.endMs < todayStart }) + } + + @Test fun `count equals clamped analyze size`() { + val today = Instant.parse("2026-07-20T12:00:00Z").toEpochMilli() + val todayStart = Instant.parse("2026-07-20T00:00:00Z").toEpochMilli() + val entries = listOf( + entry("oldA", "2026-07-06T09:00:00Z", "2026-07-06T14:00:00Z"), + entry("newA", "2026-07-20T00:30:00Z", "2026-07-20T06:00:00Z"), + ) + val cfg = config(checkGaps = false, checkOverlaps = false, checkLongDuration = true, maxDurationHours = 4) + val clamped = InboxAnalyzer.analyze(entries, cfg, emptySet(), today, utc, horizonStartMs = todayStart) + val count = InboxAnalyzer.count(entries, cfg, emptySet(), today, utc, horizonStartMs = todayStart) + assertEquals(clamped.size, count) + } + + @Test fun `issue straddling the horizon is kept`() { + // An entry that starts before today's start but ends after it must survive a today-clamp. + val today = Instant.parse("2026-07-20T12:00:00Z").toEpochMilli() + val todayStart = Instant.parse("2026-07-20T00:00:00Z").toEpochMilli() + val straddling = entry("s", "2026-07-19T22:00:00Z", "2026-07-20T04:00:00Z") // 6h, crosses midnight + val cfg = config(checkGaps = false, checkOverlaps = false, checkLongDuration = true, maxDurationHours = 4) + val clamped = InboxAnalyzer.analyze(listOf(straddling), cfg, emptySet(), today, utc, horizonStartMs = todayStart) + assertEquals(1, clamped.count { it.type == InboxIssueType.LONG_DURATION }) + } } diff --git a/app/src/test/java/dev/tricked/solidverdant/domain/inbox/InboxSettingsHorizonTest.kt b/app/src/test/java/dev/tricked/solidverdant/domain/inbox/InboxSettingsHorizonTest.kt new file mode 100644 index 0000000..689580b --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/domain/inbox/InboxSettingsHorizonTest.kt @@ -0,0 +1,49 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.domain.inbox + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * SV-005: the inbox horizon choice must round-trip through the real DataStore-backed store so the + * analyzer (and thus the badge) obeys it after a restart. Uses a Robolectric application context the + * same way the other DataStore-backed tests in this codebase do. + */ +@RunWith(RobolectricTestRunner::class) +class InboxSettingsHorizonTest { + + private val store = InboxSettingsDataStore(ApplicationProvider.getApplicationContext()) + + @Test + fun setHorizonStart_with_value_marks_chosen_and_persists_the_bound() = runTest { + val bound = 1_752_000_000_000L + store.setHorizonStart(bound) + + val settings = store.settings.first() + assertTrue(settings.horizonChosen) + assertEquals(bound, settings.horizonStartMs) + } + + @Test + fun setHorizonStart_null_marks_chosen_and_removes_the_bound() = runTest { + // First store a real bound, then switch to "Everything". + store.setHorizonStart(123L) + store.setHorizonStart(null) + + val settings = store.settings.first() + assertTrue("choosing Everything still counts as chosen", settings.horizonChosen) + assertNull("Everything removes the stored bound rather than storing a sentinel", settings.horizonStartMs) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/domain/time/TemporalPolicyProviderTest.kt b/app/src/test/java/dev/tricked/solidverdant/domain/time/TemporalPolicyProviderTest.kt new file mode 100644 index 0000000..3b2504f --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/domain/time/TemporalPolicyProviderTest.kt @@ -0,0 +1,127 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.domain.time + +import androidx.test.core.app.ApplicationProvider +import dev.tricked.solidverdant.data.local.SettingsDataStore +import dev.tricked.solidverdant.data.model.User +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.DayOfWeek +import java.time.ZoneId + +/** + * Task 2.1: reporting surfaces derive a [TemporalPolicy] from the cached Solidtime profile + * (timezone + week_start) instead of [ZoneId.systemDefault]. This exercises the timezone/week_start + * mapping, the fallback rules, and — critically — that the provider re-emits when the cached auth + * changes via the REAL [SettingsDataStore.observeCachedAuth] observable (not a fake), proving the + * change-notification wiring added to the synchronous immediate_ui_cache. + */ +@RunWith(RobolectricTestRunner::class) +class TemporalPolicyProviderTest { + + private val settings = SettingsDataStore(ApplicationProvider.getApplicationContext()) + + // immediate_ui_cache is a file-backed SharedPreferences under a fixed name, so state persists + // across tests in this class. Clear it before each test for a deterministic starting point. + @Before fun clearCache() = runBlocking { settings.clearCachedData() } + + private fun user(timezone: String = "UTC", weekStart: String = "monday") = User( + id = "u1", + name = "Tester", + email = "t@example.com", + timezone = timezone, + weekStart = weekStart, + ) + + @Test + fun valid_timezone_maps_to_zone_id() = runTest { + settings.cacheAuth(user(timezone = "Europe/Amsterdam"), emptyList(), null) + val policy = TemporalPolicyProvider(settings).current() + assertEquals(ZoneId.of("Europe/Amsterdam"), policy.zone) + } + + @Test + fun garbage_timezone_falls_back_to_device_zone() = runTest { + settings.cacheAuth(user(timezone = "Not/AZone"), emptyList(), null) + val policy = TemporalPolicyProvider(settings).current() + assertEquals(ZoneId.systemDefault(), policy.zone) + } + + @Test + fun absent_auth_uses_device_defaults() = runTest { + // No cacheAuth call -> logged out -> device zone + Monday. + val policy = TemporalPolicyProvider(settings).current() + assertEquals(ZoneId.systemDefault(), policy.zone) + assertEquals(DayOfWeek.MONDAY, policy.firstDayOfWeek) + } + + @Test + fun week_start_names_map_to_day_of_week() = runTest { + val cases = mapOf( + "monday" to DayOfWeek.MONDAY, + "tuesday" to DayOfWeek.TUESDAY, + "wednesday" to DayOfWeek.WEDNESDAY, + "thursday" to DayOfWeek.THURSDAY, + "friday" to DayOfWeek.FRIDAY, + "saturday" to DayOfWeek.SATURDAY, + "sunday" to DayOfWeek.SUNDAY, + ) + for ((name, expected) in cases) { + settings.cacheAuth(user(weekStart = name), emptyList(), null) + val policy = TemporalPolicyProvider(settings).current() + assertEquals("week_start=$name", expected, policy.firstDayOfWeek) + } + } + + @Test + fun unknown_week_start_falls_back_to_monday() = runTest { + settings.cacheAuth(user(weekStart = "funday"), emptyList(), null) + val policy = TemporalPolicyProvider(settings).current() + assertEquals(DayOfWeek.MONDAY, policy.firstDayOfWeek) + } + + @Test + fun blank_week_start_falls_back_to_monday() = runTest { + settings.cacheAuth(user(weekStart = ""), emptyList(), null) + val policy = TemporalPolicyProvider(settings).current() + assertEquals(DayOfWeek.MONDAY, policy.firstDayOfWeek) + } + + @Test + fun provider_re_emits_when_cached_auth_changes() = runTest { + settings.cacheAuth(user(timezone = "Europe/Amsterdam"), emptyList(), null) + val provider = TemporalPolicyProvider(settings) + + // A single live collector: the second value is only observed if the authCacheChanges bump + // in cacheAuth() actually fires. Two separate .first() calls would each re-read the cache + // and pass even if the bump were deleted, so this guards the change-notification wiring. + val zones = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + provider.policy.collect { zones.add(it.zone) } + } + + assertEquals(listOf(ZoneId.of("Europe/Amsterdam")), zones) + + settings.cacheAuth(user(timezone = "America/New_York"), emptyList(), null) + runCurrent() + + assertEquals( + listOf(ZoneId.of("Europe/Amsterdam"), ZoneId.of("America/New_York")), + zones, + ) + job.cancel() + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/screenshots/ReadmeScreenshotsTest.kt b/app/src/test/java/dev/tricked/solidverdant/screenshots/ReadmeScreenshotsTest.kt index cc193e3..9a989bd 100644 --- a/app/src/test/java/dev/tricked/solidverdant/screenshots/ReadmeScreenshotsTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/screenshots/ReadmeScreenshotsTest.kt @@ -40,6 +40,7 @@ import dev.tricked.solidverdant.ui.calendar.WeekCalendarView import dev.tricked.solidverdant.ui.components.EditTimeEntryDialog import dev.tricked.solidverdant.ui.review.InboxHeader import dev.tricked.solidverdant.ui.review.InboxIssueCard +import dev.tricked.solidverdant.ui.review.InboxIssueCardActions import dev.tricked.solidverdant.ui.review.ReviewContent import dev.tricked.solidverdant.ui.review.ReviewDayUiState import dev.tricked.solidverdant.ui.review.ReviewItem @@ -227,6 +228,7 @@ class ReadmeScreenshotsTest { private fun groupedHistory(): Map> = historyEntries.groupBy { LocalDate.parse(it.start.substring(0, 10)) } .toSortedMap(compareByDescending { it }) + @Suppress("LongMethod") private fun buildScreens(): List = listOf( // 1. Track — active timer running + history rows with sync chips. Screen("track") { @@ -236,7 +238,6 @@ class ReadmeScreenshotsTest { currentTimeEntry = entry("running", "Landing page build", "2026-06-10T14:00:00Z", null, 0, taskId = "t1"), projects = projects, tasks = tasks, - tags = tags, clients = clients, timeEntries = historyEntries, hasLoadedTimeEntries = true, @@ -314,7 +315,6 @@ class ReadmeScreenshotsTest { onEntryClick = {}, projects = projects, tasks = tasks, - tags = tags, ) }, // 4. Calendar — week view with a couple of overlay calendar events. @@ -460,7 +460,15 @@ class ReadmeScreenshotsTest { modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - InboxHeader(issueCount = issues.size, isRefreshing = false, onRefresh = {}, onOpenSettings = {}) + InboxHeader( + issueCount = issues.size, + isRefreshing = false, + showHorizonChip = true, + horizonLabel = "Everything", + onHorizonChipClick = {}, + onRefresh = {}, + onOpenSettings = {}, + ) issues.forEach { issue -> Column(modifier = Modifier.padding(horizontal = 16.dp)) { InboxIssueCard( @@ -468,8 +476,10 @@ class ReadmeScreenshotsTest { preventOverlap = true, projectsById = projectsById, zone = zone, - onQuickFix = {}, - onDismiss = {}, + actions = InboxIssueCardActions( + onQuickFix = {}, + onDismiss = {}, + ), ) } } diff --git a/app/src/test/java/dev/tricked/solidverdant/screenshots/ScreenshotHost.kt b/app/src/test/java/dev/tricked/solidverdant/screenshots/ScreenshotHost.kt index 491bb05..fee7d17 100644 --- a/app/src/test/java/dev/tricked/solidverdant/screenshots/ScreenshotHost.kt +++ b/app/src/test/java/dev/tricked/solidverdant/screenshots/ScreenshotHost.kt @@ -6,7 +6,9 @@ package dev.tricked.solidverdant.screenshots +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Menu @@ -32,6 +35,10 @@ import androidx.compose.runtime.Composable 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.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -97,12 +104,48 @@ object ScreenshotHost { color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.weight(1f)) - Text( - text = "▮▮▮ Wi-Fi 87%", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onBackground, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Canvas( + modifier = Modifier.size(16.dp), + ) { + val strokeWidth = 2.dp.toPx() + val dotRadius = 1.25.dp.toPx() + val inset = 1.dp.toPx() + val centerX = size.width / 2f + val bottom = size.height - 2.dp.toPx() + drawArc( + color = Color.White, + startAngle = 225f, + sweepAngle = 90f, + useCenter = false, + topLeft = Offset(inset, inset), + size = androidx.compose.ui.geometry.Size(size.width - inset * 2, size.height - inset * 2), + style = Stroke(strokeWidth, cap = StrokeCap.Round), + ) + drawArc( + color = Color.White, + startAngle = 225f, + sweepAngle = 90f, + useCenter = false, + topLeft = Offset(inset + 3.dp.toPx(), inset + 3.dp.toPx()), + size = androidx.compose.ui.geometry.Size( + size.width - (inset + 3.dp.toPx()) * 2, + size.height - (inset + 3.dp.toPx()) * 2, + ), + style = Stroke(strokeWidth, cap = StrokeCap.Round), + ) + drawCircle(Color.White, dotRadius, Offset(centerX, bottom)) + } + Text( + text = "87%", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground, + ) + } } Box(modifier = Modifier.weight(1f).fillMaxWidth()) { content() } Box( diff --git a/app/src/test/java/dev/tricked/solidverdant/sync/ConflictSnapshotTest.kt b/app/src/test/java/dev/tricked/solidverdant/sync/ConflictSnapshotTest.kt new file mode 100644 index 0000000..c2ee567 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/sync/ConflictSnapshotTest.kt @@ -0,0 +1,217 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.sync + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConflictSnapshotTest { + private val json = Json { encodeDefaults = true } + + private fun baseline() = ConflictSnapshot.of( + start = "2026-07-11T09:00:00Z", + end = "2026-07-11T10:00:00Z", + description = "work", + projectId = "p1", + taskId = "t1", + billable = true, + tagIds = listOf("a", "b"), + ) + + @Test fun `identical content matches`() { + val a = baseline() + val b = baseline() + assertTrue(a.matches(b)) + } + + @Test fun `timestamp format variance does not fabricate a conflict`() { + val a = ConflictSnapshot.of( + start = "2026-07-11T09:00:00Z", + end = "2026-07-11T10:00:00Z", + description = "work", + projectId = "p1", + taskId = "t1", + billable = true, + tagIds = listOf("a", "b"), + ) + val b = ConflictSnapshot.of( + start = "2026-07-11T11:00:00+02:00", + end = "2026-07-11T10:00:00Z", + description = "work", + projectId = "p1", + taskId = "t1", + billable = true, + tagIds = listOf("a", "b"), + ) + val c = ConflictSnapshot.of( + start = "2026-07-11T09:00:00.000Z", + end = "2026-07-11T10:00:00Z", + description = "work", + projectId = "p1", + taskId = "t1", + billable = true, + tagIds = listOf("a", "b"), + ) + assertTrue(a.matches(b)) + assertTrue(a.matches(c)) + assertTrue(b.matches(c)) + } + + @Test fun `null description equals blank description`() { + val a = ConflictSnapshot.of( + start = "2026-07-11T09:00:00Z", + end = null, + description = null, + projectId = null, + taskId = null, + billable = false, + tagIds = emptyList(), + ) + val b = ConflictSnapshot.of( + start = "2026-07-11T09:00:00Z", + end = null, + description = "", + projectId = null, + taskId = null, + billable = false, + tagIds = emptyList(), + ) + assertTrue(a.matches(b)) + } + + @Test fun `tag order is irrelevant`() { + val a = ConflictSnapshot.of( + start = "2026-07-11T09:00:00Z", + end = null, + description = "work", + projectId = null, + taskId = null, + billable = false, + tagIds = listOf("b", "a"), + ) + val b = ConflictSnapshot.of( + start = "2026-07-11T09:00:00Z", + end = null, + description = "work", + projectId = null, + taskId = null, + billable = false, + tagIds = listOf("a", "b"), + ) + assertTrue(a.matches(b)) + } + + @Test fun `each conflict-relevant field diverges`() { + val base = baseline() + + val descriptionDiff = base.copy(description = "other") + val projectDiff = base.copy(projectId = "p2") + val taskDiff = base.copy(taskId = "t2") + val billableDiff = base.copy(billable = false) + val tagsDiff = base.copy(tagIds = listOf("a")) + val startDiff = ConflictSnapshot.of( + start = "2026-07-11T09:30:00Z", + end = "2026-07-11T10:00:00Z", + description = "work", + projectId = "p1", + taskId = "t1", + billable = true, + tagIds = listOf("a", "b"), + ) + val endDiff = ConflictSnapshot.of( + start = "2026-07-11T09:00:00Z", + end = "2026-07-11T10:30:00Z", + description = "work", + projectId = "p1", + taskId = "t1", + billable = true, + tagIds = listOf("a", "b"), + ) + + assertFalse(base.matches(descriptionDiff)) + assertFalse(base.matches(projectDiff)) + assertFalse(base.matches(taskDiff)) + assertFalse(base.matches(billableDiff)) + assertFalse(base.matches(tagsDiff)) + assertFalse(base.matches(startDiff)) + assertFalse(base.matches(endDiff)) + } + + @Test fun `round-trips through json`() { + val original = baseline() + val encoded = json.encodeToString(ConflictSnapshot.serializer(), original) + val decoded = json.decodeFromString(ConflictSnapshot.serializer(), encoded) + assertEquals(original, decoded) + assertTrue(original.matches(decoded)) + } + + @Test fun `directly-constructed snapshot with unsorted tags still matches its sorted twin`() { + val sorted = baseline() + val unsorted = sorted.copy(tagIds = listOf("b", "a")) + assertTrue(sorted.matches(unsorted)) + assertTrue(unsorted.matches(sorted)) + } + + @Test fun `parseable and unparseable timestamps never match`() { + val parseable = ConflictSnapshot.of( + start = "2026-07-11T09:00:00Z", + end = null, + description = "work", + projectId = null, + taskId = null, + billable = false, + tagIds = emptyList(), + ) + val unparseable = ConflictSnapshot.of( + start = "not-a-real-timestamp", + end = null, + description = "work", + projectId = null, + taskId = null, + billable = false, + tagIds = emptyList(), + ) + assertFalse(parseable.matches(unparseable)) + assertFalse(unparseable.matches(parseable)) + } + + @Test fun `unparseable timestamp falls back to raw-string comparison`() { + val a = ConflictSnapshot.of( + start = "not-a-real-timestamp", + end = null, + description = "work", + projectId = null, + taskId = null, + billable = false, + tagIds = emptyList(), + ) + val b = ConflictSnapshot.of( + start = "not-a-real-timestamp", + end = null, + description = "work", + projectId = null, + taskId = null, + billable = false, + tagIds = emptyList(), + ) + val c = ConflictSnapshot.of( + start = "still-not-a-real-timestamp", + end = null, + description = "work", + projectId = null, + taskId = null, + billable = false, + tagIds = emptyList(), + ) + + assertTrue(a.matches(b)) + assertFalse(a.matches(c)) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/sync/SyncWorkerTest.kt b/app/src/test/java/dev/tricked/solidverdant/sync/SyncWorkerTest.kt index 8768d8b..99244d9 100644 --- a/app/src/test/java/dev/tricked/solidverdant/sync/SyncWorkerTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/sync/SyncWorkerTest.kt @@ -15,6 +15,8 @@ import dev.tricked.solidverdant.data.local.db.OutboxEntity import dev.tricked.solidverdant.data.local.db.OutboxOpType import dev.tricked.solidverdant.data.local.db.SyncState import dev.tricked.solidverdant.data.local.db.toEntity +import dev.tricked.solidverdant.data.model.Membership +import dev.tricked.solidverdant.data.model.Organization import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.data.remote.FakeRemoteDataSource import dev.tricked.solidverdant.util.Clock @@ -55,7 +57,18 @@ class SyncWorkerTest { appContext: android.content.Context, workerClassName: String, params: androidx.work.WorkerParameters, - ) = SyncWorker(appContext, params, db.outboxDao(), db.timeEntryDao(), remote, json, clock, status) + ) = SyncWorker( + appContext, + params, + db.outboxDao(), + db.timeEntryDao(), + db.syncMetaDao(), + db, + remote, + json, + clock, + status, + ) }).build() @Test fun start_op_reconciles_temp_id_to_server_id() = runTest { @@ -79,6 +92,28 @@ class SyncWorkerTest { assertNull(db.timeEntryDao().getById("local-1")) } + @Test fun successful_drain_stamps_push_timestamp() = runTest { + db.outboxDao().insert( + OutboxEntity( + opType = OutboxOpType.START, + organizationId = "org1", + timeEntryId = "local-1", + createdAtMs = 1L, + payloadJson = json.encodeToString( + StartPayload("m1", "u1", "p1", null, "work", emptyList()), + ), + ), + ) + remote.startResult = { it.copy(id = "server-1") } + + buildWorker().doWork() + + val meta = db.syncMetaDao().get("org1") + assertEquals(1L, meta?.lastPushAtMs) + // Push stamping must not invent a pull moment: lastFullSyncAtMs stays at its seed default. + assertEquals(0L, meta?.lastFullSyncAtMs) + } + @Test fun transient_failure_returns_retry_and_keeps_op() = runTest { remote.failNextWrite = true db.outboxDao().insert( @@ -93,6 +128,8 @@ class SyncWorkerTest { val result = buildWorker().doWork() assertEquals(ListenableWorker.Result.retry(), result) assertEquals(1, db.outboxDao().peekAll().size) + // No op reached the server, so no push timestamp is written. + assertNull(db.syncMetaDao().get("org1")) } @Test fun rejected_op_is_dead_lettered_and_not_reattempted() = runTest { @@ -226,6 +263,152 @@ class SyncWorkerTest { assertEquals(3600, stored?.duration) } + @Test fun update_conflict_preserves_mine_and_does_not_write_server() = runTest { + val base = TimeEntry( + id = "server-1", + userId = "u1", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "before", + ) + val server = base.copy(description = "web edit") + remote.entries = listOf(server) + remote.memberships = listOf(Membership("m1", "member", Organization("org1", "Org", "USD"))) + val local = base.copy(description = "mine") + db.timeEntryDao().upsert(local.toEntity(2L, SyncState.PENDING)) + db.outboxDao().insert( + OutboxEntity( + opType = OutboxOpType.UPDATE, + organizationId = "org1", + timeEntryId = local.id, + createdAtMs = 2L, + payloadJson = json.encodeToString( + UpdatePayload( + "u1", + local.start, + local.end, + local.description, + local.projectId, + local.taskId, + local.billable, + emptyList(), + ), + ), + baseSnapshotJson = json.encodeToString( + ConflictSnapshot.of( + base.start, + base.end, + base.description, + base.projectId, + base.taskId, + base.billable, + emptyList(), + ), + ), + ), + ) + + assertEquals(ListenableWorker.Result.success(), buildWorker().doWork()) + + assertEquals(SyncState.CONFLICT, db.timeEntryDao().getById(local.id)?.syncState) + assertEquals("mine", db.timeEntryDao().getById(local.id)?.description) + assertTrue(db.outboxDao().peekAll().isEmpty()) + } + + @Test fun update_pushes_when_server_still_matches_base() = runTest { + val base = TimeEntry( + id = "server-1", + userId = "u1", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "before", + ) + remote.entries = listOf(base) + remote.memberships = listOf(Membership("m1", "member", Organization("org1", "Org", "USD"))) + remote.updateResult = { it.copy(description = "server ack") } + val local = base.copy(description = "mine") + db.timeEntryDao().upsert(local.toEntity(2L, SyncState.PENDING)) + db.outboxDao().insert( + OutboxEntity( + opType = OutboxOpType.UPDATE, + organizationId = "org1", + timeEntryId = local.id, + createdAtMs = 2L, + payloadJson = json.encodeToString( + UpdatePayload( + "u1", + local.start, + local.end, + local.description, + local.projectId, + local.taskId, + local.billable, + emptyList(), + ), + ), + baseSnapshotJson = json.encodeToString( + ConflictSnapshot.of( + base.start, + base.end, + base.description, + base.projectId, + base.taskId, + base.billable, + emptyList(), + ), + ), + ), + ) + + assertEquals(ListenableWorker.Result.success(), buildWorker().doWork()) + + assertEquals(SyncState.SYNCED, db.timeEntryDao().getById(local.id)?.syncState) + assertEquals("server ack", db.timeEntryDao().getById(local.id)?.description) + assertTrue(db.outboxDao().peekAll().isEmpty()) + } + + @Test fun absent_server_entry_becomes_deleted_conflict() = runTest { + val local = TimeEntry( + id = "server-1", + userId = "u1", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "mine", + ) + remote.entries = emptyList() + remote.memberships = listOf(Membership("m1", "member", Organization("org1", "Org", "USD"))) + db.timeEntryDao().upsert(local.toEntity(2L, SyncState.PENDING)) + db.outboxDao().insert( + OutboxEntity( + opType = OutboxOpType.DELETE, + organizationId = "org1", + timeEntryId = local.id, + createdAtMs = 2L, + payloadJson = "{}", + baseSnapshotJson = json.encodeToString( + ConflictSnapshot.of( + local.start, + local.end, + local.description, + local.projectId, + local.taskId, + local.billable, + emptyList(), + ), + ), + ), + ) + + assertEquals(ListenableWorker.Result.success(), buildWorker().doWork()) + + assertEquals(SyncState.CONFLICT, db.timeEntryDao().getById(local.id)?.syncState) + assertEquals(ConflictSnapshot.DELETED_MARKER, db.timeEntryDao().getById(local.id)?.conflictServerJson) + assertTrue(db.outboxDao().peekAll().isEmpty()) + } + // SV-017: an offline START must transmit the timestamp captured when the user actually // pressed start, not a value recomputed at sync time (which would be whenever connectivity // happened to return). diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/calendar/CalendarDateUtilsTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/calendar/CalendarDateUtilsTest.kt index c7dd26d..7ddb29c 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/calendar/CalendarDateUtilsTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/calendar/CalendarDateUtilsTest.kt @@ -14,6 +14,8 @@ import java.time.DayOfWeek import java.time.Instant import java.time.LocalDate import java.time.YearMonth +import java.time.ZoneId +import java.time.ZoneOffset class CalendarDateUtilsTest { @@ -29,6 +31,17 @@ class CalendarDateUtilsTest { (1..31).forEach { d -> assert(all.contains(LocalDate.of(2026, 7, d))) } } + @Test + fun monthGridWeeks_honoursSundayWeekStart() { + // July 2026: 1st is a Wednesday. With SUNDAY start, the first cell is Sun June 28, 2026. + val weeks = monthGridWeeks(YearMonth.of(2026, 7), DayOfWeek.SUNDAY) + assertEquals(7, weeks.first().size) + assertEquals(DayOfWeek.SUNDAY, weeks.first().first().dayOfWeek) + assertEquals(LocalDate.of(2026, 6, 28), weeks.first().first()) + val all = weeks.flatten() + (1..31).forEach { d -> assert(all.contains(LocalDate.of(2026, 7, d))) } + } + @Test fun entryDurationSeconds_usesExplicitDuration() { val e = TimeEntry( @@ -65,7 +78,22 @@ class CalendarDateUtilsTest { duration = null, organizationId = "o", ) - assertEquals(LocalDate.of(2026, 7, 6), entryLocalDate(e)) + assertEquals(LocalDate.of(2026, 7, 6), entryLocalDate(e, ZoneOffset.UTC)) + } + + @Test + fun entryLocalDate_usesGivenZoneForDayBoundary() { + // 23:30Z on July 6 is already July 7 in Asia/Tokyo (UTC+9): the account zone decides the day. + val e = TimeEntry( + id = "1", + userId = "u", + start = "2026-07-06T23:30:00Z", + end = null, + duration = null, + organizationId = "o", + ) + assertEquals(LocalDate.of(2026, 7, 7), entryLocalDate(e, ZoneId.of("Asia/Tokyo"))) + assertEquals(LocalDate.of(2026, 7, 6), entryLocalDate(e, ZoneOffset.UTC)) } @Test @@ -79,7 +107,7 @@ class CalendarDateUtilsTest { duration = null, organizationId = "o", ) - assertNull(entryLocalDate(e)) + assertNull(entryLocalDate(e, ZoneOffset.UTC)) } @Test diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/calendar/CalendarViewModelTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/calendar/CalendarViewModelTest.kt index ce47a04..dd235af 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/calendar/CalendarViewModelTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/calendar/CalendarViewModelTest.kt @@ -12,6 +12,11 @@ import dev.tricked.solidverdant.data.calendar.DeviceCalendar import dev.tricked.solidverdant.data.calendar.DeviceCalendarEvent import dev.tricked.solidverdant.data.model.TimeEntry import dev.tricked.solidverdant.data.repository.TimeEntryReader +import dev.tricked.solidverdant.domain.time.TemporalPolicy +import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -77,11 +82,21 @@ class CalendarViewModelTest { organizationId = "org1", ) + // Device-zone + Monday policy, matching the pre-policy ZoneId.systemDefault() / + // WeekFields(MONDAY) behaviour these bucket/day-count assertions were written against. + private fun policyProvider( + policy: TemporalPolicy = TemporalPolicy(java.time.ZoneId.systemDefault(), java.time.DayOfWeek.MONDAY), + ): TemporalPolicyProvider = mockk { + every { this@mockk.policy } returns kotlinx.coroutines.flow.flowOf(policy) + coEvery { current() } returns policy + } + private fun vm( reader: TimeEntryReader, source: CalendarEventSource = FakeEventSource(), settings: CalendarOverlaySettings = FakeOverlaySettings(), - ) = CalendarViewModel(reader, source, settings) + temporalPolicyProvider: TemporalPolicyProvider = policyProvider(), + ) = CalendarViewModel(reader, source, settings, temporalPolicyProvider) @Test fun buildsBucketsAndTotalsPerDay() = runTest { diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/privacy/PrivacyViewModelTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/privacy/PrivacyViewModelTest.kt new file mode 100644 index 0000000..0782fcb --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/privacy/PrivacyViewModelTest.kt @@ -0,0 +1,150 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.privacy + +import android.content.Context +import android.net.Uri +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import dev.tricked.solidverdant.data.export.DiagnosticExporter +import dev.tricked.solidverdant.data.local.AuthDataStore +import dev.tricked.solidverdant.data.local.SettingsDataStore +import dev.tricked.solidverdant.data.local.UserCacheCleaner +import dev.tricked.solidverdant.data.local.db.AppDatabase +import dev.tricked.solidverdant.util.Clock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class PrivacyViewModelTest { + + private lateinit var context: Context + private lateinit var db: AppDatabase + private lateinit var settings: SettingsDataStore + private lateinit var authDataStore: AuthDataStore + private lateinit var cleaner: UserCacheCleaner + private lateinit var exporter: DiagnosticExporter + private val dispatcher = UnconfinedTestDispatcher() + private val viewModels = mutableListOf() + private val clock = object : Clock { + override fun nowMs() = 1_000L + } + + @Before + fun setup() { + kotlinx.coroutines.Dispatchers.setMain(dispatcher) + context = ApplicationProvider.getApplicationContext() + // Named on-disk DB so getDatabasePath("solidverdant.db") resolves to a real file to size. + db = Room.databaseBuilder(context, AppDatabase::class.java, "solidverdant.db") + .allowMainThreadQueries() + .build() + // Force the file into existence. + db.openHelper.writableDatabase.execSQL("PRAGMA user_version = 1") + settings = SettingsDataStore(context) + authDataStore = AuthDataStore(context) + cleaner = UserCacheCleaner(context, settings, db) + exporter = DiagnosticExporter(context, authDataStore, db.outboxDao(), db.syncMetaDao(), clock) + } + + @After + fun teardown() { + viewModels.forEach { it.cancelScopeForTest() } + dispatcher.scheduler.advanceUntilIdle() + db.close() + context.getDatabasePath("solidverdant.db").delete() + kotlinx.coroutines.Dispatchers.resetMain() + } + + private fun viewModel(): PrivacyViewModel = PrivacyViewModel( + context = context, + settingsDataStore = settings, + authDataStore = authDataStore, + userCacheCleaner = cleaner, + diagnosticExporter = exporter, + ).also { viewModels += it } + + @Test + fun `computes and exposes storage sizes off main thread`() = runTest(dispatcher.scheduler) { + val cacheFile = File(context.cacheDir, "probe.bin").apply { writeBytes(ByteArray(2048)) } + + val vm = viewModel() + vm.refreshStorage() + + val state = vm.state.first { !it.computingStorage && it.dbBytes > 0 } + assertTrue("db bytes should be positive", state.dbBytes > 0) + assertTrue("cache bytes should include the probe file", state.cacheBytes >= cacheFile.length()) + assertFalse(state.computingStorage) + } + + @Test + fun `clearCache calls reused cleaner and re-reads storage`() = runTest(dispatcher.scheduler) { + // Seed cached account data so the cleaner has something to remove. + val cacheFile = File(context.cacheDir, "probe.bin").apply { writeBytes(ByteArray(4096)) } + val vm = viewModel() + vm.state.first { !it.computingStorage } + + vm.clearCache() + + // The cleaner wipes account tables; storage is re-read (not stuck computing). + val state = vm.state.first { !it.clearingCache && !it.computingStorage } + assertFalse(state.computingStorage) + cacheFile.delete() + } + + @Test + fun `exportDiagnostics delegates to the exporter and returns the uri`() = runTest(dispatcher.scheduler) { + val vm = viewModel() + var received: Uri? = null + vm.exportDiagnostics { received = it } + + // Wait for the export coroutine to settle. + vm.state.first { !it.exporting } + assertNotNull("exporter should produce a shareable uri", received) + } + + @Test + fun `exposes whether a session is present`() = runTest(dispatcher.scheduler) { + val vm = viewModel() + // No token saved in this test → no session (sessionPresent defaults false and stays false). + val state = vm.state.first { it.serverHost.isNotBlank() } + assertFalse(state.sessionPresent) + } + + @Test + fun `exposes the selected server host`() = runTest(dispatcher.scheduler) { + // Set an explicit endpoint so this assertion is independent of whatever another test may + // have persisted into the shared on-disk AuthDataStore (the DataStore file is process-wide). + authDataStore.saveOAuthConfig("https://sync.privacytest.example", "test-client") + val vm = viewModel() + val state = vm.state.first { it.serverHost == "sync.privacytest.example" } + assertEquals("sync.privacytest.example", state.serverHost) + } + + @Test + fun `byte formatter renders human readable sizes`() { + assertEquals("0 B", ByteSizeFormatter.format(0)) + assertEquals("512 B", ByteSizeFormatter.format(512)) + assertEquals("1.0 KB", ByteSizeFormatter.format(1024)) + assertEquals("1.5 KB", ByteSizeFormatter.format(1536)) + assertEquals("1.0 MB", ByteSizeFormatter.format(1024L * 1024)) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/review/InboxViewModelTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/review/InboxViewModelTest.kt new file mode 100644 index 0000000..d211b04 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/review/InboxViewModelTest.kt @@ -0,0 +1,273 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.review + +import androidx.test.core.app.ApplicationProvider +import dev.tricked.solidverdant.data.local.AuthDataStore +import dev.tricked.solidverdant.data.local.SettingsDataStore +import dev.tricked.solidverdant.data.local.db.InboxDismissalDao +import dev.tricked.solidverdant.data.local.db.InboxDismissalEntity +import dev.tricked.solidverdant.data.model.Membership +import dev.tricked.solidverdant.data.model.Organization +import dev.tricked.solidverdant.data.model.TimeEntry +import dev.tricked.solidverdant.data.model.User +import dev.tricked.solidverdant.data.repository.TimeEntryRepository +import dev.tricked.solidverdant.domain.inbox.InboxIssueType +import dev.tricked.solidverdant.domain.inbox.InboxSettingsDataStore +import dev.tricked.solidverdant.domain.time.TemporalPolicy +import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider +import dev.tricked.solidverdant.sync.SyncTrigger +import dev.tricked.solidverdant.util.Clock +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.LocalDate +import java.time.ZoneId + +/** + * SV-005 T4.2: the ViewModel surfaces the horizon-chosen flag into UI state (so the pane knows to + * show the first-run picker) and [InboxViewModel.chooseHorizon] persists the correct bound through + * the real DataStore-backed store. Uses a Robolectric context for the store, mockk for the rest. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class InboxViewModelTest { + + private val zone: ZoneId = ZoneId.of("UTC") + + @Before fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @After fun tearDown() = Dispatchers.resetMain() + + // The preferences DataStore is file-backed and shared (per name) across test methods in the same + // Robolectric context; clear it so each case starts from a fresh (unchosen) horizon. + private suspend fun newStore() = InboxSettingsDataStore(ApplicationProvider.getApplicationContext()) + .also { it.clearForTest() } + + /** + * A minimal in-memory [InboxDismissalDao] whose observe flow re-emits on every write, so + * `dismissDay`/`dismiss` actually re-drive `analyze()` in the collecting pipeline (a static + * `flowOf` mock would swallow the write). + */ + private class FakeDismissalDao : InboxDismissalDao { + val rows = MutableStateFlow>(emptyList()) + override suspend fun upsert(dismissal: InboxDismissalEntity) { + rows.value = rows.value.filterNot { it.issueKey == dismissal.issueKey } + dismissal + } + override suspend fun upsertAll(dismissals: List) { + val keys = dismissals.map { it.issueKey }.toSet() + rows.value = rows.value.filterNot { it.issueKey in keys } + dismissals + } + override fun observeDismissals(orgId: String) = rows + override fun observeDismissedKeys(orgId: String) = MutableStateFlow(rows.value.map { it.issueKey }) + override fun observeDismissedCount(orgId: String) = MutableStateFlow(rows.value.size) + override suspend fun deleteByKey(issueKey: String) { + rows.value = rows.value.filterNot { it.issueKey == issueKey } + } + override suspend fun clearForOrg(orgId: String) { + rows.value = emptyList() + } + override suspend fun clear() { + rows.value = emptyList() + } + } + + private fun buildViewModel( + store: InboxSettingsDataStore, + entries: List = emptyList(), + conflicts: List = emptyList(), + dismissalDao: InboxDismissalDao = FakeDismissalDao(), + nowMs: Long = NOW_MS, + ): InboxViewModel { + val org = Organization(id = "org1", name = "Org", currency = "USD") + val membership = Membership(id = "m1", role = "member", organization = org) + val user = User(id = "u1", name = "U", email = "u@e.co", timezone = "UTC", weekStart = "monday") + + val auth = mockk() + every { auth.currentMembershipId } returns flowOf("m1") + + val settings = mockk() + every { settings.getCachedAuth() } returns + SettingsDataStore.CachedAuth(user, listOf(membership), "m1") + every { settings.longTimerHours } returns flowOf(8) + + val repo = mockk(relaxed = true) + every { repo.observeTimeEntries(any()) } returns flowOf(entries) + every { repo.observeConflicts(any()) } returns flowOf(conflicts) + every { repo.observeProjects(any()) } returns flowOf(emptyList()) + every { repo.observeTasks(any()) } returns flowOf(emptyList()) + every { repo.observeTags(any()) } returns flowOf(emptyList()) + coEvery { repo.refreshAll(any(), any()) } returns Result.success(Unit) + + val syncTrigger = mockk(relaxed = true) + val clock = object : Clock { + override fun nowMs(): Long = nowMs + } + + val policyProvider = mockk() + val policy = TemporalPolicy(zone, java.time.DayOfWeek.MONDAY) + every { policyProvider.policy } returns flowOf(policy) + coEvery { policyProvider.current() } returns policy + + return InboxViewModel( + timeEntryRepository = repo, + authDataStore = auth, + settingsDataStore = settings, + inboxSettingsDataStore = store, + dismissalDao = dismissalDao, + syncTrigger = syncTrigger, + clock = clock, + temporalPolicyProvider = policyProvider, + ) + } + + @Test + fun firstRun_horizonNotChosen_uiStateReportsUnchosen() = runTest { + val store = newStore() + val vm = buildViewModel(store) + + val state = vm.awaitState { !it.isLoading } + assertFalse("fresh install has not chosen a horizon yet", state.horizonChosen) + } + + @Test + fun chooseHorizon_today_persistsStartOfTodayAndUnlocks() = runTest { + val store = newStore() + val vm = buildViewModel(store) + vm.awaitState { !it.isLoading } + + vm.chooseHorizon(HorizonOption.TODAY) + + // Assert through uiState: the pipeline re-reads the store and unlocks the list, surfacing the + // persisted bound. (The raw store round-trip is covered by InboxSettingsHorizonTest.) + val startOfToday = LocalDate.now(zone).atStartOfDay(zone).toInstant().toEpochMilli() + val state = vm.awaitState { it.horizonChosen } + assertTrue("choosing unlocks the list", state.horizonChosen) + assertEquals(startOfToday, state.horizonStartMs) + } + + @Test + fun chooseHorizon_everything_marksChosenWithNullBound() = runTest { + val store = newStore() + val vm = buildViewModel(store) + vm.awaitState { !it.isLoading } + + vm.chooseHorizon(HorizonOption.EVERYTHING) + + val state = vm.awaitState { it.horizonChosen } + assertTrue(state.horizonChosen) + assertNull("Everything clears the stored bound", state.horizonStartMs) + } + + @Test + fun dismissDay_persistsOnlyThatDaysDismissibleIssues_notConflictNorOtherDays() = runTest { + val store = newStore().also { it.chooseEverythingForTest() } + val dao = FakeDismissalDao() + val vm = buildViewModel( + store = store, + entries = listOf(longEntry("A", DAY_A_START), longEntry("B", DAY_B_START)), + conflicts = listOf(conflict(longEntry("C", DAY_A_START).copy(id = "conflictC"))), + dismissalDao = dao, + ) + + val state = vm.awaitState { it.horizonChosen && it.issues.any { i -> i.type == InboxIssueType.LONG_DURATION } } + val dayAIssues = state.issues.filter { + java.time.Instant.ofEpochMilli(it.startMs).atZone(zone).toLocalDate() == DAY_A + } + val dayANonConflictKeys = dayAIssues + .filterNot { it.type == InboxIssueType.CONFLICT } + .map { it.key } + .toSet() + val conflictKey = state.issues.first { it.type == InboxIssueType.CONFLICT }.key + val dayBKeys = state.issues + .filter { java.time.Instant.ofEpochMilli(it.startMs).atZone(zone).toLocalDate() == DAY_B } + .map { it.key } + .toSet() + + // Pass the whole day-A group INCLUDING the conflict; the VM must drop the conflict. + vm.dismissDay(dayAIssues) + + val dismissed = dao.rows.value.map { it.issueKey }.toSet() + assertEquals("exactly day A's non-conflict keys are dismissed", dayANonConflictKeys, dismissed) + assertFalse("conflict is never batch-dismissed", conflictKey in dismissed) + dayBKeys.forEach { assertFalse("day B is untouched", it in dismissed) } + } + + @Test + fun dismissBefore_movesHorizon_soPreDayIssuesStayHiddenAcrossRetention() = runTest { + val store = newStore().also { it.chooseEverythingForTest() } + val entries = listOf(longEntry("A", DAY_A_START), longEntry("B", DAY_B_START)) + + val vm = buildViewModel(store = store, entries = entries) + vm.awaitState { it.issues.any { i -> i.type == InboxIssueType.LONG_DURATION } } + + // Move the horizon forward to the start of day B: day A's issue drops out immediately. + vm.dismissBefore(DAY_B_START_OF_DAY) + val afterMove = vm.awaitState { s -> + s.issues.none { it.startMs < DAY_B_START_OF_DAY } + } + assertTrue("day A hidden after moving the horizon", afterMove.issues.none { it.startMs < DAY_B_START_OF_DAY }) + assertTrue("day B still present", afterMove.issues.any { it.startMs >= DAY_B_START_OF_DAY }) + + // A fresh pipeline whose clock is far beyond the 45-day dismissal retention still hides day A: + // the horizon (persisted in the store), not an expiring dismissal row, is what removes it. + val vmFuture = buildViewModel(store = store, entries = entries, nowMs = FAR_FUTURE_NOW_MS) + val futureState = vmFuture.awaitState { !it.isLoading && it.horizonChosen } + assertTrue( + "horizon durably hides pre-day-B issues even past dismissal retention", + futureState.issues.none { it.startMs < DAY_B_START_OF_DAY }, + ) + } + + private fun longEntry(id: String, startMs: Long): TimeEntry { + val start = java.time.Instant.ofEpochMilli(startMs) + val end = start.plusSeconds(NINE_HOURS_SECONDS) + return TimeEntry( + id = id, + userId = "u1", + organizationId = "org1", + start = start.atZone(zone).toOffsetDateTime().toString(), + end = end.atZone(zone).toOffsetDateTime().toString(), + ) + } + + private fun conflict(local: TimeEntry) = + TimeEntryRepository.SyncConflict(local = local, server = null, serverDeleted = false, localDeleted = false) + + private suspend fun InboxSettingsDataStore.chooseEverythingForTest() = setHorizonStart(null) + + private suspend fun InboxViewModel.awaitState(predicate: (InboxUiState) -> Boolean): InboxUiState = uiState.first(predicate) + + private companion object { + const val NOW_MS = 1_752_300_000_000L // 2025-07-12T06:00Z + const val FAR_FUTURE_NOW_MS = NOW_MS + 60L * 24 * 3600 * 1000 // +60 days (> 45-day retention) + const val NINE_HOURS_SECONDS = 9L * 3600 + const val DAY_A_START = 1_752_138_000_000L // 2025-07-10T09:00Z + const val DAY_B_START = 1_752_224_400_000L // 2025-07-11T09:00Z + const val DAY_B_START_OF_DAY = 1_752_192_000_000L // 2025-07-11T00:00Z + val DAY_A: LocalDate = LocalDate.of(2025, 7, 10) + val DAY_B: LocalDate = LocalDate.of(2025, 7, 11) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/EstimateProgressTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/EstimateProgressTest.kt new file mode 100644 index 0000000..c817a06 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/EstimateProgressTest.kt @@ -0,0 +1,131 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.statistics + +import dev.tricked.solidverdant.data.model.Project +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EstimateProgressTest { + + private fun project( + id: String, + name: String = id, + color: String = "#f00", + clientId: String? = null, + estimatedTime: Int? = null, + spentTime: Int = 0, + isBillable: Boolean = false, + ) = Project( + id = id, + name = name, + color = color, + clientId = clientId, + estimatedTime = estimatedTime, + spentTime = spentTime, + isBillable = isBillable, + ) + + // ----- math on the data class ----- + + @Test + fun `remaining fraction and flags for an under-budget project`() { + val p = EstimateProgress(id = "p", name = "P", colorHex = "#f00", estimatedSeconds = 8 * 3600, spentSeconds = 2 * 3600) + assertEquals(6 * 3600, p.remainingSeconds) + assertEquals(0.25f, p.fraction, 0.0001f) + assertFalse(p.isOverBudget) + assertFalse(p.isNearEstimate) + } + + @Test + fun `near-estimate when fraction crosses the threshold but not over`() { + val p = EstimateProgress(id = "p", name = "P", colorHex = null, estimatedSeconds = 100, spentSeconds = 95) + assertTrue(p.isNearEstimate) + assertFalse(p.isOverBudget) + } + + @Test + fun `over-budget yields negative remaining and clears near-estimate`() { + val p = EstimateProgress(id = "p", name = "P", colorHex = null, estimatedSeconds = 100, spentSeconds = 130) + assertTrue(p.isOverBudget) + assertFalse(p.isNearEstimate) + assertEquals(-30, p.remainingSeconds) + assertEquals(1.3f, p.fraction, 0.0001f) + } + + @Test + fun `zero estimate yields zero fraction and no over-budget`() { + val p = EstimateProgress(id = "p", name = "P", colorHex = null, estimatedSeconds = 0, spentSeconds = 5) + assertEquals(0f, p.fraction, 0.0001f) + } + + // ----- projectEstimateProgress selection / scoping / sort ----- + + @Test + fun `projects without a positive estimate are excluded`() { + val projects = listOf( + project("a", estimatedTime = null, spentTime = 10), + project("b", estimatedTime = 0, spentTime = 10), + project("c", estimatedTime = 100, spentTime = 10), + ) + val out = StatisticsAggregator.projectEstimateProgress(projects, StatFilters()) + assertEquals(listOf("c"), out.map { it.id }) + } + + @Test + fun `project filter restricts to selected projects`() { + val projects = listOf( + project("a", estimatedTime = 100, spentTime = 10), + project("b", estimatedTime = 100, spentTime = 10), + ) + val out = StatisticsAggregator.projectEstimateProgress(projects, StatFilters(projectIds = setOf("b"))) + assertEquals(listOf("b"), out.map { it.id }) + } + + @Test + fun `client filter restricts through the project client`() { + val projects = listOf( + project("a", clientId = "c1", estimatedTime = 100, spentTime = 10), + project("b", clientId = "c2", estimatedTime = 100, spentTime = 10), + ) + val out = StatisticsAggregator.projectEstimateProgress(projects, StatFilters(clientIds = setOf("c2"))) + assertEquals(listOf("b"), out.map { it.id }) + } + + @Test + fun `billable filter restricts to billable projects`() { + val projects = listOf( + project("a", estimatedTime = 100, spentTime = 10, isBillable = true), + project("b", estimatedTime = 100, spentTime = 10, isBillable = false), + ) + val out = StatisticsAggregator.projectEstimateProgress(projects, StatFilters(billable = BillableFilter.Billable)) + assertEquals(listOf("a"), out.map { it.id }) + } + + @Test + fun `over-budget projects sort first then by fraction descending`() { + val projects = listOf( + project("low", estimatedTime = 100, spentTime = 20), // 0.2 + project("over", estimatedTime = 100, spentTime = 150), // over + project("high", estimatedTime = 100, spentTime = 80), // 0.8 + ) + val out = StatisticsAggregator.projectEstimateProgress(projects, StatFilters()) + assertEquals(listOf("over", "high", "low"), out.map { it.id }) + } + + @Test + fun `archived projects are excluded`() { + val projects = listOf( + project("a", estimatedTime = 100, spentTime = 10).copy(isArchived = true), + project("b", estimatedTime = 100, spentTime = 10), + ) + val out = StatisticsAggregator.projectEstimateProgress(projects, StatFilters()) + assertEquals(listOf("b"), out.map { it.id }) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatRangeTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatRangeTest.kt index d31ca23..4bd9b12 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatRangeTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatRangeTest.kt @@ -8,40 +8,58 @@ package dev.tricked.solidverdant.ui.statistics import org.junit.Assert.assertEquals import org.junit.Test +import java.time.DayOfWeek import java.time.LocalDate class StatRangeTest { - // Wed 2026-07-08; ISO week starts Monday 2026-07-06 + // Wed 2026-07-08; ISO (Monday-start) week starts Monday 2026-07-06 private val today = LocalDate.parse("2026-07-08") @Test fun `this week is monday to today`() { - val r = StatRange.ThisWeek.resolve(today) + val r = StatRange.ThisWeek.resolve(today, DayOfWeek.MONDAY) assertEquals(LocalDate.parse("2026-07-06"), r.start) assertEquals(today, r.endInclusive) } @Test fun `this month is first to today`() { - val r = StatRange.ThisMonth.resolve(today) + val r = StatRange.ThisMonth.resolve(today, DayOfWeek.MONDAY) assertEquals(LocalDate.parse("2026-07-01"), r.start) assertEquals(today, r.endInclusive) } @Test fun `custom returns its own bounds`() { - val r = StatRange.Custom(LocalDate.parse("2026-01-01"), LocalDate.parse("2026-01-31")).resolve(today) + val r = StatRange.Custom(LocalDate.parse("2026-01-01"), LocalDate.parse("2026-01-31")) + .resolve(today, DayOfWeek.MONDAY) assertEquals(LocalDate.parse("2026-01-01"), r.start) assertEquals(LocalDate.parse("2026-01-31"), r.endInclusive) } @Test fun `shortcut ranges resolve inclusively`() { - assertEquals(LocalDate.parse("2026-07-02"), StatRange.Last7Days.resolve(today).start) - assertEquals(LocalDate.parse("2026-06-29"), StatRange.LastWeek.resolve(today).start) - assertEquals(LocalDate.parse("2026-07-05"), StatRange.LastWeek.resolve(today).endInclusive) - assertEquals(LocalDate.parse("2026-06-01"), StatRange.PreviousMonth.resolve(today).start) - assertEquals(LocalDate.parse("2026-06-30"), StatRange.PreviousMonth.resolve(today).endInclusive) + assertEquals(LocalDate.parse("2026-07-02"), StatRange.Last7Days.resolve(today, DayOfWeek.MONDAY).start) + assertEquals(LocalDate.parse("2026-06-29"), StatRange.LastWeek.resolve(today, DayOfWeek.MONDAY).start) + assertEquals(LocalDate.parse("2026-07-05"), StatRange.LastWeek.resolve(today, DayOfWeek.MONDAY).endInclusive) + assertEquals(LocalDate.parse("2026-06-01"), StatRange.PreviousMonth.resolve(today, DayOfWeek.MONDAY).start) + assertEquals(LocalDate.parse("2026-06-30"), StatRange.PreviousMonth.resolve(today, DayOfWeek.MONDAY).endInclusive) + } + + @Test + fun `this week resolves with a sunday week start`() { + // Wed 2026-07-08: the Sunday-start week began Sunday 2026-07-05. + val r = StatRange.ThisWeek.resolve(today, DayOfWeek.SUNDAY) + assertEquals(LocalDate.parse("2026-07-05"), r.start) + assertEquals(today, r.endInclusive) + } + + @Test + fun `last week resolves with a sunday week start`() { + // This Sunday-start week began 2026-07-05, so last week is 2026-06-28..2026-07-04. + val r = StatRange.LastWeek.resolve(today, DayOfWeek.SUNDAY) + assertEquals(LocalDate.parse("2026-06-28"), r.start) + assertEquals(LocalDate.parse("2026-07-04"), r.endInclusive) } @Test diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsAggregatorTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsAggregatorTest.kt index 1951689..cc4a492 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsAggregatorTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsAggregatorTest.kt @@ -11,6 +11,7 @@ import dev.tricked.solidverdant.data.model.TimeEntry import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.time.DayOfWeek import java.time.LocalDate import java.time.ZoneId @@ -18,6 +19,26 @@ class StatisticsAggregatorTest { private val utc = ZoneId.of("UTC") + // Regression guard: the existing tests call this Monday-start wrapper so their expected values + // must stay byte-identical to the pre-policy (WeekFields.ISO) behaviour. Sunday-start / non-UTC + // cases call StatisticsAggregator.compute directly with an explicit firstDayOfWeek. + private fun compute( + entries: List, + projects: List, + rangeStart: LocalDate, + rangeEnd: LocalDate, + zone: ZoneId, + granularity: TrendGranularity, + ) = StatisticsAggregator.compute( + entries = entries, + projects = projects, + rangeStart = rangeStart, + rangeEnd = rangeEnd, + zone = zone, + granularity = granularity, + firstDayOfWeek = DayOfWeek.MONDAY, + ) + private fun entry( id: String, start: String, @@ -44,7 +65,7 @@ class StatisticsAggregatorTest { @Test fun `uses duration when present`() { val e = entry("1", "2026-07-01T09:00:00Z", "2026-07-01T10:00:00Z", duration = 3600) - val s = StatisticsAggregator.compute( + val s = compute( listOf(e), projects, LocalDate.parse("2026-07-01"), @@ -59,7 +80,7 @@ class StatisticsAggregatorTest { @Test fun `falls back to end minus start when duration null`() { val e = entry("1", "2026-07-01T09:00:00Z", "2026-07-01T09:30:00Z", duration = null) - val s = StatisticsAggregator.compute( + val s = compute( listOf(e), projects, LocalDate.parse("2026-07-01"), @@ -73,7 +94,7 @@ class StatisticsAggregatorTest { @Test fun `skips active entries`() { val active = entry("1", "2026-07-01T09:00:00Z", end = null, duration = null) - val s = StatisticsAggregator.compute( + val s = compute( listOf(active), projects, LocalDate.parse("2026-07-01"), @@ -92,7 +113,7 @@ class StatisticsAggregatorTest { entry("2", "2026-07-01T10:00:00Z", duration = 500, projectId = "p2"), entry("3", "2026-07-01T11:00:00Z", duration = 300, projectId = null), ) - val s = StatisticsAggregator.compute( + val s = compute( entries, projects, LocalDate.parse("2026-07-01"), @@ -111,7 +132,7 @@ class StatisticsAggregatorTest { entry("1", "2026-07-01T09:00:00Z", duration = 100, billable = true), entry("2", "2026-07-01T10:00:00Z", duration = 400, billable = false), ) - val s = StatisticsAggregator.compute( + val s = compute( entries, projects, LocalDate.parse("2026-07-01"), @@ -129,7 +150,7 @@ class StatisticsAggregatorTest { entry("1", "2026-07-01T09:00:00Z", duration = 600), entry("2", "2026-07-03T09:00:00Z", duration = 600), ) - val s = StatisticsAggregator.compute( + val s = compute( entries, projects, LocalDate.parse("2026-07-01"), @@ -147,7 +168,7 @@ class StatisticsAggregatorTest { entry("1", "2026-07-01T09:00:00Z", duration = 600), entry("2", "2026-07-03T09:00:00Z", duration = 300), ) - val s = StatisticsAggregator.compute( + val s = compute( entries, projects, LocalDate.parse("2026-07-01"), @@ -167,7 +188,7 @@ class StatisticsAggregatorTest { entry("1", "2026-07-01T09:00:00Z", duration = 600), // W27 entry("2", "2026-07-08T09:00:00Z", duration = 300), // W28 ) - val s = StatisticsAggregator.compute( + val s = compute( entries, projects, LocalDate.parse("2026-07-01"), @@ -186,7 +207,7 @@ class StatisticsAggregatorTest { entry("1", "2026-06-30T23:00:00Z", duration = 999), // before range entry("2", "2026-07-01T09:00:00Z", duration = 100), ) - val s = StatisticsAggregator.compute( + val s = compute( entries, projects, LocalDate.parse("2026-07-01"), @@ -202,7 +223,7 @@ class StatisticsAggregatorTest { // Starts 23:00 the day before, runs 2h -> 1h before range, 1h inside. Old code dropped it // entirely (start day out of range); it must now contribute the in-range hour. val e = entry("1", "2026-06-30T23:00:00Z", duration = 7200) - val s = StatisticsAggregator.compute( + val s = compute( listOf(e), projects, LocalDate.parse("2026-07-01"), @@ -219,7 +240,7 @@ class StatisticsAggregatorTest { fun `clips entry extending past range end`() { // Starts 23:00 on the last day, runs 2h -> only 1h falls within the range. val e = entry("1", "2026-07-01T23:00:00Z", duration = 7200) - val s = StatisticsAggregator.compute( + val s = compute( listOf(e), projects, LocalDate.parse("2026-07-01"), @@ -234,7 +255,7 @@ class StatisticsAggregatorTest { fun `splits multi-day entry across the days it spans`() { // 22:00 -> 02:00 next day: 2h on Jul 1, 2h on Jul 2. val e = entry("1", "2026-07-01T22:00:00Z", "2026-07-02T02:00:00Z", duration = null) - val s = StatisticsAggregator.compute( + val s = compute( listOf(e), projects, LocalDate.parse("2026-07-01"), @@ -254,7 +275,7 @@ class StatisticsAggregatorTest { entry("1", "2025-12-29T09:00:00Z", duration = 600), // W1 2026 (ISO week-based year) entry("2", "2026-12-28T09:00:00Z", duration = 300), // W53 2026 ) - val s = StatisticsAggregator.compute( + val s = compute( entries, projects, LocalDate.parse("2025-12-29"), @@ -268,8 +289,83 @@ class StatisticsAggregatorTest { } @Test - fun `empty input yields zero summary`() { + fun `week trend buckets on a sunday week start`() { + // Sun 2026-07-05 and the following Sun 2026-07-12 open two Sunday-start weeks. An entry on + // Sat 2026-07-11 belongs to the week that began Sun 2026-07-05 (not a Monday-start bucket). + val entries = listOf( + entry("1", "2026-07-06T09:00:00Z", duration = 600), // Mon in week starting Sun 07-05 + entry("2", "2026-07-11T09:00:00Z", duration = 300), // Sat, still week starting Sun 07-05 + entry("3", "2026-07-13T09:00:00Z", duration = 900), // Mon in week starting Sun 07-12 + ) val s = StatisticsAggregator.compute( + entries = entries, + projects = projects, + rangeStart = LocalDate.parse("2026-07-05"), + rangeEnd = LocalDate.parse("2026-07-18"), + zone = utc, + granularity = TrendGranularity.WEEK, + firstDayOfWeek = DayOfWeek.SUNDAY, + ) + assertEquals(2, s.trend.size) + // Buckets start on Sundays, proving the Sunday-start grouping key. + assertEquals(LocalDate.parse("2026-07-05"), s.trend[0].startDate) + assertEquals(LocalDate.parse("2026-07-12"), s.trend[1].startDate) + assertEquals(900L, s.trend[0].seconds) // 600 + 300 in the first Sunday-start week + assertEquals(900L, s.trend[1].seconds) + } + + @Test + fun `clips across a spring-forward DST transition in Europe Amsterdam`() { + // Amsterdam springs forward 2026-03-29 02:00 -> 03:00 (CET->CEST), so that local day is 23h. + // An entry from 2026-03-28 23:00 local (22:00Z) to 2026-03-29 04:00 local (02:00Z) spans the + // gap. Day boundaries follow the profile zone: 1h on the 28th, then the rest on the 29th + // whose wall-clock 00:00->04:00 is only 3 real hours because of the skipped hour. + val amsterdam = ZoneId.of("Europe/Amsterdam") + val e = entry("1", "2026-03-28T22:00:00Z", "2026-03-29T02:00:00Z", duration = null) + val s = StatisticsAggregator.compute( + entries = listOf(e), + projects = projects, + rangeStart = LocalDate.parse("2026-03-28"), + rangeEnd = LocalDate.parse("2026-03-29"), + zone = amsterdam, + granularity = TrendGranularity.DAY, + firstDayOfWeek = DayOfWeek.MONDAY, + ) + // Total is the wall-to-wall real duration: 22:00Z -> 02:00Z = 4h. + assertEquals(4 * 3600L, s.totalSeconds) + assertEquals(2, s.trend.size) + assertEquals(LocalDate.parse("2026-03-28"), s.trend[0].startDate) + // 23:00->00:00 local on the 28th = 1h. + assertEquals(3600L, s.trend[0].seconds) + // 00:00->04:00 local on the 29th spans the skipped 02:00->03:00 hour, so only 3 real hours. + assertEquals(3 * 3600L, s.trend[1].seconds) + } + + @Test + fun `profile zone day boundary attributes a late-UTC entry to the next local day`() { + // 2026-07-01T23:30Z is 2026-07-02T08:30 in Tokyo (UTC+9). With a Tokyo profile zone the + // entry must land entirely on Jul 2, not Jul 1 as a naive UTC-day attribution would. + val tokyo = ZoneId.of("Asia/Tokyo") + val e = entry("1", "2026-07-01T23:30:00Z", duration = 3600) + val s = StatisticsAggregator.compute( + entries = listOf(e), + projects = projects, + rangeStart = LocalDate.parse("2026-07-01"), + rangeEnd = LocalDate.parse("2026-07-02"), + zone = tokyo, + granularity = TrendGranularity.DAY, + firstDayOfWeek = DayOfWeek.MONDAY, + ) + assertEquals(3600L, s.totalSeconds) + assertEquals(2, s.trend.size) + assertEquals(LocalDate.parse("2026-07-01"), s.trend[0].startDate) + assertEquals(0L, s.trend[0].seconds) // nothing on Jul 1 (local) + assertEquals(3600L, s.trend[1].seconds) // all on Jul 2 (local) + } + + @Test + fun `empty input yields zero summary`() { + val s = compute( emptyList(), projects, LocalDate.parse("2026-07-01"), diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsEstimatesVmTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsEstimatesVmTest.kt new file mode 100644 index 0000000..b1bb2fa --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsEstimatesVmTest.kt @@ -0,0 +1,173 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.statistics + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import dev.tricked.solidverdant.data.export.CsvExporter +import dev.tricked.solidverdant.data.local.AuthDataStore +import dev.tricked.solidverdant.data.local.db.AppDatabase +import dev.tricked.solidverdant.data.local.db.MembershipEntity +import dev.tricked.solidverdant.data.local.db.OrganizationEntity +import dev.tricked.solidverdant.data.local.db.ProjectEntity +import dev.tricked.solidverdant.data.remote.FakeRemoteDataSource +import dev.tricked.solidverdant.data.repository.AuthRepository +import dev.tricked.solidverdant.data.repository.TimeEntryRepository +import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider +import dev.tricked.solidverdant.util.Clock +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The Statistics UI state must surface project estimate budgets ([EstimateProgress]) derived + * reactively from the cached project catalogue and the active filters — no new persistence, no + * server call. Uses the server-authoritative project spentTime vs estimatedTime. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class StatisticsEstimatesVmTest { + + private lateinit var db: AppDatabase + private lateinit var authDataStore: AuthDataStore + private lateinit var authRepository: AuthRepository + private lateinit var timeEntryRepository: TimeEntryRepository + private lateinit var csvExporter: CsvExporter + private lateinit var temporalPolicyProvider: TemporalPolicyProvider + private val dispatcher = StandardTestDispatcher() + private val viewModels = mutableListOf() + + @Before + fun setup() { + kotlinx.coroutines.Dispatchers.setMain(dispatcher) + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + authDataStore = AuthDataStore(context) + temporalPolicyProvider = TemporalPolicyProvider( + dev.tricked.solidverdant.data.local.SettingsDataStore(context), + ) + authRepository = mockk(relaxed = true) + coEvery { authRepository.getCurrentMembership() } returns null + coEvery { authRepository.getTimeEntries(any(), any(), any(), any(), any(), any(), any()) } returns + Result.failure(java.io.IOException("offline")) + timeEntryRepository = TimeEntryRepository( + db.timeEntryDao(), + db.catalogDao(), + db.outboxDao(), + db.syncMetaDao(), + FakeRemoteDataSource(), + object : Clock { + override fun nowMs() = 1L + }, + Json { encodeDefaults = true }, + db, + ) + csvExporter = mockk(relaxed = true) + } + + @After + fun teardown() { + viewModels.forEach { it.cancelScopeForTest() } + dispatcher.scheduler.advanceUntilIdle() + db.close() + kotlinx.coroutines.Dispatchers.resetMain() + } + + private fun viewModel() = StatisticsViewModel( + authRepository = authRepository, + timeEntryRepository = timeEntryRepository, + csvExporter = csvExporter, + authDataStore = authDataStore, + catalogDao = db.catalogDao(), + temporalPolicyProvider = temporalPolicyProvider, + ).also { viewModels += it } + + private suspend fun seed(orgId: String, memberId: String) { + db.catalogDao().upsertOrganizations( + listOf(OrganizationEntity(id = orgId, name = "Acme", currency = "USD")), + ) + db.catalogDao().upsertMemberships(listOf(MembershipEntity(id = memberId, role = "member", organizationId = orgId))) + db.catalogDao().upsertProjects( + listOf( + ProjectEntity( + id = "p-est", + name = "Estimated", + color = "#f00", + clientId = null, + isArchived = false, + billableRate = null, + isBillable = false, + estimatedTime = 8 * 3600, + spentTime = 2 * 3600, + isPublic = false, + organizationId = orgId, + ), + ProjectEntity( + id = "p-none", + name = "No estimate", + color = "#0f0", + clientId = null, + isArchived = false, + billableRate = null, + isBillable = false, + estimatedTime = null, + spentTime = 3600, + isPublic = false, + organizationId = orgId, + ), + ), + ) + } + + @Test + fun ui_state_exposes_only_estimated_projects_with_authoritative_spent() = runTest(dispatcher.scheduler) { + seed(orgId = "org1", memberId = "m1") + authDataStore.saveCurrentMembershipId("m1") + + val vm = viewModel() + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.uiState.first { !it.isLoading && it.estimateProgress.isNotEmpty() } + assertEquals(listOf("p-est"), state.estimateProgress.map { it.id }) + val ep = state.estimateProgress.first() + assertEquals(8 * 3600, ep.estimatedSeconds) + assertEquals(2 * 3600, ep.spentSeconds) + assertTrue(ep.fraction in 0.24f..0.26f) + } + + @Test + fun project_filter_restricts_the_estimate_section() = runTest(dispatcher.scheduler) { + seed(orgId = "org1", memberId = "m1") + authDataStore.saveCurrentMembershipId("m1") + + val vm = viewModel() + dispatcher.scheduler.advanceUntilIdle() + vm.uiState.first { !it.isLoading && it.estimateProgress.isNotEmpty() } + + // Filter to a project that carries no estimate -> the estimate section empties out. + vm.setFilters(StatFilters(projectIds = setOf("p-none"))) + dispatcher.scheduler.advanceUntilIdle() + + val filtered = vm.uiState.first { it.filters.projectIds == setOf("p-none") } + assertTrue(filtered.estimateProgress.isEmpty()) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsMembershipTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsMembershipTest.kt index 85fc20c..4e5a8c7 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsMembershipTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsMembershipTest.kt @@ -18,6 +18,7 @@ import dev.tricked.solidverdant.data.local.db.TimeEntryEntity import dev.tricked.solidverdant.data.remote.FakeRemoteDataSource import dev.tricked.solidverdant.data.repository.AuthRepository import dev.tricked.solidverdant.data.repository.TimeEntryRepository +import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider import dev.tricked.solidverdant.util.Clock import io.mockk.coEvery import io.mockk.mockk @@ -56,8 +57,21 @@ class StatisticsMembershipTest { private lateinit var authRepository: AuthRepository private lateinit var timeEntryRepository: TimeEntryRepository private lateinit var csvExporter: CsvExporter + + // No cached auth is seeded, so the provider yields its device-zone + Monday fallback — matching + // the pre-policy ZoneId.systemDefault() behaviour these SV-009 assertions were written against. + private lateinit var temporalPolicyProvider: TemporalPolicyProvider private val dispatcher = StandardTestDispatcher() + // Every ViewModel built during a test is tracked so teardown can cancel its viewModelScope + // before resetMain(). The VM launches coroutines on viewModelScope (Dispatchers.Main.immediate) + // — the init{} policy collector and the uiState pipeline — that are NOT children of the runTest + // scope, so they outlive the test body. That pipeline hops to real Dispatchers.IO/Default and + // posts continuations back to Main; if one lands while @After runs resetMain(), the run fails + // intermittently with "Dispatchers.Main is used concurrently with setting it". Cancelling the + // scope first makes teardown deterministic. + private val viewModels = mutableListOf() + @Before fun setup() { kotlinx.coroutines.Dispatchers.setMain(dispatcher) @@ -66,6 +80,9 @@ class StatisticsMembershipTest { .allowMainThreadQueries() .build() authDataStore = AuthDataStore(context) + temporalPolicyProvider = TemporalPolicyProvider( + dev.tricked.solidverdant.data.local.SettingsDataStore(context), + ) // AuthRepository is only used by StatisticsViewModel for the best-effort CSV org-name // lookup and the bounded server refresh of the selected range. Both are stubbed to fail, @@ -93,6 +110,11 @@ class StatisticsMembershipTest { @After fun teardown() { + // Cancel every VM's viewModelScope and drain the test scheduler so no Main-bound coroutine + // (the init policy collector or a uiState continuation returning from Dispatchers.IO/Default) + // is still active when resetMain() runs. + viewModels.forEach { it.cancelScopeForTest() } + dispatcher.scheduler.advanceUntilIdle() db.close() kotlinx.coroutines.Dispatchers.resetMain() } @@ -103,7 +125,8 @@ class StatisticsMembershipTest { csvExporter = csvExporter, authDataStore = authDataStore, catalogDao = db.catalogDao(), - ) + temporalPolicyProvider = temporalPolicyProvider, + ).also { viewModels += it } /** Seeds a cached membership plus one cached, already-synced time entry today (within [StatRange.ThisWeek]). */ private suspend fun seedMembership(membershipId: String, orgId: String) { diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/sync/SyncCenterViewModelTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/sync/SyncCenterViewModelTest.kt new file mode 100644 index 0000000..d0fc0f1 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/sync/SyncCenterViewModelTest.kt @@ -0,0 +1,226 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.sync + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import dev.tricked.solidverdant.data.local.SettingsDataStore +import dev.tricked.solidverdant.data.local.db.AppDatabase +import dev.tricked.solidverdant.data.local.db.OutboxEntity +import dev.tricked.solidverdant.data.local.db.OutboxOpType +import dev.tricked.solidverdant.data.local.db.SyncMetaEntity +import dev.tricked.solidverdant.data.model.Membership +import dev.tricked.solidverdant.data.model.Organization +import dev.tricked.solidverdant.data.model.User +import dev.tricked.solidverdant.data.remote.FakeRemoteDataSource +import dev.tricked.solidverdant.data.repository.TimeEntryRepository +import dev.tricked.solidverdant.sync.SyncTrigger +import dev.tricked.solidverdant.util.Clock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class SyncCenterViewModelTest { + + private lateinit var db: AppDatabase + private lateinit var settings: SettingsDataStore + private lateinit var repository: TimeEntryRepository + private val dispatcher = UnconfinedTestDispatcher() + private val viewModels = mutableListOf() + private var syncRequests = 0 + + private val syncTrigger = SyncTrigger { syncRequests++ } + private val clock = object : Clock { + override fun nowMs() = NOW_MS + } + + @Before + fun setup() { + kotlinx.coroutines.Dispatchers.setMain(dispatcher) + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + settings = SettingsDataStore(context) + repository = TimeEntryRepository( + db.timeEntryDao(), + db.catalogDao(), + db.outboxDao(), + db.syncMetaDao(), + FakeRemoteDataSource(), + clock, + Json { encodeDefaults = true }, + db, + ) + } + + @After + fun teardown() { + viewModels.forEach { it.cancelScopeForTest() } + // Drain the requested cancellations (including the WhileSubscribed sharing job) before + // resetMain, so no Main-bound continuation straggles into "Main is used concurrently". + dispatcher.scheduler.advanceUntilIdle() + db.close() + kotlinx.coroutines.Dispatchers.resetMain() + } + + private fun viewModel() = SyncCenterViewModel( + repository = repository, + settingsDataStore = settings, + syncTrigger = syncTrigger, + clock = clock, + ).also { viewModels += it } + + private fun seedOrg(orgId: String = ORG, memberId: String = MEMBER) { + val user = User(id = "u1", name = "Ada", email = "ada@example.com") + val membership = Membership( + id = memberId, + role = "member", + organization = Organization(id = orgId, name = "Acme", currency = "USD"), + ) + settings.cacheAuth(user, listOf(membership), memberId) + } + + private suspend fun seedOp(entryId: String, opType: OutboxOpType, deadLettered: Boolean, error: String? = null, attempts: Int = 0) { + db.outboxDao().insert( + OutboxEntity( + opType = opType, + organizationId = ORG, + timeEntryId = entryId, + payloadJson = "{}", + createdAtMs = 1L, + attemptCount = attempts, + lastError = error, + deadLettered = deadLettered, + ), + ) + } + + @Test + fun `partitions pending and failed operations with counts`() = runTest(dispatcher.scheduler) { + seedOrg() + seedOp("e-pending", OutboxOpType.UPDATE, deadLettered = false) + seedOp("e-failed", OutboxOpType.CREATE, deadLettered = true, error = "HTTP 500 Internal Server Error", attempts = 5) + db.syncMetaDao().upsert(SyncMetaEntity(organizationId = ORG, lastFullSyncAtMs = NOW_MS - 60_000L, lastPushAtMs = NOW_MS - 120_000L)) + + val vm = viewModel() + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.uiState.first { it.organizationId == ORG && it.failed.isNotEmpty() && it.pending.isNotEmpty() } + assertEquals(listOf("e-pending"), state.pending.map { it.entryId }) + assertEquals(listOf("e-failed"), state.failed.map { it.entryId }) + assertEquals(1, state.pendingCount) + assertEquals(1, state.failedCount) + assertEquals(SyncCenterUiState.TopLine.FAILURES, state.topLine) + } + + @Test + fun `all caught up when there are no operations`() = runTest(dispatcher.scheduler) { + seedOrg() + db.syncMetaDao().upsert(SyncMetaEntity(organizationId = ORG, lastFullSyncAtMs = NOW_MS, lastPushAtMs = null)) + + val vm = viewModel() + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.uiState.first { it.organizationId == ORG } + assertTrue(state.pending.isEmpty()) + assertTrue(state.failed.isEmpty()) + assertEquals(SyncCenterUiState.TopLine.SYNCED, state.topLine) + } + + @Test + fun `pending without failures reports waiting top line`() = runTest(dispatcher.scheduler) { + seedOrg() + seedOp("e1", OutboxOpType.UPDATE, deadLettered = false) + + val vm = viewModel() + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.uiState.first { it.organizationId == ORG && it.pending.isNotEmpty() } + assertEquals(SyncCenterUiState.TopLine.PENDING, state.topLine) + } + + @Test + fun `freshness reflects sync meta and sentinel zero pull becomes null`() = runTest(dispatcher.scheduler) { + seedOrg() + db.syncMetaDao().upsert(SyncMetaEntity(organizationId = ORG, lastFullSyncAtMs = 0L, lastPushAtMs = null)) + + val vm = viewModel() + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.uiState.first { it.organizationId == ORG } + assertNull(state.lastFullSyncAtMs) + assertNull(state.lastPushAtMs) + } + + @Test + fun `retryAll requests a sync`() = runTest(dispatcher.scheduler) { + seedOrg() + seedOp("e-failed", OutboxOpType.CREATE, deadLettered = true, error = "boom", attempts = 5) + val vm = viewModel() + dispatcher.scheduler.advanceUntilIdle() + vm.uiState.first { it.organizationId == ORG } + + val before = syncRequests + vm.retryAll() + dispatcher.scheduler.advanceUntilIdle() + assertTrue(syncRequests > before) + } + + @Test + fun `retry resets the failed op and requests a sync`() = runTest(dispatcher.scheduler) { + seedOrg() + seedOp("e-failed", OutboxOpType.CREATE, deadLettered = true, error = "boom", attempts = 5) + val vm = viewModel() + dispatcher.scheduler.advanceUntilIdle() + vm.uiState.first { it.organizationId == ORG && it.failed.isNotEmpty() } + + val before = syncRequests + vm.retry("e-failed") + dispatcher.scheduler.advanceUntilIdle() + + // Op is no longer dead-lettered, so it leaves the failed list and re-appears as pending. + val state = vm.uiState.first { it.organizationId == ORG && it.failed.isEmpty() && it.pending.isNotEmpty() } + assertTrue(state.pending.any { it.entryId == "e-failed" }) + assertTrue(syncRequests > before) + } + + @Test + fun `discard removes the failed op`() = runTest(dispatcher.scheduler) { + seedOrg() + seedOp("e-failed", OutboxOpType.CREATE, deadLettered = true, error = "boom", attempts = 5) + val vm = viewModel() + dispatcher.scheduler.advanceUntilIdle() + vm.uiState.first { it.organizationId == ORG && it.failed.isNotEmpty() } + + vm.discard("e-failed") + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.uiState.first { it.organizationId == ORG && it.failed.isEmpty() } + assertTrue(state.pending.isEmpty()) + } + + private companion object { + const val ORG = "org1" + const val MEMBER = "m1" + const val NOW_MS = 1_000_000_000_000L + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/EntryTrustRulesTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/EntryTrustRulesTest.kt index 810ea26..1eec10f 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/EntryTrustRulesTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/EntryTrustRulesTest.kt @@ -91,6 +91,7 @@ class EntryTrustRulesTest { projects = projects, tasks = emptyList(), clients = listOf(Client("c1", "First"), Client("c2", "Second")), + zone = java.time.ZoneOffset.UTC, ) assertEquals(listOf("b"), filtered.map { it.id }) } diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingAppBarTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingAppBarTest.kt new file mode 100644 index 0000000..1896214 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingAppBarTest.kt @@ -0,0 +1,123 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.tracking + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import dev.tricked.solidverdant.data.model.Membership +import dev.tricked.solidverdant.data.model.Organization +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * SV-006 regression coverage for the Track top-app-bar title ([TrackingAppBarTitle]). + * + * The title previously rendered the user name and organization name on adjacent unlabeled lines, + * colouring the org line `primary` even when it was inert (single membership), giving no dropdown + * affordance and no accessibility semantics. These tests pin the three fixed behaviours: + * - identical user/org names collapse to a single line (no duplicate-text glitch), + * - a switchable org line exposes a labelled [Role.Button] plus dropdown-arrow affordance, + * - an unswitchable org line is plain, inert text with none of that affordance. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TrackingAppBarTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun membership(id: String, orgName: String) = Membership( + id = id, + role = "member", + organization = Organization(id = "org-$id", name = orgName, currency = "EUR"), + ) + + private val switchDescription = "Switch organization" + + private val roleButtonMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button) + + @Test + fun identical_user_and_org_names_render_a_single_line() { + val memberships = listOf(membership("m1", "Tricked")) + composeRule.setContent { + MaterialTheme { + TrackingAppBarTitle( + userName = "Tricked", + organizationName = "Tricked", + canSwitchOrganization = false, + memberships = memberships, + currentMembershipId = "m1", + onMembershipChange = {}, + ) + } + } + + // Collapsed: "Tricked" appears exactly once, not duplicated across a user + org line. + composeRule.onAllNodesWithText("Tricked").assertCountEquals(1) + } + + @Test + fun switchable_org_line_exposes_a_labelled_role_button_affordance() { + val memberships = listOf( + membership("m1", "Acme"), + membership("m2", "Globex"), + ) + composeRule.setContent { + MaterialTheme { + TrackingAppBarTitle( + userName = "Alice", + organizationName = "Acme", + canSwitchOrganization = true, + memberships = memberships, + currentMembershipId = "m1", + onMembershipChange = {}, + ) + } + } + + // A single node carries both the "Switch organization" label and Role.Button (the merged + // Row semantics), and the org name is still visible. + composeRule.onNodeWithContentDescription(switchDescription) + .assert(hasContentDescription(switchDescription).and(roleButtonMatcher)) + composeRule.onNodeWithText("Acme").assertExists() + } + + @Test + fun single_membership_org_line_is_inert_plain_text() { + val memberships = listOf(membership("m1", "Acme")) + composeRule.setContent { + MaterialTheme { + TrackingAppBarTitle( + userName = "Alice", + organizationName = "Acme", + canSwitchOrganization = false, + memberships = memberships, + currentMembershipId = "m1", + onMembershipChange = {}, + ) + } + } + + // No switch affordance: no "Switch organization" node and no Role.Button anywhere... + composeRule.onNodeWithContentDescription(switchDescription).assertDoesNotExist() + composeRule.onNode(roleButtonMatcher).assertDoesNotExist() + // ...but the org name is still shown as plain text. + composeRule.onNodeWithText("Acme").assertExists() + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelForegroundTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelForegroundTest.kt new file mode 100644 index 0000000..276a653 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelForegroundTest.kt @@ -0,0 +1,134 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.tracking + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import dev.tricked.solidverdant.data.local.AuthDataStore +import dev.tricked.solidverdant.data.local.SettingsDataStore +import dev.tricked.solidverdant.data.local.db.AppDatabase +import dev.tricked.solidverdant.data.remote.ApiClientFactory +import dev.tricked.solidverdant.data.remote.FakeRemoteDataSource +import dev.tricked.solidverdant.data.repository.AuthRepository +import dev.tricked.solidverdant.data.repository.TimeEntryRepository +import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider +import dev.tricked.solidverdant.sync.SyncTrigger +import dev.tricked.solidverdant.util.Clock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class TrackingViewModelForegroundTest { + + private lateinit var db: AppDatabase + private val dispatcher = UnconfinedTestDispatcher() + private val viewModels = mutableListOf() + private var syncRequests = 0 + private var now = 0L + + private val syncTrigger = SyncTrigger { syncRequests++ } + private val clock = object : Clock { + override fun nowMs() = now + } + + @Before + fun setup() { + kotlinx.coroutines.Dispatchers.setMain(dispatcher) + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + } + + @After + fun teardown() { + viewModels.forEach { it.cancelScopeForTest() } + dispatcher.scheduler.advanceUntilIdle() + db.close() + kotlinx.coroutines.Dispatchers.resetMain() + } + + private fun viewModel(): TrackingViewModel { + val context = ApplicationProvider.getApplicationContext() + val settings = SettingsDataStore(context) + val repository = TimeEntryRepository( + db.timeEntryDao(), + db.catalogDao(), + db.outboxDao(), + db.syncMetaDao(), + FakeRemoteDataSource(), + clock, + Json { encodeDefaults = true }, + db, + ) + val authRepository = AuthRepository( + AuthDataStore(context), + ApiClientFactory(OkHttpClient(), Json { ignoreUnknownKeys = true }), + ) + return TrackingViewModel( + authRepository = authRepository, + settingsDataStore = settings, + timeEntryRepository = repository, + syncTrigger = syncTrigger, + temporalPolicyProvider = TemporalPolicyProvider(settings), + context = context, + clock = clock, + ).also { viewModels += it } + } + + @Test + fun `two foregrounds within debounce window trigger one refresh`() = runTest(dispatcher.scheduler) { + val vm = viewModel() + dispatcher.scheduler.advanceUntilIdle() + syncRequests = 0 + + now = 100_000L + vm.onAppForegrounded(ORG, MEMBER, refreshAll = true) + dispatcher.scheduler.advanceUntilIdle() + + now = 102_000L // within the debounce window + vm.onAppForegrounded(ORG, MEMBER, refreshAll = true) + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, syncRequests) + } + + @Test + fun `foregrounds outside debounce window trigger two refreshes`() = runTest(dispatcher.scheduler) { + val vm = viewModel() + dispatcher.scheduler.advanceUntilIdle() + syncRequests = 0 + + now = 100_000L + vm.onAppForegrounded(ORG, MEMBER, refreshAll = true) + dispatcher.scheduler.advanceUntilIdle() + + now = 100_000L + FOREGROUND_REFRESH_DEBOUNCE_MS + 1 // past the debounce window + vm.onAppForegrounded(ORG, MEMBER, refreshAll = true) + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(2, syncRequests) + } + + private companion object { + const val ORG = "org1" + const val MEMBER = "m1" + const val FOREGROUND_REFRESH_DEBOUNCE_MS = 5_000L + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/util/RelativeTimeTest.kt b/app/src/test/java/dev/tricked/solidverdant/util/RelativeTimeTest.kt new file mode 100644 index 0000000..4e64440 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/util/RelativeTimeTest.kt @@ -0,0 +1,51 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelativeTimeTest { + + private val now = 1_000_000_000_000L + + @Test + fun `null timestamp maps to Never`() { + assertEquals(RelativeTime.Bucket.Never, RelativeTime.bucketOf(null, now)) + } + + @Test + fun `moment within the last minute is JustNow`() { + assertEquals(RelativeTime.Bucket.JustNow, RelativeTime.bucketOf(now - 30_000L, now)) + } + + @Test + fun `a future or sentinel-zero timestamp is JustNow rather than negative`() { + assertEquals(RelativeTime.Bucket.JustNow, RelativeTime.bucketOf(now + 5_000L, now)) + } + + @Test + fun `minutes ago rounds down to whole minutes`() { + val result = RelativeTime.bucketOf(now - (5 * 60_000L + 45_000L), now) + assertEquals(RelativeTime.Bucket.Minutes, result) + assertEquals(5, RelativeTime.amount(now - (5 * 60_000L + 45_000L), now)) + } + + @Test + fun `hours ago rounds down to whole hours`() { + val threeHours = now - (3 * 60 * 60_000L) + assertEquals(RelativeTime.Bucket.Hours, RelativeTime.bucketOf(threeHours, now)) + assertEquals(3, RelativeTime.amount(threeHours, now)) + } + + @Test + fun `days ago rounds down to whole days`() { + val twoDays = now - (2 * 24 * 60 * 60_000L) + assertEquals(RelativeTime.Bucket.Days, RelativeTime.bucketOf(twoDays, now)) + assertEquals(2, RelativeTime.amount(twoDays, now)) + } +} diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml new file mode 100644 index 0000000..668229d --- /dev/null +++ b/config/detekt/baseline.xml @@ -0,0 +1,653 @@ + + + + + ComplexCondition:CalendarViewModel.kt$CalendarViewModel$!input.enabled || !input.hasPermission || input.ids.isEmpty() || input.range == null + ComplexCondition:ConnectionTester.kt$ConnectionTester$uri.host.isNullOrBlank() || uri.scheme !in setOf("http", "https") || uri.userInfo != null || uri.query != null || uri.fragment != null + ComplexCondition:TimeTrackingTileService.kt$TimeTrackingTileService$(entry.projectId != null && projects == null) || (entry.taskId != null && tasks == null) + ComplexCondition:TimeTrackingTileService.kt$TimeTrackingTileService$sameEntry && sameProject && sameTask && (cachedProject != null || entry.projectId == null) + CyclomaticComplexMethod:ConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ConfigScreen( configState: OAuthConfigState, onSave: (String, String) -> Unit, onReset: () -> Unit, onTestConnection: (String, String) -> Unit, onDismiss: () -> Unit, // Optional review-loop entry points. Rendered only when provided so the pre-login OAuth sheet // stays unchanged while an in-app settings caller can surface reminder/template configuration. onOpenReminderSettings: (() -> Unit)? = null, onOpenManageTemplates: (() -> Unit)? = null, ) + CyclomaticComplexMethod:ConnectionTester.kt$ConnectionTester$suspend fun test(endpoint: String, clientId: String): ConnectionTestResult + CyclomaticComplexMethod:CsvExporter.kt$CsvExporter.Companion$fun buildRows( entries: List<TimeEntry>, projects: List<Project>, clients: List<Client>, tasks: List<Task>, tags: List<Tag>, zone: ZoneId, organizationName: String, billableYes: String, billableNo: String, ): List<List<String>> + CyclomaticComplexMethod:EditTimeEntryDialog.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditTimeEntryDialog( entry: TimeEntry, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, existingEntries: List<TimeEntry> = emptyList(), preventOverlap: Boolean = false, inlinePresentation: Boolean = false, ) + CyclomaticComplexMethod:EntryTrustRules.kt$EntryTrustRules$fun filter( entries: List<TimeEntry>, filter: HistoryFilter, projects: List<Project>, tasks: List<Task>, clients: List<Client> = emptyList(), syncOperations: List<TimeEntryRepository.SyncOperation> = emptyList(), zone: ZoneId = ZoneId.systemDefault(), ): List<TimeEntry> + CyclomaticComplexMethod:InboxAnalyzer.kt$InboxAnalyzer$private fun gapIssues(intervals: List<Interval>, config: InboxCheckConfig, now: Instant, zone: ZoneId): List<InboxIssue> + CyclomaticComplexMethod:InboxPane.kt$@Composable fun InboxPane() + CyclomaticComplexMethod:MainActivity.kt$@Composable fun SolidVerdantApp( authViewModel: AuthViewModel, trackingViewModel: TrackingViewModel, handoffOrganizationId: String? = null, onHandoffConsumed: () -> Unit = {}, editActiveEntryRequested: Boolean = false, onEditActiveEntryConsumed: () -> Unit = {}, pendingReviewRoute: String? = null, onPendingReviewRouteConsumed: () -> Unit = {}, ) + CyclomaticComplexMethod:MonthCalendarView.kt$@Composable fun MonthCalendarView( state: CalendarUiState, onSelectDate: (LocalDate) -> Unit, onPreviousMonth: () -> Unit, onNextMonth: () -> Unit, onEntryClick: (TimeEntry) -> Unit, projects: List<Project> = emptyList(), tasks: List<Task> = emptyList(), tags: List<Tag> = emptyList(), modifier: Modifier = Modifier, ) + CyclomaticComplexMethod:StatisticsAggregator.kt$StatisticsAggregator$fun applyFilters(entries: List<TimeEntry>, projects: List<Project>, filters: StatFilters): List<TimeEntry> + CyclomaticComplexMethod:StatisticsScreen.kt$@Composable fun StatisticsScreen(viewModel: StatisticsViewModel = hiltViewModel()) + CyclomaticComplexMethod:SyncWorker.kt$SyncWorker$private suspend fun process(op: OutboxEntity): Outcome + CyclomaticComplexMethod:TemplateEditorSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun TemplateEditorSheet( existing: EntryTemplate?, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (TemplateDraft) -> Unit, onUpdate: (EntryTemplate) -> Unit, ) + CyclomaticComplexMethod:TemplateModels.kt$TemplateResolver$fun resolve(template: EntryTemplate, projects: List<Project>, tasks: List<Task>, tags: List<Tag>): TemplateResolution + CyclomaticComplexMethod:TimeEntryRepository.kt$TimeEntryRepository$override suspend fun loadMonth(organizationId: String, memberId: String, month: YearMonth) + CyclomaticComplexMethod:TimeTrackingTileService.kt$TimeTrackingTileService$private fun updateTileState(forceNetwork: Boolean = false) + CyclomaticComplexMethod:TimeTrackingTileService.kt$TimeTrackingTileService$private suspend fun loadNames(entry: TimeEntry): Pair<String?, String?> + CyclomaticComplexMethod:TrackingScreen.kt$@OptIn(ExperimentalLayoutApi::class) @Composable private fun CompactTimeEntryRow( entry: TimeEntry, project: Project?, task: Task?, syncStatus: TimeEntryRepository.EntrySyncStatus?, onEdit: () -> Unit, onDelete: () -> Unit, count: Int? = null, totalDuration: Int? = null, isIndented: Boolean = false ) + CyclomaticComplexMethod:TrackingScreen.kt$@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable private fun HistoryFilters(filter: HistoryFilter, uiState: TrackingUiState, onChange: (HistoryFilter) -> Unit) + CyclomaticComplexMethod:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun TrackingScreen( user: User?, memberships: List<Membership>, currentMembership: Membership?, serverEndpoint: String, clientId: String, uiState: TrackingUiState, elapsedSeconds: StateFlow<Long> = MutableStateFlow(uiState.elapsedSeconds), alwaysShowNotifications: Boolean, appTheme: AppThemeMode, optimisticRefresh: Boolean, longTimerHours: Int, editActiveEntryRequested: Boolean, onEditActiveEntryConsumed: () -> Unit, onAlwaysShowNotificationsChange: (Boolean) -> Unit, onAppThemeChange: (AppThemeMode) -> Unit, onOptimisticRefreshChange: (Boolean) -> Unit, onLongTimerHoursChange: (Int) -> Unit, onRefresh: () -> Unit, onLogout: () -> Unit, onMembershipChange: (Membership) -> Unit, onStartTracking: () -> Unit, onStopTracking: () -> Unit, onPauseTracking: () -> Unit, onResumeTracking: () -> Unit, onDescriptionChange: (String) -> Unit, onProjectChange: (String?) -> Unit, onTaskChange: (String?) -> Unit, onTagsChange: (List<String>) -> Unit, onBillableChange: (Boolean) -> Unit, onUpdateCurrentEntry: () -> Unit, onUpdatePastEntry: (TimeEntry, String?, String?, String?, List<String>, Boolean, String, String) -> Unit, onCreateEntry: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, onDeleteEntry: (String) -> Unit, onUndoDelete: (TimeEntry) -> Unit, onRetrySync: () -> Unit, onRetrySyncEntry: (String) -> Unit, onLoadMoreEntries: () -> Unit, onLoadNewerEntries: () -> Unit, onJumpToDate: (LocalDate) -> Unit, onHistoryJumpConsumed: () -> Unit, getGroupedEntries: () -> Map<LocalDate, List<TimeEntry>> ) + CyclomaticComplexMethod:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun TimeEntryFormSheet( entry: TimeEntry?, // null = create mode suggestedStart: ZonedDateTime?, // create mode: pre-filled start (end of last entry / now-1h) projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, existingEntries: List<TimeEntry> = emptyList(), preventOverlap: Boolean = false, templates: List<EntryTemplate> = emptyList(), onSaveAsTemplate: ((TemplateDraft) -> Unit)? = null, ) + CyclomaticComplexMethod:TrackingScreen.kt$internal fun LazyListScope.trackingHistoryItems( uiState: TrackingUiState, historyItems: List<HistoryListItem>, projectsById: Map<String, Project>, tasksById: Map<String, Task>, syncStatusByEntryId: Map<String, TimeEntryRepository.EntrySyncStatus>, onEdit: (TimeEntry) -> Unit, onDelete: (TimeEntry) -> Unit, onDateClick: (LocalDate) -> Unit ) + CyclomaticComplexMethod:TrackingViewModel.kt$TrackingViewModel$fun jumpToHistoryDate(date: LocalDate) + CyclomaticComplexMethod:TrackingViewModel.kt$TrackingViewModel$fun loadMoreTimeEntries() + CyclomaticComplexMethod:TrackingViewModel.kt$TrackingViewModel$private fun startDataCollectors(organizationId: String) + DestructuringDeclarationWithTooManyEntries:ProjectSelectionActivity.kt$val (projectId, taskId, projectName, taskName) = when (selection) { is ProjectTaskSelection.NoProject -> listOf(null, null, null, null) is ProjectTaskSelection.ProjectOnly -> { val proj = (selection as ProjectTaskSelection.ProjectOnly).project listOf(proj.id, null, proj.name, null) } is ProjectTaskSelection.ProjectWithTask -> { val sel = selection as ProjectTaskSelection.ProjectWithTask listOf(sel.project.id, sel.task.id, sel.project.name, sel.task.name) } } + ForbiddenComment:AuthViewModel.kt$AuthViewModel$// TODO: warn the user about unsynced changes before logging out (follow-up). + FunctionNaming:BarChart.kt$@Composable fun BarChart(bars: List<Pair<String, Float>>, barColor: Color, modifier: Modifier = Modifier) + FunctionNaming:CalendarOverlayControls.kt$@Composable fun CalendarOverlayControls( state: CalendarUiState, showRationale: Boolean, onToggleOverlay: (Boolean) -> Unit, onRequestPermission: () -> Unit, onOpenAppSettings: () -> Unit, onToggleCalendar: (String) -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier, ) + FunctionNaming:CalendarOverlayControls.kt$@Composable private fun CalendarPickerSection(state: CalendarUiState, onToggleCalendar: (String) -> Unit, onRetry: () -> Unit) + FunctionNaming:CalendarOverlayControls.kt$@Composable private fun PermissionSection(permanentlyDenied: Boolean, onRequestPermission: () -> Unit, onOpenAppSettings: () -> Unit) + FunctionNaming:CalendarOverlayControls.kt$@Composable private fun PrivacyNote() + FunctionNaming:CalendarOverlayControls.kt$@Composable private fun StatusText(text: String) + FunctionNaming:CalendarScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( organizationId: String, memberId: String, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onSaveEntry: (TimeEntry, String?, String?, String?, List<String>, Boolean, String, String) -> Unit, viewModel: CalendarViewModel = hiltViewModel(), ) + FunctionNaming:ConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ConfigScreen( configState: OAuthConfigState, onSave: (String, String) -> Unit, onReset: () -> Unit, onTestConnection: (String, String) -> Unit, onDismiss: () -> Unit, // Optional review-loop entry points. Rendered only when provided so the pre-login OAuth sheet // stays unchanged while an in-app settings caller can surface reminder/template configuration. onOpenReminderSettings: (() -> Unit)? = null, onOpenManageTemplates: (() -> Unit)? = null, ) + FunctionNaming:DeltaBadge.kt$@Composable fun DeltaBadge(direction: DeltaDirection, text: String, modifier: Modifier = Modifier) + FunctionNaming:DeltaBadge.kt$@Composable fun DeltaBadge(sign: Int, text: String, modifier: Modifier = Modifier) + FunctionNaming:DeltaBadge.kt$@Preview @Composable private fun DeltaBadgePreview() + FunctionNaming:DonutChart.kt$@Composable fun DonutChart(slices: List<Pair<Color, Float>>, modifier: Modifier = Modifier) + FunctionNaming:EditTimeEntryDialog.kt$@Composable private fun TimeFieldButton(label: String, value: ZonedDateTime, onClick: () -> Unit, modifier: Modifier = Modifier) + FunctionNaming:EditTimeEntryDialog.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditTimeEntryDialog( entry: TimeEntry, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, existingEntries: List<TimeEntry> = emptyList(), preventOverlap: Boolean = false, inlinePresentation: Boolean = false, ) + FunctionNaming:EditTimeEntryDialog.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun EntryTimePickerDialog(title: String, initial: ZonedDateTime, onDismiss: () -> Unit, onConfirm: (Int, Int) -> Unit) + FunctionNaming:EntryBlock.kt$@Composable fun EntryBlock( color: Color, title: String?, modifier: Modifier = Modifier, subtitle: String? = null, time: String? = null, minHeight: Dp = Dimens.EntryMinHeight, ) + FunctionNaming:EntryBlock.kt$@Preview @Composable private fun EntryBlockPreview() + FunctionNaming:FavoriteTemplatesRow.kt$@Composable fun FavoriteTemplatesRow( templates: List<EntryTemplate>, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onStart: (TemplateStart) -> Unit, modifier: Modifier = Modifier, ) + FunctionNaming:FavoriteTemplatesRow.kt$@Composable private fun TemplateStartDialog( template: EntryTemplate, resolution: TemplateResolution, onStart: (TemplateStart) -> Unit, onDismiss: () -> Unit, ) + FunctionNaming:InboxPane.kt$@Composable fun InboxPane() + FunctionNaming:InboxPane.kt$@Composable internal fun InboxHeader(issueCount: Int, isRefreshing: Boolean, onRefresh: () -> Unit, onOpenSettings: () -> Unit) + FunctionNaming:InboxPane.kt$@Composable internal fun InboxIssueCard( issue: InboxIssue, preventOverlap: Boolean, projectsById: Map<String, Project>, zone: ZoneId, onQuickFix: () -> Unit, onDismiss: () -> Unit, ) + FunctionNaming:InboxPane.kt$@Composable private fun CaughtUpState() + FunctionNaming:InboxPane.kt$@Composable private fun CenteredMessage(icon: ImageVector, title: String, body: String, tint: androidx.compose.ui.graphics.Color) + FunctionNaming:InboxPane.kt$@Composable private fun LoadingState() + FunctionNaming:InboxPane.kt$@Composable private fun NoDataState() + FunctionNaming:InboxPane.kt$@Composable private fun StaleBanner(onRetry: () -> Unit, onDismiss: () -> Unit) + FunctionNaming:InboxPane.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun DismissibleIssue(issue: InboxIssue, onDismiss: () -> Unit, content: @Composable () -> Unit) + FunctionNaming:InboxSettingsSheet.kt$@Composable private fun CheckToggle(label: String, checked: Boolean, onChange: (Boolean) -> Unit) + FunctionNaming:InboxSettingsSheet.kt$@Composable private fun SectionLabel(text: String) + FunctionNaming:InboxSettingsSheet.kt$@Composable private fun Stepper(label: String, value: String, onDecrease: () -> Unit, onIncrease: () -> Unit, decreaseCd: String, increaseCd: String) + FunctionNaming:InboxSettingsSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun WorkTimePickerDialog(initialMinute: Int, onDismiss: () -> Unit, onConfirm: (Int) -> Unit) + FunctionNaming:InboxSettingsSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun InboxSettingsSheet(state: InboxUiState, viewModel: InboxViewModel, onDismiss: () -> Unit) + FunctionNaming:InteractiveBarChart.kt$@Composable fun InteractiveBarChart(bars: List<TrendBucket>, barColor: Color, onBarClick: (TrendBucket) -> Unit, modifier: Modifier = Modifier) + FunctionNaming:LoginScreen.kt$@Composable private fun ErrorCard(error: String, onDismiss: () -> Unit) + FunctionNaming:LoginScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginScreen( uiState: AuthUiState, configState: OAuthConfigState, onLoginClick: () -> Unit, onConfigSave: (String, String) -> Unit, onConfigReset: () -> Unit, onTestConnection: (String, String) -> Unit, onAuthUrlReady: (String) -> Unit, onClearAuthUrl: () -> Unit, onClearError: () -> Unit, ) + FunctionNaming:MainActivity.kt$@Composable fun SolidVerdantApp( authViewModel: AuthViewModel, trackingViewModel: TrackingViewModel, handoffOrganizationId: String? = null, onHandoffConsumed: () -> Unit = {}, editActiveEntryRequested: Boolean = false, onEditActiveEntryConsumed: () -> Unit = {}, pendingReviewRoute: String? = null, onPendingReviewRouteConsumed: () -> Unit = {}, ) + FunctionNaming:MainNavHost.kt$@Composable fun MainNavHost( navController: NavHostController, trackContent: @Composable () -> Unit, calendarContent: @Composable () -> Unit, statsContent: @Composable () -> Unit, reviewContent: @Composable () -> Unit = {}, inboxBadgeCount: Int = 0, ) + FunctionNaming:MainNavHost.kt$@Composable internal fun MainNavigationBar(currentRoute: String?, inboxBadgeCount: Int, onNavigate: (Screen) -> Unit) + FunctionNaming:MainNavHost.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun EndOfDayReviewHost(onBack: () -> Unit) + FunctionNaming:ManageTemplatesScreen.kt$@Composable internal fun TemplateRow( template: EntryTemplate, resolution: TemplateResolution, projectTaskSummary: String?, label: String, canMoveUp: Boolean, canMoveDown: Boolean, onToggleFavorite: () -> Unit, onMoveUp: () -> Unit, onMoveDown: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, ) + FunctionNaming:ManageTemplatesScreen.kt$@Composable private fun EmptyState(canCreate: Boolean, onCreate: () -> Unit) + FunctionNaming:ManageTemplatesScreen.kt$@Composable private fun ErrorState(onRetry: () -> Unit) + FunctionNaming:ManageTemplatesScreen.kt$@Composable private fun LoadingState() + FunctionNaming:ManageTemplatesScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ManageTemplatesScreen(onBack: () -> Unit = {}, viewModel: ManageTemplatesViewModel = hiltViewModel()) + FunctionNaming:MonthCalendarView.kt$@Composable fun DayTimeline( day: LocalDate, entries: List<TimeEntry>, projects: List<Project>, tasks: List<Task>, onEntryClick: (TimeEntry) -> Unit, scrollState: ScrollState? = null, fillViewport: Boolean = false, modifier: Modifier = Modifier, ) + FunctionNaming:MonthCalendarView.kt$@Composable fun MonthCalendarView( state: CalendarUiState, onSelectDate: (LocalDate) -> Unit, onPreviousMonth: () -> Unit, onNextMonth: () -> Unit, onEntryClick: (TimeEntry) -> Unit, projects: List<Project> = emptyList(), tasks: List<Task> = emptyList(), tags: List<Tag> = emptyList(), modifier: Modifier = Modifier, ) + FunctionNaming:ProjectSelectionActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ProjectSelectionContent( viewModel: ProjectSelectionViewModel, onStartTracking: (projectId: String?, taskId: String?, description: String, projectName: String?, taskName: String?) -> Unit, onCancel: () -> Unit, ) + FunctionNaming:ProjectSelectionActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun StartTrackingForm( projects: List<Project>, tasks: List<Task>, onStartTracking: (projectId: String?, taskId: String?, description: String, projectName: String?, taskName: String?) -> Unit, onCancel: () -> Unit, ) + FunctionNaming:ProjectTaskDropdownMenu.kt$@Composable private fun ProjectTaskPickerDialog( projects: List<Project>, tasks: List<Task>, searchQuery: String, onSearchQueryChange: (String) -> Unit, onSelectionChanged: (projectId: String?, taskId: String?) -> Unit, onClose: () -> Unit, showProjectColors: Boolean, ) + FunctionNaming:ProjectTaskDropdownMenu.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ProjectTaskDropdown( projects: List<Project>, tasks: List<Task>, displayText: String, onSelectionChanged: (projectId: String?, taskId: String?) -> Unit, enabled: Boolean = true, showProjectColors: Boolean = false, rounded: Boolean = false, ) + FunctionNaming:ReminderSettingsScreen.kt$@Composable private fun PermissionWarningCard(onAllow: () -> Unit, onOpenSettings: () -> Unit) + FunctionNaming:ReminderSettingsScreen.kt$@Composable private fun TimeRow(timeLabel: String, enabled: Boolean, onClick: () -> Unit) + FunctionNaming:ReminderSettingsScreen.kt$@Composable private fun ToggleRow(title: String, subtitle: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) + FunctionNaming:ReminderSettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReminderSettingsScreen(onBack: () -> Unit = {}) + FunctionNaming:ReviewDayPane.kt$@Composable fun ReviewDayPane() + FunctionNaming:ReviewDayPane.kt$@Composable internal fun ReviewContent( state: ReviewDayUiState, onStop: () -> Unit, onKeepRunning: () -> Unit, onAdjustEnd: () -> Unit, onRetry: (ReviewItem) -> Unit, onKeepAsIs: (ReviewItem) -> Unit, onAssign: (ReviewItem) -> Unit, onReviewAgain: () -> Unit, ) + FunctionNaming:ReviewDayPane.kt$@Composable private fun CenteredMessage(icon: @Composable () -> Unit, text: String) + FunctionNaming:ReviewDayPane.kt$@Composable private fun CompletionCard(title: String, body: String, onReviewAgain: (() -> Unit)?) + FunctionNaming:ReviewDayPane.kt$@Composable private fun EntryDescription(description: String?) + FunctionNaming:ReviewDayPane.kt$@Composable private fun LoadingState() + FunctionNaming:ReviewDayPane.kt$@Composable private fun MessageBanner(text: String, modifier: Modifier = Modifier) + FunctionNaming:ReviewDayPane.kt$@Composable private fun ProgressHeader(state: ReviewDayUiState) + FunctionNaming:ReviewDayPane.kt$@Composable private fun ReviewItemCard( item: ReviewItem, state: ReviewDayUiState, onStop: () -> Unit, onKeepRunning: () -> Unit, onAdjustEnd: () -> Unit, onRetry: (ReviewItem) -> Unit, onKeepAsIs: (ReviewItem) -> Unit, onAssign: (ReviewItem) -> Unit, ) + FunctionNaming:ReviewDayPane.kt$@Composable private fun StartedAt(startIso: String) + FunctionNaming:ReviewDayPane.kt$@Composable private fun SummaryCard(state: ReviewDayUiState) + FunctionNaming:ReviewDayPane.kt$@Composable private fun SummaryStat(label: String, value: String) + FunctionNaming:ReviewDayPane.kt$@Composable private fun TimeRange(startIso: String?, endIso: String?) + FunctionNaming:ReviewDialogs.kt$@Composable internal fun ProjectPickerDialog(projects: List<ReviewProject>, onSelect: (projectId: String) -> Unit, onDismiss: () -> Unit) + FunctionNaming:ReviewDialogs.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ReviewTimePickerDialog( title: String, initialHour: Int, initialMinute: Int, onConfirm: (hour: Int, minute: Int) -> Unit, onDismiss: () -> Unit, ) + FunctionNaming:ReviewScreen.kt$@Composable internal fun ReviewPlaceholder(textRes: Int) + FunctionNaming:ReviewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReviewScreen(onOpenReminderSettings: () -> Unit = {}, onOpenManageTemplates: () -> Unit = {}, onOpenEndOfDayReview: () -> Unit = {}) + FunctionNaming:SectionCard.kt$@Composable fun SectionCard( modifier: Modifier = Modifier, title: String? = null, verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(Dimens.CardContentGap), content: @Composable ColumnScope.() -> Unit, ) + FunctionNaming:SectionCard.kt$@Preview @Composable private fun SectionCardPreview() + FunctionNaming:StatComparisonCard.kt$@Composable fun StatComparisonCard(comparison: PeriodComparison, modifier: Modifier = Modifier) + FunctionNaming:StatComparisonCard.kt$@Composable private fun MetricDeltaBadge(delta: MetricDelta) + FunctionNaming:StatComparisonCard.kt$@Composable private fun MetricDeltaRow(label: String, delta: MetricDelta) + FunctionNaming:StatComparisonCard.kt$@Composable private fun ProjectChangeRow(change: ProjectChange) + FunctionNaming:StatDrillDownSheet.kt$@Composable private fun DrillDownRowItem(row: DrillDownRow) + FunctionNaming:StatDrillDownSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun StatDrillDownSheet(state: DrillDownUiState, onDismiss: () -> Unit) + FunctionNaming:StatFilterBar.kt$@Composable fun StatFilterBar( filters: StatFilters, catalog: StatCatalog, onFiltersChange: (StatFilters) -> Unit, onClearFilters: () -> Unit, modifier: Modifier = Modifier, ) + FunctionNaming:StatFilterBar.kt$@OptIn(ExperimentalLayoutApi::class) @Composable private fun ActiveFilterChips(filters: StatFilters, catalog: StatCatalog, onFiltersChange: (StatFilters) -> Unit) + FunctionNaming:StatFilterBar.kt$@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable private fun FilterSection( title: String, emptyText: String, options: List<Pair<String, String>>, selected: Set<String>, onToggle: (String) -> Unit, ) + FunctionNaming:StatFilterBar.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RemovableChip(label: String, onRemove: () -> Unit) + FunctionNaming:StatFilterBar.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun StatFilterSheet( filters: StatFilters, catalog: StatCatalog, onFiltersChange: (StatFilters) -> Unit, onReset: () -> Unit, onDismiss: () -> Unit, ) + FunctionNaming:StateViews.kt$@Composable fun EmptyState(text: String, modifier: Modifier = Modifier, icon: ImageVector? = null) + FunctionNaming:StateViews.kt$@Composable fun ErrorState(text: String, modifier: Modifier = Modifier, onRetry: (() -> Unit)? = null) + FunctionNaming:StateViews.kt$@Composable fun LoadingState(modifier: Modifier = Modifier, label: String? = null) + FunctionNaming:StateViews.kt$@Preview @Composable private fun StateViewsPreview() + FunctionNaming:StatisticsScreen.kt$@Composable fun StatisticsScreen(viewModel: StatisticsViewModel = hiltViewModel()) + FunctionNaming:StatisticsScreen.kt$@Composable internal fun KpiGrid(s: StatisticsSummary) + FunctionNaming:StatisticsScreen.kt$@Composable internal fun ProjectSwatch(color: Color) + FunctionNaming:StatisticsScreen.kt$@Composable private fun ExportAction(exporting: Boolean, onExport: () -> Unit) + FunctionNaming:StatisticsScreen.kt$@Composable private fun KpiTile(label: String, value: String, modifier: Modifier = Modifier) + FunctionNaming:StatisticsScreen.kt$@Composable private fun ProjectLegendRow(projectName: String, colorHex: String, valueText: String, onClick: () -> Unit) + FunctionNaming:StatisticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun RangeChips(current: StatRange, onSelect: (StatRange) -> Unit) + FunctionNaming:SyncChip.kt$@Composable fun SyncChip(status: EntrySyncStatus, modifier: Modifier = Modifier, showLabel: Boolean = true) + FunctionNaming:SyncChip.kt$@Preview @Composable private fun SyncChipPreview() + FunctionNaming:SyncStatusBanner.kt$@Composable fun AppStatusOverlay(syncStatus: SyncStatus, onRetrySync: () -> Unit, content: @Composable () -> Unit) + FunctionNaming:SyncStatusBanner.kt$@Composable private fun StatusBanner(status: AppStatus, onRetry: () -> Unit) + FunctionNaming:TemplateEditorSheet.kt$@Composable fun TemplateDeleteDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) + FunctionNaming:TemplateEditorSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun TemplateEditorSheet( existing: EntryTemplate?, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (TemplateDraft) -> Unit, onUpdate: (EntryTemplate) -> Unit, ) + FunctionNaming:TemplateUi.kt$@Composable fun TemplateIssueList(resolution: TemplateResolution, modifier: Modifier = Modifier) + FunctionNaming:Theme.kt$@Composable fun SolidVerdantTheme( themeMode: AppThemeMode = AppThemeMode.SYSTEM, darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit, ) + FunctionNaming:TrackingScreen.kt$@Composable internal fun EntryValidationBanner(result: EntryTimeValidator.Result, durationHours: Long) + FunctionNaming:TrackingScreen.kt$@Composable private fun AboutSection(context: Context) + FunctionNaming:TrackingScreen.kt$@Composable private fun CollapsibleTimeEntryGroup( entries: List<TimeEntry>, projectsById: Map<String, Project>, tasksById: Map<String, Task>, syncStatusByEntryId: Map<String, TimeEntryRepository.EntrySyncStatus>, onEdit: (TimeEntry) -> Unit, onDelete: (TimeEntry) -> Unit ) + FunctionNaming:TrackingScreen.kt$@Composable private fun DateHeader( date: LocalDate, entries: List<TimeEntry>, projectsById: Map<String, Project>, onClick: () -> Unit ) + FunctionNaming:TrackingScreen.kt$@Composable private fun ErrorCard(error: String) + FunctionNaming:TrackingScreen.kt$@Composable private fun FilterDropdown( label: String, selectedId: String?, options: List<Pair<String, String>>, onSelect: (String?) -> Unit, ) + FunctionNaming:TrackingScreen.kt$@Composable private fun GhostBlock( modifier: Modifier, alpha: Float ) + FunctionNaming:TrackingScreen.kt$@Composable private fun HistoryLoadingEntry(index: Int) + FunctionNaming:TrackingScreen.kt$@Composable private fun HistoryLoadingHeader() + FunctionNaming:TrackingScreen.kt$@Composable private fun OverlapWarning(count: Int, prohibitedByOrganization: Boolean) + FunctionNaming:TrackingScreen.kt$@Composable private fun SyncCenter( operations: List<TimeEntryRepository.SyncOperation>, onRetry: () -> Unit, onRetryEntry: (String) -> Unit, ) + FunctionNaming:TrackingScreen.kt$@Composable private fun TimeFieldButton( label: String, value: ZonedDateTime, onClick: () -> Unit, modifier: Modifier = Modifier ) + FunctionNaming:TrackingScreen.kt$@OptIn(ExperimentalLayoutApi::class) @Composable internal fun TagsSelector( selectedTagIds: List<String>, availableTags: List<Tag>, onTagsChanged: (List<String>) -> Unit, enabled: Boolean ) + FunctionNaming:TrackingScreen.kt$@OptIn(ExperimentalLayoutApi::class) @Composable private fun CompactTimeEntryRow( entry: TimeEntry, project: Project?, task: Task?, syncStatus: TimeEntryRepository.EntrySyncStatus?, onEdit: () -> Unit, onDelete: () -> Unit, count: Int? = null, totalDuration: Int? = null, isIndented: Boolean = false ) + FunctionNaming:TrackingScreen.kt$@OptIn(ExperimentalLayoutApi::class) @Composable private fun ContinueLastEntryButton( entry: TimeEntry, projects: List<Project>, onContinue: () -> Unit ) + FunctionNaming:TrackingScreen.kt$@OptIn(ExperimentalLayoutApi::class) @Composable private fun LongTimerWarning(hours: Int, onStop: () -> Unit, onKeepRunning: () -> Unit, onAdjust: () -> Unit) + FunctionNaming:TrackingScreen.kt$@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable private fun HistoryFilters(filter: HistoryFilter, uiState: TrackingUiState, onChange: (HistoryFilter) -> Unit) + FunctionNaming:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ProjectTaskDropdown( selectedProjectId: String?, selectedTaskId: String?, projects: List<Project>, tasks: List<Task>, onSelectionChanged: (projectId: String?, taskId: String?) -> Unit, enabled: Boolean ) + FunctionNaming:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun EntryTimePickerDialog( title: String, initial: ZonedDateTime, onDismiss: () -> Unit, onConfirm: (Int, Int) -> Unit ) + FunctionNaming:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun TrackingScreen( user: User?, memberships: List<Membership>, currentMembership: Membership?, serverEndpoint: String, clientId: String, uiState: TrackingUiState, elapsedSeconds: StateFlow<Long> = MutableStateFlow(uiState.elapsedSeconds), alwaysShowNotifications: Boolean, appTheme: AppThemeMode, optimisticRefresh: Boolean, longTimerHours: Int, editActiveEntryRequested: Boolean, onEditActiveEntryConsumed: () -> Unit, onAlwaysShowNotificationsChange: (Boolean) -> Unit, onAppThemeChange: (AppThemeMode) -> Unit, onOptimisticRefreshChange: (Boolean) -> Unit, onLongTimerHoursChange: (Int) -> Unit, onRefresh: () -> Unit, onLogout: () -> Unit, onMembershipChange: (Membership) -> Unit, onStartTracking: () -> Unit, onStopTracking: () -> Unit, onPauseTracking: () -> Unit, onResumeTracking: () -> Unit, onDescriptionChange: (String) -> Unit, onProjectChange: (String?) -> Unit, onTaskChange: (String?) -> Unit, onTagsChange: (List<String>) -> Unit, onBillableChange: (Boolean) -> Unit, onUpdateCurrentEntry: () -> Unit, onUpdatePastEntry: (TimeEntry, String?, String?, String?, List<String>, Boolean, String, String) -> Unit, onCreateEntry: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, onDeleteEntry: (String) -> Unit, onUndoDelete: (TimeEntry) -> Unit, onRetrySync: () -> Unit, onRetrySyncEntry: (String) -> Unit, onLoadMoreEntries: () -> Unit, onLoadNewerEntries: () -> Unit, onJumpToDate: (LocalDate) -> Unit, onHistoryJumpConsumed: () -> Unit, getGroupedEntries: () -> Map<LocalDate, List<TimeEntry>> ) + FunctionNaming:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable internal fun TrackingControls( uiState: TrackingUiState, elapsedSeconds: Long = 0L, onDescriptionChange: (String) -> Unit, onProjectChange: (String?) -> Unit, onTaskChange: (String?) -> Unit, onTagsChange: (List<String>) -> Unit, onBillableChange: (Boolean) -> Unit, onStart: () -> Unit, onStop: () -> Unit, onPause: () -> Unit, onResume: () -> Unit, onUpdate: () -> Unit ) + FunctionNaming:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun DescriptionFieldWithSuggestions( description: String, onDescriptionChange: (String) -> Unit, timeEntries: List<TimeEntry>, projects: List<Project>, tags: List<Tag>, enabled: Boolean, onEntryCopied: (TimeEntry) -> Unit ) + FunctionNaming:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun TimeEntryFormSheet( entry: TimeEntry?, // null = create mode suggestedStart: ZonedDateTime?, // create mode: pre-filled start (end of last entry / now-1h) projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, existingEntries: List<TimeEntry> = emptyList(), preventOverlap: Boolean = false, templates: List<EntryTemplate> = emptyList(), onSaveAsTemplate: ((TemplateDraft) -> Unit)? = null, ) + FunctionNaming:WeekCalendarView.kt$@Composable fun WeekCalendarView( state: CalendarUiState, onSelectDate: (LocalDate) -> Unit, onEntryClick: (TimeEntry) -> Unit, onPrevious: () -> Unit, onNext: () -> Unit, onToday: () -> Unit, projects: List<Project>, modifier: Modifier = Modifier, ) + FunctionNaming:WeekCalendarView.kt$@Composable internal fun CurrentTimeMarker(now: Instant, day: LocalDate, zone: ZoneId, modifier: Modifier = Modifier) + FunctionNaming:WeekCalendarView.kt$@Composable internal fun HourGridlines(modifier: Modifier = Modifier) + FunctionNaming:WeekCalendarView.kt$@Composable private fun AllDayRow(days: List<LocalDate>, allDayByDay: Map<LocalDate, List<DeviceCalendarEvent>>) + FunctionNaming:WeekCalendarView.kt$@Composable private fun DayColumn( day: LocalDate, isToday: Boolean, now: Instant, zone: ZoneId, eventBlocks: List<EventBlock>, entries: List<TimeEntry>, projects: List<Project>, onEntryClick: (TimeEntry) -> Unit, modifier: Modifier = Modifier, ) + FunctionNaming:WeekCalendarView.kt$@Composable private fun DayHeaderCell( day: LocalDate, selected: Boolean, isToday: Boolean, locale: Locale, onSelect: () -> Unit, modifier: Modifier = Modifier, ) + FunctionNaming:WeekCalendarView.kt$@Composable private fun HairLine() + FunctionNaming:WeekCalendarView.kt$@Composable private fun WeekCalendarContent( state: CalendarUiState, days: List<LocalDate>, onSelectDate: (LocalDate) -> Unit, onEntryClick: (TimeEntry) -> Unit, onPrevious: () -> Unit, onNext: () -> Unit, onToday: () -> Unit, projects: List<Project>, ) + FunctionNaming:WeekCalendarView.kt$@Composable private fun WeekGrid( days: List<LocalDate>, today: LocalDate, now: Instant, zone: ZoneId, timedByDay: Map<LocalDate, List<EventBlock>>, state: CalendarUiState, projects: List<Project>, onEntryClick: (TimeEntry) -> Unit, ) + FunctionNaming:WeekCalendarView.kt$@Composable private fun WeekNavHeader( days: List<LocalDate>, viewMode: CalendarViewMode, locale: Locale, onPrevious: () -> Unit, onNext: () -> Unit, onToday: () -> Unit, ) + ImplicitDefaultLocale:TimeTrackingWidget.kt$TimeTrackingWidget.Companion$String.format("%02d:%02d", safe.toHours(), safe.toMinutes() % 60) + InstanceOfCheckForException:AuthRepository.kt$AuthRepository$e is retrofit2.HttpException + LargeClass:TrackingViewModel.kt$TrackingViewModel : ViewModel + LongMethod:CalendarOverlayControls.kt$@Composable private fun CalendarPickerSection(state: CalendarUiState, onToggleCalendar: (String) -> Unit, onRetry: () -> Unit) + LongMethod:CalendarScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( organizationId: String, memberId: String, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onSaveEntry: (TimeEntry, String?, String?, String?, List<String>, Boolean, String, String) -> Unit, viewModel: CalendarViewModel = hiltViewModel(), ) + LongMethod:ConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ConfigScreen( configState: OAuthConfigState, onSave: (String, String) -> Unit, onReset: () -> Unit, onTestConnection: (String, String) -> Unit, onDismiss: () -> Unit, // Optional review-loop entry points. Rendered only when provided so the pre-login OAuth sheet // stays unchanged while an in-app settings caller can surface reminder/template configuration. onOpenReminderSettings: (() -> Unit)? = null, onOpenManageTemplates: (() -> Unit)? = null, ) + LongMethod:EditTimeEntryDialog.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditTimeEntryDialog( entry: TimeEntry, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, existingEntries: List<TimeEntry> = emptyList(), preventOverlap: Boolean = false, inlinePresentation: Boolean = false, ) + LongMethod:FavoriteTemplatesRow.kt$@Composable fun FavoriteTemplatesRow( templates: List<EntryTemplate>, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onStart: (TemplateStart) -> Unit, modifier: Modifier = Modifier, ) + LongMethod:InboxPane.kt$@Composable fun InboxPane() + LongMethod:InboxPane.kt$@Composable internal fun InboxIssueCard( issue: InboxIssue, preventOverlap: Boolean, projectsById: Map<String, Project>, zone: ZoneId, onQuickFix: () -> Unit, onDismiss: () -> Unit, ) + LongMethod:InboxSettingsSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun InboxSettingsSheet(state: InboxUiState, viewModel: InboxViewModel, onDismiss: () -> Unit) + LongMethod:InboxViewModel.kt$InboxViewModel$@OptIn(ExperimentalCoroutinesApi::class) private suspend fun observeInbox() + LongMethod:LoginScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginScreen( uiState: AuthUiState, configState: OAuthConfigState, onLoginClick: () -> Unit, onConfigSave: (String, String) -> Unit, onConfigReset: () -> Unit, onTestConnection: (String, String) -> Unit, onAuthUrlReady: (String) -> Unit, onClearAuthUrl: () -> Unit, onClearError: () -> Unit, ) + LongMethod:MainActivity.kt$@Composable fun SolidVerdantApp( authViewModel: AuthViewModel, trackingViewModel: TrackingViewModel, handoffOrganizationId: String? = null, onHandoffConsumed: () -> Unit = {}, editActiveEntryRequested: Boolean = false, onEditActiveEntryConsumed: () -> Unit = {}, pendingReviewRoute: String? = null, onPendingReviewRouteConsumed: () -> Unit = {}, ) + LongMethod:ManageTemplatesScreen.kt$@Composable internal fun TemplateRow( template: EntryTemplate, resolution: TemplateResolution, projectTaskSummary: String?, label: String, canMoveUp: Boolean, canMoveDown: Boolean, onToggleFavorite: () -> Unit, onMoveUp: () -> Unit, onMoveDown: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, ) + LongMethod:ManageTemplatesScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ManageTemplatesScreen(onBack: () -> Unit = {}, viewModel: ManageTemplatesViewModel = hiltViewModel()) + LongMethod:MonthCalendarView.kt$@Composable fun MonthCalendarView( state: CalendarUiState, onSelectDate: (LocalDate) -> Unit, onPreviousMonth: () -> Unit, onNextMonth: () -> Unit, onEntryClick: (TimeEntry) -> Unit, projects: List<Project> = emptyList(), tasks: List<Task> = emptyList(), tags: List<Tag> = emptyList(), modifier: Modifier = Modifier, ) + LongMethod:ProjectSelectionActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ProjectSelectionContent( viewModel: ProjectSelectionViewModel, onStartTracking: (projectId: String?, taskId: String?, description: String, projectName: String?, taskName: String?) -> Unit, onCancel: () -> Unit, ) + LongMethod:ProjectSelectionActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun StartTrackingForm( projects: List<Project>, tasks: List<Task>, onStartTracking: (projectId: String?, taskId: String?, description: String, projectName: String?, taskName: String?) -> Unit, onCancel: () -> Unit, ) + LongMethod:ProjectTaskDropdownMenu.kt$@Composable private fun ProjectTaskPickerDialog( projects: List<Project>, tasks: List<Task>, searchQuery: String, onSearchQueryChange: (String) -> Unit, onSelectionChanged: (projectId: String?, taskId: String?) -> Unit, onClose: () -> Unit, showProjectColors: Boolean, ) + LongMethod:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$private fun buildScreens(): List<Screen> + LongMethod:ReminderSettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReminderSettingsScreen(onBack: () -> Unit = {}) + LongMethod:ReviewDayPane.kt$@Composable fun ReviewDayPane() + LongMethod:ReviewDayViewModel.kt$ReviewDayViewModel$private fun buildState( entries: List<TimeEntry>, projects: List<dev.tricked.solidverdant.data.model.Project>, syncOps: List<TimeEntryRepository.SyncOperation>, handled: Set<String>, ): ReviewDayUiState + LongMethod:ReviewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReviewScreen(onOpenReminderSettings: () -> Unit = {}, onOpenManageTemplates: () -> Unit = {}, onOpenEndOfDayReview: () -> Unit = {}) + LongMethod:ScreenshotHost.kt$ScreenshotHost$@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppShell(destination: Screen, inboxBadgeCount: Int = 0, content: @Composable () -> Unit) + LongMethod:StatDrillDownSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun StatDrillDownSheet(state: DrillDownUiState, onDismiss: () -> Unit) + LongMethod:StatFilterBar.kt$@Composable fun StatFilterBar( filters: StatFilters, catalog: StatCatalog, onFiltersChange: (StatFilters) -> Unit, onClearFilters: () -> Unit, modifier: Modifier = Modifier, ) + LongMethod:StatFilterBar.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun StatFilterSheet( filters: StatFilters, catalog: StatCatalog, onFiltersChange: (StatFilters) -> Unit, onReset: () -> Unit, onDismiss: () -> Unit, ) + LongMethod:StatisticsScreen.kt$@Composable fun StatisticsScreen(viewModel: StatisticsViewModel = hiltViewModel()) + LongMethod:SyncWorker.kt$SyncWorker$private suspend fun process(op: OutboxEntity): Outcome + LongMethod:TemplateEditorSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun TemplateEditorSheet( existing: EntryTemplate?, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (TemplateDraft) -> Unit, onUpdate: (EntryTemplate) -> Unit, ) + LongMethod:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$private fun buildPausedNotification(): Notification + LongMethod:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$private fun buildTrackingNotification(): Notification + LongMethod:TimeTrackingTileService.kt$TimeTrackingTileService$private fun updateTileState(forceNetwork: Boolean = false) + LongMethod:TrackingScreen.kt$@Composable private fun AboutSection(context: Context) + LongMethod:TrackingScreen.kt$@Composable private fun CollapsibleTimeEntryGroup( entries: List<TimeEntry>, projectsById: Map<String, Project>, tasksById: Map<String, Task>, syncStatusByEntryId: Map<String, TimeEntryRepository.EntrySyncStatus>, onEdit: (TimeEntry) -> Unit, onDelete: (TimeEntry) -> Unit ) + LongMethod:TrackingScreen.kt$@OptIn(ExperimentalLayoutApi::class) @Composable private fun CompactTimeEntryRow( entry: TimeEntry, project: Project?, task: Task?, syncStatus: TimeEntryRepository.EntrySyncStatus?, onEdit: () -> Unit, onDelete: () -> Unit, count: Int? = null, totalDuration: Int? = null, isIndented: Boolean = false ) + LongMethod:TrackingScreen.kt$@OptIn(ExperimentalLayoutApi::class) @Composable private fun ContinueLastEntryButton( entry: TimeEntry, projects: List<Project>, onContinue: () -> Unit ) + LongMethod:TrackingScreen.kt$@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable private fun HistoryFilters(filter: HistoryFilter, uiState: TrackingUiState, onChange: (HistoryFilter) -> Unit) + LongMethod:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun TrackingScreen( user: User?, memberships: List<Membership>, currentMembership: Membership?, serverEndpoint: String, clientId: String, uiState: TrackingUiState, elapsedSeconds: StateFlow<Long> = MutableStateFlow(uiState.elapsedSeconds), alwaysShowNotifications: Boolean, appTheme: AppThemeMode, optimisticRefresh: Boolean, longTimerHours: Int, editActiveEntryRequested: Boolean, onEditActiveEntryConsumed: () -> Unit, onAlwaysShowNotificationsChange: (Boolean) -> Unit, onAppThemeChange: (AppThemeMode) -> Unit, onOptimisticRefreshChange: (Boolean) -> Unit, onLongTimerHoursChange: (Int) -> Unit, onRefresh: () -> Unit, onLogout: () -> Unit, onMembershipChange: (Membership) -> Unit, onStartTracking: () -> Unit, onStopTracking: () -> Unit, onPauseTracking: () -> Unit, onResumeTracking: () -> Unit, onDescriptionChange: (String) -> Unit, onProjectChange: (String?) -> Unit, onTaskChange: (String?) -> Unit, onTagsChange: (List<String>) -> Unit, onBillableChange: (Boolean) -> Unit, onUpdateCurrentEntry: () -> Unit, onUpdatePastEntry: (TimeEntry, String?, String?, String?, List<String>, Boolean, String, String) -> Unit, onCreateEntry: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, onDeleteEntry: (String) -> Unit, onUndoDelete: (TimeEntry) -> Unit, onRetrySync: () -> Unit, onRetrySyncEntry: (String) -> Unit, onLoadMoreEntries: () -> Unit, onLoadNewerEntries: () -> Unit, onJumpToDate: (LocalDate) -> Unit, onHistoryJumpConsumed: () -> Unit, getGroupedEntries: () -> Map<LocalDate, List<TimeEntry>> ) + LongMethod:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable internal fun TrackingControls( uiState: TrackingUiState, elapsedSeconds: Long = 0L, onDescriptionChange: (String) -> Unit, onProjectChange: (String?) -> Unit, onTaskChange: (String?) -> Unit, onTagsChange: (List<String>) -> Unit, onBillableChange: (Boolean) -> Unit, onStart: () -> Unit, onStop: () -> Unit, onPause: () -> Unit, onResume: () -> Unit, onUpdate: () -> Unit ) + LongMethod:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun DescriptionFieldWithSuggestions( description: String, onDescriptionChange: (String) -> Unit, timeEntries: List<TimeEntry>, projects: List<Project>, tags: List<Tag>, enabled: Boolean, onEntryCopied: (TimeEntry) -> Unit ) + LongMethod:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun TimeEntryFormSheet( entry: TimeEntry?, // null = create mode suggestedStart: ZonedDateTime?, // create mode: pre-filled start (end of last entry / now-1h) projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, existingEntries: List<TimeEntry> = emptyList(), preventOverlap: Boolean = false, templates: List<EntryTemplate> = emptyList(), onSaveAsTemplate: ((TemplateDraft) -> Unit)? = null, ) + LongMethod:TrackingScreen.kt$internal fun LazyListScope.trackingHistoryItems( uiState: TrackingUiState, historyItems: List<HistoryListItem>, projectsById: Map<String, Project>, tasksById: Map<String, Task>, syncStatusByEntryId: Map<String, TimeEntryRepository.EntrySyncStatus>, onEdit: (TimeEntry) -> Unit, onDelete: (TimeEntry) -> Unit, onDateClick: (LocalDate) -> Unit ) + LongMethod:TrackingViewModel.kt$TrackingViewModel$fun jumpToHistoryDate(date: LocalDate) + LongMethod:TrackingViewModel.kt$TrackingViewModel$private fun startDataCollectors(organizationId: String) + LongMethod:TrackingViewModel.kt$TrackingViewModel$private suspend fun loadActiveTimeEntry(organizationId: String, onlyIfChanged: Boolean = false) + LongMethod:WeekCalendarView.kt$@Composable private fun DayColumn( day: LocalDate, isToday: Boolean, now: Instant, zone: ZoneId, eventBlocks: List<EventBlock>, entries: List<TimeEntry>, projects: List<Project>, onEntryClick: (TimeEntry) -> Unit, modifier: Modifier = Modifier, ) + LongMethod:WeekCalendarView.kt$@Composable private fun WeekCalendarContent( state: CalendarUiState, days: List<LocalDate>, onSelectDate: (LocalDate) -> Unit, onEntryClick: (TimeEntry) -> Unit, onPrevious: () -> Unit, onNext: () -> Unit, onToday: () -> Unit, projects: List<Project>, ) + LongParameterList:AuthRepository.kt$AuthRepository$( organizationId: String, memberId: String, limit: Int = 50, offset: Int = 0, onlyFullDates: Boolean = false, start: String? = null, end: String? = null, ) + LongParameterList:AuthRepository.kt$AuthRepository$( organizationId: String, memberId: String, userId: String, projectId: String? = null, taskId: String? = null, description: String = "", // The actual capture-time start timestamp (ISO-8601). Pass the value captured when tracking // really began so an offline-captured start is not stamped with the reconnect/sync time. // Null/blank falls back to now() for callers that do not thread a captured value. startIso: String? = null, ) + LongParameterList:AuthRepository.kt$AuthRepository$( organizationId: String, memberId: String, userId: String, start: String, end: String, description: String = "", projectId: String? = null, taskId: String? = null, tags: List<String> = emptyList(), billable: Boolean = false, ) + LongParameterList:CalendarOverlayControls.kt$( state: CalendarUiState, showRationale: Boolean, onToggleOverlay: (Boolean) -> Unit, onRequestPermission: () -> Unit, onOpenAppSettings: () -> Unit, onToggleCalendar: (String) -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier, ) + LongParameterList:CalendarScreen.kt$( organizationId: String, memberId: String, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onSaveEntry: (TimeEntry, String?, String?, String?, List<String>, Boolean, String, String) -> Unit, viewModel: CalendarViewModel = hiltViewModel(), ) + LongParameterList:ConfigScreen.kt$( configState: OAuthConfigState, onSave: (String, String) -> Unit, onReset: () -> Unit, onTestConnection: (String, String) -> Unit, onDismiss: () -> Unit, // Optional review-loop entry points. Rendered only when provided so the pre-login OAuth sheet // stays unchanged while an in-app settings caller can surface reminder/template configuration. onOpenReminderSettings: (() -> Unit)? = null, onOpenManageTemplates: (() -> Unit)? = null, ) + LongParameterList:CsvExporter.kt$CsvExporter$( entries: List<TimeEntry>, projects: List<Project>, clients: List<Client>, tasks: List<Task>, tags: List<Tag>, zone: ZoneId, organizationName: String, ) + LongParameterList:CsvExporter.kt$CsvExporter.Companion$( entries: List<TimeEntry>, projects: List<Project>, clients: List<Client>, tasks: List<Task>, tags: List<Tag>, zone: ZoneId, organizationName: String, billableYes: String, billableNo: String, ) + LongParameterList:EditTimeEntryDialog.kt$( entry: TimeEntry, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, existingEntries: List<TimeEntry> = emptyList(), preventOverlap: Boolean = false, inlinePresentation: Boolean = false, ) + LongParameterList:EntryBlock.kt$( color: Color, title: String?, modifier: Modifier = Modifier, subtitle: String? = null, time: String? = null, minHeight: Dp = Dimens.EntryMinHeight, ) + LongParameterList:EntryTrustRules.kt$EntryTrustRules$( entries: List<TimeEntry>, filter: HistoryFilter, projects: List<Project>, tasks: List<Task>, clients: List<Client> = emptyList(), syncOperations: List<TimeEntryRepository.SyncOperation> = emptyList(), zone: ZoneId = ZoneId.systemDefault(), ) + LongParameterList:FakeRemoteDataSource.kt$FakeRemoteDataSource$( var entries: List<TimeEntry> = emptyList(), var projects: List<Project> = emptyList(), var clients: List<Client> = emptyList(), var tasks: List<Task> = emptyList(), var tags: List<Tag> = emptyList(), var active: TimeEntry? = null, var memberships: List<Membership> = emptyList(), var failNextWrite: Boolean = false, /** When set, every write fails with this throwable (use a non-IOException to exercise FAIL). */ var writeError: Throwable? = null, var startResult: (TimeEntry) -> TimeEntry = { it }, var stopResult: (TimeEntry) -> TimeEntry = { it }, var updateResult: (TimeEntry) -> TimeEntry = { it }, ) + LongParameterList:FavoriteTemplatesRow.kt$( templates: List<EntryTemplate>, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onStart: (TemplateStart) -> Unit, modifier: Modifier = Modifier, ) + LongParameterList:InboxAnalyzerTest.kt$InboxAnalyzerTest$( id: String, start: String, end: String?, projectId: String? = "p1", taskId: String? = "t1", description: String? = "desc", tags: List<Tag> = listOf(Tag("tag1", "One")), ) + LongParameterList:InboxAnalyzerTest.kt$InboxAnalyzerTest$( workDays: Set<DayOfWeek> = DayOfWeek.values().toSet(), workStartMinute: Int = 9 * 60, workEndMinute: Int = 17 * 60, minGapMinutes: Int = 30, maxDurationHours: Int = 4, checkGaps: Boolean = true, checkOverlaps: Boolean = true, checkMissingProject: Boolean = false, checkMissingTask: Boolean = false, checkMissingDescription: Boolean = false, checkMissingTags: Boolean = false, checkLongDuration: Boolean = false, ) + LongParameterList:InboxPane.kt$( issue: InboxIssue, preventOverlap: Boolean, projectsById: Map<String, Project>, zone: ZoneId, onQuickFix: () -> Unit, onDismiss: () -> Unit, ) + LongParameterList:InboxSettingsSheet.kt$(label: String, value: String, onDecrease: () -> Unit, onIncrease: () -> Unit, decreaseCd: String, increaseCd: String) + LongParameterList:InboxViewModel.kt$InboxViewModel$( description: String?, projectId: String?, taskId: String?, tags: List<String>, billable: Boolean, start: String, end: String, ) + LongParameterList:InboxViewModel.kt$InboxViewModel$( entry: TimeEntry, description: String?, projectId: String?, taskId: String?, tags: List<String>, billable: Boolean, start: String, end: String, ) + LongParameterList:InboxViewModel.kt$InboxViewModel$( private val timeEntryRepository: TimeEntryRepository, private val authRepository: AuthRepository, private val authDataStore: AuthDataStore, private val settingsDataStore: SettingsDataStore, private val inboxSettingsDataStore: InboxSettingsDataStore, private val dismissalDao: InboxDismissalDao, private val syncTrigger: SyncTrigger, private val clock: Clock, ) + LongParameterList:LoginScreen.kt$( uiState: AuthUiState, configState: OAuthConfigState, onLoginClick: () -> Unit, onConfigSave: (String, String) -> Unit, onConfigReset: () -> Unit, onTestConnection: (String, String) -> Unit, onAuthUrlReady: (String) -> Unit, onClearAuthUrl: () -> Unit, onClearError: () -> Unit, ) + LongParameterList:MainActivity.kt$( authViewModel: AuthViewModel, trackingViewModel: TrackingViewModel, handoffOrganizationId: String? = null, onHandoffConsumed: () -> Unit = {}, editActiveEntryRequested: Boolean = false, onEditActiveEntryConsumed: () -> Unit = {}, pendingReviewRoute: String? = null, onPendingReviewRouteConsumed: () -> Unit = {}, ) + LongParameterList:MainNavHost.kt$( navController: NavHostController, trackContent: @Composable () -> Unit, calendarContent: @Composable () -> Unit, statsContent: @Composable () -> Unit, reviewContent: @Composable () -> Unit = {}, inboxBadgeCount: Int = 0, ) + LongParameterList:ManageTemplatesScreen.kt$( template: EntryTemplate, resolution: TemplateResolution, projectTaskSummary: String?, label: String, canMoveUp: Boolean, canMoveDown: Boolean, onToggleFavorite: () -> Unit, onMoveUp: () -> Unit, onMoveDown: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, ) + LongParameterList:MonthCalendarView.kt$( day: LocalDate, entries: List<TimeEntry>, projects: List<Project>, tasks: List<Task>, onEntryClick: (TimeEntry) -> Unit, scrollState: ScrollState? = null, fillViewport: Boolean = false, modifier: Modifier = Modifier, ) + LongParameterList:MonthCalendarView.kt$( state: CalendarUiState, onSelectDate: (LocalDate) -> Unit, onPreviousMonth: () -> Unit, onNextMonth: () -> Unit, onEntryClick: (TimeEntry) -> Unit, projects: List<Project> = emptyList(), tasks: List<Task> = emptyList(), tags: List<Tag> = emptyList(), modifier: Modifier = Modifier, ) + LongParameterList:ProjectTaskDropdownMenu.kt$( projects: List<Project>, tasks: List<Task>, displayText: String, onSelectionChanged: (projectId: String?, taskId: String?) -> Unit, enabled: Boolean = true, showProjectColors: Boolean = false, rounded: Boolean = false, ) + LongParameterList:ProjectTaskDropdownMenu.kt$( projects: List<Project>, tasks: List<Task>, searchQuery: String, onSearchQueryChange: (String) -> Unit, onSelectionChanged: (projectId: String?, taskId: String?) -> Unit, onClose: () -> Unit, showProjectColors: Boolean, ) + LongParameterList:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$( id: String, description: String?, startIso: String, endIso: String?, durationSeconds: Int, projectId: String? = "p1", taskId: String? = null, billable: Boolean = true, entryTags: List<Tag> = emptyList(), ) + LongParameterList:RemoteDataSource.kt$RemoteDataSource$( organizationId: String, memberId: String, userId: String, projectId: String?, taskId: String?, description: String, // Capture-time start (ISO-8601) so an offline-queued START keeps its real start time. startTime: String, ) + LongParameterList:ReviewDayPane.kt$( item: ReviewItem, state: ReviewDayUiState, onStop: () -> Unit, onKeepRunning: () -> Unit, onAdjustEnd: () -> Unit, onRetry: (ReviewItem) -> Unit, onKeepAsIs: (ReviewItem) -> Unit, onAssign: (ReviewItem) -> Unit, ) + LongParameterList:ReviewDayPane.kt$( state: ReviewDayUiState, onStop: () -> Unit, onKeepRunning: () -> Unit, onAdjustEnd: () -> Unit, onRetry: (ReviewItem) -> Unit, onKeepAsIs: (ReviewItem) -> Unit, onAssign: (ReviewItem) -> Unit, onReviewAgain: () -> Unit, ) + LongParameterList:SolidtimeApi.kt$SolidtimeApi$( @Path("organization") organizationId: String, @Query("member_id") memberId: String, @Query("only_full_dates") onlyFullDates: Boolean = true, @Query("limit") limit: Int = 50, @Query("offset") offset: Int = 0, @Query("start") start: String? = null, @Query("end") end: String? = null, ) + LongParameterList:StatisticsAggregator.kt$StatisticsAggregator$( entries: List<TimeEntry>, projects: List<Project>, rangeStart: LocalDate, rangeEnd: LocalDate, zone: ZoneId, granularity: TrendGranularity, ) + LongParameterList:StatisticsAggregator.kt$StatisticsAggregator$( entries: List<TimeEntry>, projects: List<Project>, tasks: List<Task>, zone: ZoneId, selStart: LocalDate, selEnd: LocalDate, matchProject: Boolean, projectId: String?, ) + LongParameterList:StatisticsAggregatorTest.kt$StatisticsAggregatorTest$( id: String, start: String, end: String? = null, duration: Int? = null, projectId: String? = null, billable: Boolean = false, ) + LongParameterList:SyncWorker.kt$SyncWorker$( @Assisted appContext: Context, @Assisted params: WorkerParameters, private val outboxDao: OutboxDao, private val timeEntryDao: TimeEntryDao, private val remote: RemoteDataSource, private val json: Json, private val clock: Clock, private val syncStatus: SyncStatusReporter, ) + LongParameterList:TemplateEditorSheet.kt$( existing: EntryTemplate?, projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (TemplateDraft) -> Unit, onUpdate: (EntryTemplate) -> Unit, ) + LongParameterList:TemplateRepository.kt$TemplateRepository$( organizationId: String, name: String?, projectId: String?, taskId: String?, description: String?, tagIds: List<String>, billable: Boolean, isFavorite: Boolean, ) + LongParameterList:TimeEntryRepository.kt$TimeEntryRepository$( organizationId: String, memberId: String, userId: String, description: String, projectId: String?, taskId: String?, tagIds: List<String>, billable: Boolean, start: String, end: String, ) + LongParameterList:TimeEntryRepository.kt$TimeEntryRepository$( organizationId: String, memberId: String, userId: String, projectId: String?, taskId: String?, description: String, tagIds: List<String>, ) + LongParameterList:TimeEntryRepository.kt$TimeEntryRepository$( private val timeEntryDao: TimeEntryDao, private val catalogDao: CatalogDao, private val outboxDao: OutboxDao, private val syncMetaDao: SyncMetaDao, private val remote: RemoteDataSource, private val clock: Clock, private val json: Json, private val database: AppDatabase, ) + LongParameterList:TimeTrackingNotificationService.kt$TimeTrackingNotificationService.Companion$( context: Context, projectId: String?, taskId: String?, description: String, projectName: String?, taskName: String?, ) + LongParameterList:TimeTrackingWidget.kt$TimeTrackingWidget$( context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, startTimeMillis: Long, projectName: String?, taskName: String?, description: String?, ) + LongParameterList:TrackingScreen.kt$( description: String, onDescriptionChange: (String) -> Unit, timeEntries: List<TimeEntry>, projects: List<Project>, tags: List<Tag>, enabled: Boolean, onEntryCopied: (TimeEntry) -> Unit ) + LongParameterList:TrackingScreen.kt$( entries: List<TimeEntry>, projectsById: Map<String, Project>, tasksById: Map<String, Task>, syncStatusByEntryId: Map<String, TimeEntryRepository.EntrySyncStatus>, onEdit: (TimeEntry) -> Unit, onDelete: (TimeEntry) -> Unit ) + LongParameterList:TrackingScreen.kt$( entry: TimeEntry, project: Project?, task: Task?, syncStatus: TimeEntryRepository.EntrySyncStatus?, onEdit: () -> Unit, onDelete: () -> Unit, count: Int? = null, totalDuration: Int? = null, isIndented: Boolean = false ) + LongParameterList:TrackingScreen.kt$( entry: TimeEntry?, // null = create mode suggestedStart: ZonedDateTime?, // create mode: pre-filled start (end of last entry / now-1h) projects: List<Project>, tasks: List<Task>, tags: List<Tag>, onDismiss: () -> Unit, onSave: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, existingEntries: List<TimeEntry> = emptyList(), preventOverlap: Boolean = false, templates: List<EntryTemplate> = emptyList(), onSaveAsTemplate: ((TemplateDraft) -> Unit)? = null, ) + LongParameterList:TrackingScreen.kt$( selectedProjectId: String?, selectedTaskId: String?, projects: List<Project>, tasks: List<Task>, onSelectionChanged: (projectId: String?, taskId: String?) -> Unit, enabled: Boolean ) + LongParameterList:TrackingScreen.kt$( uiState: TrackingUiState, elapsedSeconds: Long = 0L, onDescriptionChange: (String) -> Unit, onProjectChange: (String?) -> Unit, onTaskChange: (String?) -> Unit, onTagsChange: (List<String>) -> Unit, onBillableChange: (Boolean) -> Unit, onStart: () -> Unit, onStop: () -> Unit, onPause: () -> Unit, onResume: () -> Unit, onUpdate: () -> Unit ) + LongParameterList:TrackingScreen.kt$( uiState: TrackingUiState, historyItems: List<HistoryListItem>, projectsById: Map<String, Project>, tasksById: Map<String, Task>, syncStatusByEntryId: Map<String, TimeEntryRepository.EntrySyncStatus>, onEdit: (TimeEntry) -> Unit, onDelete: (TimeEntry) -> Unit, onDateClick: (LocalDate) -> Unit ) + LongParameterList:TrackingScreen.kt$( user: User?, memberships: List<Membership>, currentMembership: Membership?, serverEndpoint: String, clientId: String, uiState: TrackingUiState, elapsedSeconds: StateFlow<Long> = MutableStateFlow(uiState.elapsedSeconds), alwaysShowNotifications: Boolean, appTheme: AppThemeMode, optimisticRefresh: Boolean, longTimerHours: Int, editActiveEntryRequested: Boolean, onEditActiveEntryConsumed: () -> Unit, onAlwaysShowNotificationsChange: (Boolean) -> Unit, onAppThemeChange: (AppThemeMode) -> Unit, onOptimisticRefreshChange: (Boolean) -> Unit, onLongTimerHoursChange: (Int) -> Unit, onRefresh: () -> Unit, onLogout: () -> Unit, onMembershipChange: (Membership) -> Unit, onStartTracking: () -> Unit, onStopTracking: () -> Unit, onPauseTracking: () -> Unit, onResumeTracking: () -> Unit, onDescriptionChange: (String) -> Unit, onProjectChange: (String?) -> Unit, onTaskChange: (String?) -> Unit, onTagsChange: (List<String>) -> Unit, onBillableChange: (Boolean) -> Unit, onUpdateCurrentEntry: () -> Unit, onUpdatePastEntry: (TimeEntry, String?, String?, String?, List<String>, Boolean, String, String) -> Unit, onCreateEntry: (String?, String?, String?, List<String>, Boolean, String, String) -> Unit, onDeleteEntry: (String) -> Unit, onUndoDelete: (TimeEntry) -> Unit, onRetrySync: () -> Unit, onRetrySyncEntry: (String) -> Unit, onLoadMoreEntries: () -> Unit, onLoadNewerEntries: () -> Unit, onJumpToDate: (LocalDate) -> Unit, onHistoryJumpConsumed: () -> Unit, getGroupedEntries: () -> Map<LocalDate, List<TimeEntry>> ) + LongParameterList:TrackingViewModel.kt$TrackingViewModel$( organizationId: String, memberId: String, userId: String, description: String?, projectId: String?, taskId: String?, tags: List<String>, billable: Boolean, start: String, end: String, ) + LongParameterList:TrackingViewModel.kt$TrackingViewModel$( organizationId: String, timeEntry: TimeEntry, description: String?, projectId: String?, taskId: String?, tags: List<String>, billable: Boolean, start: String, end: String, ) + LongParameterList:WeekCalendarView.kt$( day: LocalDate, isToday: Boolean, now: Instant, zone: ZoneId, eventBlocks: List<EventBlock>, entries: List<TimeEntry>, projects: List<Project>, onEntryClick: (TimeEntry) -> Unit, modifier: Modifier = Modifier, ) + LongParameterList:WeekCalendarView.kt$( day: LocalDate, selected: Boolean, isToday: Boolean, locale: Locale, onSelect: () -> Unit, modifier: Modifier = Modifier, ) + LongParameterList:WeekCalendarView.kt$( days: List<LocalDate>, today: LocalDate, now: Instant, zone: ZoneId, timedByDay: Map<LocalDate, List<EventBlock>>, state: CalendarUiState, projects: List<Project>, onEntryClick: (TimeEntry) -> Unit, ) + LongParameterList:WeekCalendarView.kt$( days: List<LocalDate>, viewMode: CalendarViewMode, locale: Locale, onPrevious: () -> Unit, onNext: () -> Unit, onToday: () -> Unit, ) + LongParameterList:WeekCalendarView.kt$( state: CalendarUiState, days: List<LocalDate>, onSelectDate: (LocalDate) -> Unit, onEntryClick: (TimeEntry) -> Unit, onPrevious: () -> Unit, onNext: () -> Unit, onToday: () -> Unit, projects: List<Project>, ) + LongParameterList:WeekCalendarView.kt$( state: CalendarUiState, onSelectDate: (LocalDate) -> Unit, onEntryClick: (TimeEntry) -> Unit, onPrevious: () -> Unit, onNext: () -> Unit, onToday: () -> Unit, projects: List<Project>, modifier: Modifier = Modifier, ) + LoopWithTooManyJumpStatements:TimeEntryRepository.kt$TimeEntryRepository$while + LoopWithTooManyJumpStatements:TrackingViewModel.kt$TrackingViewModel$while + MagicNumber:AppDatabase.kt$AppDatabase.Companion.<no name provided>$3 + MagicNumber:AppDatabase.kt$AppDatabase.Companion.<no name provided>$4 + MagicNumber:AuthRepository.kt$AuthRepository$404 + MagicNumber:AuthSecretCipher.kt$AndroidKeystoreKeyProvider$256 + MagicNumber:BarChart.kt$0.0001f + MagicNumber:BarChart.kt$0.02f + MagicNumber:BarChart.kt$0.5f + MagicNumber:CalendarDateUtils.kt$3600 + MagicNumber:CalendarDateUtils.kt$6 + MagicNumber:CalendarDateUtils.kt$60 + MagicNumber:CalendarDateUtils.kt$7 + MagicNumber:CalendarScreen.kt$3 + MagicNumber:CalendarScreen.kt$7 + MagicNumber:CalendarViewModel.kt$CalendarViewModel$7 + MagicNumber:ConnectionTester.kt$ConnectionTester$15 + MagicNumber:ConnectionTester.kt$ConnectionTester$200 + MagicNumber:ConnectionTester.kt$ConnectionTester$401 + MagicNumber:ConnectionTester.kt$ConnectionTester$403 + MagicNumber:ConnectionTester.kt$ConnectionTester$404 + MagicNumber:ConnectionTester.kt$ConnectionTester$500 + MagicNumber:ConnectionTester.kt$ConnectionTester$599 + MagicNumber:CsvExporter.kt$CsvExporter.Companion$3600 + MagicNumber:CsvExporter.kt$CsvExporter.Companion$60 + MagicNumber:DonutChart.kt$0.0001f + MagicNumber:DonutChart.kt$360f + MagicNumber:DonutChart.kt$90f + MagicNumber:EditTimeEntryDialog.kt$0.9f + MagicNumber:EditTimeEntryDialog.kt$15 + MagicNumber:EditTimeEntryDialog.kt$60 + MagicNumber:EntryBlock.kt$0xFF386A20 + MagicNumber:HandoffMainActivity.kt$HandoffMainActivity$37 + MagicNumber:InboxAnalyzer.kt$InboxAnalyzer$3600L + MagicNumber:InboxAnalyzer.kt$InboxAnalyzer$60L + MagicNumber:InboxAnalyzer.kt$InboxCheckConfig$17 + MagicNumber:InboxAnalyzer.kt$InboxCheckConfig$60 + MagicNumber:InboxAnalyzer.kt$InboxCheckConfig$9 + MagicNumber:InboxPane.kt$60 + MagicNumber:InboxPane.kt$60000L + MagicNumber:InboxSettingsDataStore.kt$InboxSettingsDataStore$1440 + MagicNumber:InboxSettingsDataStore.kt$InboxSettingsDataStore$24 + MagicNumber:InboxSettingsDataStore.kt$InboxSettingsDataStore$60 + MagicNumber:InboxSettingsSheet.kt$1440 + MagicNumber:InboxSettingsSheet.kt$23 + MagicNumber:InboxSettingsSheet.kt$24 + MagicNumber:InboxSettingsSheet.kt$5 + MagicNumber:InboxSettingsSheet.kt$60 + MagicNumber:InboxViewModel.kt$InboxViewModel$1440 + MagicNumber:InboxViewModel.kt$InboxViewModel$24 + MagicNumber:InboxViewModel.kt$InboxViewModel$60 + MagicNumber:IsoTimes.kt$IsoTimes$10 + MagicNumber:IsoTimes.kt$IsoTimes$11 + MagicNumber:IsoTimes.kt$IsoTimes$13 + MagicNumber:IsoTimes.kt$IsoTimes$16 + MagicNumber:IsoTimes.kt$IsoTimes$4 + MagicNumber:IsoTimes.kt$IsoTimes$7 + MagicNumber:ManageTemplatesViewModel.kt$ManageTemplatesViewModel$5_000 + MagicNumber:MonthCalendarView.kt$8 + MagicNumber:NetworkModule.kt$NetworkModule$30 + MagicNumber:ReminderSchedule.kt$ReminderSchedule$60 + MagicNumber:ReminderScheduler.kt$ReminderScheduler$24 + MagicNumber:ReminderSettingsScreen.kt$60 + MagicNumber:ReminderSettingsViewModel.kt$ReminderSettingsViewModel$1439 + MagicNumber:ReminderSettingsViewModel.kt$ReminderSettingsViewModel$5_000L + MagicNumber:ReminderSettingsViewModel.kt$ReminderSettingsViewModel$60 + MagicNumber:ReviewDayPane.kt$3500 + MagicNumber:ReviewDayPane.kt$3600 + MagicNumber:ReviewDayPane.kt$60 + MagicNumber:ReviewDayViewModel.kt$ReviewDayViewModel$5_000L + MagicNumber:ReviewStepMachine.kt$ReviewDayUiState$100 + MagicNumber:SettingsDataStore.kt$SettingsDataStore$1439 + MagicNumber:SettingsDataStore.kt$SettingsDataStore$24 + MagicNumber:SettingsDataStore.kt$SettingsDataStore$4 + MagicNumber:StatRange.kt$31 + MagicNumber:StatRange.kt$StatRange.Last7Days$6 + MagicNumber:StatisticsAggregator.kt$StatisticsAggregator$100 + MagicNumber:StatisticsComparison.kt$MetricDelta$100.0 + MagicNumber:StatisticsScreen.kt$0xFF9E9E9E + MagicNumber:StatisticsScreen.kt$100 + MagicNumber:StatisticsScreen.kt$3600 + MagicNumber:StatisticsScreen.kt$6 + MagicNumber:StatisticsScreen.kt$60 + MagicNumber:StatisticsViewModel.kt$StatisticsViewModel$500 + MagicNumber:StatisticsViewModel.kt$StatisticsViewModel$5_000 + MagicNumber:SyncWorker.kt$SyncWorker$429 + MagicNumber:SyncWorker.kt$SyncWorker$500 + MagicNumber:TimeEntryRepository.kt$TimeEntryRepository$15_000 + MagicNumber:TimeEntryRepository.kt$TimeEntryRepository$250 + MagicNumber:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$1000L + MagicNumber:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$3 + MagicNumber:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$3600 + MagicNumber:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$3600L + MagicNumber:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$4 + MagicNumber:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$5 + MagicNumber:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$6 + MagicNumber:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$60 + MagicNumber:TimeTrackingNotificationService.kt$TimeTrackingNotificationService.Companion$7 + MagicNumber:TimeTrackingTileService.kt$TimeTrackingTileService$3000L + MagicNumber:TimeTrackingTileService.kt$TimeTrackingTileService$500 + MagicNumber:TimeTrackingTileService.kt$TimeTrackingTileService$5000L + MagicNumber:TimeTrackingWidget.kt$TimeTrackingWidget$1_000L + MagicNumber:TimeTrackingWidget.kt$TimeTrackingWidget$60_000 + MagicNumber:TimeTrackingWidget.kt$TimeTrackingWidget$60_000L + MagicNumber:TimeTrackingWidget.kt$TimeTrackingWidget.Companion$60 + MagicNumber:TokenAuthenticator.kt$HttpTokenRefresher$400 + MagicNumber:TokenAuthenticator.kt$HttpTokenRefresher$401 + MagicNumber:TrackingScreen.kt$0.45f + MagicNumber:TrackingScreen.kt$0.62f + MagicNumber:TrackingScreen.kt$0.9f + MagicNumber:TrackingScreen.kt$0xFFEF4444 + MagicNumber:TrackingScreen.kt$1.1f + MagicNumber:TrackingScreen.kt$12 + MagicNumber:TrackingScreen.kt$120 + MagicNumber:TrackingScreen.kt$15 + MagicNumber:TrackingScreen.kt$180 + MagicNumber:TrackingScreen.kt$220 + MagicNumber:TrackingScreen.kt$3600 + MagicNumber:TrackingScreen.kt$3600L + MagicNumber:TrackingScreen.kt$4 + MagicNumber:TrackingScreen.kt$5 + MagicNumber:TrackingScreen.kt$6 + MagicNumber:TrackingScreen.kt$60 + MagicNumber:TrackingScreen.kt$8 + MagicNumber:TrackingScreen.kt$900 + MagicNumber:TrackingScreen.kt$EntryTimeValidator$12 + MagicNumber:TrackingScreen.kt$EntryTimeValidator$18 + MagicNumber:TrackingScreen.kt$EntryTimeValidator$24 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$0.9f + MagicNumber:TrackingViewModel.kt$TrackingViewModel$1000 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$1_000L + MagicNumber:TrackingViewModel.kt$TrackingViewModel$3 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$429 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$5 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$60 + MagicNumber:WeekCalendarLayout.kt$1000L + MagicNumber:WeekCalendarLayout.kt$24 + MagicNumber:WeekCalendarLayout.kt$7 + MagicNumber:WeekCalendarView.kt$23 + MagicNumber:WeekCalendarView.kt$3 + MagicNumber:WeekCalendarView.kt$7 + MatchingDeclarationName:BarChart.kt$BarChartSizing + MatchingDeclarationName:DeltaBadge.kt$DeltaDirection + MatchingDeclarationName:ReviewScreen.kt$ReviewSegment + MaxLineLength:AuthRepository.kt$AuthRepository$class + MaxLineLength:AuthRepository.kt$AuthRepository$suspend + MaxLineLength:AuthViewModel.kt$AuthViewModel$ConnectionTestCode.COMPLETE_URL_REQUIRED -> context.getString(R.string.connection_complete_url_required) + MaxLineLength:AuthViewModel.kt$AuthViewModel$ConnectionTestCode.SERVER_ERROR -> context.getString(R.string.connection_server_error, result.httpStatus) + MaxLineLength:AuthViewModel.kt$AuthViewModel$ConnectionTestCode.UNEXPECTED_RESPONSE -> context.getString(R.string.connection_unexpected_response, result.httpStatus) + MaxLineLength:CalendarEventProvider.kt$CalendarEventProvider$"${CalendarContract.Calendars.ACCOUNT_NAME} ASC, ${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME} ASC" + MaxLineLength:CalendarEventProvider.kt$CalendarEventProvider$class + MaxLineLength:CalendarEventProvider.kt$CalendarEventProvider$colorArgb = if (cursor.isNull(colorCol)) null else cursor.getInt(colorCol).takeIf { it != 0 } + MaxLineLength:CalendarEventProvider.kt$CalendarEventProvider$override suspend + MaxLineLength:CalendarOverlayControls.kt$private + MaxLineLength:CalendarOverlaySettings.kt$SettingsCalendarOverlaySettings$class + MaxLineLength:CalendarViewModel.kt$CalendarViewModel$fun entriesForSelectedDay(): List<TimeEntry> + MaxLineLength:CalendarViewModel.kt$CalendarViewModel$private + MaxLineLength:CalendarViewModelTest.kt$CalendarViewModelTest$private + MaxLineLength:CalendarViewModelTest.kt$CalendarViewModelTest.FakeEventSource$override suspend + MaxLineLength:CalendarViewModelTest.kt$CalendarViewModelTest.FakeOverlaySettings$private + MaxLineLength:ConfigScreen.kt$) + MaxLineLength:ConfigScreen.kt$color = if (configState.testSuccess == true) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + MaxLineLength:EditTimeEntryDialog.kt$confirmButton = { Button(onClick = { onConfirm(state.hour, state.minute) }) { Text(stringResource(R.string.done)) } } + MaxLineLength:EditTimeEntryDialog.kt$private + MaxLineLength:Entities.kt$ClientEntity$data + MaxLineLength:FakeRemoteDataSource.kt$FakeRemoteDataSource$?: + MaxLineLength:FakeRemoteDataSource.kt$FakeRemoteDataSource$override suspend + MaxLineLength:HandoffMainActivity.kt$HandoffMainActivity$override + MaxLineLength:InboxAnalyzer.kt$InboxAnalyzer$fun + MaxLineLength:InboxAnalyzer.kt$InboxAnalyzer$key = "long:$RULE_VERSION:${entry.id}:${config.maxDurationHours}:${start.epochSecond}:${end.epochSecond}" + MaxLineLength:InboxAnalyzer.kt$InboxAnalyzer$private + MaxLineLength:InboxAnalyzerTest.kt$InboxAnalyzerTest$val entries = listOf(entry("a", "2026-07-06T09:00:00Z", "2026-07-06T17:00:00Z", projectId = null, taskId = null)) + MaxLineLength:InboxPane.kt$val endText = if (start.toLocalDate() == end.toLocalDate()) end.format(TIME_FORMAT) else end.format(DATE_TIME_FORMAT) + MaxLineLength:InboxSettingsSheet.kt$CheckToggle + MaxLineLength:InboxSettingsSheet.kt$private + MaxLineLength:InboxViewModel.kt$InboxViewModel$OrgContext + MaxLineLength:InteractiveBarChart.kt$fun + MaxLineLength:MainActivity.kt$MainActivity$if + MaxLineLength:Mappers.kt$fun + MaxLineLength:MigrationTest.kt$MigrationTest$db.query("SELECT organizationId, dismissedAtMs FROM inbox_dismissals WHERE issueKey = 'overlap:v1:abc'") + MaxLineLength:MonthCalendarView.kt$MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.15f + 0.55f * intensity) + MaxLineLength:MonthCalendarView.kt$text = "${state.visibleMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${state.visibleMonth.year}" + MaxLineLength:NetworkStatusBanner.kt$private + MaxLineLength:NotificationPermissionHelper.kt$NotificationPermissionHelper$fun + MaxLineLength:OAuth.kt$OAuthConfig$data + MaxLineLength:OutboxDao.kt$OutboxDao$@Query("DELETE FROM outbox WHERE id = (SELECT MAX(id) FROM outbox WHERE timeEntryId = :entryId AND opType = 'DELETE')") + MaxLineLength:ProjectSelectionActivity.kt$onStartTracking: (projectId: String?, taskId: String?, description: String, projectName: String?, taskName: String?) -> Unit + MaxLineLength:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$EntryTemplate("tm3", "org1", "Client call", "p3", null, null, listOf("missing-tag"), true, false, 2, 0L) + MaxLineLength:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$currentTimeEntry = entry("running", "Landing page build", "2026-06-10T14:00:00Z", null, 0, taskId = "t1") + MaxLineLength:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$entry("e1", "Design review", "2026-06-10T09:00:00Z", "2026-06-10T10:15:00Z", 4500, taskId = "t2", entryTags = listOf(tags[1])) + MaxLineLength:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$entry("e2", "Landing page build", "2026-06-10T10:30:00Z", "2026-06-10T12:45:00Z", 8100, taskId = "t1", entryTags = listOf(tags[0])) + MaxLineLength:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$entry("e3", "Standup", "2026-06-10T13:15:00Z", "2026-06-10T13:35:00Z", 1200, projectId = "p2", billable = false) + MaxLineLength:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$entry("e4", "Bug triage", "2026-06-09T14:00:00Z", "2026-06-09T15:30:00Z", 5400, projectId = "p2", billable = false) + MaxLineLength:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$entry("w1", "Deep work", "2026-06-08T09:00:00Z", "2026-06-08T12:00:00Z", 10_800, taskId = "t1") + MaxLineLength:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$entry("w3", "Design review", "2026-06-08T10:00:00Z", "2026-06-08T11:30:00Z", 5_400, taskId = "t2") + MaxLineLength:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$listOf(entry("w2", "Design system", "2026-06-09T10:00:00Z", "2026-06-09T11:30:00Z", 5_400, taskId = "t2")) + MaxLineLength:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$private + MaxLineLength:ReminderSettingsScreen.kt$color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant + MaxLineLength:ReminderSettingsViewModel.kt$ReminderSettingsViewModel$class + MaxLineLength:RemoteDataSource.kt$AuthRemoteDataSource$) + MaxLineLength:RemoteDataSource.kt$AuthRemoteDataSource$override suspend + MaxLineLength:ReviewDayPane.kt$FlowRow + MaxLineLength:ReviewDayPane.kt$Text(stringResource(R.string.review_item_failed_title), style = MaterialTheme.typography.titleMedium) + MaxLineLength:ReviewDayPane.kt$Text(stringResource(R.string.review_item_running_title), style = MaterialTheme.typography.titleMedium) + MaxLineLength:ReviewDayPane.kt$Text(stringResource(R.string.review_item_uncategorized_body), style = MaterialTheme.typography.bodyMedium) + MaxLineLength:ReviewDayPane.kt$Text(stringResource(R.string.review_item_uncategorized_title), style = MaterialTheme.typography.titleMedium) + MaxLineLength:ReviewDialogs.kt$internal + MaxLineLength:ReviewScreen.kt$fun + MaxLineLength:ReviewStepMachine.kt$ReviewStepMachine$fun + MaxLineLength:ReviewStepMachineTest.kt$ReviewStepMachineTest$private fun item(id: String, type: ReviewItemType = ReviewItemType.UNCATEGORIZED) + MaxLineLength:SemanticColors.kt$SemanticColors$data + MaxLineLength:SettingsDataStore.kt$SettingsDataStore$fun + MaxLineLength:SettingsDataStore.kt$SettingsDataStore$fun getCachedReviewBadgeCount(organizationId: String): Int + MaxLineLength:SettingsDataStore.kt$SettingsDataStore.Companion$private val LONG_TIMER_WARNING_ENTRY_START_EPOCH_MS = longPreferencesKey("long_timer_warning_entry_start_epoch_ms") + MaxLineLength:SettingsDataStoreWarningDeadlineTest.kt$SettingsDataStoreWarningDeadlineTest$* + MaxLineLength:SolidtimeApi.kt$SolidtimeApi$suspend + MaxLineLength:StatDrillDownSheet.kt$Row + MaxLineLength:StatFiltersApplyTest.kt$StatFiltersApplyTest$ids(StatisticsAggregator.applyFilters(entries, projects, StatFilters(billable = BillableFilter.NonBillable))) + MaxLineLength:StatisticsComparison.kt$ProjectChange$data + MaxLineLength:StatisticsMembershipTest.kt$StatisticsMembershipTest$db.catalogDao().upsertMemberships(listOf(MembershipEntity(id = membershipId, role = "member", organizationId = orgId))) + MaxLineLength:StatisticsMembershipTest.kt$StatisticsMembershipTest$fun + MaxLineLength:StatisticsScreen.kt$FilterChip(selected = current == range, onClick = { onSelect(range) }, label = { Text(stringResource(label)) }) + MaxLineLength:StatisticsScreen.kt$slices = s.perProject.map { hexToColor(it.colorHex, swatchFallback) to it.seconds.toFloat() } + MaxLineLength:StatisticsViewModel.kt$StatisticsViewModel$RemoteEntries + MaxLineLength:StatisticsViewModel.kt$StatisticsViewModel$private + MaxLineLength:SyncWorker.kt$SyncWorker$private suspend + MaxLineLength:SyncWorker.kt$SyncWorker$val newerSynced = current != null && current.syncState == SyncState.SYNCED && current.updatedAt > op.createdAtMs + MaxLineLength:SyncWorker.kt$SyncWorker$val server = remote.stopTimeEntry(op.organizationId, op.timeEntryId, p.userId, p.start, endTime = p.end).getOrThrow() + MaxLineLength:SyncWorkerTest.kt$SyncWorkerTest$UpdatePayload("u1", "2026-07-07T08:00:00Z", "2026-07-07T09:00:00Z", "stale", null, null, true, emptyList()) + MaxLineLength:TemplateModels.kt$TemplateResolver$fun + MaxLineLength:TemplateOrderingTest.kt$TemplateOrderingTest$private + MaxLineLength:TimeEntryDao.kt$TimeEntryDao$@Query("SELECT * FROM time_entries WHERE organizationId = :orgId AND end IS NULL AND pendingDelete = 0 ORDER BY start DESC LIMIT 1") + MaxLineLength:TimeEntryDao.kt$TimeEntryDao$suspend + MaxLineLength:TimeEntryRepository.kt$TimeEntryRepository$fun observeClients(orgId: String): Flow<List<Client>> + MaxLineLength:TimeEntryRepository.kt$TimeEntryRepository$fun observeProjects(orgId: String): Flow<List<Project>> + MaxLineLength:TimeEntryRepository.kt$TimeEntryRepository$fun observeTags(orgId: String): Flow<List<Tag>> + MaxLineLength:TimeEntryRepository.kt$TimeEntryRepository$fun observeTasks(orgId: String): Flow<List<Task>> + MaxLineLength:TimeEntryRepositoryReadTest.kt$TimeEntryRepositoryReadTest$db.timeEntryDao().upsert(srv("a").copy(description = "local").toEntity(updatedAt = 5000L, syncState = SyncState.PENDING)) + MaxLineLength:TimeEntryRepositoryWriteTest.kt$TimeEntryRepositoryWriteTest$val entry = repo.startEntry("org1", "member1", "u1", projectId = "p1", taskId = null, description = "work", tagIds = emptyList()) + MaxLineLength:TimeEntryTest.kt$TimeEntryTest$"""{"data":[{"id":"entry","user_id":"user","start":"2026-07-03T16:40:00Z","tags":["tag-id"],"organization_id":"org"}]}""" + MaxLineLength:TimeEntryTest.kt$TimeEntryTest$"""{"data":[{"id":"entry","user_id":"user","start":"2026-07-03T16:40:00Z","tags":[{"id":"tag-id","name":"focus"}],"organization_id":"org"}]}""" + MaxLineLength:TimeEntryTest.kt$TimeEntryTest$"""{"id":"u","name":"Ada Lovelace","email":"ada@example.test","profile_photo_url":"https://example.test/ada.png","timezone":"Europe/Amsterdam","week_start":"monday"}""" + MaxLineLength:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$. + MaxLineLength:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + MaxLineLength:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$private + MaxLineLength:TimeTrackingNotificationService.kt$TimeTrackingNotificationService.Companion$/** Epoch millis of the entry's start time the warning was scheduled for; see [ACTION_SHOW_LONG_TIMER_WARNING]. */ + MaxLineLength:TimeTrackingTileService.kt$TimeTrackingTileService$CachedEntry + MaxLineLength:TimeTrackingTileService.kt$TimeTrackingTileService$Timber.d("Starting time entry with orgId=${membership.organizationId}, memberId=${membership.id}, userId=${user.id}") + MaxLineLength:TimeTrackingTileService.kt$TimeTrackingTileService$Timber.d("TileService registering broadcast receiver with actions: ${filter.actionsIterator().asSequence().toList()}") + MaxLineLength:TimeTrackingTileService.kt$TimeTrackingTileService$private + MaxLineLength:TimeTrackingWidget.kt$TimeTrackingWidget$"Widget state: isTracking=${widgetState.isTracking}, startTime=${widgetState.startTimeEpochMillis}, project=${widgetState.projectName}" + MaxLineLength:TokenAuthenticator.kt$DataStoreTokenStorage$override suspend fun saveTokens(accessToken: String, refreshToken: String) + MaxLineLength:TokenAuthenticator.kt$HttpTokenRefresher$private + MaxLineLength:TrackingScreen.kt$Text(stringResource(R.string.optimistic_refresh), style = MaterialTheme.typography.bodyLarge) + MaxLineLength:TrackingScreen.kt$confirmButton = { Button(onClick = { onConfirm(state.hour, state.minute) }) { Text(stringResource(R.string.done)) } } + MaxLineLength:TrackingScreen.kt$filter.syncStatus + MaxLineLength:TrackingScreen.kt$initialSelectedStartDateMillis = filter.startDate?.atStartOfDay(ZoneOffset.UTC)?.toInstant()?.toEpochMilli() + MaxLineLength:TrackingScreen.kt$item { OverlapWarning(overlapCount, currentMembership?.organization?.preventOverlappingTimeEntries == true) } + MaxLineLength:TrackingScreen.kt$modifier = Modifier.size(48.dp).semantics { contentDescription = profileDescription } + MaxLineLength:TrackingScreen.kt$options = uiState.projects.filter { filter.clientId == null || it.clientId == filter.clientId }.map { it.id to it.name } + MaxLineLength:TrackingScreen.kt$private + MaxLineLength:TrackingScreen.kt$private const val OBTAINIUM_ADD_APP_URL = "obtainium://app/%7B%22id%22%3A%22dev.tricked.solidverdant%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2FTricked-dev%2FSolidVerdant%22%2C%22author%22%3A%22Tricked-dev%22%2C%22name%22%3A%22SolidVerdant%22%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Atrue%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%7D%22%7D" + MaxLineLength:TrackingScreen.kt$val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + MaxLineLength:TrackingScreen.kt$val onHistoryDelete = remember<(TimeEntry) -> Unit> { { entry -> deletedEntry = entry; onDeleteEntry(entry.id) } } + MaxLineLength:TrackingScreen.kt$value.toLongOrNull()?.takeIf { it > 0 }?.let { endTime = startTime.plusMinutes(it) } + MaxLineLength:TrackingViewModel.kt$HistoryWindow$fun + MaxLineLength:TrackingViewModel.kt$TrackingViewModel$editingBillable = if (activeChanged) (data.active?.billable ?: false) else currentState.editingBillable + MaxLineLength:TrackingViewModel.kt$TrackingViewModel$editingDescription = if (activeChanged) data.active?.description.orEmpty() else currentState.editingDescription + MaxLineLength:TrackingViewModel.kt$TrackingViewModel$editingTags = if (activeChanged) data.active?.tags?.map { it.id }.orEmpty() else currentState.editingTags + MaxLineLength:WeekCalendarLayout.kt$fun + MaxLineLength:WeekCalendarView.kt$private + NestedBlockDepth:ReadmeScreenshotsTest.kt$ReadmeScreenshotsTest$@Test fun captureReadmeAndMatrix() + NestedBlockDepth:TokenAuthenticator.kt$HttpTokenRefresher$override fun refresh(endpoint: String, clientId: String, refreshToken: String): RefreshResult + ReturnCount:AuthRepository.kt$AuthRepository$suspend fun handleOAuthCallback(code: String, state: String): Result<Unit> + ReturnCount:EntryTrustRules.kt$EntryTrustRules$fun isLongRunning(entry: TimeEntry, threshold: Duration, now: Instant = Instant.now()): Boolean + ReturnCount:EntryTrustRules.kt$EntryTrustRules$fun overlaps(first: TimeEntry, second: TimeEntry, now: Instant = Instant.now()): Boolean + ReturnCount:FakeRemoteDataSource.kt$FakeRemoteDataSource$override suspend fun deleteTimeEntry(organizationId: String, timeEntryId: String): Result<Unit> + ReturnCount:FakeRemoteDataSource.kt$FakeRemoteDataSource$override suspend fun startTimeEntry( organizationId: String, memberId: String, userId: String, projectId: String?, taskId: String?, description: String, startTime: String, ): Result<TimeEntry> + ReturnCount:InboxAnalyzer.kt$InboxAnalyzer$private fun gapIssues(intervals: List<Interval>, config: InboxCheckConfig, now: Instant, zone: ZoneId): List<InboxIssue> + ReturnCount:LongTimerWarningWorker.kt$LongTimerWarningWorker$override suspend fun doWork(): Result + ReturnCount:ManageTemplatesViewModel.kt$ManageTemplatesViewModel$fun moveTemplate(id: String, up: Boolean) + ReturnCount:NetworkStatusBanner.kt$private fun Context.hasValidatedInternet(): Boolean + ReturnCount:ReminderWorker.kt$ReminderWorker$override suspend fun doWork(): Result + ReturnCount:StatisticsAggregator.kt$StatisticsAggregator$private fun clippedDailyBreakdown( e: TimeEntry, zone: ZoneId, rangeStart: LocalDate, rangeEnd: LocalDate, ): List<Pair<LocalDate, Long>>? + ReturnCount:TemplateUi.kt$@Composable fun templateDisplayLabel(template: EntryTemplate, projects: List<Project>): String + ReturnCount:TimeEntryRepository.kt$TimeEntryRepository$suspend fun undoDelete(entry: TimeEntry, memberId: String?): Boolean + ReturnCount:TimeTrackingBroadcastReceiver.kt$TimeTrackingBroadcastReceiver$private suspend fun stopActiveTracking() + ReturnCount:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$private suspend fun stopActiveEntry(): Result<Boolean> + ReturnCount:TimeTrackingTileService.kt$TimeTrackingTileService$private fun getCachedEntryForStop(): CachedEntry? + ReturnCount:TimeTrackingTileService.kt$TimeTrackingTileService$private suspend fun loadNames(entry: TimeEntry): Pair<String?, String?> + ReturnCount:TrackingScreen.kt$private fun formatTimeRange(start: String, end: String?): String + ReturnCount:TrackingScreen.kt$private fun historyHeaderIndex( requestedDate: LocalDate, groupedEntries: Map<LocalDate, List<TimeEntry>> ): Int + ReturnCount:TrackingViewModel.kt$TrackingViewModel$fun jumpToHistoryDate(date: LocalDate) + ReturnCount:TrackingViewModel.kt$TrackingViewModel$fun loadMoreTimeEntries() + ReturnCount:TrackingViewModel.kt$TrackingViewModel$fun loadNewerTimeEntries() + ReturnCount:WeekCalendarLayout.kt$fun clampToDaySeconds(startMs: Long, endMs: Long, day: LocalDate, zone: ZoneId): Pair<Long, Long>? + ReturnCount:WeekCalendarView.kt$private fun rangeLabel(days: List<LocalDate>, viewMode: CalendarViewMode, locale: Locale): String + SpreadOperator:DatabaseModule.kt$DatabaseModule$(*AppDatabase.MIGRATIONS) + SwallowedException:AuthDataStore.kt$AuthDataStore$e: Exception + SwallowedException:AuthInterceptor.kt$AuthInterceptor$e: Exception + SwallowedException:AuthSecretCipher.kt$AuthSecretCipher$e: Exception + SwallowedException:ConnectionTester.kt$ConnectionTester$e: Exception + SwallowedException:ConnectionTester.kt$ConnectionTester$e: SSLException + SwallowedException:ConnectionTester.kt$ConnectionTester$e: java.net.ConnectException + SwallowedException:ConnectionTester.kt$ConnectionTester$e: java.net.UnknownHostException + SwallowedException:StatisticsAggregator.kt$StatisticsAggregator$t: Throwable + SwallowedException:StatisticsScreen.kt$t: Throwable + SwallowedException:TokenAuthenticator.kt$HttpTokenRefresher$e: IOException + SwallowedException:TrackingScreen.kt$e: Exception + TooGenericExceptionCaught:AuthDataStore.kt$AuthDataStore$e: Exception + TooGenericExceptionCaught:AuthInterceptor.kt$AuthInterceptor$e: Exception + TooGenericExceptionCaught:AuthRepository.kt$AuthRepository$e: Exception + TooGenericExceptionCaught:AuthSecretCipher.kt$AuthSecretCipher$e: Exception + TooGenericExceptionCaught:BootReceiver.kt$BootReceiver$e: Exception + TooGenericExceptionCaught:ConnectionTester.kt$ConnectionTester$e: Exception + TooGenericExceptionCaught:ProjectSelectionViewModel.kt$ProjectSelectionViewModel$e: Exception + TooGenericExceptionCaught:StatisticsAggregator.kt$StatisticsAggregator$t: Throwable + TooGenericExceptionCaught:StatisticsScreen.kt$t: Throwable + TooGenericExceptionCaught:StatisticsViewModel.kt$StatisticsViewModel$t: Throwable + TooGenericExceptionCaught:SyncWorker.kt$SyncWorker$e: Exception + TooGenericExceptionCaught:TimeEntryRepository.kt$TimeEntryRepository$e: Exception + TooGenericExceptionCaught:TimeTrackingBroadcastReceiver.kt$TimeTrackingBroadcastReceiver$e: Exception + TooGenericExceptionCaught:TimeTrackingTileService.kt$TimeTrackingTileService$e: Exception + TooGenericExceptionCaught:TimeTrackingWidget.kt$TimeTrackingWidget$e: Exception + TooGenericExceptionCaught:TokenAuthenticator.kt$TokenAuthenticator$e: Exception + TooGenericExceptionCaught:TrackingScreen.kt$e: Exception + TooGenericExceptionCaught:TrackingViewModel.kt$TrackingViewModel$e: Exception + TooManyFunctions:AuthDataStore.kt$AuthDataStore + TooManyFunctions:AuthRepository.kt$AuthRepository + TooManyFunctions:AuthViewModel.kt$AuthViewModel : ViewModel + TooManyFunctions:CalendarViewModel.kt$CalendarViewModel : ViewModel + TooManyFunctions:CatalogDao.kt$CatalogDao + TooManyFunctions:InboxPane.kt$dev.tricked.solidverdant.ui.review.InboxPane.kt + TooManyFunctions:InboxViewModel.kt$InboxViewModel : ViewModel + TooManyFunctions:Mappers.kt$dev.tricked.solidverdant.data.local.db.Mappers.kt + TooManyFunctions:OutboxDao.kt$OutboxDao + TooManyFunctions:RemoteDataSource.kt$AuthRemoteDataSource : RemoteDataSource + TooManyFunctions:RemoteDataSource.kt$RemoteDataSource + TooManyFunctions:ReviewDayPane.kt$dev.tricked.solidverdant.ui.review.ReviewDayPane.kt + TooManyFunctions:ReviewDayViewModel.kt$ReviewDayViewModel : ViewModel + TooManyFunctions:SettingsDataStore.kt$SettingsDataStore + TooManyFunctions:SolidtimeApi.kt$SolidtimeApi + TooManyFunctions:StatisticsScreen.kt$dev.tricked.solidverdant.ui.statistics.StatisticsScreen.kt + TooManyFunctions:StatisticsViewModel.kt$StatisticsViewModel : ViewModel + TooManyFunctions:TimeEntryDao.kt$TimeEntryDao + TooManyFunctions:TimeEntryRepository.kt$TimeEntryRepository : TimeEntryReader + TooManyFunctions:TimeTrackingNotificationService.kt$TimeTrackingNotificationService : Service + TooManyFunctions:TimeTrackingNotificationService.kt$TimeTrackingNotificationService$Companion + TooManyFunctions:TimeTrackingTileService.kt$TimeTrackingTileService : TileService + TooManyFunctions:TrackingScreen.kt$dev.tricked.solidverdant.ui.tracking.TrackingScreen.kt + TooManyFunctions:TrackingViewModel.kt$TrackingViewModel : ViewModel + TooManyFunctions:WeekCalendarView.kt$dev.tricked.solidverdant.ui.calendar.WeekCalendarView.kt + UnusedParameter:AuthRepository.kt$AuthRepository$userId: String + UnusedParameter:InboxPane.kt$issue: InboxIssue + UnusedParameter:LoginScreen.kt$onAuthUrlReady: (String) -> Unit + UnusedParameter:LoginScreen.kt$onDismiss: () -> Unit + UnusedParameter:MonthCalendarView.kt$tags: List<Tag> = emptyList() + UnusedParameter:ReviewDayPane.kt$state: ReviewDayUiState + UnusedParameter:TrackingScreen.kt$getGroupedEntries: () -> Map<LocalDate, List<TimeEntry>> + UnusedParameter:TrackingViewModel.kt$TrackingViewModel$memberId: String + UnusedParameter:TrackingViewModel.kt$TrackingViewModel$organizationId: String + UnusedPrivateMember:DeltaBadge.kt$@Preview @Composable private fun DeltaBadgePreview() + UnusedPrivateMember:EntryBlock.kt$@Preview @Composable private fun EntryBlockPreview() + UnusedPrivateMember:SectionCard.kt$@Preview @Composable private fun SectionCardPreview() + UnusedPrivateMember:StateViews.kt$@Preview @Composable private fun StateViewsPreview() + UnusedPrivateMember:SyncChip.kt$@Preview @Composable private fun SyncChipPreview() + UnusedPrivateMember:TrackingScreen.kt$@Composable private fun ErrorCard(error: String) + UnusedPrivateProperty:CalendarOverlayControls.kt$val desc = stringResource( R.string.calendar_overlay_calendar_desc, calendar.displayName, ) + UnusedPrivateProperty:InboxViewModel.kt$InboxViewModel$private val authRepository: AuthRepository + + diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index f12dc43..2c412d1 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -5,10 +5,55 @@ # set in app/build.gradle.kts. # # Detekt is a standalone, opt-in task and is NOT part of the default build/check/assemble -# graph. Run it with `./gradlew detekt`. The baseline (config/detekt/baseline.xml) is -# generated with `./gradlew detektBaseline` and is intentionally not hand-written. +# graph. Run it with `./gradlew detekt`. + +complexity: + # Compose screens and Android service adapters are intentionally orchestration-heavy. Keep + # these rules useful for the domain code without forcing artificial splits around UI lambdas. + CyclomaticComplexMethod: + threshold: 30 + ignoreAnnotated: + - 'Composable' + LongMethod: + threshold: 120 + ignoreAnnotated: + - 'Composable' + LongParameterList: + functionThreshold: 12 + constructorThreshold: 12 + ignoreAnnotated: + - 'Composable' + NestedBlockDepth: + threshold: 5 + TooManyFunctions: + thresholdInFiles: 40 + thresholdInClasses: 40 + thresholdInInterfaces: 25 + thresholdInObjects: 40 + ComplexCondition: + threshold: 6 + +exceptions: + # Network, persistence, and Android callback boundaries must convert unpredictable failures + # into Result/UI state. The catch sites log or translate the failure rather than leaking it. + TooGenericExceptionCaught: + active: false + SwallowedException: + active: false + +naming: + FunctionNaming: + # Jetpack Compose uses PascalCase for composable functions by convention. + ignoreAnnotated: + - 'Composable' style: + ReturnCount: + max: 6 + + MaxLineLength: + maxLineLength: 140 + # Discourage unexplained numeric literals in feature code. Dimensions, font sizes, and # durations belong in Dimens / MaterialTheme tokens, not magic numbers scattered in UI. MagicNumber: