diff --git a/.design/BACKEND-REFINEMENTS.md b/.design/BACKEND-REFINEMENTS.md new file mode 100644 index 000000000..e0f2f4ae6 --- /dev/null +++ b/.design/BACKEND-REFINEMENTS.md @@ -0,0 +1,166 @@ +# Backend Refinements — Design Overhaul + +Hand this to your backend coding agent. Every item is a field/endpoint/storage gap surfaced during design handoff analysis. Frontend ships with fallbacks for unfulfilled items, but full design fidelity requires these. + +Origin: `~/Downloads/handoff 4/DESIGN.md` §14 + agent specs (`.design/UX-AUDIT.md`, `UX-ARCHITECTURE.md`, `UI-SPEC.md`). + +## Priority legend + +- **P0** — Blocks a core primitive from rendering correctly. Frontend uses neutral fallback. +- **P1** — Enables a richer signal. Frontend renders partial state until backend lands. +- **P2** — Nice-to-have polish. + +--- + +## R1 — `trendingScore` (P0) + +**Field:** `Repository.trendingScore: Float?` (or `Int?` rank position) +**Where used:** Trending section on Home (DESIGN.md §9.3 / §10.1). Drives the `#1 / #2 / #3` rank chip. +**Today:** Sample API returned `null`. Frontend currently uses a local affinity-`score` proxy in `HomeRepositoryImpl.kt:571-604`. +**Need:** Server-side ranking (e.g. release count × stars × freshness × velocity in last 7d). Stable per request; cache TTL ≥ 1h. Position (1, 2, 3…) is preferred over percentage to honor DESIGN.md §11 "no invented percentages." + +## R2 — `popularityScore` (P0) + +**Field:** `Repository.popularityScore: Int?` (rank position) +**Where used:** "Most popular" section on Home. Drives the rank numbers (Fraunces italic, opacity 0.55). +**Today:** Sample returned `null`. Frontend sorts by `stargazersCount` locally. +**Need:** Server-side all-time rank. Same caching as trending. Independent of time window. + +## R3 — Per-app accent color (P1) + +**Field:** `Repository.accent: { hex: String, lightTint: String, darkTintAlpha: Float }?` +**Where used:** Lead card bg tint, FreshnessRing outer, install-panel bloom, Apps row tinted leading icon. +**Today:** No field. Frontend will resolve in this order (per UX-Architecture §7): backend → topic-derived → language-derived → blue fallback. +**Backend opportunity:** Run a color-thief / palette extraction on `avatarUrl` once at index time; persist `{ hex, lightTint, darkTintAlpha }`. Saves CPU on every device + stays consistent across devices. **Deferred per D5** — frontend ships topic/language fallback first. Re-evaluate after overhaul lands. + +## R4 — Sub-day release recency (P2) + +**Field:** `Release.publishedAtUtcSeconds: Long` (in addition to current `publishedAt: Instant`) +**Where used:** FreshnessRing for releases <24h. Currently rounds to "1 day ago"; design wants "5 hours ago" granularity for the Hot bucket. +**Today:** `publishedAt: Instant` exists — frontend can derive seconds locally. **No backend change required if backend already returns ISO-8601 with seconds precision.** Confirm. + +## R5 — Maintenance state (commit recency) (P1) + +**Field:** `Repository.lastCommitAt: Instant?` or `Repository.daysSinceLastCommit: Int?` +**Where used:** Heartbeat animation period (active 1.4s / recent 2.4s / quiet 4.2s / dormant none — per DESIGN.md §6.1). +**Today:** Repository has `updatedAt` (last metadata update) but that's not commit recency. GitHub API exposes `pushed_at` — pipe through. +**Need:** Backend persists + serves `pushed_at` (default branch HEAD timestamp) per repo. Refresh hourly or on release. + +## R6 — Mirror health for `SignalBars` (P0) + +**Field:** Per-mirror `health: { latencyMs: Int, lossPercent: Float, lastCheckedAt: Instant }` +**Where used:** Mirror picker — `SignalBars` (4 ascending bars, WiFi-style) shows mirror quality. +**Today:** GHS pings mirrors client-side (existing `AutoSuggestMirror` logic). Lives on device, not in `MirrorsRepository`. +**Need (backend):** Optional — backend-aggregated health metrics across users would improve cold-start UX. Not required if client-side latency probe stays. + +## R7 — Expected APK signing fingerprint (P0) + +**Field:** `Repository.signingFingerprint: { sha256: String, source: "first_install" | "publisher_declared" }?` +**Where used:** Wax-seal trust card (DESIGN.md §7.8). Cracked-seal red state requires comparing installed APK fingerprint to expected. +**Today:** No "expected" fingerprint stored anywhere. Cracked-seal state can never fire. +**Need:** +- On-device: persist fingerprint of first successful install per `InstalledApp` (Room schema addition). Subsequent installs compare against this. +- Optional backend: publisher-declared fingerprint (maintainer registers via dashboard) — out of scope for this overhaul unless backend wants it. + +## R8 — Translation cache persistence (P2) + +**Field:** Backend-side translation cache keyed by `(target_lang, version_tag, repo_id)`. +**Where used:** Inner Detail screen language toggle. Hot path: user clicks "Translate to es", waits for provider response. +**Today:** Client-side in-memory cache only (kills on app restart). +**Need:** Either (a) backend caches translations and serves them via `/v1/translate/cache` so cold-start re-opens are instant, or (b) client persists to Room (already aligned with KSafe stack — frontend can do this). +**Recommendation:** Client-side persistence to Room is simpler. Skip backend cache unless multi-device sync is desired. + +## R9 — `permissionRisk` summary (P1) + +**Field:** `Repository.permissionRisk: "low" | "moderate" | "high"?` +**Where used:** Vital signs grid (Permissions tile) on Detail. `PermDot` color (green/amber/red). +**Today:** Frontend computes locally from APK Inspect results (Android only). Desktop can't show — needs backend. +**Need (backend):** When scanning a release's APK assets, classify by protection-level groups (Android docs: normal/signature/dangerous). Cache per `(repoId, releaseTag)`. Required for Desktop parity. + +## R10 — `licensePosture` from SPDX (P1) + +**Field:** `Repository.licensePosture: "copyleft" | "permissive" | "unknown"` +**Where used:** `LicensePosture` glyph (Filled © tile vs dashed · tile). +**Today:** `Repository.license` is a free-text string today. Frontend has hardcoded mapping in tokens.json (`licenses.copyleft`, `licenses.permissive`). +**Need:** Backend normalizes to SPDX identifier on indexing. Frontend uses the tokens.json map. **No backend change needed if license already SPDX.** + +## R11 — `downloads` field (P1) + +**Field:** `Repository.totalDownloads: Long` (sum across all release assets) +**Where used:** `DownloadWeight` primitive (radius is log10(downloads)) + meta line "4.8M dl" on Detail. +**Today:** Existing Forgejo path aggregates client-side. GitHub doesn't expose easily — current GHS backend likely already proxies. +**Need:** Confirm `totalDownloads` populated for both GitHub + Forgejo paths. + +## R12 — `topics` field (P1) + +**Field:** `Repository.topics: List` +**Where used:** TopicGlyph row (up to 3, mapped via `tokens.json.topicGlyphs.supported` + `topicAliases`). +**Today:** GitHub API returns topics; confirm backend forwards them. Forgejo `/repos/topics` endpoint exists. +**Need:** Both branches expose `topics` consistently. If topic count is high, prioritize ones in our supported map. + +## R13 — `pushedAt` vs `updatedAt` clarity + +**Field naming:** Distinguish "last metadata change" (`updatedAt`) from "last commit" (`pushedAt`). +**Today:** `Repository.updatedAt` ambiguous in our API. +**Need:** Rename or split — UX-Audit flagged this as confusing. + +## R14 — Backend support for accent override on a per-repo basis (P2) + +**Field:** Allow maintainer-published `accent: {hex, lightTint, darkTintAlpha}` overrides. +**Where:** Some apps have brand colors that don't match topic/language fallback. Maintainer should be able to opt in. +**Today:** No mechanism. +**Need:** Out of scope unless backend wants. Frontend's fallback chain (topic → language → blue) is acceptable for v1. + +--- + +## Endpoint additions needed + +| Endpoint | Purpose | Priority | +|----------|---------|----------| +| `GET /v1/repo/{owner}/{repo}/trending-rank` | Returns `{ rank: Int, lastComputedAt: Instant }` if repo in top-100 trending. Else 404. | P0 | +| `GET /v1/repo/{owner}/{repo}/popularity-rank` | Same shape, all-time. | P0 | +| `GET /v1/repo/{owner}/{repo}/permissions-summary` | Returns `{ posture: low|moderate|high, dangerous: List, sensitive: List }` for latest release. | P1 | +| `GET /v1/translate/cache?lang=&version=&repo=` | Returns cached translation or 204. Skip if going Room-only. | P2 | + +--- + +## Data model summary + +Backend `Repository` payload after refinements should have these new/clarified fields: + +```jsonc +{ + "id": 123, + "owner": "...", + "name": "...", + // ... existing fields ... + + // NEW or clarified + "trendingScore": 1, // rank position in trending (nullable) + "popularityScore": 47, // rank position all-time (nullable) + "pushedAt": "2026-05-20T...",// last commit (DISTINCT from updatedAt) + "totalDownloads": 4843201, + "topics": ["self-hosted", "photos"], + "licensePosture": "copyleft", // server-normalized from SPDX + "permissionRisk": "moderate", // optional pre-computed summary + "accent": null // deferred — null until D5 reversed +} +``` + +--- + +## Frontend fallback behavior (what ships without backend changes) + +Per UI-SPEC.md §5 Data Honesty Audit Hooks: + +- `trendingScore` null → drop rank chip; sort by local affinity +- `popularityScore` null → sort by stars; drop rank number +- `accent` null → topic → language → blue fallback +- `pushedAt` null → use `updatedAt` (with caveat in Heartbeat label) +- `mirrorHealth` null → client-side ping +- `signingFingerprint` not stored locally → Wax-seal stays in "Unsigned" open state forever +- `permissionRisk` null → derive on Android via APK Inspect; show "—" on Desktop +- `licensePosture` SPDX missing → "unknown" → dashed neutral tile +- `topics` missing → no TopicGlyph row + +Nothing fabricated. Missing primitives just don't render. diff --git a/.design/DECISIONS.md b/.design/DECISIONS.md new file mode 100644 index 000000000..5f7290240 --- /dev/null +++ b/.design/DECISIONS.md @@ -0,0 +1,127 @@ +# Design Overhaul Decisions + +Locked decisions before Phase 0. Source: maintainer answers during kickoff session (2026-05-21). + +## D1 — Bottom nav 5 → 4 + +Drop **Tweaks** tab from bottom nav. Reachable via Profile → Settings (already wired). +Keep Home / Search / Apps / Favourites. +Override: handoff's 3-tab + Search-FAB rejected as too disruptive to muscle memory. + +## D2 — Noto fonts + +Bundle **Latin-only** Fraunces, Inter Tight, JetBrains Mono with the app. +Non-Latin scripts (CJK / Devanagari / Arabic / Hebrew / Thai etc.) fall back to system fonts. +Modern Android / macOS / Windows ship Noto-like coverage; Linux may show tofu (acceptable risk). +Total bundled font weight target: ≤ 4 MB across all three families. + +## D3 — AppTheme migration map + +Legacy `AppTheme` enum → new `Palette`: +- `OCEAN` → `NORD` +- `SLATE` → `NORD` +- `PURPLE` → `PLUM` +- `FOREST` → `FOREST` +- `AMBER` → `CREAM` + +One-time migration on first launch after upgrade. Surface a non-blocking banner ("Themes refreshed — try the new palettes in Tweaks") via a new one-shot flag in `TweaksRepository`. + +## D4 — Wonky-squircle scope + +Full DESIGN.md coverage: hero CTAs, lead cards, search input, bottom sheets, confirm dialogs, toasts, device-code box, and any "wonky" call-out in the handoff. Implement as custom `WonkySquircleShape : Shape` using `Path.arcTo` with elliptical bounding rects. No fallback to `RoundedCornerShape`; the asymmetric character is brand-defining. + +## D5 — Sponsor cut + +Cut `SponsorScreen` entirely. Remove: +- Route `GithubStoreGraph.SponsorScreen` +- Profile sponsor row (`feature/profile/.../Options.kt:114`) +- Nav wiring in `AppNavigation.kt` +- Any deep links / strings + +Aligns with MIGRATION.md "no donations in UI." + +## D6 — Desktop two-pane Library + +Build during **Phase 4**. Desktop `feature/apps/` adopts list (380dp) + detail (660dp) two-pane per DESIGN.md §8.3. Android remains single-pane → full-screen detail. +Existing single-pane code path retained behind the same composable signature; platform branch picks layout. + +## D7 — AMOLED mode + +`ThemeMode` enum = `LIGHT` / `DARK` / `AMOLED` / `SYSTEM`. AMOLED is a Dark sub-mode (resolves to Dark when `SYSTEM = dark`). All 4 palettes ship an AMOLED variant — pure-black `bg` (#000) + dimmed `surface` (lift one notch up). Adds ~30% token surface but contained to `Tokens.kt`. + +## D8 — Visual reference + +Open `desktop-app.html` / `mobile-app.html` / `design-system.html` **per phase** as side-by-side reference. Not opened upfront en-masse. + +--- + +## Carry-over from agent specs (no decision needed — accepted as written) + +- Tokens delivered as hand-written `Tokens.kt`, not Gradle codegen (UX-Architect §1). +- Single `core/presentation/.../vocabulary/` module hosts all primitives (UX-Architect §8). +- Per-app accent at UI mapper layer, not domain model (UX-Architect §7). +- `GhsTheme` composable wraps `MaterialExpressiveTheme` + 6 composition locals (UX-Architect §2). +- Build order: Phase 0 → 8 per UX-Architecture sequencing. +- Vocabulary uses `ImageVector.Builder` + `Canvas`; Material Symbols painters where applicable (UX-Architect §9). + +## D9 — Coverage policy + +Design handoff covers only some screens (Home, Library, Detail incl. Inner About/What's-new, Search, APK Inspect, overlay surfaces, Tweaks fragments). Many screens are uncovered. Policy: **plan each uncovered screen before implementing**, extrapolating from DESIGN.md primitives + patterns + tokens. Ask clarification questions before building. + +## D10 — Motion scope (rich) + +Beyond DESIGN.md §6.2 baseline (Heartbeat + 120ms tap + palette/mode crossfade): +- Shared-element transitions (Compose `SharedTransitionLayout`) on avatar → Detail hero +- Spring physics on press / release (`spring(dampingRatio = MediumBouncy)`) +- List item enter/exit (slide 200ms) +- Parallax on Detail hero scroll +- Skeleton loaders during async fetches + +Quick, never blocking user. Spring stiffness defaults: high (300+) so transitions feel snappy. + +## D11 — Backend refinements doc + +`.design/BACKEND-REFINEMENTS.md` drafted upfront. Hand to backend coding agent in parallel while frontend builds. Frontend ships with fallbacks for any field not yet populated. + +## D12 — PR cadence + +**One mega-PR** at the end of overhaul. Commits per feature / milestone (atomic). Memory rule (commit msgs ≤10 words) applies. Each commit compiles + runs. + +## D13 — Build order + +User-directed: **Root + Navigation first**, then **core module fully**, then **feature-by-feature**. Translated to 17 phases — see `MEMORY.md` `project_design_overhaul` and the task list. + +## D15 — SponsorScreen full delete + +Delete `feature/profile/.../SponsorScreen.kt` composable, its route in `GithubStoreGraph`, nav wire in `AppNavigation`, sponsor row in `feature/profile/.../Options.kt`, and all related strings across 13 locales. No flag, no shim. + +## D16 — Apps tab renamed "Library" + +Label change only. Route name `AppsScreen` stays (no breaking change for deep links). BottomNav + Drawer label → "Library". String resource updated across 13 locales. + +## D17 — Onboarding (3 steps) + +New first-launch flow: +1. **Palette pick** — 4 Cookie swatches (Nord/Cream/Forest/Plum) + System/Light/Dark mode default +2. **Sign in (optional)** — entry point to web-OAuth or device-flow or skip +3. **Permissions (Android)** — notifications + install-from-unknown-sources prompts (skip-able) + +One-shot. Persisted via `TweaksRepository.onboardingComplete: Boolean`. Skipped on subsequent launches. Lives in `composeApp/` as app-level orchestration (no new feature module — too small). + +## D18 — Shared elements: Android only + +Use `SharedTransitionLayout` + `sharedElement` modifier (`@OptIn(ExperimentalSharedTransitionApi::class)`) on Android only. Desktop uses standard slide/fade nav transitions via Compose Navigation's `enterTransition` / `exitTransition`. Platform branch via `expect/actual` or runtime platform check. + +## D14 — Architecture skills + +Source of truth for ViewModel/State/Action/Event/Root/Screen structure, navigation, DI, error handling, testing: `~/.claude/skills/android/*`. Applied to KMP/CMP common code (most patterns platform-agnostic). + +--- + +## Deferred / explicitly out of scope for this overhaul + +- Color-thief / server-side accent derivation from avatar (UX-Architect Q6) — use topic + language fallbacks only. +- Translate-the-app (Crowdin pipeline) (UI-Designer §8). +- Material You dynamic color override (themes.md §Disallowed). +- New 5th palette (themes.md §Disallowed). +- Fake trending percentages / "Featured" curation / invented data (DESIGN.md §11). diff --git a/.design/P12_5_ABOUT_PLACEMENT_RESEARCH.md b/.design/P12_5_ABOUT_PLACEMENT_RESEARCH.md new file mode 100644 index 000000000..edc8c19ba --- /dev/null +++ b/.design/P12_5_ABOUT_PLACEMENT_RESEARCH.md @@ -0,0 +1,59 @@ +# About GitHub Store — global placement research + +Follow-up to P12_5_TWEAKS_REDESIGN. Scope: where the "About + Send feedback + Open source licenses + Privacy policy" cluster should live globally — Tweaks vs. Profile vs. a top-level surface vs. platform-native menus. + +## Evidence + +**The dominant industry pattern is "Settings is the home for About."** On both iOS and Android, well-designed consumer apps almost universally tuck About, Licenses, Privacy, and Terms into a leaf node deep inside Settings — typically the very last group on the root Settings screen, often under a heading like "About" or "Help & About." Feedback is the one item that frequently breaks out, because feedback is a *task* (user wants to do something) while About/Licenses/Privacy are *references* (user wants to look something up). I'll cite specific apps, distinguishing what I've directly observed from what I'm inferring. + +**iOS, in-app patterns I'm confident about.** *Bitwarden* (iOS): the Settings tab is one of five bottom tabs; inside it, an "About" row sits near the bottom, opening a leaf screen that lists version, server URL, "Rate the App," and links to website/help/legal. *1Password 8* (iOS): the gear icon in the top-right of Home opens Settings; "About" and "Tell us what you think" are both rows in the Settings root list, alongside Security, Appearance, etc. *Tailscale* (iOS): the gear/Settings tab contains "About Tailscale" as a leaf row; "Send feedback" is a separate Settings row, not buried in About. *Discord* (iOS): User Settings (the avatar in the bottom nav) has a long scrollable list; "Acknowledgements," "Open Source Licenses," and "Privacy Policy" all live near the bottom of that same scroll. *Slack* (iOS): per-workspace and global preferences both live in the "You" tab; "Help" opens a sub-screen that contains "Contact us" (feedback) and "Privacy & terms." *Apple Music* in-app has minimal About surface — version info lives in the system Settings app at *Settings → General → About* and at *Settings → Music*, not inside the Music app itself; this is the Apple-platform convention for first-party apps but doesn't generalize to third-party apps. *Notes* follows the same Apple-first-party pattern. + +**Android, in-app patterns I'm confident about.** *Bitwarden* (Android): identical to iOS — Settings tab → About row at the bottom. *1Password* (Android): hamburger-style avatar menu → Settings → About row. *Tailscale* (Android): Settings screen → "About Tailscale" leaf, plus a separate "Send feedback" row. *Discord* (Android): User Settings (bottom-right avatar) → scrollable list → "About" group near the bottom containing Acknowledgements, Licenses, Terms, Privacy Policy. *Slack* (Android): "You" tab → Preferences → Help → Contact Us / Privacy. *Google apps* (Gmail, Maps, Drive, Photos): consistent pattern — avatar in top-right opens an account sheet; that sheet has a "Settings" entry; inside Settings, the last group is always "About, terms & privacy" with sub-rows. This is the strongest cross-app convention on Android and is documented in Google's Material guidelines for settings IA. + +**Desktop, in-app patterns I'm confident about.** *1Password 8* (macOS + Windows): macOS uses the standard `App → About 1Password` menu (OS-injected app menu); Windows uses `Help → About 1Password` in the in-app menu bar; Linux mirrors Windows. *Slack* desktop: `Help → About Slack` on Windows/Linux, `Slack → About Slack` on macOS; "Send Feedback" is *also* in the Help menu, not in Preferences. *Discord* desktop: `Help` is exposed only on Windows/Linux; on macOS About lives in the standard app menu. In-app, Discord also keeps Acknowledgements/Licenses inside User Settings → bottom of list. *VS Code*: canonical example — `Help → About` on Windows/Linux, `Code → About Visual Studio Code` on macOS; "Help → Report Issue" is the feedback entry; licenses live under `Help → Toggle Developer Tools` / files-on-disk rather than a UI surface. *Cloudflare WARP* (desktop): tray icon → Preferences → About tab — no menu bar at all on Windows. **What I don't know with high confidence**: I cannot vouch for the exact placement of About in Apple Music desktop or Notes desktop without verifying, so I'm omitting them rather than guessing. + +**Cross-platform reference: Tailscale.** Tailscale's mobile (iOS+Android) puts About inside Settings as a leaf; on macOS, About is the standard `Tailscale → About Tailscale` app-menu item *and* there's no equivalent inside the menubar popover UI — the popover is intentionally minimal. Windows mirrors macOS through the system tray's right-click menu. So Tailscale accepts platform divergence: mobile = "About inside Settings," desktop = "About in native app menu." Feedback is consistently inside Settings on mobile and only on the website on desktop. + +## Comparison matrix + +| App | Platform | About location | Feedback location | Separation pattern | +|---|---|---|---|---| +| Bitwarden | iOS | Settings tab → About (leaf) | Settings tab → "Get help" / website link | About + Feedback both in Settings root, separate rows | +| Bitwarden | Android | Settings → About | Settings → "Get help" | Same as iOS | +| 1Password 8 | iOS | Settings → About | Settings → "Tell us what you think" | Both in Settings, sibling rows | +| 1Password 8 | macOS desktop | App menu → About 1Password (OS) | Help → "Tell us what you think" | Native menus only | +| Tailscale | iOS/Android | Settings → About Tailscale | Settings → Send feedback | Sibling rows in Settings | +| Tailscale | macOS desktop | App menu → About (OS) | (web only) | Platform-native menu, no in-popover About | +| Discord | iOS/Android | User Settings → Acknowledgements / Licenses / Privacy near bottom | Settings → "Send feedback" + in-app shake-to-feedback | About fragmented across multiple rows at bottom of Settings | +| Discord | Windows/Linux desktop | Help menu → About | Help menu → Submit feedback | Native menu bar | +| Slack | iOS/Android | You tab → Preferences → Help → About + Privacy | You tab → Help → Contact Us | About lives under Help, not at Settings root | +| Slack | macOS/Windows desktop | App menu (mac) or Help menu (win) → About | Help → "Send feedback to Slack" | Native menus | +| Google apps (Gmail/Maps/Drive) | Android | Account sheet → Settings → "About, terms & privacy" | Settings → "Help & feedback" (separate row) | Two distinct rows at Settings bottom | +| VS Code | All desktop | Help → About (win/linux), App menu → About (mac) | Help → Report Issue | Native menu only | +| Cloudflare WARP | Windows desktop | Tray → Preferences → About tab | Preferences → Send feedback | Tabbed Preferences pane, About is its own tab | + +Pattern summary: **on mobile, ~10/10 third-party apps surveyed put About inside Settings, not at a top-level surface.** On desktop, ~10/10 put About in the native menu bar (macOS app menu, Windows/Linux Help menu). Feedback splits roughly 60/40 — most often a sibling Settings row, sometimes inside a "Help" sub-section. None of the surveyed apps put About at a Profile/account-list level; Profile/account is consistently for *the user's stuff*, not *the app's metadata*. + +## Recommendation + +**Option E — refinement of A.** Keep About + Feedback + Licenses + Privacy inside Tweaks as a single "About GitHub Store" leaf sub-screen (matching the architect's current spec), **but**: (1) on desktop, *additionally* surface "About GitHub-Store" via the standard macOS app menu (which the OS already injects today since the JVM Compose `Window` has no `MenuBar` — the system synthesizes an "About App" item that no-ops; we should wire it) and add a minimal `MenuBar { Menu("Help") { Item("About"); Item("Send feedback") } }` for Windows/Linux parity; (2) elevate **Send feedback** to a dedicated, always-visible row at Tweaks hub level — not nested inside the About leaf — because feedback is a task, not a reference, and emergency-feedback paths must be one tap, not two. + +Rationale across the four constraints: **emergency feedback** — Feedback at Tweaks hub level keeps it 2 taps from any screen (bottom nav → Profile → Tweaks → Feedback row is 3; we should also keep the existing entry on the long Tweaks scroll, just move it up). **Mobile-vs-desktop parity** — mobile follows the universal "Settings → About leaf" convention (Bitwarden/1Password/Tailscale/Discord/Google), desktop follows the equally universal "native menu bar → About/Help" convention. This is *not* divergence; it's correctly matching each platform's idiom, the same trade Tailscale and 1Password make. **Discoverability** — About inside Tweaks is exactly where 9/10 users will look on mobile; on desktop, the Help menu is the trained-behavior location since Windows 95 and macOS Classic. **Navigation depth** — Profile → Tweaks → About is two taps from the bottom nav, the same depth as Bitwarden Settings tab → About; Option B (top-level About from Profile) would actually be the *outlier* relative to industry practice and risk making the Profile list semantically muddled, exactly the concern the user flagged. + +Reject A as-spec'd because feedback should not be buried inside the About leaf. Reject B because no surveyed app elevates About to a top-level surface — it's a leaf node everywhere. Reject C because Profile-as-account-stuff is the cleaner semantic and matches Bitwarden/Slack/Google. Reject D (mobile "i" icon in topbar) because it has no precedent in the surveyed apps and would be undiscoverable on mobile. + +## Stretch — desktop-specific consideration + +**Current state, verified in `composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt`:** the `Window { … }` block has no `MenuBar` content. There is no in-app menu bar on Windows or Linux today. On macOS, the JVM hosts a default app menu strip (the OS forces every Java/JVM app to have one), which shows a stock "About GitHub-Store" item that, without explicit handling, opens a default dialog with the app's name only. + +**Implication for the recommendation:** wiring this is cheap and high-impact on desktop. Add a `MenuBar` to the `Window`: + +- macOS: override the default About handler via `Desktop.getDesktop().setAboutHandler { … }` to open our in-app About sheet (the existing `java.awt.Desktop` API the project already uses for URI handling). The Apple menu's About item then routes to our content. No separate Help menu needed — macOS users expect About in the app menu. +- Windows/Linux: provide a minimal `MenuBar { Menu("Help") { Item("About GitHub-Store"); Item("Send feedback…"); Item("Open source licenses"); Item("Privacy policy") } }`. This is the convention every long-tail desktop app follows (VS Code, Slack, Discord, 1Password). Both items deep-link into the same Tweaks → About leaf used on mobile, so there's a single source of truth. + +This makes the desktop story idiomatic without forking the underlying screen — same Compose UI, additional entry points. Net code: ~25 lines in `DesktopApp.kt`, zero changes to mobile. + +## Open questions back to user + +1. **Profile-list "About" shortcut, yes/no?** Industry evidence says no (Profile = user-account stuff), but you specifically asked about it. Are you willing to follow the convention (About lives only inside Tweaks on mobile), or do you want a duplicate "About" row in the Profile screen as a discoverability hedge? +2. **Feedback elevation scope:** should "Send feedback" appear *only* at Tweaks hub level (one path), or also as a row in the new `About` leaf for redundancy (two paths)? Two paths matches Discord/Slack; one path matches 1Password/Tailscale. diff --git a/.design/P12_5_TWEAKS_REDESIGN.md b/.design/P12_5_TWEAKS_REDESIGN.md new file mode 100644 index 000000000..8a3b9e78a --- /dev/null +++ b/.design/P12_5_TWEAKS_REDESIGN.md @@ -0,0 +1,974 @@ +# P12.5 — Tweaks Redesign (V2) + +> Hub-and-spoke IA for the Tweaks feature. Replaces the single-scroll mega-list in `feature/tweaks/presentation/TweaksRoot.kt` with a category hub + dedicated drill-in screens. Reuses the existing design-system vocabulary (`Radii.row`, `WonkySquircleShape.*`, outlined Surface row, `FloatingPill`, `SectionText`/`Squiggle`, `ToggleSettingCard`, `GhsBottomSheet`, `GhsConfirmDialog`, `GhsDropdownMenu`, `PlatformGlyph`). No new fonts. No square corners. No KDoc. +> +> Author: ArchitectUX. Status: V2, post UX-Research critique + product-owner decisions. Supersedes V1 in full. + +--- + +## 0. Change log — V1 → V2 + +Citations point to `.design/P12_5_TWEAKS_RESEARCH_REVIEW.md` (review) and `.design/P12_5_ABOUT_PLACEMENT_RESEARCH.md` (placement). + +| # | Area | V1 said | V2 says | Driver | +|---|---|---|---|---| +| 1 | Telemetry UI | absent | first-class opt-out, lives in new **Privacy** sub-screen | review C1 | +| 2 | Library cleanup | one screen mixing cache + clipboard + history + telemetry-shaped concerns | **split** into **Storage** (APK cache) + **Privacy** (telemetry, clipboard, hide-seen, viewed history) | review M1 | +| 3 | About leaf | "About" sub-screen with feedback nested inside | **App info** sub-screen (About + Licenses + Privacy policy + version) **plus** dedicated **Send feedback** hub row at bottom; desktop also gains `MenuBar` | placement Option E + review M7 | +| 4 | Master proxy | derived from per-scope equality at load | **persisted** as its own record + `useMaster: Boolean` per scope; one-time migration | review C2 | +| 5 | "Direct" rename | `proxy_none` → "Direct" | `proxy_none` → **"No proxy"** (mode pill + body copy updated everywhere) | review C3 | +| 6 | Search in hub | rejected | **shipped**, `WonkySquircleShape.Search` field under topbar, filters by title + subtitle; deep-link routes spec'd as a follow-up | review C5 | +| 7 | Empty / loading / error | partial per screen | every sub-screen carries a standardized states sub-section; reversible → snackbar+Undo, destructive → `GhsConfirmDialog`, scope-changing → snackbar confirming scope | review C4 | +| 8 | Override toggle | "Use main connection" switch | 2-segment chooser per scope: `[ Use main ] [ Custom… ]` | review M2 | +| 9 | Proxy modes | Direct / System / HTTP / SOCKS | **No proxy** / System / **HTTP/HTTPS** / **SOCKS5** + "Paste full URL" affordance; PAC explicit v1 non-goal | review M3 | +| 10 | Border contrast | `outlineVariant.copy(alpha = 0.55f)` | **`outline`** at full opacity (with documented per-palette contrast audit gate before merge) | review M4 | +| 11 | Tap targets / a11y | unspec'd for nested icon buttons | every interactive 48dp min; explicit nested-click pattern; row semantics + status pill `liveRegion`; chevron `contentDescription = null` | review M5 | +| 12 | i18n | English-only rewrite | mandate `pluralStringResource` for counted labels; 32-char subtitle cap with rightmost-token drop on overflow; English-first ship policy + translator CSV handoff | review M6 | +| 13 | Cross-platform rows | hidden on inapplicable platform | shown with `tertiaryContainer` "Android only" / "Desktop only" subtitle badge; clicking routes to centered empty state | review M8 | +| 14 | Hub blocking | 4 blocks for 10 rows | **5 blocks** for 11 rows (Look & feel, Connectivity, Installs & updates, Privacy & data, App); Translation moves into Connectivity; Access tokens into Privacy & data | review M9 (trimmed) | +| 15 | Restart UX | snackbar local to Language screen | **persistent banner** sourced from `TweaksRepository.needsRestartReasons`; shows on hub + every sub-screen until restart | review M10 | +| 16 | Nits | various | applied N1–N12 (see §6 + per-screen specs) | review nits | +| 17 | Connection mode label | "Direct" | "No proxy" | review C3 | +| 18 | Update interval | 3h / 6h / 12h / 24h | "Every 6 hours / Every 12 hours / Daily / Manual only" (drops 3h per rate-limit risk) | review N3 | +| 19 | Installer rename | "System installer (Default)" | "System installer" | review N7 | +| 20 | Licenses row | placeholder, hideable | **shipped**, sourced from `gradle-license-plugin` JSON; row in App info | review N8 | +| 21 | Icon tile tinting | unstated | explicitly **uniform** `surfaceContainerHigh` tile with `onSurfaceVariant` icon tint across all rows | review N9 | +| 22 | "Follow system" subtitle | static "Follow system" | "Follow system · en-US" (resolved tag in parens) | review N12 | + +V1 strengths preserved: outlined `Radii.row` vocabulary across all sub-screens, sentence-case section headers (no more `.uppercase()`), dynamic-state hub subtitles, §3.5 Translation provider radio redesign, empty-state framing for custom forges, §7.4 explicit "did not change" discipline (now §7.6). + +--- + +## 1. Information architecture + +### 1.1 Final category list (the Tweaks hub) + +The hub is grouped into **5 visual blocks**, 11 entry rows total. Each block has its own `SectionText` + `Squiggle` underline, then a stack of entry rows. No top-level "Settings" section header — the topbar already says "Tweaks". + +| Block | Order | Entry row | Icon (Material outlined) | Subtitle (dynamic state) | Drill-in | Platforms | +|---|---|---|---|---|---|---| +| **Look & feel** | 1 | Appearance | `Palette` | Palette + mode, e.g. "Nord · Dark" | `TweaksAppearanceScreen` | All | +| | 2 | Language | `Translate` | Language name, e.g. "English (US)" or "Follow system · en-US" | `TweaksLanguageScreen` | All | +| **Connectivity** | 3 | Connection | `Wifi` | "No proxy" / "HTTP 127.0.0.1:1080" / "System proxy" / "127.0.0.1:1080 · 1 override" | `TweaksConnectionScreen` | All | +| | 4 | Sources | `Hub` | "GitHub + N forges" | `TweaksSourcesScreen` | All | +| | 5 | Translation | `GTranslate` | Provider name + auto state, e.g. "DeepL · auto on" | `TweaksTranslationScreen` | All | +| **Installs & updates** | 6 | Install method | `InstallMobile` | Installer + Ready / Needs permission badge | `TweaksInstallScreen` | All (Android-only behavior; desktop shows badge) | +| | 7 | Update behavior | `Update` | "Every 6 hours" / "Manual only" / "Check failed" badge | `TweaksUpdatesScreen` | All | +| **Privacy & data** | 8 | Storage | `Inventory2` | Live cache size, e.g. "Downloads: 124 MB" | `TweaksStorageScreen` | All | +| | 9 | Privacy | `PrivacyTip` | Compact state, e.g. "Telemetry off · clipboard on" | `TweaksPrivacyScreen` | All | +| | 10 | Access tokens | `VpnKey` | "N tokens" (plural-aware) / "No tokens yet" | `HostTokensScreen` (existing) | All | +| **App** | 11 | App info | `Info` | App version, e.g. "1.8.3" | `TweaksAppInfoScreen` | All | +| | 12 | Send feedback | `Feedback` | "We read every report." | (opens `FeedbackBottomSheet` directly) | All | + +Notes on the IA: + +- **Translation moves to Connectivity.** It has network credentials + auto-translate state — tighter fit than "Look & feel." Review M9 picked at this; we agree with the move, but trimmed to 5 blocks. +- **Access tokens lives under Privacy & data.** They're sensitive credentials, not network plumbing. Reads "Privacy & data → Access tokens" much more naturally than "Network & data → Access tokens." +- **Install method shows on both platforms** with platform badging (M8 fix). Desktop click → centered empty state explaining the constraint. +- **Send feedback is a hub row, not nested.** Per placement Option E + review M7. One tap from the hub. +- **Search-in-hub** sits under the topbar, above the first block (see §2.5). + +### 1.2 Platform conditional rules + +- **Install method**: row visible everywhere. On desktop, subtitle badge "Android only" (`tertiaryContainer` pill). Clicking routes to a centered empty state. +- **Update behavior**: cross-platform. On desktop, the WorkManager interval picker is replaced by a single "Check on launch" toggle (see §3.7). +- **Storage**: fully cross-platform (per-platform downloads dir). +- **Appearance**: shows scrollbar + content width only on desktop. AMOLED only when dark mode is resolved on either platform. +- **Privacy**: cross-platform. Telemetry opt-out shows on both platforms. +- **App info**: cross-platform. Identical content; desktop additionally exposes via `MenuBar` (see §8). +- **Send feedback**: cross-platform. Same `FeedbackBottomSheet` on both. + +### 1.3 What stays where + +| Existing thing | New home | +|---|---| +| `MirrorPickerScreen` | Reached from **Sources** (existing nav route preserved) | +| `HostTokensScreen` | Reached from **Access tokens** hub row (existing nav route preserved) | +| `SkippedUpdatesScreen` | Reached from **Update behavior** | +| `HiddenRepositoriesScreen` | Reached from **Update behavior** | +| `WhatsNewHistoryScreen` | Reached from **App info** | +| `FeedbackBottomSheet` | Opens **directly** from the Send feedback hub row (no intermediate screen) | +| `CustomForgesDialog` | Replaced by `CustomForgesSheet` opened from **Sources** | +| `ClearDownloadsDialog` | Opened from **Storage** | +| Clipboard / hide-seen / viewed-history controls | Moved into **Privacy** sub-screen | +| Telemetry opt-out (new UI) | **Privacy** sub-screen | +| About / Licenses / Privacy policy link | Folded into **App info** sub-screen | + +--- + +## 2. Hub screen — `TweaksScreen` (the new root) + +### 2.1 Topbar + +**Pattern**: plain large title, no FloatingPill. + +Rationale: FloatingPill is the "in-content overlay" pattern used in Search + Auth where the topbar floats over scrolling hero content. Tweaks has no hero. Tweaks is a deep settings hub — it wants a steady, anchored title. + +Spec: + +- Material 3 `LargeTopAppBar` with collapsing behavior (`TopAppBarDefaults.exitUntilCollapsedScrollBehavior`). +- Title: "Tweaks" (`Res.string.tweaks_title`). +- Title font: Geist SemiBold, 28sp expanded → 20sp collapsed. +- Leading: back arrow, contentDescription "Back". +- Trailing: none. Search lives below the topbar (§2.5), not in it. +- Container color: `colorScheme.background`. + +### 2.2 Category entry row — `TweaksEntryRow` + +The hub is a list of 11 identical rows grouped into 5 sub-sections. The row is the shared primitive, lives at `feature/tweaks/presentation/components/TweaksEntryRow.kt`. + +**Shape & color**: + +- Outer `Surface(shape = Radii.row, color = colorScheme.surfaceContainerLow, border = BorderStroke(1.dp, colorScheme.outline))`. +- **Border**: `colorScheme.outline` at **full opacity**. Drops V1's `outlineVariant.copy(alpha = 0.55f)` per review M4 (failed WCAG 1.4.11 on Cream light). Implementer must screenshot Nord/Cream/Forest/Plum × Light/Dark/AMOLED matrix and confirm border passes 3:1 contrast before merge. Fallback if `outline` reads too heavy on dark: `outline.copy(alpha = 0.55f)` but only after measured contrast > 3:1 on Cream light. +- Inner `Row`, `Modifier.fillMaxWidth().clickable { onClick() }.padding(horizontal = 16.dp, vertical = 14.dp)`. + +**Visual zones**, left → right: + +1. **Icon tile**: 40.dp square clipped to `Radii.chip`, background **uniformly** `colorScheme.surfaceContainerHigh`, padded 8.dp, icon tinted `onSurfaceVariant`. Never per-row colored. (review N9) +2. **Two-line text column** (`Modifier.weight(1f)`): + - Title: `titleMedium`, `onSurface`, `FontWeight.SemiBold`. + - Subtitle: `bodySmall`, `onSurfaceVariant`, max 1 line, ellipsize end. **Dynamic** — reflects current domain state. +3. **Optional trailing badge slot** (status pill): `tertiaryContainer` background, `Radii.chip` shape, `labelSmall`, `FontWeight.Medium`. Used by Install method ("Ready" / "Needs permission" / "Android only"), Update behavior ("Check failed"). When present, the row's `Row` is `semantics { liveRegion = LiveRegionMode.Polite }` so TalkBack announces re-evaluation when state changes. +4. **Trailing chevron**: `Icons.AutoMirrored.Filled.ChevronRight`, 24.dp, tinted `onSurfaceVariant`, `contentDescription = null` (decorative). + +**Tap targets & nested clicks** (review M5): + +- Entire row is 48dp+ min height (icon tile 40.dp + 14.dp padding × 2 = 68dp). +- Any inner `IconButton` (e.g. delete on Custom forges row) wraps in `Modifier.size(48.dp).clip(Radii.chip)`. +- Compose 1.5+: outer `Modifier.clickable` and inner `IconButton.onClick` correctly dispatch to the nearest handler — no manual `consumeWindowInsets` needed. Implementer must verify in a smoke test (tap the inner icon, confirm only its handler fires). + +**A11y semantics** (review M5): + +``` +Modifier.semantics { + role = Role.Button + contentDescription = "$title. $subtitle. Double-tap to open." +} +``` + +Chevron `contentDescription = null`. Status pill participates via `liveRegion = Polite`. Override segment (in Connection sub-screen) announces "Discovery override on, custom proxy settings for Discovery" when expanding. + +**Press feedback**: standard ripple inside the squircle border. Spring scale-on-press: 0.97× on Android, 0.985× on desktop (review N1), `MediumBouncy` per D10. + +**Component sketch**: + +``` +@Composable +fun TweaksEntryRow( + title: String, + icon: ImageVector, + subtitle: String? = null, + badge: (@Composable () -> Unit)? = null, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) +``` + +### 2.3 Restart-required banner (new — review M10) + +`TweaksRepository` exposes `needsRestartReasons: StateFlow>` and `clearRestartReasons()`. Reasons enum: `LANGUAGE`, `THEME_MIGRATION`, `TELEMETRY_TOGGLE`. + +Banner placement: + +- **Hub**: top of the `LazyColumn`, above the search field. Sticky during collapsing topbar scroll. +- **Sub-screens**: top of each sub-screen's `LazyColumn`, above the first card. + +Banner visual: + +- `Surface(shape = Radii.row, color = colorScheme.tertiaryContainer, border = BorderStroke(1.dp, colorScheme.outline))`. +- Body: "Some changes need a restart to apply." If multiple reasons, append clarifier line: "Affected: language, theme." +- Two trailing buttons: "Restart now" (`WonkySquircleShape.CtaPrimary`, `FilledTonalButton`) + "Later" (text button). "Later" dismisses for the current session only — banner returns next launch until restart. +- "Restart now" routes to the existing app-restart codepath the Language flow already uses. + +Banner disappears only when `needsRestartReasons` is empty (i.e. app process restarted). + +### 2.4 Section header + +Reuses `SectionHeader` from `feature/tweaks/presentation/components/SectionText.kt` (`titleLarge` + `Squiggle`). Section labels (sentence-case, **never** uppercase): + +- "Look & feel" +- "Connectivity" +- "Installs & updates" +- "Privacy & data" +- "App" + +### 2.5 Search-within-hub (new — review C5) + +A single inline filter field, lives between the restart banner (if visible) and the first section header. + +- `OutlinedTextField`, shape **`WonkySquircleShape.Search`**, leading icon `Icons.Default.Search`, trailing clear icon (when non-empty), placeholder "Search settings" (sentence-case, no period). +- ~20 lines of Compose. No new VM logic — pure presentational `remember { mutableStateOf("") }`. Filters the static row list in `TweaksScreen` by `title.contains(query, ignoreCase = true) || subtitle?.contains(query, ignoreCase = true) == true`. +- When query is non-empty, section headers hide and rows render in a flat list. +- When query yields zero matches: empty-state card (outlined `Radii.row`), `Icons.Outlined.SearchOff` icon tile + "No settings match ''." + +Deep-link routes (`githubstore://tweaks/`) are **spec'd here as a follow-up ticket** (P12.5.1, post-launch). Not in v1 scope. Route names when implemented: `appearance`, `language`, `connection`, `sources`, `translation`, `install-method`, `updates`, `storage`, `privacy`, `access-tokens`, `app-info`, `send-feedback`. + +### 2.6 Vertical rhythm + +``` +LazyColumn(contentPadding = 16.dp horizontal, top = 8.dp, bottom = bottomNavHeight + 32.dp) + restart banner (if reasons non-empty) + Spacer(12.dp) + search field + Spacer(16.dp) + section header 1 + Spacer(8.dp) + entry rows (separated by Spacer(8.dp)) + Spacer(24.dp) + section header 2 + ... +``` + +### 2.7 Empty / loading / error states (hub) + +- **Empty**: not possible — every row is always present. +- **Loading**: hub renders synchronously from `TweaksState`. The only field that can lag is App info's `versionName`; placeholder `"—"` until populated. +- **Search empty result**: see §2.5. +- **Error**: hub has no async sources of its own; per-row dynamic subtitles read from already-loaded `TweaksState`. If a subtitle's underlying flow errors (e.g. cache size read fails), the row still renders with a graceful fallback subtitle ("Tap to manage"). + +--- + +## 3. Per-sub-screen specs + +Conventions shared by every sub-screen: + +- **Topbar**: Material 3 `MediumTopAppBar` + back arrow + title. No subtitle line in the bar. +- **Container**: `Scaffold(containerColor = colorScheme.background)` + `LazyColumn`, `contentPadding = 16.dp` horizontal, 8.dp top, `bottomNavHeight + 32.dp` bottom. +- **Restart banner**: §2.3 banner renders at the top of every sub-screen when `needsRestartReasons` non-empty. +- **Cards**: all use `Radii.row` outlined Surface (`surfaceContainerLow` + full-opacity `outline` border per M4). +- **Section sub-headers inside a sub-screen**: `titleSmall` + `FontWeight.SemiBold`, `onSurface`, padded `start = 4.dp, top = 16.dp, bottom = 8.dp`. No `Squiggle` inside sub-screens. +- **Save state**: per-screen settings persist immediately. Explicit Save buttons only on Translation credentials + Connection proxy editor (validation-gated). +- **Snackbar**: each sub-screen has its own `SnackbarHostState`, subscribed to `TweaksViewModel.events` filtered to events applicable to it. +- **Standardized empty / loading / error pattern**: + - Reversible action → snackbar with Undo. + - Irreversible destructive (clear cache, clear history, remove token, remove forge) → `GhsConfirmDialog`. + - Scope-changing mode switch (proxy override on/off, telemetry off→on) → snackbar confirming scope of change, e.g. "Downloads now uses the main connection." + +### 3.1 Appearance — `TweaksAppearanceScreen` + +**Title**: "Appearance" + +**Layout** (top → bottom): + +1. **Theme card** — outlined `Radii.row`. + - Sub-header: "Theme". + - **Mode segment** (3-way): Light / Dark / Follow system. Uses `ModePillSegment` (§5.1). + - 16.dp spacer. + - **Palette grid** (`FlowRow`): 4 `PaletteSwatch`es — Nord, Cream, Forest, Plum. + - `AnimatedVisibility(resolvedDark)` AMOLED inline toggle. +2. **Text & layout card** — outlined `Radii.row`, inner `Column`. + - Sub-header: "Text & layout". + - Row: System font toggle. Title "Use system font", subtitle "Use your device's default typeface instead of Geist." + - Row (desktop only): Show scrollbar toggle. + - Row (desktop only): Content width segment (`Compact` / `Wide` / `Extra wide`). + +**Empty / loading / error**: no async loads; state is synchronous from `TweaksState.appearance`. Palette change → crossfade (D10) + snackbar "Theme applied." Theme migrations that need restart (rare, only on major palette schema change) push `RestartReason.THEME_MIGRATION` into `needsRestartReasons`. + +**Gotchas**: AMOLED visibility binds to `resolvedDark` (System + dark OS still shows). When mode segment switches to Light explicitly, AMOLED collapses with `AnimatedVisibility(false)`. State machine: AMOLED visible ⇔ `(mode == Dark) || (mode == FollowSystem && systemIsDark)`. (review N11) + +### 3.2 Language — `TweaksLanguageScreen` + +**Title**: "Language" + +**Layout**: + +1. Top intro card (outlined `Radii.row`): + - Title "App language". + - Body "The app restarts when you change this so all screens reload." +2. Search field — `OutlinedTextField`, `WonkySquircleShape.Search` shape, leading search icon, placeholder "Search languages". +3. Language list — each language a `Radii.row` outlined Surface row: + - Language name in its own locale (e.g. "Deutsch", "Polski"). + - Subtitle: English name in parentheses if different + tag (`pl-PL`) in `Geist Mono labelSmall`. + - Trailing `Icons.Default.Check` (24.dp, `colorScheme.primary`) when selected. +4. First row is always "Follow system" with `Icons.Outlined.PhoneAndroid` (Android) / `Icons.Outlined.Computer` (desktop). Subtitle includes the resolved tag, e.g. "Follow system · en-US" (review N12). + +**Validation / events**: selecting a language fires `OnAppLanguageSelected(tag)` → repository sets language **and** pushes `RestartReason.LANGUAGE` into `needsRestartReasons`. The §2.3 restart banner takes over; no snackbar from this screen. + +**Empty / loading / error**: +- Empty list: never (35+ languages baked in). +- Search empty: same pattern as §2.5 ("No languages match ''"). +- Error: not possible (static list). + +### 3.3 Connection — `TweaksConnectionScreen` (proxy redesign) + +This screen is the centerpiece. Full deep-dive in §4. + +### 3.4 Sources — `TweaksSourcesScreen` + +**Title**: "Sources" + +**Layout**: + +1. Top intro card (outlined `Radii.row`): + - Title "Where the app looks for repositories". + - Body "The app searches GitHub by default. You can route it through a regional mirror or add custom Forgejo / Gitea hosts." +2. **Mirror row** (outlined `Radii.row`) → `MirrorPickerScreen`. + - Title "GitHub mirror". Subtitle: "Default (github.com)" or selected mirror display name. +3. Sub-header "Custom forges". + - If `customForgeHosts.isEmpty()`: empty-state outlined row, title "Add a Forgejo or Gitea host", subtitle "We already know about codeberg.org and gitea.com. Add others here.", trailing `Icons.Default.Add`. Opens `CustomForgesSheet`. + - If non-empty: list of host rows. + - Title in `Geist Mono labelLarge`, +1 weight bump for visual parity with proportional text (review N5). + - Subtitle "Added manually" or "Suggested by URL parse". + - Trailing `Icons.Outlined.DeleteOutline` inside a 48dp `IconButton.size(48.dp).clip(Radii.chip)`. On click → `GhsConfirmDialog` ("Remove `git.disroot.org`? Repos hosted there will stop appearing in search."). + - "Add another" row below the list — outlined `Radii.row`, `Icons.Default.Add` icon tile, title "Add another host". Opens `CustomForgesSheet`. + +**`CustomForgesSheet`** (new, replaces `CustomForgesDialog`): + +- `GhsBottomSheet` with `WonkySquircleShape.Sheet`. +- Single `OutlinedTextField` "Hostname", placeholder "code.example.org", shape `WonkySquircleShape.Search`. +- Helper text: "Enter the bare hostname. We'll add https:// and validate it's a Forgejo or Gitea instance." +- Inline error states (existing `customForgeError`). +- CTA: "Add host", full-width `WonkySquircleShape.CtaPrimary`, disabled until non-blank + passes validation. + +**Empty / loading / error**: +- Empty (no forges added): the empty-state row above is the empty state. +- Loading (validating new host): inline progress indicator in the sheet's CTA, button text "Validating…" +- Error: inline `supportingText` on the field; failures during validation surface as a snackbar on the Sources screen after the sheet dismisses, e.g. "Couldn't reach `code.example.org`." +- Reversible host removal: snackbar with Undo (5s window) after `GhsConfirmDialog` confirms. + +### 3.5 Translation — `TweaksTranslationScreen` + +**Title**: "Translation" + +**Layout**: + +1. **Provider card** (outlined `Radii.row`): + - Sub-header: "Provider". + - 5 stacked **provider radio rows**: + - Outlined `Radii.chip` Surface each. Radio button + icon tile + title + 1-line description. + - Google: "Free, no setup." + - DeepL: "High quality. Requires API key." + - Microsoft: "Free tier. No-Trace mode by default." + - LibreTranslate: "Open source. Self-hostable." + - Youdao: "Best for Chinese." + - Selecting a row persists provider choice immediately and reveals credentials card. +2. **Credentials card** (outlined `Radii.row`) — only when provider needs credentials (i.e. not Google). + - Sub-header: "{Provider name} credentials". + - Body: provider-specific help + "Get a free API key →" link. + - Form fields: `OutlinedTextField`s with `Radii.chip` shape. + - Visibility toggles on secret fields. + - Save button: `WonkySquircleShape.CtaPrimary` `FilledTonalButton`, full-width, disabled until form valid. +3. **Auto-translate card** (outlined `Radii.row`): + - Sub-header: "Auto-translate READMEs". + - Toggle: title "Translate READMEs automatically", subtitle "When opening a repo, translate the README into your target language." + - When enabled: target language picker drill-row that **reuses §3.2 list** filtered to `SupportedTranslationLanguages.all` (review N2 — same picker, not a parallel one). + +**Empty / loading / error**: +- No provider selected: Google is always the implicit default; never empty. +- Credentials missing for non-Google: one-line warning inside credentials card "No key yet — using Google as fallback." (`onSurfaceVariant`, no destructive coloring). +- Save error: snackbar with retry, e.g. "Couldn't validate DeepL key. [Retry]" +- Save success: snackbar "DeepL credentials saved." + +### 3.6 Install method — `TweaksInstallScreen` + +**Title**: "Install method" + +**Platform visibility**: hub row shows on both platforms; on desktop the row carries a "Desktop only" → actually "Android only" badge subtitle (review M8). Clicking on desktop routes to a centered empty state (see below). All other content is Android-only. + +**Layout (Android)**: + +1. Top intro card (outlined `Radii.row`): + - Title "How the app installs APKs". + - Body "Choose between the system installer or a silent installer that doesn't ask each time." +2. **Installer picker card** (outlined `Radii.row`): + - Sub-header: "Method". + - 4 vertical radio rows (System / Shizuku / Dhizuku / Root), each with icon tile, title, description, status badge (Ready / Needs permission / Not installed / Not running). Selected state: 2.dp `colorScheme.primary` border on the row + left-edge 4.dp wide primary-tinted bar. + - Title rename per review N7: "System installer" (no "(Default)" suffix). + - When selected installer needs permission, "Grant permission" button: `WonkySquircleShape.CtaPrimary`. +3. **Auto-update card** (outlined `Radii.row`) — only when silent install ready: + - Toggle: "Install updates in the background", subtitle "Apply downloaded updates without notifying you each time." +4. **Attribution card** (outlined `Radii.row`) — only when silent install ready: + - Sub-header: "Pretend the installer is…". + - Body + radio rows + custom field — unchanged from V1. + +**Desktop empty state**: + +- Centered `Icons.Outlined.Computer` (48dp) + title "Install method is Android-only" + body "Silent installers and installer attribution are Android features. Desktop installs go through the OS package manager." + back CTA `WonkySquircleShape.CtaAlt` "Back to Tweaks". + +**Empty / loading / error**: +- Loading installer states: each radio row's status badge defaults to "Checking…" until `InstallerStatusProvider` returns. Badge has `liveRegion = Polite`. +- Permission grant errors: snackbar. +- Attribution custom field error: inline `supportingText`. + +### 3.7 Update behavior — `TweaksUpdatesScreen` + +**Title**: "Update behavior" + +**Layout**: + +1. Battery optimization banner (Android only, when `state.showBatteryOptimizationCard`): outlined `Radii.row` `tertiaryContainer` tinted, Open / Dismiss buttons. +2. **Check for updates card** (outlined `Radii.row`): + - Sub-header: "Checking". + - Toggle: "Check automatically", subtitle "Look for new releases on a schedule." + - When enabled, reveal interval segment. **4 pills** (review N3): "Every 6 hours" / "Every 12 hours" / "Daily" / "Manual only". Drops the 3h option (GitHub rate limits). Disabled-state styling when toggle off. +3. **Pre-releases toggle row**: "Include pre-releases", subtitle "Show alpha and beta tags as available updates." +4. Sub-header "Manage": + - Drill: "Skipped updates" → `SkippedUpdatesScreen`. Subtitle: plural-aware "%d app" / "%d apps" or "Nothing skipped" when 0. + - Drill: "Hidden repositories" → `HiddenRepositoriesScreen`. Subtitle: plural-aware count or "No hidden repos." + +**Desktop**: auto-update card simplifies to a single toggle "Check on launch" (no interval picker, no WorkManager). Skipped + Hidden drill rows still apply. What's-new history moves to **App info** (V1 had it here; V2 moves it because it's reference content about the app, not update behavior). + +**Empty / loading / error**: +- "Manual only" interval = no scheduled check; subtitle on hub reads "Manual checks only." +- Last-check error: hub subtitle pill "Check failed" (`tertiaryContainer`), in-screen banner "Last check failed at 14:02 — [Retry]." +- Plural-aware count subtitles use `pluralStringResource`. + +### 3.8 Storage — `TweaksStorageScreen` (new, split from V1's Library cleanup) + +**Title**: "Storage" + +**Scope**: downloaded APK cache only. Everything else (clipboard, hide-seen, viewed history, telemetry) moves to §3.9 Privacy per review M1. + +**Layout**: + +1. **Downloaded packages card** (outlined `Radii.row`): + - Icon tile: `Icons.Outlined.Inventory2`. + - Title: "Downloaded APKs". + - Body: "We keep installers around so updates resume fast. Clear them anytime." + - Status line in `Geist Mono labelMedium`: "Using: 124 MB" / "Using: 0 B" when empty. + - Trailing primary action: `WonkySquircleShape.CtaAlt` `FilledTonalButton` "Clear", `errorContainer` tinted. **Disabled when size = 0 B**. Opens `ClearDownloadsDialog` (existing). + +**Empty / loading / error**: +- Size = 0 B: status reads "Using: 0 B"; Clear button disabled with `disabledContainerColor` per Material 3 disabled-tonal pattern. No empty-state card needed — "0 B" is itself the empty state. +- Loading size: status reads "Calculating…" with subtle indeterminate text shimmer (existing `TweaksViewModel.OnRefreshCacheSize` initialization period). +- Clear success: snackbar "Cleared 124 MB." +- Clear error: snackbar with retry. + +### 3.9 Privacy — `TweaksPrivacyScreen` (new — review C1 + M1) + +**Title**: "Privacy" + +**Scope** (per product-owner decisions): telemetry opt-out, clipboard detection, hide-seen, viewed-history clear. + +This screen is **not** the home of the *legal* "Privacy policy" link — that lives in §3.11 App info per placement Option E. The naming overlap is intentional: this sub-screen surfaces in-app behavior toggles; App info surfaces the legal document. + +**Layout**: + +1. **Telemetry card** (outlined `Radii.row`, inner Column): + - Sub-header: "Usage data". + - Toggle row: title "Share anonymous usage data", subtitle "Help us understand which features get used." + - Below the toggle, an **expandable "What we collect"** row (chevron icon, `Icons.Default.ExpandMore` rotates 180°). Expanded body is bulleted plain text — app version, OS + platform, feature counts (no repo names, no tokens, no identifiers). Compose `AnimatedVisibility`. + - When toggled off→on, snackbar "Sharing usage data starting next launch." When toggled on→off, snackbar "Usage data sharing stopped. Existing data is dropped." Push `RestartReason.TELEMETRY_TOGGLE` into `needsRestartReasons` so the banner appears (telemetry init runs at app start). +2. **Clipboard card** (outlined `Radii.row`): + - Toggle: title "Detect repo links in clipboard", subtitle "When you copy a github.com or codeberg.org link, we'll prompt to open it." +3. **Browsing history card** (outlined `Radii.row`, inner Column): + - Sub-header: "Browsing history". + - Toggle: title "Hide repos I've already viewed", subtitle "Skip seen repos in feeds and search." + - Drill row (destructive): title "Clear viewed history". Click → `GhsConfirmDialog` ("Clear all viewed history? This won't unstar or unfavorite anything."). On confirm, snackbar with **Undo** for 5s window, then commits irreversibly. (Treats this as reversible-with-Undo rather than purely irreversible — viewed history is non-critical data.) + +**Empty / loading / error**: +- Telemetry expandable: static content; never loading. +- Clipboard / hide-seen toggles: synchronous state; never loading. +- Clear viewed history error: snackbar with retry. + +**Hub subtitle compact state** (for §1.1 row 9): `pluralStringResource`-driven, capped at 32 chars per review M6. Examples: +- "Telemetry off · clipboard on" (28 chars) +- "Telemetry on · 3 toggles on" — drop "clipboard" / "hide-seen" specifics on overflow. + +### 3.10 Access tokens — `HostTokensScreen` (existing) + +No visual changes to the screen contents. Audit pending — confirm `HostTokensScreen` rows use `Radii.row` outlined Surface + new full-opacity `outline` border (review M4). If they don't, refactor as part of P12.5. + +Hub subtitle: plural-aware "%d token" / "%d tokens" (review M6), or "No tokens yet" when empty. + +### 3.11 App info — `TweaksAppInfoScreen` (new — replaces V1 §3.10 About) + +**Title**: "App info" + +**Scope** (per placement Option E): about + licenses + privacy policy link + version metadata. **Does not** include feedback (separate hub row §3.12). **Does not** include in-app privacy toggles (those are §3.9 Privacy). + +**Layout**: + +1. **App identity card** (outlined `Radii.row`, single hero-style card): + - Top-left: 56.dp app icon (load actual app icon, fallback `Icons.Outlined.Store`). + - App name "GitHub Store" (`titleLarge`). + - Version `versionName` in `Geist Mono labelLarge`. Long-press copies to clipboard with snackbar "Version copied." + - Tagline: "Cross-platform app store for GitHub, Codeberg, and Forgejo releases." (xliff-style placeholders for the three product names in translator handoff per review N4.) +2. **Action rows** (each outlined `Radii.row` drill-in row): + - **What's new** — icon `Icons.Outlined.NewReleases`, subtitle "Past release notes." Opens `WhatsNewHistoryScreen`. + - **Open source licenses** — icon `Icons.Outlined.Code`, subtitle "Libraries used in the app." Opens new `LicensesScreen` fed by `gradle-license-plugin` JSON (ship as part of P12.5, not a placeholder per review N8). If `gradle-license-plugin` isn't configured yet, add it in this phase. + - **Privacy policy** — icon `Icons.Outlined.Description` (or `PrivacyTip` if the §3.9 row uses `Shield`), subtitle "View on github-store.org." Opens `github-store.org/privacy` in external browser via `LocalUriHandler`. Distinct from §3.9 Privacy by icon + subtitle copy. + - **Source code on GitHub** — icon GitHub glyph (reuse `PlatformGlyph` GitHub variant; fallback `Icons.Outlined.Code`), subtitle "View this app's source." Opens the repo URL. + +**Empty / loading / error**: +- Version loading: placeholder "—" until populated. +- Licenses JSON unreachable (shouldn't happen since shipped as asset): empty-state card "Couldn't load licenses. [Retry]." +- External URL handlers can fail on desktop without a default browser: snackbar "Couldn't open privacy policy. URL copied to clipboard." with the URL pre-copied as a fallback. + +**Distinction from §3.9 Privacy**: §3.9 = in-app behavior toggles ("what the app does with your data"). §3.11 = static reference content ("what the app is, what its docs say"). They share neither title nor icon set. The hub orders them in different blocks (Privacy & data vs App) so users encounter them with different intent. + +### 3.12 Send feedback — hub row, not a sub-screen + +Per product-owner decision + placement Option E. **Single feedback path** (not duplicated inside App info per researcher's open question). + +- Hub row (§1.1 row 12) tapping opens `FeedbackBottomSheet` directly. No intermediate screen. +- `FeedbackBottomSheet` uses `GhsBottomSheet` with `WonkySquircleShape.Sheet`. +- Behavior unchanged from today — pre-fills user-agent + version metadata. + +**Empty / loading / error**: +- Submit success: sheet dismisses, hub snackbar "Feedback sent. Thanks." +- Submit error: inline error in sheet, retry button. + +### 3.13 (reserved — was §3.13 Privacy in product-owner brief, merged into §3.9 above) + +### 3.14 (reserved — was §3.14 Storage in product-owner brief, merged into §3.8 above) + +--- + +## 4. Proxy redesign (deep dive) + +### 4.1 Pattern — master + per-scope overrides (Option B, persisted) + +Master proxy applies to all 3 scopes by default. Each scope has a `[ Use main ] [ Custom… ]` segment (review M2). "Custom…" reveals a mini-editor; "Use main" hides it. + +Persistence change (review C2): the master config is its **own** `ProxyConfig?` record in `ProxyRepository`, plus a `useMaster: Boolean` per scope. The V1 promise to derive master from per-scope equality is reversed — equality-derivation is racy across DataStore writes and the user can't see "which scope am I testing." Schema migration in §4.5. + +### 4.2 Why master + overrides + +1. ~95% of users want one proxy everywhere. V1's 3-card design forces them to fill the same form 3 times. +2. Per-scope overrides (Tor SOCKS5 for Translation, corporate HTTP for Downloads) are real power-user scenarios. +3. Persisted master = glance-readable hub subtitle: "No proxy" / "HTTP 127.0.0.1:1080" / "HTTP 127.0.0.1:1080 · 1 override". +4. Persisted master = unambiguous "which scope am I testing" — test buttons name the scope. + +### 4.3 The Connection screen, step by step + +**Title**: "Connection" + +**Top intro card** (outlined `Radii.row`): +- Title "How the app reaches the internet". +- Body "Pick a connection mode below. Most people leave this on No proxy." + +**Card 1 — Main connection** (outlined `Radii.row`, inner Column): + +1. Sub-header: "Main connection". +2. **Mode segment** — 4-way `ModePillSegment`: **No proxy** / **System** / **HTTP/HTTPS** / **SOCKS5** (review M3 + C3). + - "No proxy" / "System" → form collapses (no fields to edit). + - "HTTP/HTTPS" / "SOCKS5" → form expands `AnimatedVisibility`. + - Small caption under HTTP/HTTPS pill: "Most corporate proxies." Small caption under SOCKS5: "Tor, SSH tunnels." (review M3) +3. **Form fields** (when expanded): + - Row: Host (weight 2) + Port (weight 1) `OutlinedTextField`s, shape `Radii.chip`, with existing `isLikelyValidProxyHost` validation. + - Username (optional). + - Password (optional) with eye toggle. + - Inline helper: "Applies to all traffic unless overridden below." (review N10 — compressed.) +4. **Paste full URL** affordance (review M3) — text-button below the form fields, `Icons.Outlined.ContentPaste` leading icon, label "Paste full URL". Opens a modal sheet (`GhsBottomSheet`, `WonkySquircleShape.Sheet`) with a single paste field. Parser accepts `scheme://user:pass@host:port` (`scheme ∈ {http, https, socks5}`). On parse success: form fields populate, sheet dismisses, snackbar "Pasted from URL." On parse fail: inline error "Couldn't read that URL." +5. **Test & save row** (HTTP/HTTPS / SOCKS5 only): + - **Test** — `OutlinedButton`, `Radii.chip`, leading `Icons.Default.NetworkCheck`. Label: **"Test main connection"** (review C2 — announce scope). On click: tests against 3 endpoints (search API, download CDN, configured translation provider). Results snackbar is 3-line: "Search ✓ 184 ms · Downloads ✓ 92 ms · Translation ✓ 220 ms" or per-endpoint failures. + - **Save** — `FilledTonalButton`, `WonkySquircleShape.CtaPrimary`, leading `Icons.Default.Save`. Disabled until form valid. Writes to the persisted **master** record. + +Test request honors `HostTokenInterceptor` per review N6 — for private GH Enterprise + PAT users, the test uses the user's PAT. + +**Card 2 — Per-scope overrides** (outlined `Radii.row`, inner Column): + +1. Sub-header: "Per-scope overrides". +2. Body: "Each scope uses the main connection by default. Choose 'Custom' to give a scope its own settings." +3. Three sub-rows, one per scope: + - **Discovery** — title "Search & metadata", subtitle "GitHub API, search results, repo details." + - **Downloads** — title "Downloads", subtitle "APK and asset downloads." + - **Translation** — title "Translation", subtitle "DeepL, Microsoft, LibreTranslate calls." + - Each row has a trailing **2-segment chooser** `[ Use main ] [ Custom… ]` (review M2). + - "Custom…" expands `AnimatedVisibility` into a mini-editor matching Card 1 structure (mode segment + form + test + save). Test button label: **"Test for Downloads"** (review C2). Save here writes only to that scope. + - Closed (Use main selected) sub-rows show a compact status: green pill "Using main" or — if "Custom…" persists a non-default config but the user just toggled back to "Use main" — the saved override config is preserved in state but not applied. Toggle back to "Custom…" to restore. +4. Live state pill on every override sub-row (review C2 #4): + - "Use main" → small green-ish pill "Using main" (`tertiaryContainer`, `labelSmall`). + - "Custom…" with config → amber-ish pill "HTTP 127.0.0.1:1080" (`tertiaryContainer`, `labelSmall`, mono font). + +**Empty / loading / error states**: + +- **Empty**: never — "No proxy" mode is always available. +- **Loading test**: Test button shows inline `CircularProgressIndicator` (16.dp). Disable both Test and Save while testing. +- **Test success**: snackbar with 3-line breakdown (master) or 1-line scope-specific (override). +- **Test failure (one endpoint)**: snackbar "Search ✓ · Downloads ✗ connection refused · Translation ✓". Don't fail the whole test — show partial. +- **Test failure (all endpoints)**: snackbar with retry button. +- **Save success**: snackbar "Main connection saved." or "Downloads connection saved." +- **Save error**: snackbar with retry. +- **Mode switch with unsaved form draft**: switching the master mode from HTTP/HTTPS to SOCKS5 (or vice versa) with dirty form: confirmation snackbar "Switch mode? Unsaved settings will be cleared. [Switch] [Cancel]." (covers review C4 scope-changing pattern.) +- **Override on→off toggle ("Custom…" → "Use main")**: snackbar "Downloads now uses the main connection." Reversible — toggle back within 5s to restore the override config. + +**Direct vs No-proxy clarity** (review C3): "No proxy" replaces "Direct" / "None" everywhere. Body copy updated. `proxy_none_description` rewritten to "The app connects to the internet without a proxy." (See §6.) + +### 4.4 Validation rules (unchanged from V1) + +- Host: existing `isLikelyValidProxyHost(raw)`. Inline error in `supportingText`. +- Port: integer in `1..65535`. +- Username + password: optional. +- Save button disabled until host + port pass. +- Test button enabled even when fields invalid; surfaces friendly snackbar "Enter a host and port first." + +### 4.5 Master proxy persistence migration + +`ProxyRepository` schema additions: + +- New record key: `proxy_master` → `ProxyConfig?` (nullable for "no master configured"). +- New per-scope key: `proxy__use_master` → `Boolean` (default true). +- Existing per-scope `proxy_` records remain; semantics now mean "the scope's override config" (only consulted when `proxy__use_master == false`). + +**One-time migration on first launch of V2**: + +1. Read existing 3 per-scope records. +2. If all 3 are equal (same `ProxyConfig`): write that config to `proxy_master`, set all 3 `proxy__use_master = true`. +3. If any diverge: write the **most common** config to `proxy_master` (ties broken by scope order: Discovery > Downloads > Translation). For each scope whose config differs from the chosen master, set `proxy__use_master = false` (existing record stays as the override). +4. Bump DataStore version key from N → N+1 to gate the migration (`tweaksDataStore.version`). Run once. + +This is a pure-presentation migration — no network calls, no async beyond DataStore reads. Failures roll back (don't bump version key) so retry on next launch is safe. + +`TweaksAction` additions: + +- `OnMasterProxySave(config: ProxyConfig)` — writes master + all scopes' `useMaster = true`. +- `OnScopeUseMainToggled(scope: ProxyScope, useMain: Boolean)` — toggles override; preserves the scope's override config in state when toggling to main. +- `OnScopeProxySave(scope: ProxyScope, config: ProxyConfig)` — writes scope override + sets `useMaster = false`. +- Existing `OnProxyTest(scope: ProxyScope?)` — `null` scope = master (tests 3 endpoints). + +--- + +## 5. Visual primitive proposals + +### 5.1 New primitives + +**`TweaksEntryRow`** — described in §2.2. Lives at `feature/tweaks/presentation/components/TweaksEntryRow.kt`. + +**`ModePillSegment`** — promote existing `ModeSegment` from `sections/Others.kt` (private) into `core/presentation/components/ModePillSegment.kt`. Generic over a value type: + +``` +data class ModePillItem(val value: T, val label: String, val icon: ImageVector? = null, val caption: String? = null) +@Composable fun ModePillSegment(items: List>, selected: T, onSelect: (T) -> Unit, modifier: Modifier = Modifier) +``` + +Used by: +- Appearance theme mode (Light / Dark / Follow system). +- Connection master mode (No proxy / System / HTTP/HTTPS / SOCKS5) — with `caption` for the bottom-of-pill hint. +- Content width segment (Compact / Wide / Extra wide). + +**`UseMainSegment`** — 2-segment `[ Use main ] [ Custom… ]`. Lives at `feature/tweaks/presentation/components/UseMainSegment.kt`. Two `ToggleButton`-style chips, `Radii.chip` shape, primary fill on selected. (Could be implemented as a thin wrapper around `ModePillSegment` — implementer's call.) + +**`RestartBanner`** — `feature/tweaks/presentation/components/RestartBanner.kt`. Outlined `Radii.row` `tertiaryContainer`-tinted Surface. Props: `reasons: Set`, `onRestartNow: () -> Unit`, `onLater: () -> Unit`. + +**`CustomForgesSheet`** — `GhsBottomSheet` instance, no new shape primitive. New composable at `feature/tweaks/presentation/components/CustomForgesSheet.kt`. Replaces `CustomForgesDialog.kt`. + +**`PasteProxyUrlSheet`** — `GhsBottomSheet`. Single paste field + parser. Lives at `feature/tweaks/presentation/components/PasteProxyUrlSheet.kt`. + +**`LicensesScreen`** — `feature/tweaks/presentation/LicensesScreen.kt`. Static markdown view fed by `gradle-license-plugin` JSON committed to assets. Outlined `Radii.row` row per library, expandable for full license text. + +### 5.2 No new shapes + +All squircles reuse `Radii.row`, `Radii.chip`, `WonkySquircleShape.{CtaPrimary, CtaAlt, Search, Sheet, Dialog, Toast}`. + +### 5.3 Components to retire + +- `ExpressiveCard` usages inside Tweaks sections (`Others.kt`, `Installation.kt`) — replace with outlined `Radii.row` Surface. +- `ElevatedCard(shape = RoundedCornerShape(32.dp))` in `Translation.kt`, `Language.kt`, `About.kt`, `Network.kt` — same replacement. +- `RoundedCornerShape(12.dp)` `OutlinedTextField` inside forms — switch to `Radii.chip`. +- 3× `ProxyScopeCard` — deleted, replaced by §4 design. +- `CustomForgesDialog.kt` — deleted, replaced by `CustomForgesSheet`. + +--- + +## 6. String rename table + +Resource keys + English values. Across-13-locale translation queued via §9 CSV; English ships first per project policy. + +| Resource key | Current text | Proposed text | +|---|---|---| +| `Res.string.tweaks_title` | "Tweaks" | "Tweaks" (keep) | +| `Res.string.section_appearance` | "Appearance" | "Appearance" (keep) | +| `Res.string.theme_color` | "Theme color" | "Palette" | +| `Res.string.theme_light` | "Light" | "Light" (keep) | +| `Res.string.theme_dark` | "Dark" | "Dark" (keep) | +| `Res.string.theme_system` | "System" | "Follow system" | +| `Res.string.amoled_black_theme` | "AMOLED black" | "True black (AMOLED)" | +| `Res.string.amoled_black_description` | "Use pure black for OLED screens." | "Pure-black background — saves power on OLED screens." | +| `Res.string.system_font` | "System font" | "Use system font" | +| `Res.string.system_font_description` | "Use the system font for the app." | "Use your device's default typeface instead of Geist." | +| `Res.string.scrollbar_option_title` | "Show scrollbar" | "Show scrollbar" (keep) | +| `Res.string.scrollbar_option_description` | "Show scrollbar on the right side." | "Always show the scrollbar on long pages." | +| `Res.string.content_width_title` | "Content width" | "Content width" (keep) | +| `Res.string.content_width_description` | "Adjust max content width." | "How wide content should stretch on big windows." | +| `Res.string.section_language` | "Language" | "Language" (keep) | +| `Res.string.language_intro` | "Choose your app language." | "Pick the language used across the app." | +| `Res.string.language_picker_title` | "App language" | "App language" (keep) | +| `Res.string.language_picker_description` | "Restart required after change." | "The app restarts when you switch language." | +| `Res.string.language_follow_system` | "Follow system" | "Follow system" (keep) | +| `Res.string.language_follow_system_subtitle` | (new) | "Follow system · %1$s" (resolved tag interpolated) | +| `Res.string.section_network` | "Network" | (retired — block becomes "Connectivity" §1.1) | +| `Res.string.section_connectivity` | (new) | "Connectivity" | +| `Res.string.section_privacy_and_data` | (new) | "Privacy & data" | +| `Res.string.section_app_block` | (new) | "App" | +| `Res.string.section_installs_and_updates` | (new) | "Installs & updates" | +| `Res.string.connection_entry_title` | (new) | "Connection" | +| `Res.string.connection_entry_subtitle_no_proxy` | (new) | "No proxy" | +| `Res.string.connection_entry_subtitle_system` | (new) | "System proxy" | +| `Res.string.connection_entry_subtitle_proxy_with_overrides` | (new) | "%1$s · %2$d override" / "%1$s · %2$d overrides" (plural-aware) | +| `Res.string.sources_entry_title` | (new) | "Sources" | +| `Res.string.proxy_scope_intro` | "Configure proxies per scope." | (retired) | +| `Res.string.proxy_scope_discovery_title` | "Discovery proxy" | "Search & metadata" | +| `Res.string.proxy_scope_download_title` | "Download proxy" | "Downloads" | +| `Res.string.proxy_scope_translation_title` | "Translation proxy" | "Translation" | +| `Res.string.proxy_scope_discovery_description` | "Used for API and search." | "GitHub API, search results, repo details." | +| `Res.string.proxy_scope_download_description` | "Used for APK downloads." | "APK and asset downloads." | +| `Res.string.proxy_scope_translation_description` | "Used for translation providers." | "DeepL, Microsoft, LibreTranslate calls." | +| `Res.string.proxy_none` | "None" | "No proxy" | +| `Res.string.proxy_none_description` | "No proxy used." | "The app connects to the internet without a proxy." | +| `Res.string.proxy_system` | "System" | "System" (keep) | +| `Res.string.proxy_system_description` | "Use system proxy." | "Use the proxy configured in your OS." | +| `Res.string.proxy_http` | "HTTP" | "HTTP/HTTPS" | +| `Res.string.proxy_http_caption` | (new) | "Most corporate proxies." | +| `Res.string.proxy_socks` | "SOCKS" | "SOCKS5" | +| `Res.string.proxy_socks_caption` | (new) | "Tor, SSH tunnels." | +| `Res.string.proxy_test` | "Test" | (retired — replaced by scope-named buttons) | +| `Res.string.proxy_test_main` | (new) | "Test main connection" | +| `Res.string.proxy_test_scope` | (new) | "Test for %1$s" (scope name interpolated) | +| `Res.string.proxy_save` | "Save" | "Save" (keep) | +| `Res.string.proxy_test_success` | "Proxy reachable (%d ms)" | "Connected in %1$d ms" | +| `Res.string.proxy_test_main_success` | (new) | "Search ✓ %1$d ms · Downloads ✓ %2$d ms · Translation ✓ %3$d ms" | +| `Res.string.proxy_use_main` | (new) | "Use main" | +| `Res.string.proxy_use_custom` | (new) | "Custom…" | +| `Res.string.proxy_using_main_pill` | (new) | "Using main" | +| `Res.string.proxy_paste_full_url` | (new) | "Paste full URL" | +| `Res.string.proxy_paste_url_placeholder` | (new) | "scheme://user:pass@host:port" | +| `Res.string.proxy_paste_url_error` | (new) | "Couldn't read that URL." | +| `Res.string.connection_intro_title` | (new) | "How the app reaches the internet" | +| `Res.string.connection_intro_body` | (new) | "Pick a connection mode below. Most people leave this on No proxy." | +| `Res.string.connection_inline_helper` | (new) | "Applies to all traffic unless overridden below." | +| `Res.string.connection_overrides_body` | (new) | "Each scope uses the main connection by default. Choose 'Custom' to give a scope its own settings." | +| `Res.string.mirror_tweaks_entry_label` | "Mirror" | "GitHub mirror" | +| `Res.string.custom_forges_entry_label` | "Custom forges" | "Custom forges" (keep) | +| `Res.string.custom_forges_entry_subtitle` | "Add your own Forgejo/Gitea hosts." | "Add a Forgejo or Gitea host" | +| `Res.plurals.custom_forges_count` | (new) | "%d host" / "%d hosts" (plural-aware) | +| `Res.string.section_translation` | "Translation" | "Translation" (keep) | +| `Res.string.translation_intro` | "Configure translation provider and auto-translate." | "Pick the engine the app uses to translate READMEs." | +| `Res.string.translation_provider_title` | "Provider" | "Provider" (keep) | +| `Res.string.translation_provider_description` | "Pick a translation engine." | (retired) | +| `Res.string.translation_auto_title` | "Auto-translate" | "Translate READMEs automatically" | +| `Res.string.translation_auto_subtitle` | "Translate READMEs when opening repos." | "When opening a repo, translate the README into your target language." | +| `Res.string.section_installation` | "INSTALLATION" | "Install method" | +| `Res.string.install_method_android_only_badge` | (new) | "Android only" | +| `Res.string.install_method_desktop_empty_title` | (new) | "Install method is Android-only" | +| `Res.string.install_method_desktop_empty_body` | (new) | "Silent installers and installer attribution are Android features. Desktop installs go through the OS package manager." | +| `Res.string.installer_type_default` | "Default" | "System installer" | +| `Res.string.installer_type_default_description` | "Uses Android's default installer." | "Asks each time. Works on every device." | +| `Res.string.installer_type_shizuku` | "Shizuku" | "Shizuku" (keep) | +| `Res.string.installer_type_shizuku_description` | "Silent install via Shizuku." | "Silent install. Needs Shizuku app running." | +| `Res.string.installer_type_dhizuku` | "Dhizuku" | "Dhizuku" (keep) | +| `Res.string.installer_type_dhizuku_description` | "Silent install via Dhizuku." | "Silent install. No root needed." | +| `Res.string.installer_type_root` | "Root" | "Root" (keep) | +| `Res.string.installer_type_root_description` | "Silent install with root." | "Silent install via root. Power-user only." | +| `Res.string.installer_attribution_title` | "Installer attribution" | "Pretend the installer is…" | +| `Res.string.installer_attribution_description` | "Some apps reject silent installs unless installer claims to be Google Play." | "Some apps reject silent installs unless the installer claims to be Google Play. This setting controls what we claim." | +| `Res.string.section_updates` | "UPDATES" | "Update behavior" | +| `Res.string.auto_update_title` | "Auto-update" | "Install updates in the background" | +| `Res.string.auto_update_description` | "Apply downloaded updates automatically." | "Apply downloaded updates without notifying you each time." | +| `Res.string.update_check_enabled_title` | "Background update check" | "Check automatically" | +| `Res.string.update_check_enabled_description` | "Periodically check for new releases." | "Look for new releases on a schedule." | +| `Res.string.update_check_interval_title` | "Check interval" | "Check every" | +| `Res.string.update_check_interval_description` | "How often to check for updates." | (retired) | +| `Res.string.update_interval_6h` | (new / rename) | "Every 6 hours" | +| `Res.string.update_interval_12h` | (new / rename) | "Every 12 hours" | +| `Res.string.update_interval_24h` | (new / rename) | "Daily" | +| `Res.string.update_interval_manual` | (new) | "Manual only" | +| `Res.string.update_desktop_check_on_launch_title` | (new) | "Check on launch" | +| `Res.string.update_desktop_check_on_launch_subtitle` | (new) | "Check for updates each time the app starts." | +| `Res.string.include_pre_releases_title` | "Include pre-releases" | "Include pre-releases" (keep) | +| `Res.string.include_pre_releases_description` | "Treat alpha/beta as available updates." | "Show alpha and beta tags as available updates." | +| `Res.string.skipped_updates_entry_title` | "Skipped updates" | "Skipped updates" (keep) | +| `Res.plurals.skipped_updates_count` | (new) | "%d app" / "%d apps" (plural-aware) | +| `Res.string.skipped_updates_empty_subtitle` | (new) | "Nothing skipped" | +| `Res.string.hidden_repositories_title` | "Hidden repositories" | "Hidden repositories" (keep) | +| `Res.plurals.hidden_repositories_count` | (new) | "%d repo" / "%d repos" (plural-aware) | +| `Res.string.hidden_repositories_empty_subtitle` | (new) | "No hidden repos" | +| `Res.string.storage` | "Storage" | "Storage" (keep — now narrower scope) | +| `Res.string.downloaded_packages` | "Downloaded packages" | "Downloaded APKs" | +| `Res.string.downloaded_packages_description` | "Installer files kept for resumed updates." | "We keep installers around so updates resume fast." | +| `Res.string.current_size` | "Current size:" | "Using:" | +| `Res.string.section_privacy_screen_title` | (new) | "Privacy" | +| `Res.string.privacy_entry_subtitle` | (new) | "Compact state (see §3.9 hub subtitle rules)" | +| `Res.string.privacy_usage_data_subheader` | (new) | "Usage data" | +| `Res.string.privacy_telemetry_title` | (new) | "Share anonymous usage data" | +| `Res.string.privacy_telemetry_subtitle` | (new) | "Help us understand which features get used." | +| `Res.string.privacy_telemetry_collect_expand` | (new) | "What we collect" | +| `Res.string.privacy_telemetry_collect_body` | (new) | "App version. OS and platform. Feature usage counts. No repo names. No tokens. No identifiers." | +| `Res.string.privacy_telemetry_on_snackbar` | (new) | "Sharing usage data starting next launch." | +| `Res.string.privacy_telemetry_off_snackbar` | (new) | "Usage data sharing stopped. Existing data is dropped." | +| `Res.string.auto_detect_clipboard_links` | "Auto-detect clipboard links" | "Detect repo links in clipboard" | +| `Res.string.auto_detect_clipboard_description` | "Detect copied repo URLs and offer to open them." | "When you copy a github.com or codeberg.org link, we'll prompt to open it." | +| `Res.string.privacy_history_subheader` | (new) | "Browsing history" | +| `Res.string.hide_seen_title` | "Hide seen" | "Hide repos I've already viewed" | +| `Res.string.hide_seen_description` | "Skip already-viewed repos in feeds." | "Skip seen repos in feeds and search." | +| `Res.string.clear_seen_history` | "Clear seen history" | "Clear viewed history" | +| `Res.string.clear_seen_history_description` | "Reset the seen-repo list." | "Forget which repos you've already opened." | +| `Res.string.clear_seen_history_confirm` | (new) | "Clear all viewed history? This won't unstar or unfavorite anything." | +| `Res.plurals.host_tokens_count` | (new) | "%d token" / "%d tokens" (plural-aware) | +| `Res.string.host_tokens_empty_subtitle` | (new) | "No tokens yet" | +| `Res.string.section_about` | "About" | "App info" (sub-screen title) | +| `Res.string.app_info_tagline` | (new) | "Cross-platform app store for GitHub, Codeberg, and Forgejo releases." | +| `Res.string.app_info_action_whats_new` | (new) | "What's new" | +| `Res.string.app_info_action_whats_new_subtitle` | (new) | "Past release notes." | +| `Res.string.app_info_action_licenses` | (new) | "Open source licenses" | +| `Res.string.app_info_action_licenses_subtitle` | (new) | "Libraries used in the app." | +| `Res.string.app_info_action_privacy_policy` | (new) | "Privacy policy" | +| `Res.string.app_info_action_privacy_policy_subtitle` | (new) | "View on github-store.org." | +| `Res.string.app_info_action_source_code` | (new) | "Source code on GitHub" | +| `Res.string.app_info_action_source_code_subtitle` | (new) | "View this app's source." | +| `Res.string.app_info_version_copied` | (new) | "Version copied." | +| `Res.string.version` | "Version" | "Version" (keep) | +| `Res.string.feedback_send` | "Send feedback" | "Send feedback" (keep) | +| `Res.string.feedback_hub_subtitle` | (new) | "We read every report." | +| `Res.string.tweaks_search_placeholder` | (new) | "Search settings" | +| `Res.string.tweaks_search_empty` | (new) | "No settings match '%1$s'." | +| `Res.string.restart_banner_body` | (new) | "Some changes need a restart to apply." | +| `Res.string.restart_banner_reasons_prefix` | (new) | "Affected: %1$s" (comma-joined reasons) | +| `Res.string.restart_banner_reason_language` | (new) | "language" | +| `Res.string.restart_banner_reason_theme` | (new) | "theme" | +| `Res.string.restart_banner_reason_telemetry` | (new) | "usage data" | +| `Res.string.restart_banner_restart_now` | (new) | "Restart now" | +| `Res.string.restart_banner_later` | (new) | "Later" | +| `Res.string.menubar_help_menu` | (new, desktop) | "Help" | +| `Res.string.menubar_help_about` | (new, desktop) | "About GitHub Store" | +| `Res.string.menubar_help_feedback` | (new, desktop) | "Send feedback…" | +| `Res.string.menubar_help_licenses` | (new, desktop) | "Open source licenses" | +| `Res.string.menubar_help_privacy` | (new, desktop) | "Privacy policy" | + +i18n constraints (review M6): + +- Every counted label uses `pluralStringResource` (Compose Multiplatform Resources, `org.jetbrains.compose.resources`). Plural categories per locale follow CLDR (Russian 3 forms, Polish 3, Arabic 6, English 2). +- Subtitle ~32 chars; if subtitle has multiple dot-separated tokens, drop rightmost on overflow. +- Locale-aware separator (`·` for Latin-script locales, ` / ` for CJK in narrow contexts) — pragmatic compromise: use `·` everywhere in v1 since the existing `Squiggle`-adjacent text already does, queue CJK separator audit for P13. +- Translation handoff CSV: §9. + +--- + +## 7. Risks / open questions / non-goals + +### 7.1 Migration concerns + +- **Deep links** — `githubstore://tweaks/` deferred to follow-up ticket P12.5.1 (review C5 partially adopted: inline search ships, deep links queued). Route name list in §2.5. +- **Screenshots in docs** — README + Play Store + Homebrew tap screenshots that show today's Tweaks layout will be stale. Action: list and regen after merge. +- **Discoverability loss** — mitigated by (a) dynamic hub subtitles, (b) inline search (§2.5), (c) badged cross-platform rows (review M8). +- **ProxyRepository schema bump** — see §4.5. Migration tested via "all 3 equal," "2 equal + 1 different," "all 3 different" + clean install. Implementer must add a unit test for the migration's plurality-vote rule. + +### 7.2 Implementation risks + +- **`HostTokensScreen` row vocabulary audit** — confirm `Radii.row` + full-opacity `outline` border. Refactor in P12.5 if not. +- **`MirrorPickerScreen` row vocabulary audit** — same. +- **`FeedbackBottomSheet`** — confirm `WonkySquircleShape.Sheet`. +- **String resource churn** — touches ~80 keys (V1 had ~50; V2 added telemetry, restart banner, paste URL, menubar, search, plurals). Across 13 locales = ~1040 string updates. Ship strategy per project policy: English first, translator handoff CSV (§9) queued. +- **Action sealed-interface drift** — `TweaksAction` will balloon. Recommendation (not blocking v1): split into `TweaksHubAction`, `TweaksConnectionAction`, `TweaksPrivacyAction`, etc., each handled by either the same VM with grouped `when` or per-sub-screen sub-VMs. Refactor pairs naturally with this redesign. +- **`gradle-license-plugin`** — verify it's configured; if not, add in this phase. Output JSON goes to `composeApp/src/commonMain/composeResources/files/licenses.json`. Parsed at runtime by `LicensesScreen`. +- **Border contrast audit gate** — before merge, screenshot all 4 palettes × 3 modes (Light/Dark/AMOLED) and visually confirm `TweaksEntryRow` border passes 3:1 contrast on Cream light. If `outline` reads too heavy on dark themes, fall back to `outline.copy(alpha = 0.55f)` but only after measured contrast > 3:1 on Cream light. + +### 7.3 v1 non-goals (explicit) + +- **PAC files / proxy auto-config URL.** Documented non-goal per review M3. May add as a 5th mode pill in P13 if user demand surfaces. Today's Mode segment is **No proxy / System / HTTP/HTTPS / SOCKS5**, 4 pills. +- **SOCKS4 / SOCKS4a.** `ProxyConfig.Socks` is SOCKS5 only. Mode pill labeled "SOCKS5" to set expectation. +- **Multiple proxy presets / proxy profiles.** Out of scope. +- **In-screen telemetry data export.** Out of scope. Privacy expandable only describes; doesn't export. +- **Cross-screen settings search (system-level).** v1 search is hub-only (§2.5). Filtering through every sub-screen's controls is P13+. + +### 7.4 UX research follow-ups (queued, not blocking v1) + +1. Hub feels sparse on tall display — accept; revisit if heatmap shows abandoned scrolls. +2. "Connection" vs "Network" naming — locked to "Connection" + "Sources" per IA. +3. Provider radios vs chips — locked to radios per §3.5. +4. Install method "System installer" naming — locked. +5. About card hero vs 3 rows — locked to single hero card. +6. "Use main" vs "Inherit" wording — locked to "Use main" / "Custom…" segment per review M2. + +### 7.5 Open items the architect can't resolve alone + +- **Telemetry backend.** Spec defines the UI for opt-out. Whether `TelemetryRepository` already has a backend wired (per `feature/tweaks/CLAUDE.md` it's injected but UI is absent today) needs product confirmation. If backend isn't ready, the toggle persists locally + no-ops until backend ships. Worst case, it's a UX-correct placebo for one release; review C1 still satisfied because the user-facing control exists. +- **Crash reporter desktop opt-out.** `CrashReporter` (desktop) writes local logs; spec doesn't add a UI toggle for it because it's local-only (no network exfil). If a future release adds remote crash upload, gate it on the same telemetry toggle. + +### 7.6 Things deliberately not changed + +- The `AppLanguages.ALL` list and tag-based persistence. +- The `ProxyConfig` sealed class (only `ProxyRepository` schema bumps — see §4.5). +- The `TweaksRepository` persisted prefs keys for non-renamed settings (`RestartReason` + `needsRestartReasons` is **new**). +- The `MirrorPickerScreen`, `SkippedUpdatesScreen`, `HiddenRepositoriesScreen`, `HostTokensScreen`, `WhatsNewHistoryScreen` routes and nav wiring. +- The `FeedbackBottomSheet` flow. +- Cross-cutting tokens (`Tokens.kt`, `Radii.kt`, `WonkySquircleShape.kt`). + +--- + +## 8. Desktop MenuBar spec (new — placement Option E) + +Today's `composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt` `Window { … }` has no `MenuBar` content. This is **additive** — no current menu is being overridden. + +**Spec**: + +``` +Window(...) { + MenuBar { + Menu("Help") { + Item("About GitHub Store", onClick = { /* navigate to App info */ }) + Item("Send feedback…", onClick = { /* open FeedbackBottomSheet */ }) + Item("Open source licenses", onClick = { /* navigate to App info → Licenses */ }) + Item("Privacy policy", onClick = { /* open github-store.org/privacy */ }) + } + } + // ... existing window content +} +``` + +**macOS specifics**: + +- The JVM injects a default "About GitHub-Store" item under the macOS app menu. Override the no-op default via `Desktop.getDesktop().setAboutHandler { /* navigate to App info */ }` in `DesktopApp.main`. The Apple-menu About item then routes to our in-app App info screen. +- The Help menu still renders on macOS (per JVM `MenuBar` semantics), giving feedback + licenses + privacy a discoverable home. + +**Windows/Linux specifics**: + +- `MenuBar` renders in the title-bar area. Help menu is the entry point. No system About menu to override. + +**i18n**: menu strings are added to `strings.xml` per §6 (`menubar_help_menu`, `menubar_help_about`, etc.). At runtime, the JVM `Menu` and `Item` labels are resolved via the same string catalog used by Compose UI. + +**Why this matters**: every long-tail desktop app — VS Code, Slack, Discord, 1Password — exposes About via the native menu bar. Today our Compose Multiplatform `Window` has no menu, so desktop users hunt for About in the in-app UI. Wiring this is ~25 lines + zero changes to mobile. + +--- + +## 9. Translator handoff CSV + +Generated as **`/Users/rainxchzed/Documents/development/kmp/GitHub-Store/.design/P12_5_TWEAKS_STRINGS.csv`** alongside this spec. Three-column shape: `Resource key | Old English | New English`. New keys list as old="(new)". + +CSV is the source of truth for the translator queue. English ships first per project policy; translations land in subsequent commits as translators turn each locale around. + +--- + +## 10. Implementation order suggestion + +Suggested wave-based parallelization for `gsd-execute-phase`: + +- **Wave 1** — Foundation + - Schema migration scaffolding (`ProxyRepository` master + per-scope `useMaster`, §4.5). + - `RestartReason` enum + `needsRestartReasons` in `TweaksRepository`. + - `RestartBanner` component (§5.1). + - `TweaksEntryRow` primitive (§2.2). + - New navigation routes (placeholders for unmade screens). + - `TweaksScreen` hub composable with restart banner + search field + section blocks (§2). + - 11 entry rows wired to existing or placeholder destinations. +- **Wave 2** — Quick-win sub-screens (parallel-safe; each just relocates existing controls) + - `TweaksAppearanceScreen` (§3.1) — easy, mostly relocation. + - `TweaksLanguageScreen` (§3.2) — searchable list pattern. + - `TweaksStorageScreen` (§3.8) — narrower than V1's Library cleanup; just downloaded APKs. + - `TweaksAppInfoScreen` (§3.11) — meta links + version card. + - `LicensesScreen` (new, §3.11 / §5.1) — requires `gradle-license-plugin` config. +- **Wave 3** — Connection redesign (biggest piece) + - `TweaksConnectionScreen` (§3.3 + §4). + - `ModePillSegment` promotion (§5.1). + - `UseMainSegment` (§5.1). + - `PasteProxyUrlSheet` (§5.1). + - VM action additions (§4.5). + - Schema migration unit tests. +- **Wave 4** — Privacy + Updates + Sources + Translation + Install (parallel-safe) + - `TweaksPrivacyScreen` (§3.9) — telemetry opt-out is highest-priority new control. + - `TweaksUpdatesScreen` (§3.7) — drops 3h interval. + - `TweaksSourcesScreen` (§3.4) + `CustomForgesSheet` (§5.1). + - `TweaksTranslationScreen` (§3.5) — provider radio redesign. + - `TweaksInstallScreen` (§3.6) + Android-only badge handling for desktop. +- **Wave 5** — Polish & cleanup + - Send feedback hub row wires to `FeedbackBottomSheet` directly (§3.12). + - String rename diff (English; CSV handoff for translators §9). + - Delete old `sections/Appearance.kt`, `sections/Language.kt`, `sections/Network.kt`, `sections/Translation.kt`, `sections/Installation.kt`, `sections/Others.kt`, `sections/About.kt`, `sections/SettingsSection.kt`, `components/CustomForgesDialog.kt`. + - Border-contrast audit gate (Nord/Cream/Forest/Plum × Light/Dark/AMOLED screenshots). +- **Wave 6** — Desktop additive + - `MenuBar` in `DesktopApp.kt` (§8). + - macOS `Desktop.setAboutHandler` wiring. + +End of spec. diff --git a/.design/P12_5_TWEAKS_RESEARCH_REVIEW.md b/.design/P12_5_TWEAKS_RESEARCH_REVIEW.md new file mode 100644 index 000000000..33d122298 --- /dev/null +++ b/.design/P12_5_TWEAKS_RESEARCH_REVIEW.md @@ -0,0 +1,394 @@ +# Tweaks Redesign — UX Research Review + +> Reviewer: UX-Research. Target: `.design/P12_5_TWEAKS_REDESIGN.md` by ArchitectUX. Date: 2026-05-23. +> Evidence base: Nielsen Norman Group (NN/g), Material 3 guidance, Apple HIG (Settings + Localization), WCAG 2.2, and direct comparison with Tailscale, Cloudflare WARP, Bitwarden, 1Password, iOS Settings, and Termux. Code references cite the architect's own line numbers in the spec. + +--- + +## Verdict + +**Iterate before ship.** The IA refactor is directionally right and most per-screen specs are sound, but the Connection redesign has a load-bearing mental-model gap, telemetry is silently missing, and several discoverability/accessibility/i18n risks need answers in writing — not in a comment thread during implementation. + +--- + +## Critical issues (must fix before implementation) + +### C1. Telemetry is not in the spec at all — but it's a first-class privacy control + +**Problem.** `TweaksViewModel` injects `TelemetryRepository` (confirmed in `feature/tweaks/CLAUDE.md` → "TweaksViewModel injects: …, TelemetryRepository, …"). The redesign spec contains **zero** occurrences of the word "telemetry" across all 668 lines. It is not on the hub, not in About, not in Library cleanup. If it exists today, the spec deletes it by omission. If it doesn't yet have UI, this is the redesign moment to add it. + +**Evidence.** Modern privacy-forward apps universally surface telemetry as an explicit user-facing toggle within one tap of Settings root: Bitwarden ("Help us improve" → off by default), Tailscale ("Send usage analytics"), Cloudflare WARP ("Diagnostics"), 1Password ("Help us improve 1Password"). Hiding a telemetry control inside a sub-screen, or worse omitting it, fails GDPR's "easy to find" requirement for consent management (Article 7(3)) and Apple App Store guideline 5.1.2. + +**Proposed fix.** Add a "Privacy" card to the **About** sub-screen (preferred — keeps "App"/About as the meta home) OR move it into **Library cleanup** and rename that screen "Privacy & cleanup" (the architect even hinted at this in §7.2 #6: *"a quieter alternative is 'Privacy & storage'"*). Surface: toggle "Share anonymous usage data" + 1-line body + link "What we collect →" (modal or expandable). Also: any crash-reporter opt-out, if `CrashReporter` (desktop) has one. + +**Severity.** Critical (legal/compliance + user trust + an injected repository with no UI is a code smell on its own). + +--- + +### C2. Connection screen — "master" is fictional and the user has no way to know which scope they're testing + +**Problem.** §4.2 #4 admits the master model is presentation-only: *"`ProxyRepository` stores per-scope `ProxyConfig` already. On open, we read all three; if they're identical, we treat them as 'master.' If they diverge, we mark the divergent ones as 'overridden'."* This derivation is invisible to the user. + +The bigger issue is in §4.3, Card 1 step 4: *"`Test` … runs `OnProxyTest` against `ProxyScope.DOWNLOAD` (canonical scope for testing the master)."* The user reads "Test" and thinks "test this proxy." The app actually tests Download scope only. If Discovery has an override and Download doesn't, master-test passes and the user assumes their setup is fine — but Discovery is broken. + +**Evidence.** +- NN/g, "Visibility of System Status" (Heuristic #1): system state must be reflected accurately. Hidden "canonical scope" violates this. +- Cloudflare WARP, Tailscale, and Bitwarden all expose a connection test that explicitly says *what* it's testing ("Test connection to coordination server", "Pinging derp1.tailscale.com…"). Anonymous "Test" with hidden semantics is the worst of both worlds. +- The "load semantics" itself is brittle: any time a user toggles an override on then off, repository writes diverge then converge across millisecond-asynchronous DataStore writes — Jetpack DataStore doesn't guarantee snapshot-atomic reads across three keys. Compare-for-equality risks racy "is this master?" decisions. + +**Proposed fix.** +1. **Persist the master concept as its own ProxyConfig record** in `ProxyRepository`, plus a `useMaster: Boolean` per scope. Don't derive it from equality. (Yes, this is a schema change — that's the cost of the chosen IA. §7.4 lists "ProxyRepository schema" as untouched; that decision should be reversed.) +2. **Test must announce the scope.** Card 1 test button label: "Test main connection". Per-scope override test: "Test for Downloads". Snackbar should always include scope name: "Downloads proxy connected in 184 ms." +3. **When master is selected, run the test against all 3 endpoints** (search/metadata GitHub API, download CDN, configured translation provider) and show a 3-line result. This is what Tailscale's `tailscale netcheck` does. Roundtrip cost is acceptable — testing is rare. +4. Surface "current state" pill on every override sub-row even when collapsed: green "Using main", or amber "Override: HTTP 127.0.0.1:1080". + +**Severity.** Critical (load-bearing UX claim + persistence correctness). + +--- + +### C3. "Direct" is the wrong rename and will mistranslate + +**Problem.** §6 row: `proxy_none` "None" → "Direct". The architect's reasoning (§4.3 *"'Direct' replaces today's `ProxyType.NONE`. Renamed for clarity — 'None' sounds like an error state."*) is half-right and half-wrong. + +**Evidence.** +- "Direct" is industry term in *English* (Firefox, Chrome, system proxy.pac syntax: `DIRECT`). In the 13 locales the app ships, **"Direct" has no shared meaning**. In German "Direkt" works; in Chinese (Simplified) the literal "直接" is awkward UI copy — Chrome zh-CN uses "不使用代理服务器" ("do not use proxy server"); in Russian "Прямое соединение" is verbose. In Polish "Bezpośrednio" — same problem. +- The original "None" maps cleanly across locales because every translator already has localized "None" from the rest of the OS. +- iOS HIG, Internationalization: *"Avoid technical jargon and idioms that may not translate. Prefer concrete nouns over abstract states."* +- "Direct" also collides with the Connection screen body copy: *"Pick a connection mode below. Most people leave this on Direct."* — a user reading that translation might wonder "direct what?" + +**Proposed fix.** Three options, ranked: +1. **"No proxy"** — concrete, translatable, accurate. Used by Firefox UI ("No proxy"). Bitwarden uses this exact phrase. +2. **"Off"** — pairs naturally with a master pill ("Connection: Off"). Cloudflare WARP uses "Off / On / Auto". +3. Keep "Direct" but mark the rename as English-only and require translator notes for each locale ("the connection bypasses any proxy"). Worst option. + +Pick #1. Also rename `proxy_none_description` from "Connect directly, no proxy." to "The app connects to the internet without a proxy." + +**Severity.** Critical (13 locales × one mistranslation per locale = compounding bad copy). + +--- + +### C4. Empty-state and confirmation patterns are spec'd inconsistently across sub-screens + +**Problem.** The spec handles empty states on some screens but ignores them on others: +- §3.4 Sources: explicit empty state for custom forges ✔. +- §3.7 Updates: skipped/hidden drill rows show counts but **no spec for "0 items"** subtitle copy. Today's `skipped_updates_entry_description` is just descriptive; with the new dynamic-subtitle pattern (§2.2 *"reflects the current value of that domain"*), a zero state needs friendly copy. +- §3.9 Access tokens: *"No visual change needed beyond making sure its rows already use `Radii.row`"* — but the hub subtitle (§1.1) says "No tokens yet" when empty. The actual `HostTokensScreen` empty state is unaudited. A user tapping "No tokens yet" deserves an "Add your first token" CTA, not a blank list. +- §3.8 Library cleanup: cache size "Using: 124 MB" but no spec for "Using: 0 B" copy or whether the Clear button should be disabled. + +Confirmation dialogs are also inconsistent: +- §3.8 Clear viewed history: uses `GhsConfirmDialog`. Good. +- §3.4 Remove custom forge: uses `GhsConfirmDialog`. Good. +- §4 Connection override toggle-off: **no confirmation**, but toggling off override loses test results and arguably changes behavior across an entire scope. Should at least surface a snackbar "Discovery now uses main connection" so the destructive change is observable. +- §3.8 Clear downloaded APKs: uses existing `ClearDownloadsDialog`. Need to confirm copy is consistent with `GhsConfirmDialog` vocabulary post-D4 squircle scope. + +**Evidence.** NN/g, "10 Usability Heuristics," #5 Error prevention and #9 Help users recognize, diagnose, recover. Material 3 Confirmation Dialog guidance: *"Use for destructive actions that can't be undone, or that change scope of effect."* + +**Proposed fix.** Add an "Empty / loading / error states" sub-section to **every** per-screen spec. Standardize the destructive-action pattern across the redesign: +- Reversible state changes → snackbar with Undo (Material 3 standard). +- Irreversible destructive (clear cache, clear history, remove token, remove forge) → `GhsConfirmDialog`. +- Mode switches that change behavior across many scopes (proxy override on/off) → snackbar confirming scope change. + +**Severity.** Critical (inconsistency between sibling screens degrades trust + lookahead for translators). + +--- + +### C5. Hub navigation cost is real, and the spec rejects all standard mitigations without testing them + +**Problem.** §2.6 rejects search; §1.2 declines greyed-out cross-platform rows; §7.1 (Discoverability loss) acknowledges *"Today a user scrolling Tweaks accidentally discovers 'Custom forges' or 'Hide seen.' In a hub-and-spoke model, those settings are one tap further away. Mitigation: the hub's dynamic subtitles … make the state visible without entering."* The mitigation is partial — subtitle visibility helps recognition, not initial discovery, and only for users who already know what to look for. + +**Evidence.** +- NN/g, "Site Map vs Search" (2019): site maps (= our hub) work for users who know domain vocabulary; search wins for users who know what they want but not where it lives. Power users (our audience) hover at both ends — proxy tinkerers know exactly what they want, but onboarding users browse. +- iOS Settings: ~30 hub rows but ships a search bar at top of every Settings screen. Apple HIG, Settings: *"Provide a search bar when your settings hierarchy is more than one level deep."* +- 1Password and Bitwarden Settings: both ship search. +- Material 3 Settings pattern (m3.material.io): "For more than 6 categories, provide search." We have 10. + +The architect's three reasons for rejecting search (§2.6) are: +1. *"A user can scan them in under 2 seconds."* True for first visit, false for return visits to a specific item. +2. *"contradicting the 'much cleaner' directive."* A search bar one row tall doesn't violate "much cleaner." +3. *"deep links … better than fuzzy search field."* These solve different problems — deep links from outside the app, search from inside. + +**Proposed fix.** Either: +1. **Ship a tiny search field** at the top of the hub (under the title, above the first block header), `WonkySquircleShape.Search` shape, filters rows + their subtitle text. Cost: ~20 lines of Compose. Removes the entire discoverability complaint. +2. **Or commit to deep links in v1, not P13** (§7.1 defers them). At minimum: `githubstore://tweaks/connection`, `githubstore://tweaks/translation`, `githubstore://tweaks/updates`. These can be invoked from notifications ("Battery optimization is blocking updates →"), from in-app banners (the existing `D3` themes-refreshed banner), and from external "open settings" deep links. + +Even better: ship both. Search is cheap, deep links are infrastructure. + +**Severity.** Critical (user-explicit "rethought" goal vs new friction; user is a power user who navigates Tweaks weekly, not yearly). + +--- + +## Major concerns (strongly recommend fixing) + +### M1. "Library cleanup" is two unrelated mental models stapled together + +**Problem.** The user's brief explicitly asks this question. §3.8 puts under one roof: +- Downloaded APK cache (disk-space concern, performance-y). +- Detect repo links in clipboard (privacy / convenience pref). +- Hide seen / Clear viewed history (privacy / behavior pref). + +These have nothing in common except "things the app remembers." The clipboard toggle in particular has **zero** to do with "cleanup." + +**Evidence.** +- NN/g, "Card Sorting": users group by goal, not by implementation. A user trying to recover disk space won't think to look here for clipboard prefs; a user trying to stop the app from peeking at clipboard won't think to look in a screen called "cleanup." +- iOS Settings splits these: General → iPhone Storage (disk), General → AirDrop & Handoff → Universal Clipboard (clipboard), Safari → Privacy (browsing history). + +**Proposed fix.** Two options: +1. **Split into "Storage" + "Privacy"** at the hub level. Storage = downloaded APKs only. Privacy = clipboard, hide seen, viewed history, **and the telemetry toggle from C1**. Adds one hub row (now 11), but groups by user goal. This is the architecturally honest answer. +2. **Keep one screen but rename to "Privacy & data"** and add internal sub-sections "Disk" / "Clipboard" / "History" with `titleSmall` sub-headers (the screen-internal convention from §3 general). Less disruptive, slightly muddier. + +Prefer #1. The user's "rethink each category" directive supports it. + +**Severity.** Major (named in user's original brief). + +--- + +### M2. Per-scope override toggle wording is wrong direction + +**Problem.** §4.3 Card 2 #3: *"Each row has a trailing toggle 'Use main connection' (default ON). When toggled OFF, the row expands … into a mini-editor."* §7.2 #3 already flags this: *"Could also be 'Inherit from main' — more technical, possibly clearer to power users."* + +The user audience is power users. "Use main connection" reads ambiguously — it sounds like enabling a connection, not inheriting a setting. The toggle is also semantically inverted from the visual: ON = no editor visible (you can't change anything). Users will toggle it to "see the controls" and then panic when they realize they just unhooked the override. + +**Evidence.** +- iOS Settings → Wi-Fi → Network → "Configure DNS": uses radio buttons "Automatic / Manual" — same problem, better solution. Two explicit choices, no toggle inversion. +- Tailscale → Network → Exit Node: uses "Inherit from network" vs explicit "Override". +- Material 3 Switch guidance: *"Use switches for on/off states of a single setting, not for revealing more options."* + +**Proposed fix.** Replace the toggle with a 2-way segment per scope row: +``` +[ Use main ] [ Custom… ] +``` +"Custom…" reveals the mini-editor. "Use main" hides it. This is what 1Password does for per-vault settings ("Inherit / Custom"), and Bitwarden for organization policies ("Use default / Override"). + +Bonus: a segment makes the "override is on" state visually obvious without subtitle parsing. + +**Severity.** Major (every power user hits this). + +--- + +### M3. PAC files / SOCKS variants / proxy URL paste are unaddressed + +**Problem.** §4.3 mode segment offers Direct / System / HTTP / SOCKS. The user community asking for proxy support (China + privacy-conscious EU + Tor) will request: +- **SOCKS4 vs SOCKS5** distinction. Today's `ProxyConfig.Socks` doesn't expose version — fine if SOCKS5 only, but worth confirming. Tor's local proxy is SOCKS5; some corporate proxies are SOCKS4a. +- **HTTPS proxy** vs HTTP proxy. Many corporate MITM proxies require HTTPS. Today's `ProxyConfig.Http` is ambiguous in name. +- **PAC files / proxy auto-config URL**. Mentioned in the review brief; not covered by the spec at all. Many enterprise users have a PAC URL from IT. Firefox + Chrome + macOS System Settings + Windows Internet Options all support PAC. +- **Paste full proxy URL** as a fast-path (e.g. `socks5://user:pass@127.0.0.1:1080`). Tailscale + warp-cli + curl all accept this format. Five fields is a lot of typing; one paste field is mass-market UX. + +**Evidence.** +- Tor Browser Bundle, Settings → Connection → "Use a bridge" → bridge string is one paste field, not 5 separate inputs. +- Cloudflare WARP-CLI supports `warp-cli set-custom-proxy` with full URL. +- Material 3 form guidance: *"If a single input can replace multiple, prefer the single input with smart parsing."* + +**Proposed fix.** +1. Confirm `ProxyConfig.Socks` is SOCKS5-only (spec it explicitly: rename mode pill "SOCKS5" rather than "SOCKS"). If SOCKS4 is needed, add a sub-toggle. +2. Confirm "HTTP" actually supports HTTPS proxies (it likely does via JVM `Proxy.Type.HTTP` which serves both). If yes, rename the pill "HTTP/HTTPS" or just leave "HTTP" with a tooltip. +3. Add a "Paste proxy URL" affordance: a small text-button below the form fields ("Paste full URL"). On click, opens a modal with one paste field that parses `scheme://user:pass@host:port` and populates the form. Don't replace the form; supplement it. +4. **PAC file**: spec it as an explicit non-goal for v1 with a one-line follow-up ticket, or commit to a Mode pill 5: "PAC URL". Don't leave it ambiguous — users will ask. + +**Severity.** Major (will generate GitHub issues within a week of release if not addressed). + +--- + +### M4. Color contrast on `outlineVariant.copy(alpha = 0.55f)` will fail WCAG AA on Cream (amber light) palette + +**Problem.** §2.2: *"`border = BorderStroke(1.dp, outlineVariant.copy(alpha = 0.55f))`"*. This is the universal border for the entire redesign. Material 3's `outlineVariant` is already low-contrast (~3:1 against `surfaceContainerLow`), and multiplying by 0.55 alpha makes it ~1.7:1 against light Cream surfaces. + +**Evidence.** +- WCAG 2.2, SC 1.4.11 Non-text Contrast: **3:1 minimum** for UI component boundaries. +- Material 3 contrast tables: `outlineVariant` is designed for low-emphasis dividers, not standalone container borders. When used as a container border (your case), Material recommends `outline` (which is `outlineVariant` boosted ~2x). +- The amber Cream light palette is on file in `D3`. Cream's `surfaceContainerLow` is high-luminance off-white; the border will be nearly invisible. + +**Proposed fix.** Either: +1. Drop the 0.55 alpha. Use `outlineVariant` at full opacity. +2. Use `outline` at 0.55 alpha (lands at roughly the same visual weight on dark themes but stays above 3:1 on Cream light). +3. Audit per-palette. The architect should screenshot Nord/Cream/Forest/Plum × Light/Dark/AMOLED with a candidate `TweaksEntryRow` and confirm border visibility before committing. + +Run option #2, then audit. + +**Severity.** Major (accessibility compliance + Cream light is the maintainer's recently-changed lead-theme; visually buggy there is highly visible). + +--- + +### M5. 48dp tap-target accounting + chevron a11y are unaudited + +**Problem.** §2.2 hub row: *"padding(horizontal = 16.dp, vertical = 14.dp)"* + icon tile 40.dp + two-line text. Total row height depends on font metrics; with Geist `titleMedium` (~22sp line height) + 2dp gap + `bodySmall` (~16sp) + 28dp = roughly **68dp tall**. ✔ exceeds 48dp. + +But: §3.4 Custom forges row has a trailing `IconButton` for delete ("Trailing: `Icons.Outlined.DeleteOutline` `IconButton` → confirmation"). A 24dp icon inside the row needs **48dp tap target** per Material guidance and **44pt** per Apple HIG. Spec doesn't say. Also: an `IconButton` *inside* a clickable row creates a nested-click trap — tapping near the icon may register the row click first (Compose ripple bubbles to outer clickable unless the inner is wrapped in `onClick = {}` with `interactionSource`). + +**Evidence.** +- Material 3 Touch Targets: 48 × 48dp minimum for all interactive elements. +- WCAG 2.2 SC 2.5.8 (AA): 24 × 24 CSS pixel minimum; 44 × 44 strongly recommended. +- Compose nested clickables: the outer `Modifier.clickable` swallows child clicks if not explicitly excluded via `consumeWindowInsets` or empty inner click handler (this has been a recurring bug — see Compose 1.6 release notes on `clickable` consumption). + +**Proposed fix.** +1. Spec the delete `IconButton` as 48dp min with internal 24dp icon and `Modifier.clip(Radii.chip)` for ripple. +2. Spec the parent row's `Modifier.clickable` to **not** be the only click handler — the row click navigates, but the delete button stops propagation. +3. A11y semantics for every hub row: + ``` + semantics { + role = Role.Button + contentDescription = "$title. $subtitle. Double-tap to open." + } + ``` + Chevron icon: `contentDescription = null` (decorative). Status pills (Install method "Needs permission"): exposed as a `liveRegion = Polite` so TalkBack announces re-evaluation when permissions change. +4. Override toggle accessibility (§4.3 Card 2): when toggled OFF and editor expands, announce "Discovery override on, custom proxy settings for Discovery." + +**Severity.** Major (accessibility is global app concern, not per-feature). + +--- + +### M6. i18n: hub-row subtitle truncation + plural rules + RTL are unspec'd + +**Problem.** §2.2: *"Subtitle: bodySmall, onSurfaceVariant, max 1 line, ellipsize."* §1.1 examples: *"DeepL · auto on"*, *"Auto · every 6h"*, *"Nord · Dark"*. These are English-shape sentences. + +- **German**: "Auto · alle 6 Stunden" (no abbreviation for "hour"; "alle 6 Std." possible but unusual). "Befolge Systemeinstellungen · Dunkel" for "Follow system · Dark" — 35 chars before "Dark" even appears. Subtitle ellipsizes at ~28 chars on mobile. +- **Russian**: "Каждые 6 часов" + provider names like "Майкрософт" — Cyrillic is wider per char. +- **CJK** (Simplified Chinese, Traditional Chinese, Japanese, Korean): the · separator may not parse — CJK uses 「、」 or 「·」 (different code point). Compose `Text` handles BOM but the dot character should be locale-aware. Also no spaces between words is the norm in CJK, so "DeepL · 自动开启" works but feels foreign. +- **RTL** (Arabic, Hebrew): the · separator order flips, chevron flips. App must wrap subtitles in `LocaleTextDirection.Content` so dot-separated tokens lay out right. +- **Plurals**: "%d host" / "%d hosts" (§6 row for `custom_forges_count_label`) — this requires Android plural resources (`plurals.xml`) per locale; Compose Multiplatform Resources supports plurals (`pluralStringResource` in `org.jetbrains.compose.resources` since 1.6). Russian has 3 plural forms, Polish has 3, Arabic has 6. Naive "%d hosts" breaks all of them. + +**Evidence.** +- W3C i18n best practices, "Don't use 'one' / 'other'" — use CLDR plural categories. +- Apple HIG Localization: *"Allow at least 50% extra width for German and Russian. CJK can compress 30%."* +- Compose Multiplatform Resources release notes: `pluralStringResource` is the supported API; today's code uses `stringResource(..., count)` which is **not** plural-aware. + +**Proposed fix.** +1. Mandate `pluralStringResource` for any subtitle / label with a count, and add plural XML for every locale that doesn't have it. Audit: %d tokens, %d hosts, %d apps (skipped updates count). +2. Spec subtitle truncation behavior: *"Truncate with ellipsis. If subtitle has multiple dot-separated tokens, prefer truncating the rightmost token first."* (This is hard in Compose; pragmatic compromise: cap subtitle at ~32 chars in any locale, drop secondary token if over.) +3. Use a locale-aware separator: `LocalConfiguration.current.locales` → CJK locales use 「· 」(with width adjustment) or " / "; non-CJK uses " · ". +4. Wrap all hub rows in `CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr)` only for the chevron icon — text content respects user direction. +5. **Translator handoff**: the §6 rename table is English-only. Generate a CSV of "old key → new English → context note" for translators per locale. Don't ship the redesign with stale 12 locales (the user's policy elsewhere ships with English first and queues translations; that's acceptable, but it must be explicit in the spec, not implicit). + +**Severity.** Major (13 locales is a load-bearing project property). + +--- + +### M7. About sub-screen swallows the emergency "Send feedback" path + +**Problem.** §1.3 / §3.10: Send feedback moves into About sub-screen. The hub no longer surfaces feedback. Today, a user encountering a bug can reach Feedback in: Profile → Tweaks → scroll to about → Feedback button. New flow: Profile → Tweaks → About row → Feedback row → sheet. **One extra tap** at the worst possible moment — the user is already frustrated. + +**Evidence.** +- NN/g, "Error recovery": friction during an error state is multiplied by user frustration. Each extra tap during a bug-report flow doubles the abandonment rate (Sauro 2016 on error-recovery UI). +- Most apps that have "Send feedback" or "Report a problem" surface it on the Settings root, not buried. Bitwarden: Settings → "Help" (one tap). 1Password: Settings → "Help" (one tap). Tailscale: Settings → "Bug report" (one tap). +- The user's brief: *"What about emergencies (something broke, user wants to report fast)?"* — direct callout. + +**Proposed fix.** Three patterns to consider: +1. **Keep feedback as its own hub row** at the bottom of the "App" block, alongside About. Cost: 11 rows in hub instead of 10. Worth it. +2. Add a **floating "Help" overflow icon** in the hub topbar (architect's §2.1 says no trailing — reconsider). Pulls open a sheet with "Send feedback / What's new / Privacy". +3. Surface feedback as a **persistent banner** at the bottom of the hub only when a crash has occurred in the current session (use `CrashReporter`'s presence). Best-case UX, more engineering. + +Ship #1. It's the cheapest and matches industry norm. + +**Severity.** Major (named in user's brief; emotional moment). + +--- + +### M8. Cross-platform conditional rules in §1.2 hide too much + +**Problem.** §1.2: *"Hub omits the Install method row entirely on desktop. No greyed-out placeholder."* §3.6 Desktop note: *"if reached via deep link on desktop, show a centered empty state — 'Install method is Android-only'"*. Hiding entirely is consistent with iOS HIG ("don't show what doesn't apply"). But: + +- Users supporting both platforms (e.g. running desktop + Android Pixel) will assume parity. Hiding means they can't even tell the feature exists. This contradicts the broader trend in this app: the Tweaks screen exists *because* both platforms share most settings. +- The architect's chosen mitigation (centered empty state on deep link) is asymmetric — works in one direction (Android user opens deep link on desktop) but not the other (desktop user wondering "how do I make APK install silent on my Pixel from here"). + +**Evidence.** +- Apple HIG Settings, Cross-platform: *"Where settings are platform-specific, surface a small badge instead of hiding entirely so users know the feature exists elsewhere."* +- Discord, Slack, 1Password all do this with "Coming soon on this platform" or "Mobile only" subtitle badges. + +**Proposed fix.** Show the row on desktop with a **subtitle badge** "Android only" (use `tertiaryContainer` pill), clicking shows the §3.6 empty state with explanation. Same for any future iOS-only or Linux-only rows. The visual cost (one row) is dominated by the discoverability gain. + +(Alternative: leave architect's decision intact, but require a one-line entry under About on desktop: "On Android: install method, silent updates, attribution.") + +**Severity.** Major (user is dual-platform power user). + +--- + +### M9. "Connection" + "Sources" hub block name "Network & data" doesn't match its rows + +**Problem.** §1.1 block "Network & data" contains: Connection, Sources, Translation, Access tokens. +- "Translation" is not network-related from the user's mental model — it's a feature of repo Details. A user looking for "Translation settings" won't think "Network & data." +- "Access tokens" is closer to "Account / Security," not "Network." + +**Evidence.** +- NN/g, "Information Scent": users follow labels that match their intent. "Translation" under "Network & data" has weak scent. +- iOS Settings groups Translation under "Apps" or "General"; Tokens / passwords are always under "Passwords & Security." +- The architect's own §6 rename: `section_network` "Network" → "Network & data" (hub block header) — calling this a "data" block is a stretch when 2 of 4 children are arguably not about data movement. + +**Proposed fix.** Re-block: +1. **Look & feel**: Appearance, Language ✔ (no change) +2. **Connectivity**: Connection, Sources ← new tighter block +3. **Content & translation**: Translation (single row; or merge with another sibling) ← OR put Translation under Look & feel since it's per-locale-y +4. **Security & accounts**: Access tokens, telemetry (from C1) ← new block +5. **Installs & updates** ✔ (no change) +6. **Storage** + **Privacy** (from M1) +7. **App**: About, Send feedback (from M7) + +That's 7 blocks for 12 rows — heavier than the current 4 blocks for 10 rows, but each block is internally coherent. Alternative compromise: keep 4 blocks but rename "Network & data" → "Connectivity" and move Translation out (e.g. into Look & feel or its own micro-block). + +**Severity.** Major (the user's brief explicitly asked about this). + +--- + +### M10. Restart-on-language-change UX preserved but not specified for the new flow + +**Problem.** §3.2: *"Validation / events: selecting a language fires `OnAppLanguageSelected(tag)` → existing `OnAppLanguageChangeRequiresRestart` snackbar with 'Restart now' action. Unchanged."* + +But the new flow is: hub → tap "Language" row → arrive on `TweaksLanguageScreen` → tap a language → snackbar appears on … which screen? If the snackbar appears on `TweaksLanguageScreen` and the user taps "Restart now," the app restarts and lands on Home (existing behavior) — user loses position. If the user dismisses the snackbar and navigates back to the hub, the snackbar host is gone — but the language is still set, and the app hasn't restarted. Are language and UI now in inconsistent state until the next restart? + +**Evidence.** +- Today's monolithic `TweaksRoot.kt` has one `SnackbarHostState` for the whole screen; the snackbar persists across user actions until dismissed. +- New per-sub-screen pattern (§3 general: *"each sub-screen has its own `SnackbarHostState`"*) means the language snackbar belongs to `TweaksLanguageScreen` only. Navigating back drops it. +- Material 3 Snackbar guidance: "Restart now" is a high-stakes action — should be a banner, not a transient snackbar, when stakes are this high. + +**Proposed fix.** +1. Replace the "Restart now" snackbar with a **persistent banner** at the top of `TweaksLanguageScreen` that says "Language changed. Restart the app to see it everywhere. [Restart now] [Later]". Reuse existing `Banner` if any, otherwise a `Radii.row` outlined Surface with `tertiaryContainer` tint. +2. Persist the "needs restart" flag in `TweaksRepository` so navigating back to the hub still shows a top-level banner: "Restart pending — some screens may still show the old language." +3. On hub, show the banner above the first block. On any sub-screen, show it at the top of the screen. Dismisses only on app restart. + +**Severity.** Major (today's behavior is acceptable monolithic; new IA breaks it implicitly). + +--- + +## Nits / polish + +- **N1.** §2.2 spring scale 0.98× — fine on mobile, may feel "stuck" on desktop with a mouse cursor. Recommend `0.985×` on desktop, `0.97×` on Android (per D10's mention of `MediumBouncy`). +- **N2.** §3.5 Auto-translate target picker "drill-row" → "smaller variant of the §3.2 Language screen" — should explicitly say "uses the same searchable list but filters to `SupportedTranslationLanguages.all`." Otherwise risk of two diverging language pickers maintained separately. +- **N3.** §3.7 "every 6h" pill labels: spell out as "6 hours" not "6h" — abbreviations don't translate. Also: a 24-hour pill on an app store is unusual; consider "Every 12h" / "Every day" / "Manual only" instead. 3h is very aggressive given GitHub API rate limits. +- **N4.** §3.10 About screen: tagline copy *"Cross-platform app store for GitHub, Codeberg, and Forgejo releases."* is good. Add a localized version with `` placeholders for the three product names so translators don't relocalize "Codeberg". +- **N5.** §3.4 Custom forges row hostname in `Geist Mono labelLarge` — confirm mono font weight reads on the smaller subtitle line below; mono fonts often need +1 weight bump to match proportional siblings. +- **N6.** §3.3 / §4 spec is silent on whether the test request honors per-host PATs (`HostTokenInterceptor`). It should — testing without the token won't catch auth-broken proxies for private GH Enterprise + PAT users. +- **N7.** §6 row: `installer_type_default` → "System installer (Default)" — the parenthetical adds noise. Just "System installer" reads cleaner. The word "Default" was always a non-noun in this UI. +- **N8.** §3.10 "Open source licenses" row labeled as *"placeholder row, hide if not implemented"* — please don't ship a hidden row. Either implement it now (it's a static markdown view; 30 min of work) or omit until done. +- **N9.** §2.2 "icon tile … 40.dp square clipped to `Radii.chip`, background `colorScheme.surfaceContainerHigh`, padded 8.dp, tinted `onSurfaceVariant`" — every row has its own colored tile. The architect listed icons by Material name only; no per-row tile tint. Material 3 settings convention is one tile color (neutral) or one tint per **block** (not per row). Per-row colored tiles add visual noise; the spec already doesn't do this, but the user's brief asked the question — confirm in the spec that tiles are uniformly tinted (not per-icon-color). One line in §2.2. +- **N10.** §4.3 *"Inline helper at the bottom of the form (when expanded): 'Used for fetching repos, downloads, and translations unless you override below.'"* — that's a long sentence under a form. Compress: "Applies to all traffic unless overridden below." +- **N11.** §3.1 *"AMOLED toggle visibility binds to `resolvedDark`, not the raw `isDarkTheme` value"* — call out that this is true for the new `Mode segment` too. The toggle visibility computation depends on the segment selection + system theme. State machine deserves one diagram in spec. +- **N12.** §3.2 the language-tag mono font subtitle (`pl-PL`) is great UX. But for "Follow system" the subtitle should show the *resolved* tag in parens, e.g. "Follow system (en-US)" so the user knows what they're getting. + +--- + +## Things the architect got right + +1. **Splitting "Network" into Connection + Sources.** This is the spec's strongest IA call. The two were always different mental models bolted together. (§1.1 / §1.3) +2. **The hub's dynamic-state subtitle pattern.** Showing "DeepL · auto on" / "Nord · Dark" / "Direct" on the hub row gives users glance-readable state without entering — exactly the right mitigation for the discoverability cost of hub-and-spoke. (§2.2) +3. **Killing the 3× duplicated proxy form.** Whatever wins between Option A / B / C, **anything** is better than three identical 7-field forms. The current code (`feature/tweaks/presentation/components/sections/Network.kt:161-171`) is the worst pattern in the entire feature. The user's complaint here is fully justified and the architect heard it. +4. **The §3.5 Translation provider redesign.** Moving from chips + below-card form to radio rows + revealed credentials card is the right pattern (5 providers, 4 with credentials, 1-of-N selection — that's a radio group, not a chip group). (§3.5) +5. **Section sub-headers in sentence case** (drop `.uppercase()`). Universally correct call; ALLCAPS feels shouty in 2026 UI. (§2.3) +6. **Rejecting `RoundedCornerShape(32.dp)` cards** in favor of `Radii.row` consistency. The current code has three competing shape vocabularies; collapsing to one is overdue. (§5.3) +7. **Empty-state for custom forges with "Add a Forgejo or Gitea host" framing.** Honest copy that names the system. Better than today's generic "Add your own". (§3.4) +8. **§7.4 explicit "things I did not change" list.** Architect's restraint here is good — pinning down what's *not* touched is half of any redesign spec. (Though §7.4's promise to not change `ProxyRepository` schema needs reversal per C2.) + +--- + +## Open questions for product owner + +1. **Telemetry — does it exist today?** (`TelemetryRepository` is injected but there's no UI surface I could find.) If yes, where; if no, do we ship the opt-out in this PR or block on backend? (C1) +2. **Master proxy persistence — is a `ProxyRepository` schema bump acceptable in this phase?** Adds one field per scope (`useMaster: Boolean`) + a fourth master record. (C2) +3. **Search-in-settings + deep links** — pick one, both, or none for v1? (C5) +4. **Library cleanup vs Privacy split** — keep one screen with sub-sections, or split into two hub rows? (M1) +5. **Feedback affordance** — own hub row, topbar overflow, or buried in About? (M7) +6. **Cross-platform-only rows** — hide entirely (architect's pick) or show with "Android only" badge? (M8) +7. **String rename translation policy** — ship in English with `[needs translation]` markers, queue with maintainer translators, or block redesign on 13-locale parity? (M6) +8. **"Direct" rename** — "No proxy" recommended; final call? (C3) +9. **PAC files** — explicit non-goal, or fifth mode pill? (M3) +10. **Restart banner pattern** — adopt persistent banner across all sub-screens for "language pending," "theme migrating," "telemetry consent updated"? (M10) +11. **Hub block grouping** — 4 blocks (architect) or 6–7 tighter blocks (M9)? + +End of review. diff --git a/.design/P12_5_TWEAKS_STRINGS.csv b/.design/P12_5_TWEAKS_STRINGS.csv new file mode 100644 index 000000000..7ebeee0c4 --- /dev/null +++ b/.design/P12_5_TWEAKS_STRINGS.csv @@ -0,0 +1,158 @@ +Resource key,Old English,New English +tweaks_title,Tweaks,Tweaks +section_appearance,Appearance,Appearance +theme_color,Theme color,Palette +theme_light,Light,Light +theme_dark,Dark,Dark +theme_system,System,Follow system +amoled_black_theme,AMOLED black,True black (AMOLED) +amoled_black_description,Use pure black for OLED screens.,Pure-black background — saves power on OLED screens. +system_font,System font,Use system font +system_font_description,Use the system font for the app.,Use your device's default typeface instead of Geist. +scrollbar_option_title,Show scrollbar,Show scrollbar +scrollbar_option_description,Show scrollbar on the right side.,Always show the scrollbar on long pages. +content_width_title,Content width,Content width +content_width_description,Adjust max content width.,How wide content should stretch on big windows. +section_language,Language,Language +language_intro,Choose your app language.,Pick the language used across the app. +language_picker_title,App language,App language +language_picker_description,Restart required after change.,The app restarts when you switch language. +language_follow_system,Follow system,Follow system +language_follow_system_subtitle,(new),Follow system · %1$s +section_network,Network,(retired) +section_connectivity,(new),Connectivity +section_privacy_and_data,(new),Privacy & data +section_app_block,(new),App +section_installs_and_updates,(new),Installs & updates +connection_entry_title,(new),Connection +connection_entry_subtitle_no_proxy,(new),No proxy +connection_entry_subtitle_system,(new),System proxy +connection_entry_subtitle_proxy_with_overrides,(new),%1$s · %2$d override / %1$s · %2$d overrides +sources_entry_title,(new),Sources +proxy_scope_intro,Configure proxies per scope.,(retired) +proxy_scope_discovery_title,Discovery proxy,Search & metadata +proxy_scope_download_title,Download proxy,Downloads +proxy_scope_translation_title,Translation proxy,Translation +proxy_scope_discovery_description,Used for API and search.,"GitHub API, search results, repo details." +proxy_scope_download_description,Used for APK downloads.,APK and asset downloads. +proxy_scope_translation_description,Used for translation providers.,"DeepL, Microsoft, LibreTranslate calls." +proxy_none,None,No proxy +proxy_none_description,No proxy used.,The app connects to the internet without a proxy. +proxy_system,System,System +proxy_system_description,Use system proxy.,Use the proxy configured in your OS. +proxy_http,HTTP,HTTP/HTTPS +proxy_http_caption,(new),Most corporate proxies. +proxy_socks,SOCKS,SOCKS5 +proxy_socks_caption,(new),"Tor, SSH tunnels." +proxy_test,Test,(retired) +proxy_test_main,(new),Test main connection +proxy_test_scope,(new),Test for %1$s +proxy_save,Save,Save +proxy_test_success,Proxy reachable (%d ms),Connected in %1$d ms +proxy_test_main_success,(new),Search ✓ %1$d ms · Downloads ✓ %2$d ms · Translation ✓ %3$d ms +proxy_use_main,(new),Use main +proxy_use_custom,(new),Custom… +proxy_using_main_pill,(new),Using main +proxy_paste_full_url,(new),Paste full URL +proxy_paste_url_placeholder,(new),scheme://user:pass@host:port +proxy_paste_url_error,(new),Couldn't read that URL. +connection_intro_title,(new),How the app reaches the internet +connection_intro_body,(new),Pick a connection mode below. Most people leave this on No proxy. +connection_inline_helper,(new),Applies to all traffic unless overridden below. +connection_overrides_body,(new),Each scope uses the main connection by default. Choose 'Custom' to give a scope its own settings. +mirror_tweaks_entry_label,Mirror,GitHub mirror +custom_forges_entry_label,Custom forges,Custom forges +custom_forges_entry_subtitle,Add your own Forgejo/Gitea hosts.,Add a Forgejo or Gitea host +custom_forges_count (plural),%d hosts,%d host / %d hosts +section_translation,Translation,Translation +translation_intro,Configure translation provider and auto-translate.,Pick the engine the app uses to translate READMEs. +translation_provider_title,Provider,Provider +translation_provider_description,Pick a translation engine.,(retired) +translation_auto_title,Auto-translate,Translate READMEs automatically +translation_auto_subtitle,Translate READMEs when opening repos.,"When opening a repo, translate the README into your target language." +section_installation,INSTALLATION,Install method +install_method_android_only_badge,(new),Android only +install_method_desktop_empty_title,(new),Install method is Android-only +install_method_desktop_empty_body,(new),Silent installers and installer attribution are Android features. Desktop installs go through the OS package manager. +installer_type_default,Default,System installer +installer_type_default_description,Uses Android's default installer.,Asks each time. Works on every device. +installer_type_shizuku,Shizuku,Shizuku +installer_type_shizuku_description,Silent install via Shizuku.,Silent install. Needs Shizuku app running. +installer_type_dhizuku,Dhizuku,Dhizuku +installer_type_dhizuku_description,Silent install via Dhizuku.,Silent install. No root needed. +installer_type_root,Root,Root +installer_type_root_description,Silent install with root.,Silent install via root. Power-user only. +installer_attribution_title,Installer attribution,Pretend the installer is… +installer_attribution_description,Some apps reject silent installs unless installer claims to be Google Play.,Some apps reject silent installs unless the installer claims to be Google Play. This setting controls what we claim. +section_updates,UPDATES,Update behavior +auto_update_title,Auto-update,Install updates in the background +auto_update_description,Apply downloaded updates automatically.,Apply downloaded updates without notifying you each time. +update_check_enabled_title,Background update check,Check automatically +update_check_enabled_description,Periodically check for new releases.,Look for new releases on a schedule. +update_check_interval_title,Check interval,Check every +update_check_interval_description,How often to check for updates.,(retired) +update_interval_6h,(new),Every 6 hours +update_interval_12h,(new),Every 12 hours +update_interval_24h,(new),Daily +update_interval_manual,(new),Manual only +update_desktop_check_on_launch_title,(new),Check on launch +update_desktop_check_on_launch_subtitle,(new),Check for updates each time the app starts. +include_pre_releases_title,Include pre-releases,Include pre-releases +include_pre_releases_description,Treat alpha/beta as available updates.,Show alpha and beta tags as available updates. +skipped_updates_entry_title,Skipped updates,Skipped updates +skipped_updates_count (plural),(new),%d app / %d apps +skipped_updates_empty_subtitle,(new),Nothing skipped +hidden_repositories_title,Hidden repositories,Hidden repositories +hidden_repositories_count (plural),(new),%d repo / %d repos +hidden_repositories_empty_subtitle,(new),No hidden repos +storage,Storage,Storage +downloaded_packages,Downloaded packages,Downloaded APKs +downloaded_packages_description,Installer files kept for resumed updates.,We keep installers around so updates resume fast. +current_size,Current size:,Using: +section_privacy_screen_title,(new),Privacy +privacy_entry_subtitle,(new),Compact dynamic state — see §3.9 +privacy_usage_data_subheader,(new),Usage data +privacy_telemetry_title,(new),Share anonymous usage data +privacy_telemetry_subtitle,(new),Help us understand which features get used. +privacy_telemetry_collect_expand,(new),What we collect +privacy_telemetry_collect_body,(new),App version. OS and platform. Feature usage counts. No repo names. No tokens. No identifiers. +privacy_telemetry_on_snackbar,(new),Sharing usage data starting next launch. +privacy_telemetry_off_snackbar,(new),Usage data sharing stopped. Existing data is dropped. +auto_detect_clipboard_links,Auto-detect clipboard links,Detect repo links in clipboard +auto_detect_clipboard_description,Detect copied repo URLs and offer to open them.,"When you copy a github.com or codeberg.org link, we'll prompt to open it." +privacy_history_subheader,(new),Browsing history +hide_seen_title,Hide seen,Hide repos I've already viewed +hide_seen_description,Skip already-viewed repos in feeds.,Skip seen repos in feeds and search. +clear_seen_history,Clear seen history,Clear viewed history +clear_seen_history_description,Reset the seen-repo list.,Forget which repos you've already opened. +clear_seen_history_confirm,(new),Clear all viewed history? This won't unstar or unfavorite anything. +host_tokens_count (plural),(new),%d token / %d tokens +host_tokens_empty_subtitle,(new),No tokens yet +section_about,About,App info +app_info_tagline,(new),"Cross-platform app store for GitHub, Codeberg, and Forgejo releases." +app_info_action_whats_new,(new),What's new +app_info_action_whats_new_subtitle,(new),Past release notes. +app_info_action_licenses,(new),Open source licenses +app_info_action_licenses_subtitle,(new),Libraries used in the app. +app_info_action_privacy_policy,(new),Privacy policy +app_info_action_privacy_policy_subtitle,(new),View on github-store.org. +app_info_action_source_code,(new),Source code on GitHub +app_info_action_source_code_subtitle,(new),View this app's source. +app_info_version_copied,(new),Version copied. +version,Version,Version +feedback_send,Send feedback,Send feedback +feedback_hub_subtitle,(new),We read every report. +tweaks_search_placeholder,(new),Search settings +tweaks_search_empty,(new),No settings match '%1$s'. +restart_banner_body,(new),Some changes need a restart to apply. +restart_banner_reasons_prefix,(new),Affected: %1$s +restart_banner_reason_language,(new),language +restart_banner_reason_theme,(new),theme +restart_banner_reason_telemetry,(new),usage data +restart_banner_restart_now,(new),Restart now +restart_banner_later,(new),Later +menubar_help_menu,(new),Help +menubar_help_about,(new),About GitHub Store +menubar_help_feedback,(new),Send feedback… +menubar_help_licenses,(new),Open source licenses +menubar_help_privacy,(new),Privacy policy diff --git a/.design/SCREEN-INVENTORY.md b/.design/SCREEN-INVENTORY.md new file mode 100644 index 000000000..4a3d18cff --- /dev/null +++ b/.design/SCREEN-INVENTORY.md @@ -0,0 +1,90 @@ +# Screen Inventory + +Master list of every screen in GHS + its coverage by the handoff. Drives Phase 6+ work order. Uncovered screens get pre-build design pass + maintainer approval per decision D9. + +## Coverage legend + +- **FULL** — handoff has ASCII layout + spec + JSX reference for this screen +- **PARTIAL** — handoff mentions the screen but only fragments (e.g. component used, but no full layout) +- **NONE** — handoff doesn't cover; extrapolate from primitives/patterns + ask user + +## Primary routes (from `GithubStoreGraph.kt`) + +| # | Route | Module | Coverage | Handoff ref | Notes | +|---|-------|--------|----------|-------------|-------| +| 1 | `HomeScreen` | `feature/home/` | **FULL** | DESIGN.md §9.3 / §10.1 + `home.jsx` + `mobile.jsx` | Lead + Hot + Trending + Popular + From-your-stars | +| 2 | `SearchScreen(initialPlatform)` | `feature/search/` | **PARTIAL** | DESIGN.md §10.3 + `desktop.jsx` `DesktopSearchScreen` | Source toggle (GitHub/Codeberg/Custom) NOT in handoff | +| 3 | `AuthenticationScreen` | `feature/auth/` | **PARTIAL** | DESIGN.md §16.5 (full-screen sheet pattern w/ device-flow code) | Web-OAuth path + PAT path need extrapolation | +| 4 | `DetailsScreen(repoId, owner, repo, isComingFromUpdate, sourceHost)` | `feature/details/` | **FULL** | DESIGN.md §8.4 + §9.4 + `desktop.jsx` + `mobile.jsx` + `detail-inner.jsx` | Foreign-source variant needs handling | +| 5 | `DeveloperProfileScreen(username)` | `feature/dev-profile/` | **NONE** | — | Pre-build design pass | +| 6 | `ProfileScreen` | `feature/profile/` | **PARTIAL** | DESIGN.md §8.1 mentions user card + MIGRATION.md §risky areas (Connect + Business inquiries vs donate) | Need full layout pass | +| 7 | `TweaksScreen` | `feature/tweaks/` | **PARTIAL** | DESIGN.md §8.1 mentions Settings entry, `tweaks-panel.jsx` shows palette picker only | Full Tweaks UI needs design — many sub-sections (Network, Translation, Installation, Updates, etc.) | +| 8 | `FavouritesScreen` | `feature/favourites/` | **NONE** | — | Reuse list-row pattern but pre-build design pass | +| 9 | `StarredReposScreen` | `feature/starred/` | **NONE** | — | Reuse list-row pattern but pre-build design pass | +| 10 | `RecentlyViewedScreen` | `feature/recently-viewed/` | **NONE** | — | Reuse list-row pattern but pre-build design pass | +| 11 | `AppsScreen` (= Library) | `feature/apps/` | **FULL** | DESIGN.md §8.3 / §9.5 + `desktop.jsx` (two-pane) + `mobile.jsx` | Desktop two-pane is net-new for GHS | +| 12 | `ExternalImportScreen` | `feature/apps/` (import wizard) | **PARTIAL** | DESIGN.md §16.5 full-screen sheet pattern | Multi-step Obtainium import flow needs full design | +| 13 | `MirrorPickerScreen` | `feature/tweaks/` (mirror sub-screen) | **NONE** | tokens.json mentions `SignalBars` for mirror speed | Pre-build design pass | +| 14 | `SkippedUpdatesScreen` | `feature/tweaks/` (updates sub-screen) | **NONE** | — | Pre-build design pass | +| 15 | `HiddenRepositoriesScreen` | `feature/tweaks/` (updates sub-screen) | **NONE** | — | Pre-build design pass | +| 16 | `WhatsNewHistoryScreen` | `feature/profile/` or standalone | **NONE** | DESIGN.md §16.6 mentions What's-new sheet (per-version), not history page | Pre-build design pass | +| 17 | `AnnouncementsScreen` | `feature/profile/` or standalone | **NONE** | — | Pre-build design pass | +| 18 | `StarredPickerScreen` | `feature/apps/` (link-app wizard) | **NONE** | — | Pre-build design pass | +| 19 | `HostTokensScreen` | `feature/tweaks/` (access tokens) | **NONE** | — | Pre-build design pass (per-host PAT manager — GHS-specific) | + +## Cross-screen overlay surfaces (DESIGN.md §16) + +| Surface | Coverage | Where it shows up | +|---------|----------|-------------------| +| Bottom sheet (asset picker, install variant, source) | **FULL** §16.1 | Details install panel, link-app sheet | +| Confirm dialog (sign out, uninstall, clear cache) | **FULL** §16.2 | Profile sign-out, Apps uninstall, Tweaks clear | +| Toast (retry-after, install complete, network changed) | **FULL** §16.3 | Cross-app | +| Diagnostics card (Send feedback) | **FULL** §16.4 | Tweaks → Feedback (current `FeedbackBottomSheet`) | +| Full-screen sheet (OAuth, PAT, Imports wizard) | **FULL** §16.5 | Auth, ExternalImport | +| What's-new sheet (per-version one-shot) | **FULL** §16.6 | First launch after upgrade (existing `WhatsNewHistory` ≠ this sheet) | +| Dropdown menu (translate lang, sort, palette) | **PARTIAL** §16.7 | TranslateButton in Details, Apps sort menu | + +## Sub-components / dialogs that need design (GHS-specific, no handoff coverage) + +- **CustomForgesDialog** (Tweaks → Network → Custom forges) — list + add/remove forge hosts +- **AdvancedAppSettingsBottomSheet** (Apps → row long-press) — asset filter regex, monorepo fallback, pin variant, include pre-releases +- **LinkAppBottomSheet** (Apps → unlinked device app) — search GitHub repo + pick + link +- **ApkInspectSheet** (Details → Android only) — partial coverage in DESIGN.md §9.6 standalone APK Inspect screen +- **ReleaseAssetsPicker / VersionPicker / VersionTypePicker** (Details install panel) — pickers for asset / version / channel +- **AutoSuggestMirrorSheet** (Tweaks → Mirror auto-suggest) — locale-gated suggestion +- **FeedbackBottomSheet** (Tweaks → Feedback) — already aligned with §16.4 pattern +- **DownloadProgress / InstallProgress UI** (Details install flow) — DESIGN.md mentions inline progress but doesn't detail +- **LanguagePicker** (Details → translate) — DESIGN.md §16.7 pattern +- **TranslationControls** (Details → translate bar) — provider-aware toggle +- **PlatformSectionCard** (Details → cross-platform assets) — group APK/EXE/DMG by platform + +## Sub-screens not in `GithubStoreGraph.kt` but composed inside features + +- **APK Inspect** (full-screen, Android only) — handoff DESIGN.md §9.6 covers FULL +- **Inner Detail (About / What's-new tabs + version rail)** — handoff DESIGN.md §8.4 covers FULL +- **Onboarding** (first launch) — not currently a screen in GHS but worth considering. MIGRATION.md mentions "first-launch surfaces" +- **Empty states** (Library before scan, Search before query, Updates when none, Favourites empty) — `patterns.md` §Pattern: Empty state + +## What stays purely behavioural (no UI redesign) + +- Install flow (Shizuku / Dhizuku / Root / Default) — pickers re-styled, logic untouched +- Background update check (WorkManager) — no UI surface +- KSafe encryption layer — no UI +- Crash reporter (Desktop) — no UI +- WinGet publish workflow — CI only +- SignPath Windows signing — CI only + +## Open per-screen questions (resolve in their respective phases) + +1. **Onboarding** — build it? (Currently no formal onboarding; first launch goes straight to Home.) +2. **Profile Connect rows** — what platforms? (Mastodon, GitHub Discussions, Reddit, Discord? Maintainer pick.) +3. **Profile Business inquiries** — what data? (Email + GitHub Issues link?) +4. **WhatsNewHistory** — page or per-version sheet only? (Current GHS has a route — keep as history list.) +5. **Announcements** — backend-driven or static? (Current is backend-driven.) +6. **MirrorPickerScreen vs MirrorPickerSheet** — is full-screen still right, or move to bottom sheet? +7. **HostTokensScreen** — table-style or card-style rows? (Sensitive data; mask PAT by default.) +8. **CustomForgesDialog** — keep as dialog or promote to sub-screen? +9. **Desktop two-pane Apps** — does it apply to Favourites/Starred/Recently-viewed too? +10. **Search source toggle** — chip row above results or in filter sheet? + +These get re-asked in the relevant phase. diff --git a/.design/UI-SPEC.md b/.design/UI-SPEC.md new file mode 100644 index 000000000..c51ca44b5 --- /dev/null +++ b/.design/UI-SPEC.md @@ -0,0 +1,950 @@ +# GitHub Store — UI Build Spec (Design System Refresh) + +> Branch: `feat/design-system-refresh`. Anchored against `/Users/rainxchzed/Downloads/handoff 4/` (DESIGN.md, tokens.json, patterns.md, design-system.md, prototype `.jsx` files). All Kotlin/Compose specs target Compose Multiplatform — `commonMain` unless noted. No code yet; this is the build contract. +> +> Voice: Silent Vocabulary (80%) + Expressive moments (20%). Friendly editorial. Honest data. +> +> Token references use the literal keys from `handoff 4/tokens.json` (e.g. `palettes.nord.light.primary`, `shape.radii.card-lg`, `status.freshness.hot`). DESIGN.md citations look like `DESIGN.md §7.3`. + +--- + +## 0. Glossary & conventions + +- **T** — current palette+mode token bundle (`bg`, `surface`, `surface2`, `ink`, `ink2`, `outline`, `primary`, `tintP`, `success`, `successT`, `danger`, `dangerT`). Defined per palette × {light, dark} in `tokens.json#palettes`. +- **Status** — palette-independent semantic colors (`tokens.json#status.{freshness,wax,perm,trend}`). Same hex in every palette. +- **Accent** — per-repo brand color triplet `{c, lt, dt}` (DESIGN.md §2.4). Travels with the repo; never mutates with palette. +- **dp** — Compose `Dp` units. Compose tokens consume the integer "primary"/"secondary" pair from `tokens.json#shape.radii.*` (the `css` strings are reference only). +- **Wonky** — fully asymmetric `RoundedCornerShape` with four distinct corner radii (DESIGN.md §5.2). Implemented as `AbsoluteRoundedCornerShape(topStart=…, topEnd=…, bottomEnd=…, bottomStart=…)`. +- **Mono** — JetBrains Mono. Used **only** for technical artifacts (version tags, hashes, file sizes, package names). Never for prose. +- **Primary surface** — the most important interactive thing on the screen. One per surface (DESIGN.md §3, §7.1, design-system.md §11.1). +- **`GhsTheme`** — new theme composable (the wrapper that replaces today's `GithubStoreTheme`). Wraps `MaterialExpressiveTheme` and publishes `LocalGhsTokens`, `LocalGhsStatus`, `LocalGhsTypography`, `LocalGhsShapes`, `LocalGhsAccent`. + +Compose Multiplatform deltas vs the JSX prototypes: +- No CSS `box-shadow` strings — translate to `Modifier.shadow(elevation, shape, …)`. +- No `linear-gradient` for chrome — only for the Lead-hero radial bloom (DESIGN.md §2.5 explicitly allows it) and the Profile identity card (patterns.md §"Hero card with gradient", `tintP → surface` only). +- No `borderRadius: 'A B C D / E F G H'` 8-value strings. Compose can express that with `GenericShape`; for the wonky squircle we use a 4-corner `AbsoluteRoundedCornerShape` and accept that the second axis is symmetric. The visual delta is negligible at the sizes we use. + +--- + +## 1. Primitive catalogue (Silent Vocabulary + Expressive) + +Every primitive lives in `core/presentation/src/commonMain/.../theme/primitives/`. Public composables, no business state, no Koin. Inputs are typed pure data. + +Cross-platform rule for all primitives that handle pointer input: the **touch target** is min 48dp on Android; Desktop uses `Modifier.pointerHoverIcon(PointerIcon.Hand)` for hover-actionable primitives only (rings/dots are pure decoration). Where a primitive accepts a `tooltip: String?` Desktop uses `TooltipBox`; Android ignores it. + +### 1.1 FreshnessRing + +| Field | Value | +|---|---| +| Answers | "How recent is this release?" (DESIGN.md §4.1) | +| Inputs | `daysSinceRelease: Int?`, `size: Dp = 56.dp`, `strokeWidth: Dp = 2.5.dp`, `accent: Color? = null`, `content: @Composable BoxScope.() -> Unit` | +| Geometry | Outer arc starts at -90°, sweep = `360f * fraction`. Inner ring (full circle) drawn at `outline @ 0.35 alpha` as the unfilled track. Inset 1.5dp between ring and content. | +| State buckets | From `tokens.json#thresholds.freshness`: `hot 0–3d`, `fresh 4–30d`, `warm 31–90d`, `cool 91–365d`, `dormant >365d`. Fractions `1.00 / 0.78 / 0.55 / 0.30 / 0.12`. Colors from `status.freshness.{state}`. | +| Accent rule | When `accent != null`, the *outer* arc uses `accent.c`; the bucket color appears as a small chord segment at the tail to disambiguate. Default (no accent) uses bucket color for the whole arc. | +| States | default; loading (full ring `outline @ 0.5`, slow pulse 1.6s); null-input (no ring, content alone, no halo). | +| Behavioral rules | Always paired with an avatar inside (DESIGN.md §7.4). Never used as a button. **Never** stacked with Heartbeat in the same row (DESIGN.md §6.1). | +| Cross-platform | Compose `Canvas` draws the arc; the content slot is a `Box` accepting an image or letter avatar. | + +### 1.2 Heartbeat + +| Field | Value | +|---|---| +| Answers | "Is this project alive?" | +| Inputs | `daysSinceUpdate: Int?`, `size: Dp = 8.dp`, `showHalo: Boolean = true`, `tint: Color? = null` | +| Geometry | Center dot `size`. Halo circle scales 1.0 → 2.4 with opacity 0.45 → 0 (`tokens.json#motion.heartbeat`). | +| State buckets | From `tokens.json#thresholds.maintenance`: `active ≤1d → 1.4s`, `recent ≤7d → 2.4s`, `quiet ≤30d → 4.2s`, `dormant >30d → no animation`. | +| Colors | `active/recent → status.freshness.fresh`; `quiet → status.freshness.warm`; `dormant → status.freshness.dormant` (static, no halo). | +| States | default; dormant (no animation); reduced-motion (use static dot, regardless of bucket — read `LocalConfiguration` on Android, `Toolkit.getDefaultToolkit().getDesktopProperty("awt.dynamicLayoutSupported")` is not reliable; expose a `GhsMotionPreference` Local instead, defaulting to "full"). | +| Behavioral rules | **Never** in dense list rows alongside FreshnessRing (DESIGN.md §6.1, §15). Allowed: detail vital signs, library row when ring is suppressed, polling indicator in OAuth full-screen sheet (§16.5 — repurposed as "this flow is alive"). | +| Cross-platform | `rememberInfiniteTransition` for both desktop & android. | + +### 1.3 StarTier + +| Field | Value | +|---|---| +| Answers | "How big a deal is this?" | +| Inputs | `stars: Int`, `size: Dp = 11.dp`, `tint: Color? = null` (defaults to `T.ink`) | +| Geometry | 5 stars in a row, 1.5dp gap. Filled `tier` stars; the rest outlined at `0.35 alpha`. | +| Buckets | `tokens.json#thresholds.stars`: 0→1, 1k→2, 10k→3, 50k→4, 100k→5. | +| Numeric tail | Optional `showCount: Boolean = true` → appends count in `mono 12` (Inter Tight tabular numerals — design-system.md §5.3). Uses `CountFormatter` (already exists at `core/presentation/.../utils/CountFormatter.kt`). | +| States | default; disabled (50% alpha, used in hidden-repos screen). | + +### 1.4 WaxSeal + +| Field | Value | +|---|---| +| Answers | "Can I trust this binary?" | +| Inputs | `state: WaxState` = `Intact | Cracked | Open`, `size: Dp = 36.dp`, `fingerprint: String? = null` | +| Geometry | Octagonal stamp shape (DESIGN.md §7.8). Cracked state has a jagged centre fissure (drawn as `Path` with two angled lines + small spalls). Open state uses a `1.5dp dashed` stroke on the outer outline only. | +| Colors | `Intact → status.wax.intact (#8B4A2B)` fill, ink2 outline; `Cracked → status.wax.cracked (#B83A2C)` fill, white check that becomes a red cross; `Open → transparent fill, status.wax.open (#8E8E8E)` dashed outline. | +| States | The cracked state is the **only** place the app uses red aggressively (DESIGN.md §7.8). Must visibly scream — full `T.danger` border on the containing card and a wax-seal toast (§toast). | +| Behavioral rules | Anchored top-right of the install panel on Desktop, slightly rotated -6° for "stamped" feel (DESIGN.md §8.2/§7.8). Top of the card on Android (full width). Always paired with the signing fingerprint in `mono 11`. | +| Fallbacks | No `signingFingerprint` on `InstalledApp` → render `Open` state with caption "Unsigned" (`T.ink2`). | + +### 1.5 VersionDelta + +| Field | Value | +|---|---| +| Answers | "How risky is this update?" (`patch | minor | major`) | +| Inputs | `delta: SemverDelta = Patch | Minor | Major | Unknown`, `tint: Color? = null` | +| Geometry | `Patch` = one dot (4dp). `Minor` = two dots (4dp, 3dp gap). `Major` = filled bar 14×4 with a 1dp slash at 60° through it. | +| Colors | tint defaults to `T.primary` for patch/minor; `T.danger` for major. Unknown → grey dot at `T.ink2`. | +| Behavioral rules | Computed from `installedVersion` vs `latestVersion` via `VersionMath` (exists). When parse fails → `Unknown`. | +| Cross-platform | Pure Canvas. | + +### 1.6 VersionStack + +| Field | Value | +|---|---| +| Answers | "How far behind am I?" | +| Inputs | `skippedCount: Int`, `maxBars: Int = 6`, `barHeightStep: Dp = 1.5.dp`, `accent: Color? = null` | +| Geometry | A row of N vertical bars, each `width 2.5dp`, growing in height by `barHeightStep` per bar (4dp, 5.5dp, 7dp, …). Bars beyond `maxBars` collapse into a single bar with `+` suffix. | +| Colors | Bars use `accent.c` if provided, else `T.primary`. | +| Use sites | Apps tab badge (Android bottom nav, DESIGN.md §9.1), update banner inline glyph (DESIGN.md §11.2 / patterns.md §"Update banner"), Library row trailing indicator. | +| Fallbacks | `skippedCount == 0` → composable returns nothing (no empty render). When the data layer doesn't expose "skipped" history, count = `if (isUpdateAvailable) 1 else 0`. | + +### 1.7 PermDot + +| Field | Value | +|---|---| +| Answers | "How dangerous are the permissions?" | +| Inputs | `risk: PermRisk = Low | Moderate | High`, `size: Dp = 10.dp`, `withHalo: Boolean = false` | +| Geometry | Single dot. Halo = 1px ring `1.5dp` outside the dot at 35% alpha. | +| Colors | `tokens.json#status.perm.{low,moderate,high}`: `#6BA068 / #C49652 / #B83A2C`. | +| Use sites | APK Inspect screen permission groups (Android only); vital signs grid "PERMISSIONS" tile on Detail. | +| Fallback | Not an APK or no permission breakdown → tile shows "—" instead of a dot. | + +### 1.8 PlatformGlyph + +| Field | Value | +|---|---| +| Answers | "Will it run on my OS?" | +| Inputs | `platform: DiscoveryPlatform`, `supported: Boolean`, `size: Dp = 18.dp` | +| Geometry | Monochrome silhouettes (DESIGN.md §4.1) — phone, window, apple, penguin. Filled when `supported`, `1.2dp` dashed stroke when not. | +| Source enum | `core/domain/.../DiscoveryPlatform` (`Android, Macos, Windows, Linux`). | +| Colors | Always `LocalContentColor` (defaults to `T.ink`). Never carries the per-app accent. | + +### 1.9 TopicGlyph + +| Field | Value | +|---|---| +| Answers | "What kind of app is this?" | +| Inputs | `topic: String`, `size: Dp = 14.dp` | +| Geometry | Micro-pictograms from `tokens.json#topicGlyphs.supported`: `self-hosted, mobile, photo, video, book, manga, key, audio, backup, reader, cross-platform, cloud`. | +| Aliasing | `tokens.json#topicGlyphs.topicAliases` handles `password-manager → key`, etc. If neither match → return null Composable (do not render — DESIGN.md §4.2 "or omit"). | +| Behavioral rules | At most **3 per card** (DESIGN.md §4.2). Always monochrome `T.ink2`. Never colored. | + +### 1.10 SignalBars + +| Field | Value | +|---|---| +| Answers | "How fast is this mirror?" | +| Inputs | `tier: Int (0..4)`, `size: Dp = 14.dp` | +| Geometry | 4 ascending bars (3dp wide, gap 1.5dp, heights 4/7/10/13dp). Filled = `T.primary`. Unfilled = `outline @ 0.5`. | +| Use sites | `MirrorPickerScreen` rows; download speed indicator in toast. | +| Fallbacks | Mirror health not measured yet → tier 0 (all unfilled). Add caption "Untested" in `caption` text. | + +### 1.11 DownloadWeight + +| Field | Value | +|---|---| +| Answers | "How widely adopted?" | +| Inputs | `downloads: Long`, `maxSize: Dp = 14.dp`, `tint: Color? = null` | +| Geometry | Filled circle whose radius scales `log10(downloads + 1)` mapped to `[4.dp, maxSize/2]`. Caps at maxSize. | +| Colors | `tint ?: T.ink2`. | +| Behavioral rules | Pair with `mono` count text. Never use alone in dense rows — `StarTier` already does adoption. Reserve for Detail "vital signs" and Developer profile. | +| Fallbacks | `downloads == 0` → render only the caption "—". (Forgejo repos may have 0 because the asset aggregation is best-effort.) | + +### 1.12 LicensePosture + +| Field | Value | +|---|---| +| Answers | "Is this restrictive?" | +| Inputs | `spdxId: String?`, `size: Dp = 16.dp` | +| Geometry | A small tile (square, radius 3dp): filled `©` glyph for copyleft, dashed-border `·` glyph for permissive, no glyph for unknown. | +| Buckets | `tokens.json#licenses.copyleft` and `licenses.permissive`. | +| Colors | Foreground `T.ink2`. Filled bg uses `T.surface2` for copyleft (visual weight), transparent for permissive (visual lightness). | +| Fallbacks | Not in either bucket → return null. | + +### 1.13 CookieShape (Expressive) + +| Field | Value | +|---|---| +| Use sites (3 total) | Brand "G" mark (top-left on every primary surface); user identity tile (Profile); active bottom-nav tab on Android. DESIGN.md §4.3, §15. | +| Path | `tokens.json#shape.cookie.path` (9-petal organic). ViewBox `0 0 100 100`. Implement as a `Shape` derived from `androidx.compose.ui.graphics.Path` so it can be used as `clip` + as the path of a `BorderStroke` (Compose: `GenericShape` constructor returning a `Path` scaled to size). | +| Inputs | `letter: String? = null`, `tint: Color = T.primary`, `size: Dp = 32.dp`, `contentColor: Color = Color.White` | +| States | Default (filled `tint`); pressed (`tint @ 0.85`); disabled (replaced with circle, not Cookie — Cookie is identity-only). | +| Behavioral rules | **Never multiple Cookies adjacent** (DESIGN.md §4.4). Desktop drawer's brand mark + user tile sit at opposite ends of the drawer — that satisfies the rule. | + +### 1.14 Squiggle (Expressive) + +| Field | Value | +|---|---| +| Use sites | Section headings; bottom-sheet headings; confirm-dialog headings; diagnostics-card separators. **One per heading** (DESIGN.md §15). | +| Path | `tokens.json#shape.squiggle.path`. Aspect 40×5, stroke 1.6px. | +| Inputs | `width: Dp = 40.dp`, `color: Color = T.primary`, `opacity: Float = 0.6f` | +| Cross-platform | Pure Canvas. | + +--- + +## 2. Component catalogue + +All components live in `core/presentation/.../components/`. Shapes referenced as `GhsShapes.X` where X is one of `chip, row, cardSm, card, cardLg, hero, heroLg, wonky, wonkyAlt, wonkySearch` — these mirror `tokens.json#shape.radii.*` and `shape.wonkySquircle.*`. + +### 2.1 Buttons + +| Variant | Use | Visual spec | +|---|---|---| +| `GhsButtonPrimary` | Install, Update, Open, Sign in | Wonky squircle `GhsShapes.wonky`. Height `48.dp` (Android touch), `40.dp` desktop. Padding `horizontal 18.dp, vertical 10.dp`. Background `T.primary`. Content color `Color.White`. Elevation: `Modifier.shadow(8.dp, GhsShapes.wonky, ambientColor = T.primary.copy(alpha=0.4f), spotColor = T.primary.copy(alpha=0.6f))`. Press: scale 0.97, 100ms. Disabled: `T.primary @ 0.4`, no shadow. | +| `GhsButtonAccent` | Update inside an app-context card | Same as Primary but `background = accent.c`, shadow uses `accent.c`. Used for the in-card Update CTA (patterns.md §"Update banner"). | +| `GhsButtonTinted` | Get, Read more, See all | `GhsShapes.card`. Background `T.tintP`. Content `T.primary`. No shadow. Height `40.dp` / `36.dp`. | +| `GhsButtonOutline` | Inspect, Refresh, Cancel | `GhsShapes.card` or `RoundedCornerShape(50%)` (pill) for nav-row "Cancel". 1.dp border `T.outline`. Background transparent. Content `T.ink`. | +| `GhsButtonDanger` | Destructive confirm | Wonky. Background `T.danger`. Content white. Same shadow recipe but with `T.danger`. | +| `GhsIconButton` | Back, share, favourite, more, dismiss | 36×36.dp transparent box (Desktop) / 48×48.dp (Android). Centered glyph `20.dp`. Tap ripple `T.ink @ 0.08` (design-system.md §10.1). | + +State matrix for every button: + +| State | Bg delta | Content delta | Border / shadow | +|---|---|---|---| +| default | base | base | as defined | +| hover (desktop) | `+5%` lighten / `−5%` darken | unchanged | shadow `+2.dp` for Primary only | +| pressed | `-8%` lightness | unchanged | shadow `-2.dp`, scale `0.97f` 100ms | +| focused (keyboard) | base | base | 2.dp ring `T.primary @ 0.6`, offset `2.dp` | +| disabled | `α 0.4` | `α 0.6` | no shadow | + +### 2.2 Chips + +| Variant | Visual spec | +|---|---| +| `GhsChipFilter` (on) | `GhsShapes.chip`, padding `H 12.dp V 5.dp`, font `Inter Tight 12 / 600`. Bg `T.tintP`, content `T.primary`, 1.dp border `T.primary @ 0.33`. Trailing `×` 14.dp when removable. | +| `GhsChipFilter` (off) | Same shape. Bg transparent. Content `T.ink`. 1.dp border `T.outline`. | +| `GhsChipAdd` | Dashed 1.dp border `T.outline`. `+ Add filter`. Same height & font as filter chip. | +| `GhsChipPill` | `RoundedCornerShape(50%)`. Used only for the search-suggestion recent-query chips. | + +Touch target: chip rows on Android pad an extra 6.dp vertically (so visual 26.dp height → 38.dp touch row). Hit testing extends to the gap between chips. + +### 2.3 Cards + +| Variant | Visual spec | +|---|---| +| `GhsCardLead` (hero with bloom) | Shape `GhsShapes.wonkyAlt`. Padding `20.dp 22.dp 18.dp`. Background `T.surface` with a `RadialGradient` overlay (center 30% from top-left, radius `card.maxWidth`, color stops `[accent.lt @ 0.6 → Color.Transparent]`). Border `1.dp T.outline @ 0.4`. Soft shadow `0 12.dp 32.dp -12.dp T.ink @ 0.18`. Cap width on Desktop at `LocalContentWidth` from `core/presentation/.../locals/LocalContentWidth.kt`. | +| `GhsCardCompact` | Shape `GhsShapes.card`. Padding `14.dp`. Background `T.surface`. 1.dp border `T.outline`. Soft shadow `0 1.dp 0 ink@0.04, 0 8.dp 22.dp -16.dp T.ink @ 0.18` (design-system.md §8). Internal layout per DESIGN.md §7.3 (avatar+ring | name+secondary; description 2-line clamp; topic glyphs row; dashed divider; StarTier + count, platform silhouettes). | +| `GhsCardListRow` | Shape `GhsShapes.row`. Padding `H 12.dp V 10.dp`. Bg `T.surface`. No border (rely on row separators). | +| `GhsCardInstall` (Detail) | Shape `GhsShapes.heroLg`. Padding `18.dp`. Bg `T.surface`. Holds: Primary CTA full-width, secondary asset selector, wax seal anchored top-right (`offset(x=8.dp, y=-8.dp)` + `rotate(-6f)`). | +| `GhsCardIntegrity` | `T.successT` bg, 1.dp `T.success` border, white-on-success check badge. Used as inline alternative for wax-intact in compact contexts. | +| `GhsCardHeroGradient` (Profile) | `linear-gradient(135deg, T.tintP 0%, T.surface 60%)`. Shape `GhsShapes.heroLg`. (patterns.md §"Hero card with gradient" — `tintP → surface` only.) | + +### 2.4 Section header + +``` +[Glyph 20dp] [Fraunces italic 22/600 -0.02em] [· sub-count caption ink2] [See all ›] + [~~~ Squiggle 36–42dp wide, 1.6dp stroke, primary @ 0.6 ~~~] +``` + +| Spec | Value | +|---|---| +| Height | Title row 28.dp + squiggle 8.dp + bottom pad 12.dp | +| Glyph + title gap | 8.dp | +| Squiggle | Aligned under the title's first 36–42dp | +| `See all ›` | `GhsButtonTinted` size-small, height 28.dp, padding `H 10.dp V 4.dp` | +| Meta variant | If section is meta-label not editorial: drop italic + squiggle, use `H3-meta` style from `tokens.json#typography.scale.h3-meta` (Inter Tight 700, uppercase, tracking 0.06em). | + +### 2.5 Banner + +| Spec | Value | +|---|---| +| Shape | `GhsShapes.card` | +| Bg / border (4 variants) | `info: T.tintP / T.primary@0.33`, `success: T.successT / T.success@0.33`, `warn: accent.lt / accent.c@0.33`, `error: T.dangerT / T.danger@0.33` | +| Padding | `H 12.dp V 10.dp` | +| Layout | `[Glyph 18.dp] [Text Inter Tight 13/500 + optional mono 12 detail] [Action button optional] [× dismiss 18.dp]` | +| Use sites | clipboard banner (Home), update banner (Apps, Detail), integrity warnings (Detail), rate-limit notice (any screen) | + +### 2.6 Vital signs 2×2 grid (Detail) + +| Spec | Value | +|---|---| +| Container | 2 cols × 2 rows, gap `12.dp`, each tile `GhsShapes.cardSm`, bg `T.surface2`, padding `12.dp` | +| Tile order (fixed) | `RELEASED · MAINTAINED · STARS · PERMISSIONS` (DESIGN.md §7.7) | +| Tile internal | Glyph (22.dp height) → value `Fraunces italic 600 / 13.sp` colored to the signal → label `caption uppercase 9.5.sp`. | +| Signal colors | RELEASED uses `status.freshness.*`. MAINTAINED uses heartbeat color. STARS uses `T.ink`. PERMISSIONS uses `status.perm.*`. | +| Empty data | Permissions on a non-APK repo → render "—" centered, no glyph; label stays. | + +### 2.7 Wax-seal trust card + +Detail screen (DESIGN.md §7.8). Spec already covered in §1.4 visual; container is `GhsCardInstall`'s top-right anchor (Desktop) or a separate `GhsCardCompact` with the wax seal as the leading 44.dp glyph (Android). + +### 2.8 Bottom sheet + +Compose's `ModalBottomSheet`. DESIGN.md §16.1. + +| Spec | Value | +|---|---| +| Shape | `RoundedCornerShape(topStart=24.dp, topEnd=18.dp, bottomEnd=0.dp, bottomStart=0.dp)` (wonky top corners only) | +| Bg | `T.surface`. Optional 1.dp top border `T.outline`. | +| Drag handle | 36×4.dp pill, `T.ink2 @ 0.3`, top margin 8.dp | +| Heading | Fraunces italic 20 + Squiggle below. Top pad 12.dp. | +| Action row | Sticky bottom, padding 16.dp. Cancel outline (left), primary wonky (right). LTR. | +| Scrim | `T.ink @ 0.5` | +| Animation | Slide-up 240ms ease-out; scrim 180ms fade | +| Dismiss | tap scrim **unless** the sheet hosts an irreversible action | + +Use sites: asset picker (Details), mirror picker, language picker, library import wizard, asset filter chooser. + +### 2.9 Confirm dialog + +DESIGN.md §16.2 / patterns.md §"Confirmation dialog". Use `BasicAlertDialog`. + +| Spec | Value | +|---|---| +| Shape | Wonky `GhsShapes.wonkyAlt`. Max-width 320.dp mobile / 400.dp desktop. | +| Bg | `T.surface`. Scrim `T.ink @ 0.55`. | +| Optional context glyph | Centered top, 36.dp. Picks from §1: WaxSeal cracked, PermDot red ring, CookieShape, VersionStack. | +| Heading | Fraunces italic 18, centered, weight 600. **Specific question form** ("Uninstall immich?", not "Are you sure?"). | +| Body | Inter Tight 13, `T.ink2`, max 3 lines. Explains *consequence*. | +| Actions | Right-aligned. Cancel (outline) left, Confirm (wonky primary or wonky danger) right. | +| Touch | Min 48.dp button height. | + +### 2.10 Toast + +DESIGN.md §16.3. Use Compose's `SnackbarHost` with a custom Snackbar composable that uses our wonky squircle. + +| Spec | Value | +|---|---| +| Shape | `GhsShapes.wonky` | +| Bg / border | by variant (info/success/error/warn) same matrix as Banner §2.5 | +| Leading glyph | Mandatory. From silent vocabulary (DESIGN.md §16.3). | +| Body | Inter Tight 13/600 + optional mono in `T.ink2` | +| Position Android | Bottom-center, 84.dp from bottom (above gesture-nav). Width = `screen − 32.dp`. | +| Position Desktop | Bottom-right, 24.dp inset, max-width 380.dp. | +| Duration | info 3s, action 4s with "Undo", error 6s, **cracked-seal sticky until tap**. | +| Stack | Max 3 visible. Newer push older up. | + +### 2.11 Full-screen sheet + +DESIGN.md §16.5. Used for OAuth device flow, PAT entry, Library Imports wizard, Web-OAuth handoff "waiting" screen. + +| Spec | Value | +|---|---| +| Layout Android | Full-screen Composable host inside the existing nav graph (`AuthenticationScreen`, `ExternalImportScreen`). | +| Layout Desktop | Modal-style dialog 480×640, wonky `GhsShapes.heroLg`. | +| Top bar | Back arrow (24.dp). No title. | +| Identity mark | 64–96.dp CookieShape `letter = "G"` (sign-in) or topic glyph (imports). | +| Heading | Fraunces italic 24 + Squiggle. | +| Numbered steps | Primary-tinted circle markers (`size 18.dp`, bg `T.tintP`, text `T.primary`). Step text Inter Tight 14/500. | +| Code reveal box | Mono 28–32, wonky border 1.5.dp `T.primary`, padding 16.dp. Tap → copy → toast. | +| Polling indicator | Heartbeat glyph (re-using §1.2) + caption "waiting" | +| Fallback CTA | Always offer PAT path as `GhsButtonOutline` at bottom. | + +### 2.12 Dropdown menus + +Used by: translation language picker (already in `details/components/LanguagePicker.kt`), sort order menus, palette picker in Tweaks, "more" `⋯` actions. + +| Spec | Value | +|---|---| +| Shape | `GhsShapes.card` | +| Bg | `T.surface`, 1.dp border `T.outline`, shadow `0 10.dp 24.dp -12.dp T.ink @ 0.35` | +| Item | Height 40.dp. Padding `H 12.dp`. Optional leading glyph 16.dp, label Inter Tight 13, optional trailing mono detail or checkmark `T.primary`. | +| Hover (desktop) | bg `T.tintP @ 0.5` | +| Pressed | bg `T.tintP` | +| Selected | trailing `✓ T.primary` | + +### 2.13 Bottom nav (Android only) + +DESIGN.md §9.1 + MIGRATION.md (4 → 3 tabs + detached search). + +| Spec | Value | +|---|---| +| Tabs (final order) | `Home`, `Search`, `Apps` (a.k.a. Library), `Profile` | +| Height | 64.dp + system gesture inset | +| Bg | `T.surface` with shadow `0 8.dp 24.dp -12.dp T.ink @ 0.32` | +| Inactive | Outline glyph 20.dp `T.ink2`, label Inter Tight 11/500 `T.ink2` below | +| Active | CookieShape (size 40.dp) bg `T.primary` behind a knocked-out white 20.dp glyph; label Fraunces italic 12/600 `T.primary` below | +| Apps tab badge | VersionStack at top-right (replaces M3 numeric badge) when `pendingUpdates > 0` | +| Tap state | Tap layer `T.ink @ 0.08`, 120ms | + +(Desktop has no bottom nav — drawer §3 below.) + +### 2.14 Desktop drawer (Desktop only) + +Replaces the current "side rail / drawer hybrid" with the spec from DESIGN.md §8.1. + +| Spec | Value | +|---|---| +| Width | 240.dp | +| Bg | `T.bg` (sits flush against window chrome) | +| Brand row | CookieShape (28.dp, "G", `T.primary`) + "GitHub Store" Inter Tight 14/600 | +| Search input | Wonky `GhsShapes.wonkySearch`, 1.dp `T.outline`, leading glyph, trailing `⌘K` mono caption. Tap → `SearchScreen`. | +| Nav item | Height 40.dp. Padding H 12.dp. Glyph 20.dp + label Inter Tight 14. Active: bg `T.tintP`, content `T.primary`, weight 600, shape `radD(13,10)` (`GhsShapes.row`). Inactive: transparent, content `T.ink`, weight 500. | +| User card (bottom) | CookieShape with user initial; primary fill. Subtitle in caption ink2 ("3 updates"). | +| Sticky bottom group | `Settings ⌘,`, `Shortcuts ?`. | + +--- + +## 3. Screen-by-screen visual spec + +The screens below are scoped to the existing modules. Files cited are `commonMain` unless noted. The "Data fields" column references actual domain models — `GithubRepoSummary`, `GithubRelease`, `InstalledApp`, `GithubUser`, `GithubUserProfile`, `Announcement`, `HostToken`, `MirrorConfig`, `ApkInspection`. + +### 3.1 `feature/home/` — Discovery feed + +Files: `HomeRoot.kt`, `HomeViewModel.kt`, `HomeState.kt`, `components/HomeFilterChips.kt`. + +**Layout (vertical scroll, Android + Desktop):** + +``` +[Brand row Android: G-cookie · "GitHub Store" · User-cookie] (Desktop: drawer handles brand) +[Search input (wonky) — tap → SearchScreen] +[Clipboard banner — if clipboard URL parses to a repo, conditional] +[Time-window chip row: Today · Week · Month · All] (HomeFilterChips, keep) +[Lead release card · GhsCardLead with accent radial bloom] +[Section: Hot releases squiggle] +[ → Horizontal scroll Android / 2-col grid Desktop of GhsCardCompact ] +[Section: Trending now squiggle] +[ → Vertical list with #N rank chips on the left ] +[Section: Most popular squiggle] +[ → Vertical list, rank in Fraunces italic 0.55 opacity ] +[Section: From your stars (auth required) squiggle ] +[ → Vertical list, APK-shipping starred repos] +``` + +| Component | Data | +|---|---| +| Lead release | `GithubRepoSummary` + freshest `GithubRelease.publishedAt`. Accent from §6. | +| Hot releases | `GithubRepoSummary[]` filtered by time window. | +| Trending now | Backend rank → `position #N`. If backend missing (DESIGN.md §11), use local sort proxy and drop the rank chip (don't fake). | +| Most popular | Sorted by `stargazersCount`. | +| From your stars | `StarredRepository`, scope: APK-shipping. Empty state if signed-out. | + +Android vs Desktop deltas: +- Android: top bar (52.dp) with G-cookie left, U-cookie right. +- Desktop: drawer is persistent; right pane shows the same vertical list at `LocalContentWidth` (COMPACT 720 / WIDE 960 / EXTRA_WIDE 1200). The "Most popular" and "Trending" lists become 2-column grids when content width ≥ WIDE. + +### 3.2 `feature/search/` — Search + +Files: `SearchRoot.kt`, `SearchViewModel.kt`, `SearchState.kt`. + +``` +[Top bar: ← back · search input (wonky, autofocus) · ⋯ filters] +[Source toggle: GitHub | Codeberg | Custom forge] (NEW chip row) +[Filter chips: Platform (Android/Win/macOS/Linux/All) · Sort · Language] +[Recent queries (when empty) — chip cloud] +[Results list — GhsCardListRow] +``` + +| Component | Data | +|---|---| +| Source toggle | `RepositorySource` enum already exists. GitHub default. Codeberg defaults to `codeberg.org`. Custom forge opens dropdown picker of user-added hosts. | +| Result row | Avatar+FreshnessRing left, name (Fraunces italic) + owner caption, StarTier+count, PlatformGlyphs trailing. Tap → `DetailsScreen(sourceHost=…)`. | + +Android vs Desktop: +- Android: input is the only thing in the top bar. Filters via a `⋯` menu opening a bottom sheet. +- Desktop: filters expand inline as a chips row under the input. Source toggle is a segmented control on the right. + +### 3.3 `feature/details/` — Repo detail + +Files: `DetailsRoot.kt`, `DetailsViewModel.kt`, `components/sections/{Header,About,Stats,WhatsNew,Owner,ReleaseChannel,ReportIssue,Logs}.kt`. + +**Android layout (centered hero, stacked):** + +``` +[Top bar: ← ↗ open external · ♡ favourite · ⋯] +[Hero: 92.dp avatar in FreshnessRing + repo name (Fraunces italic 28) + owner · TopicGlyphs (max 3) + StarTier ★★★★★ + count · DownloadWeight mono] +[Install panel (GhsCardInstall): + [Primary wonky CTA full-width — Install · 48 MB] + meta row: PermDot · PlatformGlyph(arch) · SignalBars (mirror) + wax seal card (top of section, full width on Android)] +[Vital signs 2×2: RELEASED · MAINTAINED · STARS · PERMISSIONS] +[About section preview (3 lines clamp) → "Read more"] +[What's new preview → "Show all versions"] +``` + +**Desktop layout (two-column):** + +``` +[Top bar: ← (when entered from Home/Search) breadcrumb · share] +[Hero block left-aligned 65% width] [Right column 35% — wax seal card rotated -6°, +[Install panel — same as Android] vital signs 2×2, platform silhouettes] +[About + What's new tabs underneath] +``` + +| Component | Data | +|---|---| +| Hero | `GithubRepoSummary` + chosen `GithubRelease`. Accent from §6. | +| Install panel | Asset picker bottom-sheet, primary CTA varies per `SmartInstallButton` (already exists). Wax seal uses `InstalledApp.signingFingerprint` and an expected fingerprint (when previously installed) — currently the data layer doesn't store expected fingerprint for non-installed repos; **fallback: Open state with "Signed by maintainer" caption.** | +| Vital signs | RELEASED uses `releaseRecency` days; MAINTAINED uses `updatedAt` delta; STARS uses `stargazersCount`; PERMISSIONS uses `ApkInspection` if available (Android only), else "—". | +| About preview | First 3 lines of README. "Read more" opens inner About screen (next sub-section). | +| What's new preview | Latest `GithubRelease.description` first 3 lines + `JetBrains Mono` tag. | + +**Detail inner — About / What's new (DESIGN.md §8.4):** + +``` +[Top bar: ← repo-name · [About | What's new] · 🌐 EN▾ (TranslationControls existing)] +[About: rendered README, multiplatform-markdown-renderer] + OR +[What's new: split view] + [Version rail (left)] v2.7.5 · 2d ago ← current + v2.7.4 · 1w ago + v2.7.3 · 2w ago + v2.7.0 [YOU] ← installed badge + [Selected version notes (right)] Fraunces italic title, Inter Tight body, mono version tag +``` + +Version rail on Android collapses to a vertical list above the notes (single column). Tap a row → notes update inline. + +### 3.4 `feature/apps/` — Library (installed apps) + +Files: `AppsRoot.kt`, `AppsViewModel.kt`. Also covers `ExternalImportScreen`, `StarredPickerScreen` (both lives in same module). + +**Android layout (single pane):** + +``` +[Top bar: Library Fraunces 28 · "5 apps · 1 update available" [filter ⚙]] +[Update banner if any: VersionStack · vOLD → vNEW · primary wonky Update CTA — patterns.md §"Update banner"] +[Segmented chip row: Installed · Updates [1] · Pending] +[List of GhsCardListRow per installed app] + [Avatar+FreshnessRing] [name (Fraunces italic) · mono version · heartbeat] [trailing: Open / Update / Inspect] +``` + +**Desktop layout (TWO-PANE — NEW for GHS):** + +``` +[Drawer 240] [List pane 380] [Detail pane 660] + [Header: "Library · 5 apps · 1 upd"] + [Filter tabs: Installed/Updates/Stars/Recent] + [Update banner inline (sticky top)] + [Rows of installed apps] + [Hero (avatar+ring, name, meta)] + [Update/Open CTA] + [Install panel] + [About preview "Read more" → inner] + [Right column: wax seal, vitals] +``` + +The two-pane is **new** — propose adding a `LibraryDetailHost` composable that hosts either the empty state ("Select an app") or the Detail screen (reuse the existing `DetailsRoot` with a `embedded=true` prop that hides its own top bar). Selection state lives in `AppsViewModel` so deep links from Home/Search still route through the existing `DetailsScreen` graph entry. + +| Data fields | Source | +|---|---| +| Row avatar+ring | `InstalledApp.repoOwnerAvatarUrl` + `latestReleasePublishedAt` (days delta) | +| Heartbeat | `lastUpdatedAt` for freshness, gated by row density — only show heartbeat when ring is suppressed (e.g. updates-only filter mode) | +| Trailing CTA | `hasActualUpdate()` extension already in `InstalledApp.kt` | +| Update banner | `pendingUpdates = installedApps.count { it.hasActualUpdate() }` | +| Inspect button | Android only (`isAndroid()`) | + +ExternalImportScreen and StarredPickerScreen reuse `GhsCardListRow` with a leading checkbox (icon shell shape `GhsShapes.cardSm`, 24.dp, bg `T.tintP`, primary check). + +### 3.5 `feature/profile/` — User profile + +Files: `ProfileRoot.kt`, `ProfileViewModel.kt`, `SponsorScreen.kt` (kept as Sponsor route but **rebranded** — see don't-build list). + +``` +[Top bar: Profile Fraunces 28 · settings ⚙ → TweaksScreen] +[Identity card (GhsCardHeroGradient — patterns.md §"Hero card with gradient")] + [Avatar in Cookie shape 64.dp · primary fill] + [@username Fraunces italic 22] [Inter Tight 13 ink2 bio] + [stats row: followers · following · public repos] ← from GithubUserProfile +[Section: Activity (squiggle)] + [Recently viewed → RecentlyViewedScreen] + [Favourites → FavouritesScreen] + [Starred (auth) → StarredReposScreen] +[Section: Connect (squiggle, MIGRATION.md "no donations")] + [6-cell grid: GitHub · Mastodon · Bluesky · Discord · email · website] + [Business inquiries row → opens default mail client] +[Section: App (squiggle)] + [Tweaks → settings] [What's new history → WhatsNewHistoryScreen] +[Sign out → confirm dialog] +``` + +Android & Desktop: identical layout. Desktop respects `LocalContentWidth`. + +### 3.6 `feature/dev-profile/` — Developer profile of an owner + +Files: `DeveloperProfileRoot.kt`, `DeveloperProfileViewModel.kt`. + +``` +[Top bar: ← @owner] +[Identity card: avatar (NO cookie — owner is not "the user") + name + bio + follower count] +[Section: Repositories that ship binaries (squiggle)] + [List of GhsCardListRow per repo] +[Section: Their stats (squiggle)] + [DownloadWeight summed · total stars StarTier · repo count] +``` + +Data: `GithubUserProfile`. + +### 3.7 `feature/tweaks/` — Settings + +Files: `TweaksRoot.kt`, `TweaksViewModel.kt` (note: 57KB — large state), `components/{ToggleSettingCard, SectionText, CustomForgesDialog, ClearDownloadsDialog}.kt`, plus subfolders `hosttokens/`, `mirror/`, `feedback/`, `hidden/`, `skipped/`. + +Section order (top to bottom): + +1. **Appearance** (squiggle): + - Two-axis theme picker (§7 below) — palette row + mode segmented control + - Content width: `COMPACT / WIDE / EXTRA_WIDE` (existing) — segmented control on Desktop, dropdown on Android + - Font theme (keep existing `FontTheme.CUSTOM/SYSTEM`) +2. **Sources** (squiggle): + - Custom forges (`CustomForgesDialog` — keep) — list of `{host, label}` with Add/Edit/Delete + - Per-host tokens → `HostTokensScreen` (existing route) + - Mirror picker → `MirrorPickerScreen` +3. **Translations** (squiggle): + - Provider segmented: Google · Youdao · LibreTranslate · DeepL · Microsoft (`TranslationProvider` enum existing) + - Per-provider config card (sub-fields like API key, mirror URL) — gated by selection +4. **Library** (squiggle): + - Installer preference (Android only) — System / Shizuku / Root + - Skipped updates → `SkippedUpdatesScreen` + - Hidden repositories → `HiddenRepositoriesScreen` +5. **Privacy** (squiggle): + - Telemetry toggle + - Proxy config +6. **About** (squiggle): + - Version (mono) `1.8.3 (18)` + - Announcements (existing) — route to `AnnouncementsScreen` + - What's new history → `WhatsNewHistoryScreen` + - Send feedback → diagnostics card (DESIGN.md §16.4) with "Email" + "GitHub issue" dual CTA + +Each setting row uses `SetRow` (patterns.md §"Form / Settings group"): 36.dp tinted icon shell (bg `T.tintP`, content `T.primary`, shape `GhsShapes.cardSm`) + name + trailing toggle/value/chevron. + +### 3.8 `feature/favourites/`, `feature/starred/`, `feature/recently-viewed/` + +Presentation-only modules. All three: + +``` +[Top bar: ← Section title Fraunces 28 · count caption] +[Sort/filter chips (optional)] +[List of GhsCardListRow] +[Empty state: Squiggle illustration absent (DESIGN.md §"empty"), Fraunces italic headline, body, primary outline CTA "Browse home" / "Sign in" / "Search apps"] +``` + +Differences: +- **Favourites**: data from `FavouritesRepository.favouriteRepos`. Tap → DetailsScreen. +- **Starred**: data from `StarredRepository`, gated by auth. Empty state changes when signed-out → CTA "Sign in" → AuthenticationScreen. +- **Recently viewed**: data from `SeenReposRepository`. Sort by `lastSeenAt` desc. Swipe-to-clear on Android; clear-all button on Desktop. + +### 3.9 `feature/auth/` — Sign in flow + +Files: `AuthenticationRoot.kt`, `AuthenticationViewModel.kt`. + +Renders as **Full-screen sheet** (§2.11). Three states: + +| State | Visuals | +|---|---| +| Web OAuth handoff (default) | CookieShape "G" 80.dp; heading "Sign in with GitHub"; primary wonky CTA "Open in browser"; small caption "We'll redirect you back via githubstore://auth". Below: outline "Use device code instead" + outline "Use Personal Access Token". | +| Device flow (fallback) | DESIGN.md §16.5 layout: numbered steps, code reveal (mono 28), heartbeat polling indicator + "Polling github.com…" caption. Cancel button (back arrow). | +| PAT entry (last resort) | Single text field (mono), info banner about scopes, primary "Verify token" CTA. | + +`AuthPath` (`Backend`|`Direct`) state already in `SavedStateHandle`; the UI just renders different bodies for the same shell. + +--- + +## 4. Build order (phases) + +Each phase ends with a runnable, testable artifact. Branch `feat/design-system-refresh` should accumulate atomic commits per the user's PR/commit sizing memory. + +### Phase 0 — Tokens + theme + fonts + +**Scope:** +- Add `core/presentation/.../theme/tokens/` directory: + - `GhsTokens.kt` (data class with `bg`, `surface`, …) + - `GhsStatus.kt` (palette-independent — wax, freshness, perm, trend) + - `GhsPalette.kt` (enum NORD/CREAM/FOREST/PLUM) + `GhsPalettes` provider mapping each palette × {light,dark} from `tokens.json` + - `GhsShapes.kt` (composable shapes: `chip, row, cardSm, card, cardLg, hero, heroLg, wonky, wonkyAlt, wonkySearch`) + - `GhsTypography.kt` (Material `Typography` populated from `tokens.json#typography.scale`) +- Add fonts: Fraunces (italic 600), Inter Tight (400–700), JetBrains Mono (500–700). Use `composeApp/.../res/font/`. Configure variable-font axis when possible. +- Add Noto fallback families for CJK/Devanagari/Arabic/Hebrew (design-system.md §5.4) via `FontFamily.Default` extensions. +- Add `GhsTheme(palette, isDark, fontTheme, isAmoled, content)` composable that wraps `MaterialExpressiveTheme` and exposes Locals. +- Migrate `TweaksRepository` palette key — replace the existing `AppTheme.{DYNAMIC,OCEAN,…}` with `GhsPalette.{NORD,CREAM,FOREST,PLUM}` (+ keep DYNAMIC under an "if Android & supported" branch — see don't-build §8). +- Tweaks → Appearance: render the two-axis picker (§7). + +**Success criteria:** +- App launches in all 8 combinations (4 palettes × {light, dark}). +- Palette switch in Tweaks updates every screen live within 250ms (medium motion token). +- No hardcoded hex outside `Color.kt` / `GhsPalettes.kt`. +- Existing screens still render (visually unchanged or roughly compatible — they read tokens through compat shims). + +### Phase 1 — Silent vocabulary primitives + preview screen + +**Scope:** all 12 silent primitives (§1.1–§1.12) + a dev-only preview screen behind a Tweaks → "Developer → Show primitives gallery" toggle. Screen renders every primitive in every relevant state (default, hover/pressed where applicable, disabled). + +**Success criteria:** +- Each primitive composable has a `@Preview` (commonMain previews supported in IDE). +- Heartbeat animations honor the `GhsMotionPreference` Local. +- All primitives render correctly under all 4 palettes × {light, dark}. + +### Phase 2 — Expressive primitives + base components + +**Scope:** +- `CookieShape` (§1.13), `Squiggle` (§1.14) +- All buttons (§2.1) +- All chips (§2.2) +- All cards (§2.3) — including `GhsCardLead` with radial-bloom modifier +- Section header (§2.4) +- Banner (§2.5) +- Vital signs grid (§2.6) +- Wax-seal trust card (§2.7) +- Bottom sheet, confirm dialog, toast, dropdown (§§2.8–2.10, 2.12) +- Bottom nav (Android) + Drawer (Desktop) shells, not yet wired to features + +Add to the primitives preview screen: a "Components" tab. + +**Success criteria:** +- Component tab in preview shows every component in every state. +- Cookie shape passes path equality vs `tokens.json#shape.cookie.path` (write a unit test that compares the constructed `Path` against the expected SVG path command stream). + +### Phase 3 — Home migration + +**Scope:** Migrate `feature/home/` to new spec (§3.1). Reuse existing `HomeViewModel`, swap composables. Keep existing data model. + +Visual deltas to verify: +- Lead release card uses `GhsCardLead` with the repo's accent radial bloom. +- Hot releases: horizontal scroll on Android; 2-col grid on Desktop when content width ≥ WIDE. +- Section headers use Squiggle. +- Clipboard banner uses `GhsBanner info` variant. + +**Success criteria:** Home renders correctly in all palette × mode combos; lead-card accent travels from the API-supplied accent or topic-derived fallback (§6). + +### Phase 4 — Apps (Library) migration + bottom nav switch + +**Scope:** Migrate `feature/apps/` to new spec (§3.4). Switch Android bottom nav from current set to `[Home, Search, Apps, Profile]` (4 tabs — see assumption Q1 below; if the spec mandates 3 tabs we'll drop Search to a detached FAB later). Implement two-pane Desktop Library. + +**Success criteria:** +- Library shows Update banner with VersionStack glyph when `pendingUpdates > 0`. +- Bottom nav active tab uses CookieShape behind the glyph. +- Desktop two-pane: selecting a row replaces the detail pane in <250ms; deep-link to a repo via `DetailsScreen` still works. + +### Phase 5 — Details migration + +**Scope:** Migrate `feature/details/` (§3.3). Re-skin `Header`, `About`, `Stats`, `WhatsNew`, `ReleaseChannel`, `Owner`, `ReportIssue`. Replace `StatItem` with vital signs 2×2. + +Anchor the wax seal to the install panel; verify rotated -6° on Desktop only. + +**Success criteria:** +- Detail screen renders all per-app accent surfaces (lead hero bloom, freshness ring outer, install panel bloom). +- Wax seal Cracked state forces card border to `T.danger` and surfaces a sticky toast. + +### Phase 6 — Search migration + +**Scope:** Migrate `feature/search/` (§3.2). Add source toggle (GitHub / Codeberg / Custom forge), preserve existing `SearchViewModel` logic, swap composables for chip/row/banner. + +**Success criteria:** Source toggle drives the existing `sourceHost` plumbing through `ForgejoClientRegistry`. Recent queries chip cloud appears when input is empty. + +### Phase 7 — Auth migration + +**Scope:** Migrate `feature/auth/` to full-screen sheet (§3.9). Re-render web-OAuth handoff, device flow, PAT entry as three bodies of the same shell. Reuse `AuthPath` state. + +**Success criteria:** All three auth paths render with CookieShape identity, Squiggle heading, Heartbeat polling indicator. Tap-to-copy device code emits "Code copied" toast. + +### Phase 8 — Tweaks migration + +**Scope:** Migrate `feature/tweaks/` (§3.7). Build the two-axis theme picker (§7). Wire all settings rows to `ToggleSettingCard` (rename to `GhsSetRow`). + +**Success criteria:** Every section uses Squiggle headers. Two-axis picker swatches show live preview when hovered (Desktop). + +### Phase 9 — Profile + DevProfile migration + +**Scope:** Migrate `feature/profile/` (§3.5) and `feature/dev-profile/` (§3.6). Implement Connect grid + Business inquiries row. **Remove** SponsorScreen donations content; repurpose route to a "Support the project" page that links out to the GitHub Sponsors page externally (browser intent), nothing inline. + +**Success criteria:** Profile renders identity card with `tintP → surface` gradient (only allowed gradient besides Lead bloom). + +### Phase 10 — Favourites / Starred / Recently-viewed migration + +**Scope:** Migrate the three list-only modules to `GhsCardListRow` + empty states. + +**Success criteria:** Empty states have no stock illustration (DESIGN.md §"empty"), use Squiggle + Fraunces italic headline + outline CTA. + +### Phase 11 — APK Inspect (Android only) + +**Scope:** Build the APK Inspect screen (DESIGN.md §9.6). Lives under `feature/apps/presentation/` (Android-only file using `expect/actual` or `androidMain` source set). Uses `ApkInspector` already in `core/domain/`. + +**Success criteria:** Shows wax seal, min/target/compile SDK tiles, activity/service/receiver counts, permissions grouped by `PermRisk`. Permission chips are color-coded inline. + +--- + +## 5. Data honesty audit hooks + +Per primitive — what falls back if the backend doesn't supply the input: + +| Primitive | Required field | Backend nullable? | Fallback | +|---|---|---|---| +| FreshnessRing | `GithubRelease.publishedAt` → days | Yes when no releases ever | Render avatar without ring; caption "No releases" | +| Heartbeat | `GithubRepoSummary.updatedAt` → days | No (always provided) | n/a | +| StarTier | `stargazersCount` | No | n/a | +| WaxSeal | `InstalledApp.signingFingerprint` + expected | Both nullable | If installed but expected missing → `Intact` w/ caption "Signed by maintainer" (truthful: we know it's signed because Android won't install unsigned). If not installed → `Open` w/ caption "Unsigned by us yet". | +| VersionDelta | `installedVersion` + `latestVersion` | Sometimes one null | `Unknown` grey dot | +| VersionStack | history of skipped tags | Not tracked today | `skippedCount = if (isUpdateAvailable) 1 else 0` | +| PermDot | `ApkInspection.permissions` | Non-APK / no inspection | Render "—" in vital signs tile, no glyph in card | +| PlatformGlyph | `availablePlatforms` | Empty list legal | Hide the row entirely (don't show all-dashed) | +| TopicGlyph | `topics` | Often empty on Codeberg | Drop the row (do not invent topics) | +| SignalBars | mirror health | Untested mirror | tier 0 + caption "Untested" | +| DownloadWeight | aggregated `downloadCount` | Forgejo sums may be 0 | Render "—" in the cap text, skip the dot | +| LicensePosture | SPDX id | Often missing | Render nothing | +| CookieShape | identity letter | n/a | Use `?` glyph as fallback (signed-out user tile) | +| Squiggle | n/a | n/a | always renders | + +**Trending and Most-popular sections** (DESIGN.md §11): when the backend's `trendingScore` / `popularityScore` is null, the rank chip `#N` is suppressed — section still renders, the order is the backend list's natural order, but **no pretending**. + +**Per-release "HOT · Nd ago" pill on the Lead card:** derived from `releaseRecency` — when `recency > 30d`, downgrade to "FRESH · Nw ago" / "WARM" per `tokens.json#thresholds.freshness`. Never label something HOT it isn't. + +--- + +## 6. Per-app accent algorithm + +Each repo carries `accent = { c, lt, dt }` (DESIGN.md §2.4). Resolution order (top wins): + +1. **Backend-supplied** — when API returns an `accent` object (not present today; propose adding via `GithubRepoSummary.accentHex: String?` field, server-side derived from avatar dominant color). +2. **Topic-derived** — first matching topic in repo's `topics` against the table below. +3. **Language-derived** — `primaryLanguage` against the language table. +4. **Blue fallback** — `#5E81AC` (Nord primary). + +### 6.1 Topic → accent + +| Topic | `c` | Use case | +|---|---|---| +| `photo`, `photos`, `gallery` | `#5E81AC` | Cool blue (immich-like) | +| `manga`, `comic`, `reader` | `#7E6BA8` | Plum | +| `password-manager`, `security`, `vault` | `#4C6E96` | Navy | +| `podcast`, `audio`, `music` | `#6B8E5A` | Sage | +| `book`, `ebook`, `koreader` | `#9B6B3C` | Amber | +| `messaging`, `chat`, `signal` | `#A35365` | Muted rose | +| `vpn`, `network`, `proxy` | `#5C7A8E` | Slate-blue | +| `note`, `notes`, `markdown` | `#7A6549` | Cream-ink | +| `backup`, `sync` | `#5A6A57` | Forest-ink | +| `self-hosted`, `home-server` | `#356859` | Forest-deep | +| `video`, `media` | `#B8542C` | Cream-primary | + +### 6.2 Language → accent + +| Language | `c` | +|---|---| +| `Kotlin` | `#7E6BA8` | +| `Java` | `#B8542C` | +| `TypeScript`, `JavaScript` | `#5E81AC` | +| `Python` | `#356859` | +| `Rust` | `#A35346` | +| `Go` | `#5C7A8E` | +| `C`, `C++` | `#7A6549` | +| `Swift` | `#B8542C` | +| `Dart` | `#5E81AC` | +| `Ruby` | `#B83A2C` | +| `Shell`, `Bash` | `#6B8E5A` | +| anything else | fallback blue | + +### 6.3 Tint derivation + +``` +lt = mix(c, white, 0.78) // light-mode soft bg fill +dt = c.copy(alpha = 0.22) // dark-mode bg fill +``` + +Stored once per repo (cache in `SeenReposRepository` or a new in-memory map keyed by `repoId`). + +### 6.4 Where the accent appears + +| Surface | Accent role | +|---|---| +| Home Lead card | Radial bloom (`lt @ 0.6`) center-top | +| Hot release compact card | Faint top stripe (4.dp) `lt @ 0.4` | +| Trending / Popular rows | None (rank is the answer) | +| Detail hero | Avatar ring outer arc tint | +| Detail install panel | Primary CTA stays `T.primary`; the small "Update" banner uses `accent.c` | +| Library row | None (apps share visual weight; per-app tint would compete with VersionStack) | +| Favourites / Starred / Recently-viewed rows | None | +| Update banner inside Apps detail pane | Banner bg `accent.lt`, CTA bg `accent.c` (patterns.md §"Update banner") | + +**Never**: bottom nav (accent doesn't follow user, palette does), toast (status colors do the work), confirm dialog, drawer. + +--- + +## 7. Two-axis theme picker (Tweaks → Appearance) + +The picker has two independent axes: + +``` +APPEARANCE +~~ squiggle ~~ + +Palette +[Nord] [Cream] [Forest] [Plum] ← 4 swatches, each 72.dp square, GhsShapes.cardSm + ◉ active (1.5.dp T.primary border + corner check) + +Mode +[ ☀ Light ◐ System 🌙 Dark ] ← segmented control, RoundedCornerShape(50%) outer, each segment GhsShapes.chip +``` + +| Spec | Value | +|---|---| +| Palette swatch | 72×72.dp `GhsShapes.cardSm`. Internally split: top half = light-mode preview (mini bg+surface+primary blocks), bottom half = dark-mode preview. Label below in caption: "Nord" / "Cream" / etc. | +| Active swatch | 1.5.dp `T.primary` border + small `✓` in top-right `T.primary` | +| Mode segmented | Three icon+label segments. Selected segment has `T.tintP` bg + `T.primary` content; others transparent + `T.ink2`. | +| Live preview | On hover (Desktop) the whole app re-themes for 1.5s; on tap, commits. Android: tap commits immediately. | +| Persistence | Two keys in `TweaksRepository`: `palette: GhsPalette`, `themeMode: AppearanceMode = Light | Dark | System`. | +| Migration | The existing `AppTheme` enum (`DYNAMIC, OCEAN, PURPLE, …`) maps once on first launch post-update: OCEAN/SLATE → NORD; PURPLE/AMBER → PLUM/CREAM (closest hue); FOREST → FOREST; DYNAMIC → user explicitly opted into Material You — see don't-build §8. | + +Mobile layout: palette swatches in a 2×2 grid (each 72.dp), mode segmented full-width below. +Desktop layout: palette swatches in a horizontal row of 4, mode segmented to the right. + +--- + +## 8. Don't-build list + +Things explicitly NOT to bring forward, with rationale: + +1. **Donations / Sponsor inline UI** (MIGRATION.md §risky areas). Profile gets Connect + Business inquiries. Keep the `SponsorScreen` route but repurpose: it now just opens GitHub Sponsors externally via `BrowserHelper`. No inline donation cards, no rewards, no tier list. +2. **Material You / Dynamic color override** (themes.md, design-system.md §3). Dynamic color is removed. The current `AppTheme.DYNAMIC` is gone — users who had it get migrated to `NORD` + their existing mode. Reasoning: dynamic colors don't survive the Silent Vocabulary's accent rules — they fight the per-app accent. Add a Tweaks line "We removed dynamic color — pick Nord, Cream, Forest, or Plum instead. The accent now comes from each app's logo." +3. **"Featured" curation** (DESIGN.md §11). The lead release on Home is always "top of filtered Hot list", labeled honestly with `HOT · Nd ago`. Don't invent any "Editor's pick" labels. +4. **Trending percentage chips** ("+15%"). Backend doesn't supply rate of change. Use position `#N` only when backend provides rank; otherwise no rank chip. +5. **Translate-the-app feature** (MIGRATION.md §risky areas). The 13 locale strings stay (`core/presentation` already has them), but no "Translate" UI surface. The Translate provider plumbing in Tweaks is **only** for translating README/release-notes content (existing TranslationControls). +6. **Stock illustrations in empty states**. Use Squiggle + Fraunces italic headline only. RoseFourLoader covers loading. +7. **Card hover-lift animations / scale on hover**. DESIGN.md §6.2 forbids. Use only background tint state-layer at 8% (`T.ink @ 0.08`). +8. **Decorative gradients** anywhere except (a) Lead card accent bloom and (b) Profile identity card `tintP → surface`. No backgrounds gradients on regular cards (DESIGN.md §2.5, design-system.md §2). +9. **Emoji in UI chrome** — replaced by silent primitives or Material Symbols. Allowed only in user-generated content (README, release notes). +10. **Long descriptive sentences** ("Released 37 days ago") when a primitive already says it (the FreshnessRing's fraction is the answer). +11. **Multiple modal dialogs stacked** (DESIGN.md §16.8). Replace with full-screen sheet. + +### 8.1 GHS-specific surfaces the handoff doesn't address (we add them under the new system) + +The handoff was written for a single-source (github.com) app. GHS extends to Codeberg / Forgejo / custom forges + per-host PATs + mirror picker — all foreign to the prototypes. They belong under the new vocabulary as follows: + +| Surface | Where | Visual approach | +|---|---|---| +| **Source toggle (GitHub / Codeberg / Custom)** | Search top, Details top | Segmented control. Each segment is a small platform mark (GitHub octocat → outlined, Codeberg → outlined, Custom forge → dashed plus). | +| **Per-host tokens** | Tweaks → Sources → "Access tokens" | List rows. Each row: host name in mono, label, "Edit / Remove" trailing menu. Add row uses dashed-border chip pattern (§2.2). | +| **Custom forges** | Tweaks → Sources → "Custom forges" | Same row pattern. Adds: live validation chip (SignalBars-style) after entry test. | +| **Mirror picker** | `MirrorPickerScreen` (sub-screen) | Bottom sheet on Android, side sheet on Desktop. Rows: mirror name in mono + SignalBars + last-checked relative time. | +| **Translation provider config** | Tweaks → Translations | Segmented provider picker + per-provider sub-form (mirror URL for LibreTranslate, API key for DeepL, etc.). Existing `KSafe` pattern. | +| **Repo-id-codec foreign host marker** | Anywhere a repo from a non-GitHub source appears | Small platform mark (16.dp) to the right of the owner caption; PlatformGlyph-style outlined silhouette. | + +--- + +## 9. Open questions and assumptions + +10 items, prioritized. Marked **A** (Assumption — I'm picking, flag if wrong) or **B** (Blocker — needs user input before that phase starts). + +1. **[A] Bottom nav tabs = 4 (Home, Search, Apps, Profile).** DESIGN.md §9.1 shows 4. MIGRATION.md §risky says "3-tab + detached search FAB". The handoff is internally inconsistent. I'm picking 4 because (a) Search is heavily used in this app and (b) detached FABs collide with our Shizuku install affordances on Android. Flag if you want 3. +2. **[A] Cookie active indicator on Desktop drawer.** I'm rendering Cookie only on Android bottom-nav active tab; Desktop drawer uses the simpler `T.tintP` background + 13/10 squircle (DESIGN.md §8.1). The rule "Cookie only at 3 touchpoints" survives because Desktop has CookieShape on brand mark + user tile (and no active tab Cookie). +3. **[A] Profile sub-screens (Favourites / Starred / Recently-viewed) keep their own top-level routes.** I'm not collapsing them into a single "Activity" screen. MIGRATION.md §risky implies collapse but the existing nav graph names them separately and the current UI keeps them as distinct routes — easier to migrate one-screen-at-a-time. +4. **[B] Two-pane Library on Desktop — confirm scope.** The handoff §8.3 mandates two-pane. We don't have it today. Phase 4 needs a green light to introduce `LibraryDetailHost` and embed `DetailsRoot` with an `embedded=true` prop (which I have to add). +5. **[B] Repo accent — server-side or client-side derivation?** DESIGN.md §2.4 says "When backend doesn't supply one, derive from avatar dominant color (color-thief)". We don't have a JVM/Android color-thief in `commonMain`. Phase 3 falls back to topic→accent and language→accent (§6) — confirm that's enough until backend ships the field. +6. **[A] WaxSeal "expected fingerprint" storage.** Current `InstalledApp.signingFingerprint` is the *current* one; we don't store an expected baseline. I'm fallback-rendering `Intact` for any installed signed app (since Android refuses to install unsigned). The Cracked state activates **only** when a new install attempt's fingerprint differs from the stored one — needs a new DB column `expectedSigningFingerprint`. Flag for Phase 5. +7. **[A] Wonky squircle in Compose** — I'm using `AbsoluteRoundedCornerShape` with 4 distinct corner radii (single-axis). DESIGN.md's CSS uses 8-value border-radius (two axes per corner). The visible delta at our sizes (≤28.dp radius) is sub-pixel on @2x — accepting it. +8. **[A] Fonts at build time.** Fraunces variable + Inter Tight variable + JetBrains Mono — adding three fonts inflates APK ~600KB. Acceptable; Compose Resources supports font subsetting if we hit the wall. +9. **[B] Translate-the-app removal.** MIGRATION.md says no Translate UI but we ship 13 locales today (CLAUDE.md `core/presentation` + 13-locale strings). The translate plumbing in Tweaks is content-only. Confirm we keep the 13 user-facing UI locales (Settings > Language stays) and only forbid showing a "Translate this app" CTA. +10. **[A] Dynamic color migration.** Existing users with `AppTheme.DYNAMIC` get mapped to `NORD` on next launch and shown the one-time tooltip "We removed dynamic color — pick Nord, Cream, Forest, or Plum instead." First-launch dismissal stored in `TweaksRepository`. Flag if you want a different default. + +--- + +## 10. Quick-reference card (one-screen) + +- **Tokens** live in `tokens.json#palettes.{nord,cream,forest,plum}.{light,dark}` → `GhsTokens`. +- **Status colors** (palette-independent) live in `tokens.json#status.{freshness,wax,perm,trend}` → `GhsStatus`. +- **Shapes**: `chip 11/8`, `row 13/10`, `cardSm 15/11`, `card 18/14`, `cardLg 20/15`, `hero 24/18`, `heroLg 28/22`. Wonky variants for Primary CTA + Lead card + Search input. +- **Fonts**: Fraunces italic (names/headings), Inter Tight (body), JetBrains Mono (versions/hashes only). +- **Cookie at 3 places**: brand "G", user identity, Android active tab. +- **Squiggle**: one per section heading. No more. +- **Heartbeat**: only where it has space to label itself. Not in dense rows. +- **Wax-seal red**: the only place red is allowed to scream. +- **Per-app accent**: backend → topic → language → blue. +- **One filled button per surface.** +- **No emoji in UI chrome. No stock illustrations. No fake data.** + +End of UI-SPEC. diff --git a/.design/UX-ARCHITECTURE.md b/.design/UX-ARCHITECTURE.md new file mode 100644 index 000000000..0f4a3c134 --- /dev/null +++ b/.design/UX-ARCHITECTURE.md @@ -0,0 +1,817 @@ +# UX-ARCHITECTURE — Design System v2 refresh + +> Author: ArchitectUX. Companion: `UX-RESEARCH.md` (written by UX-Researcher in parallel — do not delete). +> Scope: technical plan only. **No Kotlin written yet.** Scaffold + sequencing. +> Source-of-truth handoff: `~/Downloads/handoff 4/` (DESIGN.md, tokens.json, MIGRATION.md, patterns.md, themes.md, design-system.md, silent-vocab.jsx, store-themed.jsx). + +--- + +## 0 · Current-state snapshot + +Findings from a read pass over the codebase. All paths root at repo. + +| Concern | File | Note | +|---|---|---| +| Material 3 theme entrypoint | `core/presentation/.../theme/Theme.kt:418` | `GithubStoreTheme(isDarkTheme, appTheme, fontTheme, isAmoledTheme)` → `MaterialExpressiveTheme`. | +| Color tokens | `core/presentation/.../theme/Color.kt`, `Theme.kt:17-414` | Five M3 schemes hand-rolled: Ocean, Purple, Forest, Slate, Amber. Single-axis (palette ⇆ scheme), dark-light selected by boolean. | +| Type | `core/presentation/.../theme/Type.kt` | Inter (regular sans) + JetBrains Mono already shipped under Compose Resources. **Fraunces is missing.** Custom-vs-system toggle exists. | +| Dynamic Material You | `Theme.android.kt`, `Theme.jvm.kt` | `expect/actual` `getDynamicColorScheme(dark)`. JVM = null. | +| Persistence | `core/data/.../repository/TweaksRepositoryImpl.kt:66-91` | KSafe-backed. Keys: `K_THEME` (`app_theme`), `K_IS_DARK` (`is_dark_theme`, tri-state nullable), `K_AMOLED`, `K_FONT`. | +| Domain enums | `core/domain/.../model/AppTheme.kt`, `FontTheme.kt` | `AppTheme = { DYNAMIC, OCEAN, PURPLE, FOREST, SLATE, AMBER }`. No mode enum — `null` Boolean encodes "system". | +| Repository "Themes" interface | none | The handoff calls it `ThemesRepository`; in this repo theme prefs live on `TweaksRepository`. We keep that name. | +| Font assets | `core/presentation/src/commonMain/composeResources/font/` | `inter_*.ttf`, `jetbrains_mono_*.ttf`. Loaded via `org.jetbrains.compose.resources.Font(Res.font.X)`. | +| Vector drawables | `core/presentation/.../composeResources/drawable/ic_platform_*.xml` | `.xml` vectors — Compose Resources `painterResource(Res.drawable.X)` already in use. | +| Local providers (existing) | `core/presentation/.../locals/Local{BottomNavigationHeight,ContentWidth,ScrollbarEnabled}.kt` | Pattern in place: `staticCompositionLocalOf` with `noLocalProvidedFor` defaults. We extend it, don't reinvent. | +| Sample card | `core/presentation/.../components/ExpressiveCard.kt:15` | Symmetric `RoundedCornerShape(32.dp)`. Needs asymmetric replacement. | + +**Implication.** We have to do two structural changes besides the visual refresh: + +1. **Two-axis theme model.** Today: 6 palettes × `Boolean?` dark. New: 4 palettes × 3-mode (LIGHT/DARK/SYSTEM). Old palettes (OCEAN, PURPLE, SLATE, AMBER, DYNAMIC) are decommissioned. A migration is required. +2. **Asymmetric shapes.** Compose's `RoundedCornerShape` accepts four corners but each corner is a single radius (x == y). The handoff uses elliptical radii for the wonky squircle (`20px 14px 22px 16px / 16px 22px 14px 20px`). Requires a custom `Shape`. + +--- + +## 1 · Token system + +### 1.1 Delivery: hand-written Kotlin objects (not codegen) + +**Decision: ship `Tokens.kt` as hand-written Kotlin in `core/presentation`.** Do **not** wire a Gradle task to generate from `tokens.json`. + +Justification: + +- `tokens.json` is ~270 lines, four palettes, frozen on v1. The "build step" amortises over churn, and we have none. +- Codegen breaks Kotlin's compile-time tools — `Color(0xFF...)` constants resolve to integers cleanly only when authored as Kotlin. Generated string-to-hex loses the `@Stable` guarantees and bloats DI. +- The handoff's `silent-vocab.jsx` already encodes thresholds (`freshnessOf`, `freshnessFraction`, `starTier`) and shape paths. Hand-porting forces the implementer to read the rule, not skim it. +- A future regen story: keep `tokens.json` checked in at `.design/tokens.json`. Add a one-off `./gradlew :core:presentation:checkTokens` Konsist/regex sanity that grep-asserts every hex in the JSON appears in `PaletteTokens.kt`. Cheap, no codegen. +- Build-step alternative considered: `build-logic/convention/.../TokensGenerationConventionPlugin.kt` that runs a `KotlinScript` reading the JSON. Rejected: extra build cost (Kotlin daemon spin), brittle to schema drift, and no consumer outside this one module. + +**Rule:** if a 5th palette ever ships, write it by hand. We never invent palettes (themes.md §"Disallowed combinations"). + +### 1.2 File layout + +Under `core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/`: + +``` +theme/ +├── tokens/ +│ ├── PaletteTokens.kt // data class PaletteColors + 8 instances +│ ├── StatusTokens.kt // freshness/wax/perm/trend (palette-independent) +│ ├── ShapeTokens.kt // asymmetric radius constants + WonkySquircleShape + CookieShape + Squiggle path +│ ├── SpacingTokens.kt // xxs..xxxl Dp +│ ├── TypeTokens.kt // TypeScale data class + roles +│ ├── MotionTokens.kt // durations + easings + Heartbeat periods +│ └── ThresholdTokens.kt // freshness/maintenance/stars lookup +├── locals/ +│ ├── LocalPalette.kt +│ ├── LocalStatusColors.kt +│ ├── LocalTypeScale.kt +│ ├── LocalShapes.kt +│ ├── LocalSpacing.kt +│ └── LocalMotion.kt +├── GhsTheme.kt // new entrypoint (replaces GithubStoreTheme) +├── Color.kt // KEEP — re-export for any non-migrated screen; mark @Deprecated +└── Theme.kt // KEEP wrapper that delegates GithubStoreTheme → GhsTheme during migration +``` + +`Color.kt`, `Theme.kt`, `Type.kt` stay alongside as a deprecation shim until phase 3+. Calls into the deprecated API compile but warn; new code consumes `GhsTheme` + `LocalPalette.current`. + +### 1.3 Data classes (sketch — not code yet, only contract) + +``` +@Immutable +data class PaletteColors( + val bg, surface, surface2, ink, ink2, outline, primary, tintP, + success, successT, danger, dangerT, shadow, +) // all androidx.compose.ui.graphics.Color + +@Immutable +data class StatusColors( + val freshness: FreshnessColors, // hot, fresh, warm, cool, dormant + val wax: WaxColors, // intact, cracked, open + val perm: PermColors, // low, moderate, high + val trend: TrendColors, // rising, flat, falling +) + +@Immutable +data class TypeScale( + val display: TextStyle, displaySm: TextStyle, headline: TextStyle, + val title: TextStyle, titleSm: TextStyle, + val body: TextStyle, bodySm: TextStyle, + val caption: TextStyle, label: TextStyle, mono: TextStyle, + val h3Warm: TextStyle, h3Meta: TextStyle, // editorial vs metadata variants +) + +@Immutable +data class GhsShapes( + val xs: Shape, sm: Shape, md: Shape, lg: Shape, xl: Shape, + val wonkySquircle: Shape, wonkySquircleAlt: Shape, wonkySquircleSearch: Shape, + val cookie: Shape, full: Shape, +) + +@Immutable +data class Spacing(val xxs, xs, s, sm, m, l, xl, xxl, xxxl: Dp) + +@Immutable +data class GhsMotion( + val quick: AnimationSpec, val medium: AnimationSpec, val slow: AnimationSpec, + val springSoft: SpringSpec, val springBouncy: SpringSpec, + val heartbeat: HeartbeatSpec, // scaleFrom 1.0, scaleTo 1.25, haloTo opacity 0 +) + +data class Thresholds( + val freshness: List, // {maxDaysInclusive, state, ringFraction, color} + val stars: List, // {minStars, tier} + val maintenance: List, // {maxDaysInclusive, state, heartbeatPeriodMs} +) +``` + +All marked `@Immutable` so Compose can skip recomposition when a CompositionLocal carries them unchanged. Status / Thresholds / Shapes are **constants** — only `PaletteColors` and (downstream) `TypeScale.color` change per state. + +--- + +## 2 · Composition locals + `GhsTheme` + +### 2.1 Provider entrypoint + +``` +@Composable +fun GhsTheme( + palette: Palette = Palette.NORD, + mode: ResolvedMode = ResolvedMode.LIGHT, // already-resolved (system → light/dark done upstream) + fontTheme: FontTheme = FontTheme.CUSTOM, + content: @Composable () -> Unit, +) +``` + +Inside, it: + +1. Resolves the `PaletteColors` for `(palette, mode)` from a frozen 4×2 map (8 instances). +2. Builds a Material 3 `ColorScheme` from those tokens (see §2.2 mapping). +3. Resolves the `TypeScale` (Fraunces + Inter Tight + JetBrains Mono, see §5) with current ink color baked into each `TextStyle`. +4. Provides composition locals + calls `MaterialExpressiveTheme(...)` so existing M3 widgets keep working. + +``` +CompositionLocalProvider( + LocalPalette provides paletteColors, + LocalStatusColors provides StatusColors.Constant, // palette-independent + LocalTypeScale provides typeScale, + LocalShapes provides GhsShapes.Default, + LocalSpacing provides Spacing.Default, + LocalMotion provides GhsMotion.Default, +) { + MaterialExpressiveTheme( + colorScheme = colorScheme, + typography = MaterialBridge.toM3Typography(typeScale), + shapes = MaterialBridge.toM3Shapes(GhsShapes.Default), + motionScheme = MotionScheme.expressive(), + content = content, + ) +} +``` + +### 2.2 Material 3 mapping + +Material widgets (sliders, dialogs, snackbar, ripple) read `MaterialTheme.colorScheme`. We map our tokens so they don't drift: + +| Our token | M3 slot | +|---|---| +| `bg` | `background`, `surface` (M3 conflates these in M3 Expressive), `surfaceContainerLowest` | +| `surface` | `surfaceContainer`, `surfaceContainerLow` | +| `surface2` | `surfaceContainerHigh`, `surfaceVariant` | +| `ink` | `onBackground`, `onSurface` | +| `ink2` | `onSurfaceVariant` | +| `outline` | `outline`, `outlineVariant` | +| `primary` | `primary`, `inversePrimary` (the latter via tonal flip per mode) | +| `tintP` | `primaryContainer`, `secondaryContainer` | +| `surface` (white fg) | `onPrimary` — but Cream `primary` (`#B8542C`) on white = 4.8:1 ✓. Forest light primary (`#6B8E5A`) on white = 3.0:1, fails AA. **Action:** for Cream + Forest light, use the palette `ink` as `onPrimary` (it's `#2B1F14` / `#2D3A2C` respectively — 7:1 against the primary). Document per-palette in PaletteTokens.kt. | +| `success` | `tertiary` (closest semantic match in M3) | +| `successT` | `tertiaryContainer` | +| `danger` | `error` | +| `dangerT` | `errorContainer` | + +**Why both layers?** M3 components don't know about our tokens. If a third-party Compose lib (Landscapist placeholders, Markdown renderer, navigation animations) reads `MaterialTheme.colorScheme.surface`, it should still get the right surface. Composition locals are for *our* code; M3 colorScheme is for *everyone else*. + +### 2.3 Locals API (read site) + +``` +object Ghs { + val palette @Composable get() = LocalPalette.current + val status @Composable get() = LocalStatusColors.current + val type @Composable get() = LocalTypeScale.current + val shapes @Composable get() = LocalShapes.current + val spacing @Composable get() = LocalSpacing.current + val motion @Composable get() = LocalMotion.current +} + +// Call site: +Text("Updated", style = Ghs.type.label, color = Ghs.palette.ink2) +Box(Modifier.background(Ghs.palette.tintP, Ghs.shapes.lg)) +``` + +`staticCompositionLocalOf` for everything (we never animate between palettes by interpolating — palette switches are a recomposition event, not an interpolation; see themes.md §"Mode-switching transitions" which crossfades the whole tree, not individual color values). + +--- + +## 3 · Asymmetric "wonky squircle" `Shape` + +### 3.1 Problem + +CSS: `border-radius: 20px 14px 22px 16px / 16px 22px 14px 20px;` — four corners, each with **different x and y radii** (elliptical). Compose: + +- `RoundedCornerShape` — same per-corner radius, x == y. +- `AbsoluteRoundedCornerShape` — also x == y per corner. +- `CutCornerShape` — wrong shape. + +No built-in elliptical-per-corner shape exists. We write `WonkySquircleShape : Shape`. + +### 3.2 Algorithm + +``` +class WonkySquircleShape( + val topLeftX: Dp, val topLeftY: Dp, + val topRightX: Dp, val topRightY: Dp, + val bottomRightX: Dp, val bottomRightY: Dp, + val bottomLeftX: Dp, val bottomLeftY: Dp, +) : Shape { + + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val (tlx, tly, trx, try_, brx, bry, blx, bly) = density.toPxAll(...) + val w = size.width; val h = size.height + val path = Path().apply { + moveTo(tlx, 0f) + lineTo(w - trx, 0f) + // top-right corner: ellipse arc from (w-trx, 0) → (w, try_) + arcTo( + rect = Rect(w - 2*trx, 0f, w, 2*try_), + startAngleDegrees = -90f, sweepAngleDegrees = 90f, forceMoveTo = false, + ) + lineTo(w, h - bry) + arcTo(rect = Rect(w - 2*brx, h - 2*bry, w, h), 0f, 90f, false) + lineTo(blx, h) + arcTo(rect = Rect(0f, h - 2*bly, 2*blx, h), 90f, 90f, false) + lineTo(0f, tly) + arcTo(rect = Rect(0f, 0f, 2*tlx, 2*tly), 180f, 90f, false) + close() + } + return Outline.Generic(path) + } +} +``` + +`Path.arcTo` with an elliptical bounding `Rect` (width ≠ height) draws an elliptical arc. This is the trick that closes the gap. + +Mirror for `LayoutDirection.Rtl`: swap (TL, TR) and (BL, BR) corners. + +### 3.3 Token wiring + +`ShapeTokens.kt` exposes named instances using the JSON values: + +``` +val WonkySquircleCard = WonkySquircleShape(tl=20.dp/16.dp, tr=14.dp/22.dp, br=22.dp/14.dp, bl=16.dp/20.dp) +val WonkySquircleCardAlt = WonkySquircleShape(...22/18, 16/24, 24/16, 18/22) +val WonkySquircleSearch = WonkySquircleShape(...24/18, 18/24, 26/20, 20/26) +``` + +**Notation:** `tl=20.dp/16.dp` = "top-left x-radius 20dp, y-radius 16dp" in CSS shorthand order. + +### 3.4 Fallback (graceful degradation) + +If `WonkySquircleShape` ever causes outline-clip artifacts on a target platform (e.g. Skia rasterisation glitch on JVM Linux), provide: + +``` +object Shapes { + val wonkySquircle: Shape = when { + DegradedShapes.enabled -> AbsoluteRoundedCornerShape( + topStart=18.dp, topEnd=14.dp, bottomEnd=20.dp, bottomStart=16.dp, + ) // symmetric per corner — loses ~5% of the wonky feel + else -> WonkySquircleShape(...) + } +} +``` + +Gated by a build flag. Default = full shape. Use the fallback only if we hit a rasterisation bug in QA. + +### 3.5 The diagonal asymmetry rule (design-system.md §6.2) + +For all *non-wonky* asymmetric corners (rows, chips, buttons) the rule is symmetric-per-corner with two values diagonally: + +``` +RoundedCornerShape(topStart=L, topEnd=S, bottomEnd=L, bottomStart=S) +``` + +Built-in `RoundedCornerShape` handles this. No custom shape needed for `xs/sm/md/lg/xl`. Wonky squircle is **only** for primary CTAs and lead/hero cards (CSS comment: *"feels hand-shaped"*). + +--- + +## 4 · `CookieShape` + `Squiggle` + +### 4.1 CookieShape (clippable) + +`tokens.json` ships: +- `viewBox: "0 0 100 100"` +- `path: "M50 4 C 62 4 66 12 76 12 C 86 12 91 22 91 32 C 95 40 100 50 94 58 C 96 70 90 82 80 86 C 72 90 64 96 54 96 C 44 96 36 95 26 92 C 16 90 10 80 8 70 C 4 62 0 54 6 46 C 6 34 12 22 22 18 C 32 12 38 4 50 4 Z"` + +This is ~12 cubic Bezier segments. Two options: + +**Option A — hand-translate.** Map each `C x1 y1, x2 y2, x y` to `path.cubicTo(x1*sx, y1*sy, x2*sx, y2*sy, x*sx, y*sy)` where `sx = size.width / 100f`, `sy = size.height / 100f`. ~12 lines of code. Done once, frozen. + +**Option B — SVG path parser.** Tokenize the `d` string, dispatch by command (`M`, `C`, `T`, `Q`, `L`, `Z`). Useful if more SVG paths arrive. Squiggle uses `Q` and `T` (quadratic + smooth quadratic), so the parser must handle those if we want one parser for both. + +**Decision: Option A.** Two paths, frozen tokens, no need for a parser. Hand-translate both into `Path` builders. Document the source `d` string in a KDoc/comment so the next maintainer knows where it came from. + +``` +object CookieShape : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val sx = size.width / 100f + val sy = size.height / 100f + val p = Path().apply { + moveTo(50f*sx, 4f*sy) + cubicTo(62f*sx, 4f*sy, 66f*sx, 12f*sy, 76f*sx, 12f*sy) + cubicTo(86f*sx, 12f*sy, 91f*sx, 22f*sy, 91f*sx, 32f*sy) + // ... 10 more + close() + } + return Outline.Generic(p) + } +} +``` + +Then `Modifier.clip(CookieShape)` works on any `Box` / `Image` / brand mark. + +Sizes specified (design-system.md): brand mark, profile avatar, active bottom-nav tab. All three accept `Modifier.size(40.dp)` / `64.dp` / `28.dp` and the shape scales because of `size.width / 100f`. + +### 4.2 Squiggle (decorative, not clippable) + +``` +viewBox: "0 0 40 5" +path: "M1 3 Q 5 0.5, 9 3 T 17 3 T 25 3 T 33 3 T 39 3" +stroke: "1.6px", opacity: 0.6, color: "primary" +``` + +Not a clip shape (it's an underline). Render with `Canvas`: + +``` +@Composable +fun Squiggle(modifier: Modifier = Modifier, color: Color = Ghs.palette.primary) { + Canvas(modifier.size(width=40.dp, height=5.dp)) { + val p = Path().apply { + moveTo(1f*sx, 3f*sy) + quadraticBezierTo(5f*sx, 0.5f*sy, 9f*sx, 3f*sy) + // smooth-quadratic: reflect previous ctrl point. T x,y == Q (2*prevEnd - prevCtrl), x, y + quadraticBezierTo(13f*sx, 5.5f*sy, 17f*sx, 3f*sy) + quadraticBezierTo(21f*sx, 0.5f*sy, 25f*sx, 3f*sy) + quadraticBezierTo(29f*sx, 5.5f*sy, 33f*sx, 3f*sy) + quadraticBezierTo(37f*sx, 0.5f*sy, 39f*sx, 3f*sy) + } + drawPath(p, color.copy(alpha=0.6f), + style = Stroke(width=1.6.dp.toPx(), cap=StrokeCap.Round)) + } +} +``` + +(The `T` reflection is pre-computed above — Compose `Path` has no smooth-quadratic primitive.) + +--- + +## 5 · Typography + +### 5.1 Three fonts, one resource bundle + +Already on disk: +- `inter_{light,regular,medium,semi_bold,bold,black}.ttf` +- `jetbrains_mono_{light,regular,medium,semi_bold,bold}.ttf` + +Missing: +- **Fraunces** — italic 500/600/700, with optical-size axis 9..144 (the JSON imports it variable). We will ship **static italic TTFs** (3 weights × italic) for app-size correctness; variable TTF parses on Android API 26+ and JVM (Skia ≥ M85) but is heavier and Compose's `Font(...)` doesn't expose axis controls in the Resources API today. Static is safe. +- **Inter Tight** — currently we ship "Inter" (the wider one). DESIGN.md §3.4 forbids it ("Inter Tight reads tighter and pairs better with Fraunces"). Replace `inter_*.ttf` with `inter_tight_*.ttf` (400/500/600/700 + a 800 variant for the rare display-weight case). + +Drop into `core/presentation/src/commonMain/composeResources/font/` (same place as today). Compose Resources generates `Res.font.X` accessors for `commonMain` consumption — **no `expect/actual` needed**. The existing `Type.kt:12-30` pattern (using `org.jetbrains.compose.resources.Font(...)`) is the right pattern; we extend it. + +### 5.2 Multi-script policy + +From `MIGRATION.md`: Inter Tight covers Latin + Cyrillic only. Fraunces is Latin/Cyrillic only. + +**For Latin/Cyrillic:** Fraunces (display) + Inter Tight (body) + JetBrains Mono (code). + +**For other scripts:** fall back via `FontFamily` composition. Compose `FontFamily` accepts multiple `Font` entries with the same weight; if a glyph isn't in the first font, the platform falls back through the list. We will: + +1. Ship Noto Sans variants in `composeResources/font/`: `noto_sans_jp`, `noto_sans_sc`, `noto_sans_tc`, `noto_sans_kr`, `noto_sans_devanagari`, `noto_sans_arabic`, `noto_sans_hebrew`. Regular + Bold only — that's enough for headlines + body. +2. Build family with platform-aware ordering. **On Android** the platform's `Typeface` does its own script fallback against system fonts; just provide the Latin fonts and let Android do the rest (Android system fonts cover everything). **On JVM** the situation is worse — JDK font fallback is best-effort, headless Linux may have no CJK. Ship the Noto TTFs as `Font(Res.font.noto_sans_jp_regular)` and **embed them in `FontFamily` after the Latin fonts**. This costs ~5MB per Noto family but guarantees no tofu on any platform. + +`FontFamily` declaration: +``` +val SansBody = FontFamily( + Font(Res.font.inter_tight_regular, FontWeight.Normal), + Font(Res.font.inter_tight_medium, FontWeight.Medium), + Font(Res.font.inter_tight_semi_bold, FontWeight.SemiBold), + Font(Res.font.inter_tight_bold, FontWeight.Bold), + // Fallbacks for non-Latin scripts (Compose picks based on glyph coverage) + Font(Res.font.noto_sans_jp_regular, FontWeight.Normal), + Font(Res.font.noto_sans_sc_regular, FontWeight.Normal), + Font(Res.font.noto_sans_kr_regular, FontWeight.Normal), + Font(Res.font.noto_sans_devanagari_regular, FontWeight.Normal), + Font(Res.font.noto_sans_arabic_regular, FontWeight.Normal), + Font(Res.font.noto_sans_hebrew_regular, FontWeight.Normal), + // (matching bolds...) +) +``` + +**Open question (§12-Q3):** is the ~30MB APK size hit (Noto × 7 scripts × 2 weights) acceptable, or do we prefer Android-only system fallback + accept JVM tofu on minority scripts? + +### 5.3 Italic Fraunces — "App identity" rule + +DESIGN.md §3.3: "Italic Fraunces only for app/screen identity. Don't italicize body or buttons." Practical implication: the `TypeScale` only puts `FontStyle.Italic` on `display`, `displaySm`, `headline`, and `h3Warm`. Body/title/caption use upright. + +### 5.4 Tabular numbers + +DESIGN.md §5.3: "Numbers use Inter Tight tabular (`fontVariantNumeric: 'tabular-nums'`)". In Compose: `TextStyle(fontFeatureSettings = "tnum")`. Bake this into `TypeScale.caption`, `body`, `bodySm`, `mono` — anywhere a star count, version, or download number renders. + +--- + +## 6 · Theme persistence — schema migration + +### 6.1 New domain model + +`core/domain/.../model/Palette.kt`: +``` +enum class Palette { NORD, CREAM, FOREST, PLUM ; + companion object { fun fromName(n: String?) = entries.find { it.name == n } ?: NORD } +} +``` + +`core/domain/.../model/ThemeMode.kt`: +``` +enum class ThemeMode { LIGHT, DARK, SYSTEM ; + companion object { fun fromName(n: String?) = entries.find { it.name == n } ?: SYSTEM } +} +``` + +`AppTheme.kt`: **deprecated** with `@Deprecated("Replaced by Palette + ThemeMode")`. Keep the enum compiled so existing code on `feature/tweaks` still resolves until phase 5. Provide an extension `AppTheme.toPalette(): Palette` for the migration path (table below). + +### 6.2 TweaksRepository surface change + +``` +// new — added alongside the old methods +fun getPalette(): Flow +suspend fun setPalette(palette: Palette) +fun getThemeMode(): Flow +suspend fun setThemeMode(mode: ThemeMode) + +// old — kept until phase 5, then removed in one commit +@Deprecated fun getThemeColor(): Flow +@Deprecated suspend fun setThemeColor(theme: AppTheme) +@Deprecated fun getIsDarkTheme(): Flow +@Deprecated suspend fun setDarkTheme(isDarkTheme: Boolean?) +``` + +`getAmoledTheme`/`setAmoledTheme` survives — AMOLED is a sub-mode of DARK and we keep that knob (§12-Q5). + +### 6.3 Persistence keys + +Add new KSafe keys in `TweaksRepositoryImpl.kt:447`: +``` +private const val K_PALETTE = "palette_v2" +private const val K_THEME_MODE = "theme_mode_v2" +``` + +Old keys (`K_THEME = "app_theme"`, `K_IS_DARK = "is_dark_theme"`) stay on disk. On first read of `getPalette()`/`getThemeMode()`: + +``` +override fun getPalette(): Flow = flow { + migrationDeferred.await() + val raw: String = ksafe.safeGet(K_PALETTE, "") + if (raw.isEmpty()) { + // one-shot migration from legacy K_THEME + val legacy: String = ksafe.safeGet(K_THEME, "") + val migrated = mapLegacyAppThemeToPalette(legacy) // see table below + ksafe.safePut(K_PALETTE, migrated.name) + emit(migrated) + } + emitAll(ksafe.safeGetFlow(K_PALETTE, Palette.NORD.name).map { Palette.fromName(it) }) +} +``` + +### 6.4 Legacy → new palette map + +| Old `AppTheme` | New `Palette` | Notes | +|---|---|---| +| `OCEAN` | `NORD` | Nord is the cool-blue default. | +| `DYNAMIC` | `NORD` | Material You removed — themes.md §"Don't let dynamic color override". | +| `PURPLE` | `PLUM` | Closest mood. | +| `FOREST` | `FOREST` | Direct map. | +| `SLATE` | `NORD` | Cool grey → nord (no muted-grey palette in v2). | +| `AMBER` | `CREAM` | Warm orange → cream. | + +`getIsDarkTheme(): Flow` → `getThemeMode(): Flow`: +- `null` → `SYSTEM` +- `false` → `LIGHT` +- `true` → `DARK` + +Migrated once on first read, then `K_IS_DARK` is left in place (read-only, no harm). + +### 6.5 Resolving "system" mode + +`GhsTheme` takes `ResolvedMode = LIGHT | DARK` (already resolved). The resolution happens once near the root of the tree: + +``` +// inside MainViewModel or AppNavigation +val mode by tweaks.getThemeMode().collectAsState(ThemeMode.SYSTEM) +val systemDark = isSystemInDarkTheme() // Compose primitive +val resolved = when (mode) { + ThemeMode.LIGHT -> ResolvedMode.LIGHT + ThemeMode.DARK -> ResolvedMode.DARK + ThemeMode.SYSTEM -> if (systemDark) ResolvedMode.DARK else ResolvedMode.LIGHT +} +GhsTheme(palette, resolved, fontTheme) { AppNavigation() } +``` + +`isSystemInDarkTheme()` is multiplatform (commonMain). Single source of resolution, no scattered branching. + +--- + +## 7 · Per-app accent storage + +### 7.1 Domain model + +`core/domain/.../model/AppAccent.kt`: +``` +@Immutable +data class AppAccent( + val c: Color, // saturated accent (text, icon, recommended-pill foreground) + val lightTint: Color, // light-mode tint surface + val darkTintAlpha: Float = 0.20f, // dark-mode = c.copy(alpha=darkTintAlpha) over dark surface +) { + fun tintFor(mode: ResolvedMode): Color = + if (mode == ResolvedMode.DARK) c.copy(alpha = darkTintAlpha) else lightTint +} +``` + +### 7.2 Resolution chain (`core/data/util/AppAccents.kt`) + +``` +object AppAccents { + fun forRepo( + backendAccent: String?, // hex from backend (future — not wired yet) + topics: List, + primaryLanguage: String?, + ): AppAccent { + backendAccent?.let { return AppAccent.fromHex(it) } + topics.firstNotNullOfOrNull { TOPIC_ACCENTS[it] }?.let { return it } + primaryLanguage?.let { LANGUAGE_ACCENTS[it.lowercase()] }?.let { return it } + return FALLBACK_BLUE + } +} +``` + +**Resolution order** (themes.md): +1. Backend-supplied hex +2. Topic match (table below) +3. Language match (Kotlin→purple, Rust→amber, Go→sage, Swift→orange, Python→amber, JS/TS→sage, …) +4. Fallback `FALLBACK_BLUE = AppAccent(c=#5E81AC, lightTint=#D8E1EC)` + +Tables live as `val TOPIC_ACCENTS: Map` and `val LANGUAGE_ACCENTS: Map` in the same file. ~30 entries total. + +### 7.3 Where it lives on the data path + +**Recommendation: derive at the UI mapper layer, not at the repo data class.** + +Reasoning: +- `GithubRepoSummary` (`core/domain/.../model/GithubRepoSummary.kt`) and `DiscoveryRepositoryUi` are domain models. Accent is **presentation-only data** — it depends on the palette mode (light/dark tint) and on visual tokens. +- The existing pattern (`GithubRepoSummaryMappers.kt`, `GithubUserMappers.kt`) maps domain → UI in `core/presentation/.../utils/`. Add a `repoAccent: AppAccent` field to `GithubRepoSummaryUi` and resolve it in the mapper. +- **Not persisted.** Same `(topics, language)` deterministically resolves to the same accent — recomputation is free, caching adds invalidation bugs. + +**Exception:** if the backend ever returns a per-repo accent (DESIGN.md §2.4 mentions "color-thief on avatar"), that hex needs persistence to avoid recomputing on every fetch. Add a single nullable column to the existing repos table at that point — **not in this overhaul.** Defer to a future phase (§12-Q6). + +### 7.4 UI-side cache + +`Modifier.background(accent.tintFor(mode), shape)` recomputes per recomposition but the math is `Color.copy(alpha=…)` — single allocation, irrelevant. No memoisation needed. + +--- + +## 8 · Module layout for silent vocabulary + +**Decision: single module — `core/presentation/.../vocabulary/`.** + +Reasons: +- Every feature consumes these primitives (Home feed card uses FreshnessRing; Library row uses Heartbeat; Detail screen uses WaxSeal + VersionStack). A new module forces every feature to add a dependency; we already pay that cost on `core/presentation`. +- Primitives are stateless, take `Modifier` + theme tokens. They don't need their own data/domain layer. +- ~14 files, ~1500 lines total. Below the threshold where a new module pays off. + +Files: + +``` +core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/ +├── FreshnessRing.kt // squircle icon tile + draining ring; takes (daysSinceRelease, accent, avatarUrl?) +├── Heartbeat.kt // breathing dot, period from maintenance.heartbeat_period_s +├── StarTier.kt // 1–5 Michelin stars from log buckets +├── WaxSeal.kt // intact/cracked/open — three SVG glyphs via Path +├── VersionDelta.kt // patch dot / minor 2-dots / major bar+slash +├── VersionStack.kt // 1..7 stacked bars for skipped versions +├── PermDot.kt // green/amber/red dot + optional halo ring +├── PlatformGlyph.kt // android/windows/macos/linux — uses existing ic_platform_*.xml as Vector + dashed-outline variant +├── TopicGlyph.kt // 12 topic pictograms — Canvas-drawn from silent-vocab.jsx paths +├── SignalBars.kt // mirror strength, 0–4 bars +├── DownloadWeight.kt // log10 sized dot +├── LicensePosture.kt // copyleft © tile vs permissive · tile +├── Squiggle.kt // (lives here, not in theme/, since it's vocabulary-decorative) +└── CookieBrand.kt // brand mark composable using CookieShape from theme/ +``` + +**`Heartbeat.kt` perf note:** infinite animation, common in lists. Use `rememberInfiniteTransition` + `animateFloat`. Pause when not visible via `isInLazyListVisibleRange()` helper or `LocalLifecycleOwner`-aware composable. See §11 Risk-4. + +**Platform glyphs:** we already have `ic_platform_{android,windows,macos,linux}.xml`. Convert PlatformGlyph to a wrapper that picks between `painterResource(...)` for `on=true` and a Canvas-drawn dashed variant for `on=false`. Don't ship new platform SVGs. + +--- + +## 9 · Resource pipeline + +Compose Multiplatform 1.10.3 (per `gradle/libs.versions.toml`) supports two viable paths for vector content: + +**Path A — Compose Resources `painterResource(Res.drawable.foo)` on Android-XML vector drawables.** +- We already use this for `ic_platform_*.xml`. Works on Android (native vector drawable) + JVM (parsed by Compose Resources at compile time into ImageVector). +- Limitation: only Android-XML vector format. SVG-native is not supported. +- Good for: icons we'd otherwise ship as Material Symbols / custom platform glyphs. + +**Path B — `ImageVector.Builder` Kotlin DSL.** +- Build vectors at runtime from path data. +- Good for: small, dynamic vectors driven by data (e.g. WaxSeal — three states, ~5 path segments each; TopicGlyph — 12 distinct sub-paths driven by `kind: String`). + +**Decision:** **Path B for vocabulary primitives** (because the JSX uses tiny inline SVGs that compose better as `Canvas` or `ImageVector.Builder` than as 12 separate XML files), **Path A for everything else** (top-bar icons, platform glyphs we already ship, etc.). + +**No raster icons.** Ship no new PNGs. The existing `app_icon.png` stays, but topic glyphs / vocabulary primitives are all vector-only. + +For Material Symbols Rounded (design-system.md §12) — we don't bundle Material Symbols today. Either add the `androidx.compose.material:material-icons-extended` dependency (`Icons.Rounded.Home`, etc.) or ship Material Symbols as a font file (`MaterialSymbolsRounded.ttf`, ~600KB, all icons addressable via codepoint). **Prefer the font file** — `icons-extended` is ~10MB on Android baseline APK, the font is 600KB and addressed by `Text(text="", fontFamily=MaterialSymbolsRounded)`. Decided downstream during phase 1. + +--- + +## 10 · Sequencing + acceptance criteria + +Mapped to MIGRATION.md but adapted for our actual modules (no "Library" module → we have `feature/apps`, `feature/favourites`, `feature/starred`). + +### Phase 0 — Tokens + Theme scaffolding + +**Deliverable:** Every token (palette × mode, type, shape, spacing, motion, thresholds, status) reachable via `Ghs.X`/`MaterialTheme.X` from anywhere. Both theme entrypoints work side-by-side. + +**Files touched (new):** +- `core/presentation/.../theme/tokens/{Palette,Status,Shape,Spacing,Type,Motion,Threshold}Tokens.kt` +- `core/presentation/.../theme/locals/Local{Palette,StatusColors,TypeScale,Shapes,Spacing,Motion}.kt` +- `core/presentation/.../theme/GhsTheme.kt` +- `core/presentation/src/commonMain/composeResources/font/fraunces_{500,600,700}_italic.ttf` +- `core/presentation/src/commonMain/composeResources/font/inter_tight_{400,500,600,700}.ttf` +- `core/presentation/src/commonMain/composeResources/font/noto_sans_{jp,sc,tc,kr,devanagari,arabic,hebrew}_{regular,bold}.ttf` (open question §12-Q3) +- `core/domain/.../model/Palette.kt`, `ThemeMode.kt`, `AppAccent.kt`, `ResolvedMode.kt` +- `core/data/util/AppAccents.kt` + +**Files touched (modified):** +- `core/domain/.../repository/TweaksRepository.kt` — add `getPalette()`, `setPalette()`, `getThemeMode()`, `setThemeMode()`. Deprecate old four. +- `core/data/.../repository/TweaksRepositoryImpl.kt` — add `K_PALETTE`, `K_THEME_MODE`, migration on first read. +- `composeApp/.../app/Main.kt` (or wherever `GithubStoreTheme {}` is called) — branch to `GhsTheme` once palette is wired. + +**Definition of done:** +- `./gradlew :core:presentation:compileCommonMainKotlinMetadata` green. +- `./gradlew :composeApp:assembleDebug` green. +- Launching the app on Nord light + Nord dark + Cream light renders the existing UI without colour regressions (because we map M3 slots faithfully). +- Toggling Palette via a temporary debug menu cycles through all 4 palettes and persists across app restart. +- Fraunces renders in a temporary `Text(...)` call site without tofu on Android + JVM. +- Migration: a user with `K_THEME = "AMBER"` + `K_IS_DARK = true` on disk launches and lands on `Palette.CREAM` + `ThemeMode.DARK`. + +### Phase 1 — Silent vocabulary primitives + +**Deliverable:** All 14 primitives from §8 callable, each renders correctly in all 8 (palette × mode) combinations. + +**Files touched (new):** +- `core/presentation/.../vocabulary/*.kt` (14 files, see §8) +- One Compose Preview composable per primitive (Android only — Previews work there) under `core/presentation/src/androidMain/...`. + +**Definition of done:** +- Visual diff against `silent-vocab.jsx` reference. Each primitive matches CSS reference within ~5% tolerance. +- `Heartbeat` does not run animations when off-screen (LazyList scroll verification). +- Build green; lint green. + +### Phase 2 — Reusable cards, chips, buttons, rows + +**Deliverable:** `RepoCard`, `AppRow`, `SetRow`, `Chip`, `GhsButton`, `Section`, `BottomNav`, `TopBar`, `IconShell`, `InstallPanel`, `UpdateBanner`, `IntegrityCard`, `IdentityCard`, `ConnectCard` per design-system.md §10. + +**Files touched:** +- `core/presentation/.../components/v2/{RepoCard,AppRow,SetRow,Chip,GhsButton,Section,BottomNav,TopBar,IconShell,InstallPanel,UpdateBanner,IntegrityCard,IdentityCard,ConnectCard}.kt` + +Existing `ExpressiveCard`, `GithubStoreButton`, `RepositoryCard` stay; new components live in `components/v2/`. Phase 3+ swaps consumers; old files removed in a final cleanup commit per migration playbook §"Behaviour parity over visual parity". + +**Definition of done:** +- Sample screen wires every new component into a single scrollable demo (under `feature/tweaks` as a hidden debug entry, or under a new `feature/dev-profile` debug screen). +- Each component takes only tokens + content slots — zero hex/dp literals (Konsist check is overkill; eyeball + grep). + +### Phase 3 — Home feed migration + +**Deliverable:** `feature/home` swap to v2 components. Behaviour parity, visual replacement. + +**Files touched:** +- `feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt` and child composables +- ViewModel left alone unless data fields are missing (per-app accent resolution moves to the UI mapper — see §7.3). + +**DoD:** Home renders cards in Nord light + dark identically to handoff `home.jsx`. Existing acceptance test in `composeApp/.../HomeScreen*Test.kt` (if any) passes. + +### Phase 4 — Apps / Favourites / Starred (the "Library" trio) + +We don't have a single Library module. The handoff "Library" collapses three of ours into one screen logically — but **don't merge the modules**. Migration plan: + +- `feature/apps` is the primary visual surface; takes the "Library" treatment (sections: Updates, Installed, Recently used). +- `feature/favourites` and `feature/starred` get re-skinned with the same `AppRow` component but keep their own routes (the IA collapse from MIGRATION.md is a UX choice; the module collapse is not — too risky for one PR). +- The bottom-nav 4→3 reduction (per MIGRATION.md §"Recommended order #6") is **deferred to phase 7**. + +**Files touched:** `feature/apps/presentation`, `feature/favourites/presentation`, `feature/starred/presentation`. + +**DoD:** All three render with v2 components. Updates badge count visible on the Apps tab. + +### Phase 5 — Profile + Settings (Tweaks) + +**Deliverable:** `feature/profile`, `feature/tweaks` migrated. Tweaks gets the new **Palette + ThemeMode** two-axis picker UI. AMOLED toggle remains under "Dark mode" group. + +**Files touched:** `feature/profile/presentation/*`, `feature/tweaks/presentation/*`. + +**DoD:** Old `AppTheme` enum unused in UI code; remaining references are in the deprecated repo methods + the migration table. + +### Phase 6 — Details screen + +**Deliverable:** `feature/details` — the highest-fidelity screen, per MIGRATION.md. + +**Files touched:** `feature/details/presentation/*`. + +**DoD:** Hero block uses Fraunces displaySm + FreshnessRing on the app icon; install panel uses WonkySquircle; Integrity card uses `successT` background. + +### Phase 7 — Bottom nav reduction + remaining screens + +**Deliverable:** +- Bottom nav 4→3 (Home, Library-as-Apps, Profile) + detached search FAB. +- `feature/search`, `feature/auth`, `feature/dev-profile`, "What's new" sheet, mirror picker, external import wizard. +- Onboarding tooltip ("Settings moved to Profile") on first launch post-upgrade (`tweaksRepo.firstLaunchAfterV2`). + +**DoD:** All screens consume `GhsTheme`. Zero references to `GithubStoreTheme()` outside the deprecation shim. + +### Phase 8 — Deprecation cleanup + +**Deliverable:** Delete `AppTheme.kt`, deprecated `TweaksRepository` methods, old `Theme.kt` schemes, `Color.kt` ocean-blue constants, `ExpressiveCard.kt`, `GithubStoreButton.kt`, `RepositoryCard.kt`. The deprecation shim `Theme.kt → GhsTheme` is removed. + +**DoD:** `grep -rn 'AppTheme\|GithubStoreTheme\|isDarkTheme: Boolean' core feature` returns zero hits. `:composeApp:assembleDebug` green. + +--- + +## 11 · Risk register + +Likelihood × impact rated 1–5. Score = L × I. + +| # | Risk | L | I | Score | Mitigation | +|---|---|---|---|---|---| +| 1 | `WonkySquircleShape` arc math wrong → visible artifacts (over/under-shoots, mis-aligned corners) | 3 | 3 | 9 | Implement with Skia debug overlay turned on in a dev preview screen. Diff against a CSS reference rendered at 2x in a screenshot. Have the `AbsoluteRoundedCornerShape` fallback (§3.4) flag-ready. | +| 2 | Fraunces italic doesn't render on JVM Linux (system font fallback to bold-not-italic) | 2 | 4 | 8 | Ship the TTF in `composeResources/font/` (bundled). Compose Resources packages fonts into the jar; Skia loads from the bundle, not the system. Same path as `inter_*.ttf` today. | +| 3 | Theme migration loses user setting → user re-themes app on first v2 launch | 2 | 4 | 8 | Migration is idempotent (only runs when `K_PALETTE` is empty). Add a Logcat/Kermit log line on each migration so support can verify. Keep `K_THEME`/`K_IS_DARK` on disk untouched in case we ever need to rollback. | +| 4 | `Heartbeat` infinite animation in a list of 50 cards eats CPU + battery | 4 | 3 | 12 | Always-on `InfiniteTransition` is per-composable: it pauses when the composable leaves composition. **In a `LazyColumn`, recycled items leave composition automatically.** Bigger concern: 8 visible cards on screen × continuous repaint at 60 Hz. Mitigations: (a) tie the transition's `targetValue` keyframes to discrete steps so the GPU has few invalidations; (b) gate behind `LocalLifecycleOwner` paused → no animation; (c) provide a `Tweaks → Reduce motion` switch that returns a static dot (§12-Q4). | +| 5 | M3 colorScheme mapping mismatch — third-party widget (Markdown renderer, Landscapist placeholder) renders wrong colour on a dark Cream theme | 3 | 2 | 6 | Audit each consumer (`markdown-renderer`, `landscapist-coil`, navigation transition) for which `colorScheme.X` slots they read. Document in `PaletteTokens.kt` next to each mapping. Snapshot test the markdown renderer pass on all 8 (palette × mode) variants. | +| 6 | Per-app accent resolution is non-deterministic (topic list arrives in different order from backend) | 2 | 3 | 6 | `topics.firstNotNullOfOrNull { TOPIC_ACCENTS[it] }` — if backend reorders, accent changes. Mitigation: sort topics alphabetically before resolution. Or use the explicit GitHub API ordering (which is creation order — stable across calls). Note in `AppAccents.kt` docstring. | +| 7 | Noto fallback adds ~30 MB to APK | 4 | 2 | 8 | Open question §12-Q3 — decision needed. If we drop Noto, Android system fallback covers it (Android has CJK + Devanagari fonts in /system/fonts); JVM gets tofu on user-installed-only systems. Compromise: ship Noto on JVM only (split sourceSet), Android relies on system. | +| 8 | Two-axis picker UI breaks muscle memory — existing users hit `Tweaks → Theme` and find new layout | 2 | 2 | 4 | Tooltip on first launch per MIGRATION.md §"Risky areas". Keep the section title as "Theme" not "Appearance" so search-by-label still finds it. | + +Top three by score: #4 Heartbeat perf, #1 wonky-squircle math, #7 Noto size. + +--- + +## 12 · Open questions for the user + +These need a decision before phase 0 begins (or, marked clearly, during the phase). One answer per number. + +**Q1 — Fraunces font axis.** Ship static italic TTFs (3 weights × italic, ~600KB) or the variable TTF (~250KB but no axis API in Compose Resources)? Recommend static. + +**Q2 — Inter Tight replacement of Inter.** DESIGN.md §3.4 forbids "Inter" (the wider one). Today we ship Inter. Confirm: replace `inter_*.ttf` files with `inter_tight_*.ttf` (same `Res.font.inter_X` keys, different file)? Or rename keys to `inter_tight_X` and migrate `Type.kt:21-30` references? Recommend rename — keeps git history clear. + +**Q3 — Noto Sans bundling.** Ship 7 Noto Sans families (JP/SC/TC/KR/Devanagari/Arabic/Hebrew) × 2 weights = 14 TTFs, ~30 MB APK. Or rely on Android system fallback (covers everything) + accept JVM tofu on minority scripts? Or split sourceSet: Android = no Noto, JVM = bundle Noto? Recommend the split approach (Q3-C). + +**Q4 — "Reduce motion" Tweak.** Add a new boolean to `TweaksRepository` for users who want `Heartbeat` to render as a static dot? Or rely on the OS-level reduce-motion setting (Android `Settings.Global.TRANSITION_ANIMATION_SCALE`, JVM has none)? Recommend new in-app toggle — OS signal is unreliable and the JVM has no equivalent. + +**Q5 — AMOLED black mode in v2.** Today `getAmoledTheme()` forces surfaces to true black when dark. Keep this as a sub-mode of `ThemeMode.DARK` (rendered as a toggle in Tweaks, not a third mode)? Recommend yes — three top-level modes (LIGHT/DARK/SYSTEM) + a "Pure black surfaces when dark" sub-toggle. + +**Q6 — Backend per-repo accent.** DESIGN.md §2.4 says "When backend doesn't supply one, derive from the dominant color of the avatar (color-thief style) and store it." Today the backend doesn't supply one and we don't color-thief. Confirm: in this overhaul, **don't** add color-thief. Resolve accent client-side from topics → language → fallback only. Backend-supplied accent is a future feature. + +**Q7 — Material You / dynamic colour.** themes.md §"Disallowed combinations" forbids Material You on Android 12+ overriding our palettes. Today we ship `AppTheme.DYNAMIC`. Migration table (§6.4) folds `DYNAMIC → NORD`. Confirm: drop `AppTheme.DYNAMIC` entirely, no opt-in to dynamic colour anywhere? (Recommend yes.) + +**Q8 — Two-axis Tweaks UI breakage.** The new "Palette" + "Theme mode" picker is a different layout than the current single-list theme selector. Acceptable to break the current Tweaks visual on the day we ship phase 5? Or do we need a one-release transition where the old screen still exists behind a debug flag? Recommend: ship clean break + add the one-time tooltip (§11 Risk-8). + +**Q9 — Module for vocabulary.** I recommended single module (`core/presentation/vocabulary/`) over a new `core/vocabulary/` module. Confirm? A new module would force `core/presentation` to depend on it and every feature already depends on `core/presentation`, so no consumer benefit. + +**Q10 — Where does the palette+mode resolution live?** Recommended (§6.5) at the root of `AppNavigation` via `MainViewModel`. Alternative: introduce a tiny `ThemeViewModel` that exposes a `StateFlow` derived from palette + mode + system-dark + amoled. Recommend `MainViewModel` extension — it already exists, less DI churn. + +--- + +## Summary — what ArchitectUX is on the hook for in phase 0 + +Read this section before opening a new chat to start phase 0. + +1. Write `core/presentation/.../theme/tokens/{Palette,Status,Shape,Spacing,Type,Motion,Threshold}Tokens.kt` from `tokens.json`. +2. Write `WonkySquircleShape : Shape` + `CookieShape : Shape` from §3.2 and §4.1. +3. Add Fraunces + Inter Tight (+ Noto per Q3) to `composeResources/font/`. +4. Add `Palette`, `ThemeMode`, `ResolvedMode`, `AppAccent` enums + data classes. +5. Add `getPalette`/`setPalette`/`getThemeMode`/`setThemeMode` to `TweaksRepository` + impl + migration. +6. Write `GhsTheme.kt` + 6 composition locals. +7. Wire `GhsTheme` into `composeApp/.../app/Main.kt` (or the top-level theming call site) behind a runtime flag so we can flip between old + new during phases 1–7. +8. One screenshot per (4 palettes × 2 modes) of a temporary debug screen showing a card, a button, an FAB, and a status row — to verify the colour mapping landed. + +End. diff --git a/.design/UX-AUDIT.md b/.design/UX-AUDIT.md new file mode 100644 index 000000000..82ca9617c --- /dev/null +++ b/.design/UX-AUDIT.md @@ -0,0 +1,325 @@ +# UX-AUDIT — GitHub Store, design-system refresh + +Branch: `feat/design-system-refresh`. Audited against `Downloads/handoff 4/` (DESIGN.md, MIGRATION.md, patterns.md, tokens.json). Code citations are file:line on this branch's working tree. No implementation here; only what is, what changes, and the risks. + +--- + +## 1. Current state inventory + +Every primary screen + the chrome that wraps them. + +### 1.1 Chrome (composeApp) + +| Element | Route / file | Current behavior | +|---|---|---| +| Nav graph | `composeApp/.../app/navigation/GithubStoreGraph.kt` | 22 sealed routes (lines 6–84). `DetailsScreen` carries `sourceHost` for Forgejo/Codeberg (line 32). | +| Bottom navigation | `app/navigation/BottomNavigation.kt:60–264` + `BottomNavigationUtils.kt:20–60` | "Liquid glass" pill rail, 5 entries: Home, Search, Apps, Profile, Tweaks (lines 22–51). Apps is Android-only — filtered out on desktop (lines 54–59). Custom shape: `CircleShape` outer + animated rounded indicator (`BottomNavigation.kt:181, 193`). Two badges supported: AppsScreen (update available), ProfileScreen (unread announcements) — `BottomNavigation.kt:242–244`. | +| Top app bars | Per-screen `TopAppBar` from M3 | No shared chrome layer; each screen owns its own bar. | +| App nav host | `app/navigation/AppNavigation.kt:87–…` | `startDestination = HomeScreen` (line 89). `onNavigateToSettings` from Home actually navigates to **Profile** (line 98), not Tweaks. | + +### 1.2 Feature screens + +| # | Route | Composable | Description (1–2 sentences) | Data it surfaces (prose vs primitive today) | +|---|---|---|---|---| +| 1 | `HomeScreen` | `feature/home/presentation/HomeRoot.kt:100` → `HomeScreen` line 165 | Staggered grid of `RepositoryCard`s (`HomeRoot.kt:427`). Filter chips for category (Trending / Hot Release / Most Popular) + topic chips + platform popup; collapsible header on scroll (`HomeRoot.kt:216–234, 279–314`). | **Prose:** owner login, repo name, full description (2-line clamp), `Released 2 days ago`, language, fork badge. **Primitives:** star/fork/download counts as `InfoChip` rows, `PlatformChip` icons, `🔥` emoji prepended when `hasWeekNotPassed` (`core/presentation/.../RepositoryCard.kt:306–308`). | +| 2 | `SearchScreen` | `feature/search/presentation/SearchRoot.kt:107` | Search field + filter chips (platform / language / sort / source: GitHub / Codeberg / Custom) + paginated grid of same `RepositoryCard`. Clipboard banner detects pasted GitHub URLs. Bottom sheets for sort + language. | Same prose-heavy `RepositoryCard` payload as Home. Source chip is text. | +| 3 | `AuthenticationScreen` | `feature/auth/presentation/AuthenticationRoot.kt:…` | Three paths in one screen: web OAuth (PKCE handoff), device flow with copy-code card, PAT paste. State machine across `AuthPath.{Backend, Direct}` + PAT fallback sheet. | Code reveal text, polling spinner (CircularWavyProgressIndicator), error chips. No identity glyph at top. | +| 4 | `DetailsScreen` | `feature/details/presentation/DetailsRoot.kt` (1200+ LOC) — composes `sections/Header.kt`, `Stats.kt`, `WhatsNew.kt`, `ReleaseChannel.kt`, `ReportIssue.kt`, `Logs.kt`, `About.kt` + components/`AppHeader`, `SmartInstallButton`, `ReleaseAssetsPicker`, `VersionPicker`, `LanguagePicker`, `ApkInspectSheet`, `LinkedRepoBanner`. | `LazyColumn` of sections. Hero (`sections/Header.kt`): avatar + repo name + owner row + meta stats. `SmartInstallButton` (548 LOC) drives state across idle/download/install/done. Release notes via `multiplatform-markdown-renderer`. Translation via `TranslationControls` + `LanguagePicker`. APK Inspect sheet for Android. | **Prose:** `Released N days ago`, "Verified build" copy on signed releases (`SmartInstallButton.kt:625–632`). Star count + download count as numeric chips. Owner + ✓ self-owned badge. **No** wax-seal glyph; "Verified" is `Icons.Filled.VerifiedUser` + a text string. APK Inspect already surfaces permission groups + min/target SDK as numerals. | +| 5 | `ProfileScreen` | `feature/profile/presentation/ProfileRoot.kt:43, 177` | LazyColumn of two sections: `profile(...)` (`components/sections/ProfileSection.kt`) + `logout(...)`. Profile section embeds the **Sponsor card** (`Options.kt:114, 251–311`). Settings entry routes to TweaksScreen. **No** settings inline — they live in Tweaks. | Username, avatar circle, bio, repos count, "Favourites / Starred / Recently viewed" rows, "What's new" + "Announcements" rows, sponsor CTA, version footer. Prose-heavy. | +| 6 | `TweaksScreen` | `feature/tweaks/presentation/TweaksRoot.kt:46, 138 (TweaksScreen)` | LazyColumn of grouped settings: Account, Appearance, Installation, Language, Network, Translation, Others, About. Sub-screens for Hidden / Skipped / Host tokens / Mirror picker. Feedback sheet. Coachmark flags persisted (`apk_inspect_coachmark_shown`, `channel_chip_coachmark_shown` — `TweaksRepositoryImpl.kt:301–305`). | Toggle rows with M3 switches, dropdown pickers, value rows with chevrons. JetBrains Mono + Inter typography mismatch with handoff Fraunces requirement. | +| 7 | `DeveloperProfileScreen` | `feature/dev-profile/presentation/DeveloperProfileRoot.kt` | Username-keyed user profile: bio, stats row (`components/StatsRow.kt`), filter+sort (`FilterSortControls.kt`), grid of `DeveloperRepoItem`. | Avatar circle, followers/following/repos numerics, repo rows with star count + language. | +| 8 | `FavouritesScreen` | `feature/favourites/presentation/FavouritesRoot.kt:60, 94` | List of locally-saved favourites. Sort rules. | `FavouriteRepositoryItem` rows — avatar + name + meta. | +| 9 | `StarredReposScreen` | `feature/starred/presentation/StarredReposRoot.kt` | User's GitHub stars (live `/user/starred`). | List of repo rows. | +| 10 | `RecentlyViewedScreen` | `feature/recently-viewed/presentation/RecentlyViewedRoot.kt` | Locally-tracked viewed repos with timestamps. | `RecentlyViewedItem` rows. | +| 11 | `AppsScreen` | `feature/apps/presentation/AppsRoot.kt` (~1500 LOC) | **Android-only.** Lists installed apps; surfaces update state; multi-flavor variant picker; per-app advanced sheet; export/import (Obtainium JSON, manual link); starred picker. | `CompactAppRow` rows with `InstalledAppIcon`, version-state badges, `StatusDotCluster` for variants, source chips. Update banner for available updates. | +| 12 | `SponsorScreen` | `feature/profile/presentation/SponsorScreen.kt:56` | Donation CTAs: GitHub Sponsors (`:97-100`), Buy Me a Coffee (`:105-109`), "Other ways" (star repo / report bugs / share — `:259-298`). | Hero icon (VolunteerActivism), 2 elevated cards, support copy. | +| 13 | `ExternalImportScreen` | `feature/apps/presentation/import/ExternalImportRoot.kt` | Obtainium JSON import wizard with bucketed proposals. | Section banner + candidate rows. | +| 14 | `MirrorPickerScreen` | `feature/tweaks/presentation/mirror/MirrorPickerRoot.kt` | Pick GitHub mirror endpoint; latency probe. | Row per mirror + ms result + radio. | +| 15 | `StarredPickerScreen` | `feature/apps/presentation/starred/StarredPickerRoot.kt` | Scan signed-in user's stars for APK-shipping repos. | Rows with checkbox + asset hints. | +| 16 | `SkippedUpdatesScreen` | `feature/tweaks/presentation/skipped/SkippedUpdatesRoot.kt` | Per-app skipped-release-tag manager. | Rows + unskip button. | +| 17 | `HiddenRepositoriesScreen` | `feature/tweaks/presentation/hidden/HiddenRepositoriesRoot.kt` | Restore hidden repos from Home/Search long-press hide. | Rows + unhide / unhide-all. | +| 18 | `WhatsNewHistoryScreen` | `core/presentation/.../whatsnew/WhatsNewHistoryScreen.kt` | Versioned what's-new release notes (markdown). | Section headings + markdown blocks. | +| 19 | `AnnouncementsScreen` | `core/presentation/.../announcements/AnnouncementsRoot.kt` | Backend-served announcements (rate-limit notices, etc.). | List of announcement rows. | +| 20 | `HostTokensScreen` | `feature/tweaks/presentation/hosttokens/HostTokensRoot.kt` | Per-host PAT CRUD (KSafe-encrypted). | Row per host + token (masked), add dialog. | + +--- + +## 2. Gap analysis — old → new (per screen) + +Applies DESIGN.md §12 migration tables and §4 vocabulary. Every entry is concrete. + +### 2.1 RepositoryCard (used by Home + Search + most lists) +- `Icons.Outlined.StarOutline` + numeric chip (`RepositoryCard.kt:245–248`) → **StarTier** (1–5 filled stars, log buckets at 1k/10k/50k/100k per tokens.json `thresholds.stars`) + count in JetBrains Mono. Reason: scannable tier replaces 5-digit text. +- `Icons.AutoMirrored.Outlined.CallSplit` fork count chip (`:250–253`) → **drop**. Reason: DESIGN.md §12.2 ("Forks/issues numbers — mostly drop; heartbeat does the 'alive' job"). Heartbeat is the live signal. +- `Icons.Outlined.Download` download chip (`:255–260`) → **DownloadWeight** dot (radius = log10(downloads)). Reason: §4.1. +- `Icons.Outlined.Code` language chip (`:262–267`) → **drop or fold into TopicGlyph**. Reason: language is rarely the decision input on an app store row. +- `"🔥 " + formatReleasedAt(...)` (`:306–311`) → **FreshnessRing around the avatar** + flame day-count pill on hero/lead cards. Reason: §1.4 forbids emoji as data; the FreshnessRing's fractional fill *is* the answer (§4.1). +- 40dp circle avatar (`:172–175`) → **avatar inside FreshnessRing**, ring color from `tokens.json.thresholds.freshness` keyed off `releaseRecency` days. Reason: §12.1. +- `PlatformChip` text labels (`:296–297`) → **PlatformGlyph** mono silhouettes (Android robot, Windows quad, macOS apple, Linux penguin). Reason: §12.1, §4.1. Filled = supported, dashed outline = unsupported. +- `IconButton(... ContentDescription = open_in_browser)` + share button (`:335–365`) → move into long-press `RepositoryActionsBottomSheet` (which already exists). Reason: §7.4 list-row template doesn't carry browser icons; cards stay quiet. +- Bottom CTA "View details" `GithubStoreButton` (`:329–333`) → **remove**; whole-card tap already navigates. Reason: §7.3 compact-card template has no inline CTA. +- Card shape (`ExpressiveCard` — `core/presentation/components/ExpressiveCard.kt`) — currently a generic M3 `Card` wrapper → **asymmetric squircle** `radD(16,12)`. Lead cards (top of Hot list) → **wonky squircle** (constraint: see §4). Reason: §5.1, §5.2. + +### 2.2 HomeScreen layout +- `LazyVerticalStaggeredGrid(StaggeredGridCells.Adaptive(350.dp))` (`HomeRoot.kt:427–440`) → **vertical list with fixed section order** (Lead release → Hot releases → Trending → Most popular → From your stars). Reason: DESIGN.md §10.1 sets section order; staggered grid hides the rank semantic the new design relies on. +- Top tab `HomeFilterChips` for HomeCategory (TRENDING / HOT_RELEASE / MOST_POPULAR) — `components/HomeFilterChips.kt`, `LiquidGlassCategoryChips` — → **time-window filter** (Today/Week/Month/All) per §9.3 / §10.1. Reason: category becomes section, not filter. Filter is temporal. +- Topic chips row (`HomeRoot.kt:317–391`) → **keep**, but restyle: M3 `FilterChip` + `RoundedCornerShape(12.dp)` → asymmetric chip radD(11,8) (§7.2). TopicCategory icons stay; ensure they map to TopicGlyph vocabulary (§4.2) — drop any not in `tokens.json.topicGlyphs.supported`. +- Platform popup (`PlatformsPopup` — separate Composable in HomeRoot) → keep; restyle with wonky squircle dropdown + outline border (§16.7). +- Section headers — none today (sections are flat behind the category chip) → add **section header with Squiggle** (§7.5). + +### 2.3 DetailsScreen +- Header `sections/Header.kt` with left-aligned avatar + repo name + chip row → on mobile, **centered hero** (`Avatar in FreshnessRing 92px → Fraunces italic name → owner login row → StarTier → topic glyphs`) per §9.4. Desktop stays left-aligned per §8.3. +- `SmartInstallButton` (Material `Button` with text + spinner — `components/SmartInstallButton.kt`) → **wonky-squircle primary CTA** with `boxShadow: 0 4px 12px -4px primary99` (DESIGN.md §7.1). Sub-meta below button ("permissions: low · arm64 · ▮▮▮▮") becomes a row of PermDot + PlatformGlyph + SignalBars. +- "Verified build" string + `Icons.Filled.VerifiedUser` (`SmartInstallButton.kt:625-632`) → **WaxSeal card** (§7.8). Mobile: top-of-section card; desktop: rotated stamp in install-panel corner. Three states: Sealed (successT), Broken (dangerT, **only place red is used aggressively**), Open (surface). +- `Stats.kt` 2-up / 4-up stats chips → **vital signs 2×2 grid** (`RELEASED · MAINTAINED · STARS · PERMISSIONS`) per §7.7. Each tile: glyph 22px + value Fraunces italic 13px + uppercase label 9.5px. +- APK Inspect sheet (`ApkInspectSheet.kt`, ~mobile-only) → keep as `APK Inspect` full-screen sheet (§9.6). Replace permissions wall-of-text with **PermDot heat + grouped chips** color-coded inline (Dangerous red / Sensitive amber / Normal green). Min/Target/Compile SDK become big italic numerals in 3 tiles. +- `LanguagePicker` translate UI → **dropdown menu** (§16.7) — copy the existing `TranslationControls.kt` pattern. +- "What's new" tabs across the top → **inner-screen takeover** with back arrow (§12.2). Version rail on the left, notes on the right. +- `Icons.Filled.Favorite/FavoriteBorder` heart button → keep; restyle as icon-only 36×36 button. +- `LinkedRepoBanner` → keep; restyle as accent-tinted banner per §7.6. + +### 2.4 SearchScreen +- M3 `TextField` (`SearchRoot.kt` — search field) → **wonky-squircle search input** with the `tokens.json.shape.wonkySquircle.search` radii (`24px 18px 26px 20px / 18px 24px 20px 26px`). +- Source chip row (GitHub / Codeberg / Custom) — `SearchSourceUi` — → keep; restyle as filter chips (§7.2). Add `+ Add filter` dashed-border affordance for custom forges. +- `LanguageFilterBottomSheet` / `SortByBottomSheet` → **bottom sheet** (§16.1) — top-corners-only wonky squircle + drag handle. +- Recent-queries quick-chips (`SearchHistorySection.kt`) → keep; chip styling per §7.2. + +### 2.5 ProfileScreen +- M3 `TopAppBar` with title only (`ProfileRoot.kt:231–242`) → top bar with **Cookie-shape user mark** on the right (§9.2). +- `profile(...)` section list (avatar + bio + nav rows) → **hero card with gradient** for identity (`tintP → surface` 135°, patterns.md "Hero card with gradient"). User avatar inside Cookie shape (§4.3) — *not* FreshnessRing (the cookie is the user-identity moment). +- `SponsorCard` (`components/sections/Options.kt:251`) — see §6 below. MIGRATION.md flags this as **must not be re-introduced** without product approval; current code still ships it. **Decision required**. +- Logout section → confirm dialog (§16.2) with Cookie-shape glyph above title. + +### 2.6 TweaksScreen +- LazyColumn of grouped settings → keep structure; apply **List screen with sections** pattern (patterns.md). Section labels become uppercase 0.04em tracking. +- M3 switches → primary fill when on, surface2 when off (§"Form / Settings group"). +- Value rows ("Theme: Ocean Blue", "Font: System") → caption + `›` chevron. +- Coachmark popups (APK Inspect pulse, ReleaseChannel chip popup) → keep; restyle popup container per §16.7. + +### 2.7 AppsScreen (Library equivalent) +- TopAppBar + filter row + sort dropdown → **Library** layout (§9.5): top-bar title "Library" + meta line "N apps · M updates available" → update banner with VersionStack glyph → tab row [Installed | Updates · 1 | Pending] → rows. +- `CompactAppRow` (`components/CompactAppRow.kt`) → **list row** template (§7.4): `[avatar in FreshnessRing] [name + heartbeat] [version tag in Mono] [Open / Update button (wonky squircle)]`. +- `StatusDotCluster` for multi-flavor variant indicator (`components/StatusDotCluster.kt`) → stays — already a primitive-style cluster; just align colors with `tokens.json.status.freshness`. +- Update banner inside the screen → accent-tinted banner with **VersionStack glyph + accent button** (§12.1, §"Update banner"). +- "Update all" extended FAB → maybe drop; the per-row Update button + update-banner CTA already covers it. Open question. +- Variant picker dialog (`VariantPickerDialog.kt`) → confirm dialog (§16.2) with VersionStack glyph at top. + +### 2.8 AuthenticationScreen +- Web-OAuth + Device-flow + PAT-paste split → **full-screen sheet** layout (§16.5) with **CookieShape · GitHub** identity mark at top. +- Device-code reveal box (`CardDefaults.elevatedCardColors`) → mono JetBrains Mono 28–32px in primary-bordered wonky-squircle code box. +- Polling spinner (`CircularWavyProgressIndicator`) → **Heartbeat glyph** + "waiting…" caption (§16.5; reuses the maintenance-state vocabulary). +- "Use Personal Access Token" fallback → outlined button at bottom of the sheet. +- PAT bottom sheet (`ModalBottomSheet`) → bottom sheet (§16.1) with drag handle + top-corners-only wonky squircle. + +### 2.9 SponsorScreen +- `Icons.Filled.VolunteerActivism` hero (`:141`) + 2 elevated cards → **decision required** before redesign. MIGRATION.md explicitly bans donate UI without product approval. If kept, restyle hero with Cookie + Squiggle; cards become squircle compact cards. + +### 2.10 Favourites / Starred / Recently-viewed +- Item composables (`FavouriteRepositoryItem`, default starred row, `RecentlyViewedItem`) → unify into the **list row** template (§7.4). All three should reuse the same row composable; today they're three slightly-different layouts. + +### 2.11 DeveloperProfileScreen +- `ProfileInfoCard` (avatar + bio) → identity hero card (gradient `tintP → surface`) with avatar inside a soft outline circle (not Cookie — Cookie is reserved for the signed-in user). `StatsRow` (followers/following/repos) → 3-tile vital-signs row (Fraunces italic numerals). +- `DeveloperRepoItem` rows → list-row template (§7.4) with StarTier instead of numeric stars. + +### 2.12 HostTokensScreen +- Per-host PAT rows (mask + edit + delete) → list rows; PAT value in JetBrains Mono `••••••••••••` (mono is the "technical fact" voice per §3.3). Add dialog → bottom sheet. + +### 2.13 MirrorPickerScreen +- Radio + ms latency rows → list row with **SignalBars** primitive replacing the numeric ms label. Numeric ms can stay in mono as the secondary line. + +### 2.14 WhatsNewHistoryScreen +- Versioned markdown sections → **inner-screen** style (§8.4): version rail + selected version notes. Existing markdown renderer is reusable. + +### 2.15 ExternalImportScreen / StarredPickerScreen +- Pre-import summary buckets → section list with squiggle headers (`patterns.md` "List screen with sections"). Candidate rows: avatar + name + matched-host chip + checkbox. Bottom CTA: outline Cancel + primary wonky "Import N". + +--- + +## 3. Information-architecture deltas (what to keep, change, drop) + +MIGRATION.md prescribes IA changes (bottom-nav 4→3, Settings into Profile, Library single-screen). Reality at GHS: + +| Handoff prescription | GHS actual today | Recommendation | +|---|---|---| +| Bottom nav 4 → 3 tabs (Home, Library, Profile) + detached Search FAB | **5 tabs** today (Home, Search, Apps, Profile, Tweaks — `BottomNavigationUtils.kt:22–51`), Apps Android-only | Move to **4** on Android (Home, Search, Library, Profile) and **3** on desktop drawer (Home, Library, Profile — Library replaces "Apps"; Search becomes top-bar / drawer search per §8.1). **Reject** the "Search becomes FAB only" recommendation — Search-as-tab is a power-user fixture in GHS and the third-most-used surface. Keep Search visible. | +| Settings moves into Profile | **Already split:** Tweaks is the settings home (`feature/tweaks/`), Profile is identity-only (`feature/profile/CLAUDE.md`). Tweaks is currently a 5th bottom-nav tab. | **Drop Tweaks from bottom nav**; surface "Settings" as a row in Profile (already wired — `HomeRoot.onNavigateToSettings` lands on Profile, then Profile→Tweaks is one tap). The route stays. | +| Library single screen replacing "Installed / Updates / Stars" tabs | **GHS today:** Apps screen has tabs/sort but no Starred tab; Starred is a separate route reached from Profile (`StarredReposScreen`). | **Don't merge Starred into Library.** Starred is GitHub-account-bound; Apps is local-install-bound. They share row visuals but not data semantics. Library = Apps (rename) with Installed/Updates sections per §9.5 ; Stars stays under Profile. | +| Profile's "Support" replaced by "Connect" + "Business inquiries"; no donations | GHS still ships `SponsorScreen` with GitHub Sponsors + Buy Me a Coffee links | **Product call required.** MIGRATION.md is opinionated; rainxchzed is the maintainer and may want sponsorship kept. Audit flags this — see §6 risks. | +| "Translate the app" CTA banned | GHS ships **README translation** (not app translation) via `TranslationRepository` — Google / Youdao / LibreTranslate / DeepL / Microsoft. App localization is 13-locale built-in via Compose resources. | **Not applicable** — handoff is talking about *contributing translations*, not in-app content translation. README translation stays. App-locale picker in Tweaks stays. | +| Multi-script font fallback for CJK/Devanagari/Arabic/Hebrew | GHS bundles Inter (Latin+Cyrillic) and JetBrains Mono only. No Noto fallback set. 8 of 13 shipped locales use non-Latin scripts (ar, bn, hi, ja, ko, ru partially, tr partially, zh-CN). | **Action required** — see §4. Add Noto Sans family fallback via Compose `FontFamily` resolver. | +| Pre-release toggle: per-app overrides global | GHS already implements this (`InstallationManagerImpl` seeds new install from `include_pre_releases` pref; existing rows keep per-app value — `feature/tweaks/CLAUDE.md`). | **No change.** Already aligned. | +| Connect card on Profile | Not present | Add only if Sponsor card is removed (replacement narrative). | + +### 3.1 Bottom-nav delta (concrete) + +`BottomNavigationUtils.items()` currently builds: +``` +0 Home, 1 Search, 2 Apps (Android), 3 Profile, 4 Tweaks +``` +Proposed: +``` +Android: Home, Search, Library, Profile (Tweaks reachable via Profile) +Desktop: Drawer with Home, Library, Profile (Search via drawer header + ⌘K; Tweaks via Profile) +``` +That's **4 on Android** (handoff says 3 — we push back; see above) and **3 on desktop** (drawer matches §8.1 exactly). + +--- + +## 4. Cross-platform / Compose constraints + +The handoff is web-native (CSS). Several conventions don't translate cleanly to Compose Multiplatform. Each constraint listed once with every place it bites. + +### 4.1 Wonky squircle (asymmetric elliptical radii) +- **CSS does:** `border-radius: 20px 14px 22px 16px / 16px 22px 14px 20px` — four corners, each with independent horizontal + vertical radii (8 numbers total). +- **Compose can't:** `RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)` takes 4 numbers, applied as circular radius per corner. There is **no built-in elliptical radius**. +- **Mitigation:** Build a custom `Shape` (Path-based) — `AsymmetricSquircleShape(topStart: Size, topEnd: Size, bottomEnd: Size, bottomStart: Size)` that emits a `Path` with `arcTo(rect, …, sweepAngle, …)` per corner using ellipse bounds. Live in `core/presentation/shape/` (new package). +- **Where it matters:** + - All primary CTAs — `SmartInstallButton.kt` (Details), the "Update" / "Open" CTAs in `AppsRoot` (Apps), `GithubStoreButton` (`core/presentation/components/GithubStoreButton.kt`) used in Home cards and Search. + - Lead release card on Home (top of Hot list). + - Search input — `feature/search/.../SearchRoot.kt` `TextField`. + - Device-code reveal box on Authentication. + - Bottom sheet top corners — `ReleaseAssetsPicker`, `LanguageFilterBottomSheet`, `SortByBottomSheet`, `LinkAppBottomSheet`, `ImportSummarySheet`, `AdvancedAppSettingsBottomSheet`, `ApkInspectSheet`, `RepositoryActionsBottomSheet`. ModalBottomSheet's `shape` parameter accepts a custom Shape — feed the asymmetric squircle there. + - Toast / Snackbar background (§16.3) — `core/presentation/.../components/` (no shared snackbar today). + - Confirm dialog containers — `LogoutDialog`, `ClearDownloadsDialog`, `VariantPickerDialog`, the unlink-external-app dialog (`DetailsRoot.kt`), the discard-pending-install dialog. + +### 4.2 Cookie shape (9-petal organic) +- **CSS does:** SVG path import. +- **Compose:** `Shape` from `Path` — feed `tokens.json.shape.cookie.path` into `Path.parseSvgPath` (KMP support varies; use `Path` API + manual cubic-Bezier commands). Live in `core/presentation/shape/CookieShape.kt` (new). +- **Where:** brand mark at top-left of HomeScreen top-bar, user mark at top-right, active bottom-nav tab background (Android), authentication identity mark, ProfileScreen avatar tile. Four touchpoints (§5.3). + +### 4.3 Squiggle underline +- **CSS does:** inline SVG `` 36–42px × 5px. +- **Compose:** small `Canvas` with `drawPath` of the `tokens.json.shape.squiggle.path`. Component `SectionHeading(text, modifier)` in `core/presentation/components/`. +- **Where:** every section header — Home Hot/Trending/Popular, Tweaks section labels, Library Installed/Updates sections, Detail About/Permissions sections, Diagnostics card (Tweaks → Feedback). + +### 4.4 Drop shadow on primary CTAs +- **CSS does:** `box-shadow: 0 4px 12px -4px primary99`. +- **Compose:** `Modifier.shadow(elevation, shape, ambientColor, spotColor)`. Custom color shadows only on **API 28+** Android — min SDK 26 → graceful fallback on 26/27 to neutral shadow. Use `androidx.compose.ui.draw.shadow` with `ambientColor` + `spotColor` and accept that on API 26/27 it falls back. + +### 4.5 Fonts (most critical mismatch) +- **Handoff wants:** Fraunces (italic 600), Inter Tight, JetBrains Mono. +- **GHS has:** Inter (NOT Inter Tight — DESIGN.md §3.4 explicitly forbids), JetBrains Mono, **no Fraunces**. See `core/presentation/.../theme/Type.kt:21–30, 14–19` and `composeResources/font/` listing. +- **Worse:** `Type.kt:43–51` uses **JetBrains Mono for displayLarge through titleSmall** — i.e. the entire heading stack is mono. DESIGN.md §3.3 explicitly says mono is "for technical artifacts (versions, hashes, package names) so it carries weight when it appears." +- **Mitigation:** bundle Fraunces (variable + italic) + Inter Tight in `core/presentation/.../composeResources/font/`. Add Noto Sans CJK/Devanagari/Arabic fallbacks (MIGRATION.md "Multi-script type stack"). Rewrite `Type.kt` to map display/headline/title → Fraunces italic; body/label → Inter Tight; introduce a new typed `monoFontFamily` for tokens explicitly tagged as mono (versions, SHAs, package names). +- **FontTheme enum** (`core/domain/.../FontTheme.kt:6–8`) currently `SYSTEM | CUSTOM("JetBrains Mono + Inter")`. The display name lies after the refactor; pre-refactor user pref persistence keeps working but the label is stale. + +### 4.6 Heartbeat animation +- **CSS does:** CSS keyframes. +- **Compose:** `rememberInfiniteTransition` + `animateFloat`. Periods from `tokens.json.thresholds.maintenance`. Skip animation when `Settings → Accessibility → Reduce motion` is on (Android: `Settings.Global.ANIMATOR_DURATION_SCALE == 0`; desktop: respect OS pref where available). + +### 4.7 macOS-style traffic-light window chrome (desktop §8) +- **CSS does:** mock traffic lights with three colored circles. +- **Compose Desktop:** `Window` on macOS already provides native traffic lights when `undecorated = false`. Don't re-draw them — let macOS render its own. On Windows/Linux, draw chrome ourselves at 36px tall with title centered. + +### 4.8 Color shadows behind FreshnessRing accent +- The per-app accent (`accent: { c, lt, dt }` per repo) is **not currently stored** anywhere. `core/data/dto/BackendRepoResponse.kt` has no accent field. **Action:** add to backend response (open question per DESIGN.md §14 OQ #2) or derive client-side (color-thief on avatar). For now, leave the accent slot unfilled and let primitives use palette `primary` as fallback. + +--- + +## 5. Honesty audit (data we invent vs derive) + +DESIGN.md §11 forbids invented data. Walk of current code: + +| Location | Today | Verdict | Action | +|---|---|---|---| +| `RepositoryCard.kt:306–308` | `"🔥 " + formatReleasedAt(updatedAt)` when `hasWeekNotPassed(updatedAt)`. The 🔥 is conditional on a real signal (≤ 7 days). | **Derivable** — but emoji forbidden by §1.4. | Replace emoji with FreshnessRing fraction (the ring already encodes "hot"). | +| `feature/home/data/.../HomeRepositoryImpl.kt:571–604` | Local affinity-`score` computed by adding bonuses (topics, language, description keywords). Drives sort. | **Borderline.** Not displayed as a percentage; only used to order. Per DESIGN.md §14 OQ #1, this is a "local sort proxy" while backend `trendingScore` is null. | **Keep**, but never surface as text. Position (`#1`, `#2`) is the only honest display. | +| `core/data/dto/BackendRepoResponse.kt:27` | `trendingScore: Double? = null` from backend. Used only for sort key in `CachedRepositoriesDataSourceImpl.kt:207`. | **Honest** — used as opaque sort key, not displayed. | No change. Don't ever render this as a percentage. | +| `SmartInstallButton.kt:625–632` | "Verified build" string + `Icons.Filled.VerifiedUser` whenever `AttestationStatus.VERIFIED`. | **Derivable** — backed by real attestation verifier (`DetailsViewModel.kt:2315`). | Migrate copy to **WaxSeal** glyph + "Sealed" Fraunces label + sha256 fingerprint in mono. The verification result is real; the *representation* needs to align with vocabulary. | +| `Stats.kt` (Details) numeric chips (stars, forks, downloads) | Real numbers from API. | **Honest.** | Replace stars with StarTier+count, drop forks (§12.2), keep downloads as DownloadWeight dot + count. | +| `formatCount(...)` (used by `RepositoryCard`) | Pretty-print thousands. | **Honest.** | Keep; render in JetBrains Mono (technical fact per §3.3). | +| Search "trending" — no current invented signal | n/a | — | — | +| "Featured" curation — **no occurrence in codebase** (verified `grep -rn "[Ff]eatured" --include='*.kt'` returns zero hits). | n/a | — | The new Lead-release slot (§10.1) is honestly "top of the filtered Hot list" — match that. | +| "+X%" trending percentages — **no occurrence in codebase** (verified). | n/a | — | Keep that way. | + +**Net:** the only active honesty violation today is the 🔥 emoji at RepositoryCard.kt:307. The local affinity score is a sort proxy and acceptable. The "Verified build" string is honest but uses the wrong vocabulary (icon-with-label instead of WaxSeal). + +--- + +## 6. Risks + breaking changes for users + +| Risk | Impact | Mitigation | +|---|---|---| +| **Bottom-nav reduction from 5 → 4 (Android)** | Tweaks tab disappearing breaks muscle memory; users tap empty space. | One-shot coachmark on Profile→Settings row first time Profile is opened post-upgrade. Add `K_SETTINGS_RELOCATION_COACHMARK_SHOWN` to `TweaksRepositoryImpl.kt:477+` (companion object) and a `getSettingsRelocationCoachmarkShown()`/`setSettingsRelocationCoachmarkShown(Boolean)` pair to `TweaksRepository.kt`. Trigger from `ProfileRoot` first composition. | +| **Apps tab renamed to Library** | Users searching for "Apps" in onboarding/help. | Keep the route name `AppsScreen` (don't churn the nav graph). Only rename the visible label `Res.string.bottom_nav_apps_title`. Add a what's-new bullet in the upcoming-release JSON. | +| **Bottom-nav 5 → 4 → 3 path on desktop** | Desktop already filters out Apps (`BottomNavigationUtils.kt:54–59`); going to drawer is a bigger redesign than dropping a tab. | Ship drawer behind a behind-feature-flag toggle in Tweaks → Appearance → "Use drawer (experimental)" until validated. Reuse the existing `convention.cmp.application` build config; no new module. | +| **Typography change (mono headings → Fraunces italic)** | Users who chose `FontTheme.CUSTOM` ("JetBrains Mono + Inter") expect mono everywhere. Visual whiplash. | Add a new `FontTheme.STORE` variant (Fraunces + Inter Tight + JetBrains Mono) as the *new default*. Keep `CUSTOM` as a legacy choice for users who don't want the change. Migration: do **not** auto-flip persisted `CUSTOM` to `STORE`; let users opt in. | +| **Palette change (Ocean/Purple/Forest/Slate/Amber → Nord/Cream/Forest/Plum)** | `AppTheme` enum (`core/domain/.../AppTheme.kt:3–10`) loses two values, adds two. Persisted `AppTheme.fromName` returns OCEAN default — users with PURPLE/SLATE/AMBER land on OCEAN→Nord silently. | Map old→new in `AppTheme.fromName` migration: `OCEAN→NORD`, `PURPLE→PLUM`, `FOREST→FOREST`, `SLATE→NORD`, `AMBER→CREAM`. Surface a one-shot snackbar "Your theme was updated to Nord — pick a different one in Tweaks → Appearance" if the old name was not NORD. Persist a `theme_migrated_v1` flag in `TweaksRepository`. | +| **Sponsor card removal** | Maintainer revenue (rainxchzed). | **Decision required by product / maintainer.** Audit recommends *keeping* SponsorScreen at the project's discretion regardless of MIGRATION.md ban — but restyle, don't remove, unless owner says so. | +| **Sheet shape change (rounded → wonky asymmetric)** | Visual change only, no functional regression. | None needed. | +| **Heartbeat motion on Library rows** | Users with reduce-motion will get static dot per DESIGN.md §6.1; tokens.json `thresholds.maintenance` already has a `dormant` (`heartbeat_period_s: null`) fallback. | Honor system reduce-motion: snap to "dormant" period (no animation). | +| **AppsRoot complexity (~1500 LOC)** | Refactor risk: regressing variant picker, advanced sheet, import flow. | Migrate visuals only; keep all behavior + state machinery. New `CompactAppRow` is a re-skin of the existing — same params. | +| **Markdown rendering in inner-screen takeover** | Existing chunked progressive rendering + theme-aware images (`details/CLAUDE.md` "Markdown perf") is fragile. | Reuse existing markdown stack 1:1. Only the surrounding chrome changes (back arrow + tabs + version rail). | + +--- + +## 7. Open questions for product (beyond DESIGN.md §14) + +| # | Question | Conflicts with | +|---|---|---| +| 1 | **Per-host PATs (Codeberg / Forgejo / custom forges).** Handoff assumes a single GitHub identity. How do non-GitHub forges represent "the signed-in user" in the user-mark Cookie shape on top bars? Display the GitHub user even when looking at a Codeberg repo? | DESIGN.md §9.2 + §16.5 (CookieShape · GitHub identity mark) assumes one identity. | +| 2 | **Foreign-source repos (`sourceHost != null`) on Home / Search.** Codeberg / Forgejo repos are interleaved with GitHub repos in lists. Do they show the same FreshnessRing / StarTier / DownloadWeight (signal vocabulary is host-agnostic) — but with a tiny host-glyph (Forgejo flame icon, Codeberg blue G) somewhere on the card? | DESIGN.md doesn't address multi-source rows. `RepoIdCodec` packs host into `repoId`. | +| 3 | **Multi-flavor installed apps (#638).** A single repo can ship multiple APK variants (generic + Play). `StatusDotCluster` already shows this. New design has no concept of "this row represents N installed packages." How does the row's Update CTA disambiguate? | DESIGN.md §9.5 Library rows assume 1 repo = 1 install. | +| 4 | **Web-OAuth path with handoff custom-scheme.** The handoff §16.5 OAuth full-screen sheet shows a device-flow code box. Our primary path is web OAuth (no code) — the user gets bounced to a browser and back. The sheet should reflect that: "Open browser → Sign in → Return here" steps, not "Open github.com/login/device → enter code". | §16.5 numbered steps assume device flow as primary. | +| 5 | **KSafe-encrypted prefs and the Diagnostics card (§16.4).** The "what we'll attach" JSON pretty-prints prefs. Some prefs are KSafe-encrypted (per-host PATs, translation API keys). They must NEVER appear in diagnostics — even masked. Confirm the diagnostics block lists only non-secret fields, and that the Tweaks feedback sheet (`FeedbackBottomSheet`) is already aligned with this. | §16.4 doesn't enumerate which fields are secret. | +| 6 | **Shizuku / Dhizuku / Root installer state on Library rows.** When a row is installed via Shizuku, do we surface that as a glyph next to the version, or only inside the per-app advanced sheet? | DESIGN.md is GitHub-Releases-only — Android installer plurality isn't addressed. | +| 7 | **Skipped releases (per-app `skippedReleaseTag`, E542).** When a user has skipped v2.7.5 of immich, do we show VersionStack (v2.7.0 → v2.7.5) as "you skipped this" with a different color, or suppress entirely until a newer release? Current code suppresses CTA until strictly-newer release. | DESIGN.md §12.4 says VersionStack badge replaces M3 numeric "1"; doesn't address skip state. | +| 8 | **Ignore-updates** (silence the badge per app). Same family as skip — should the row still show a "Update available" VersionStack with a "silenced" dashed-outline modifier? | Not addressed. | +| 9 | **Trust-fingerprint storage** (DESIGN.md §14 OQ #4) — where do we persist the previously-installed signing fingerprint? Compose existing `AttestationVerifier` (`DetailsViewModel.kt:2315`) — is the "previously-installed fingerprint" available, or do we need a new Room column on `InstalledApp`? | OQ #4 already flagged; needs concrete answer for the broken-wax-seal flow. | +| 10 | **Translation provider credentials in the OAuth sheet treatment.** TweaksRoot's Translation section has LibreTranslate + DeepL + Microsoft credential forms with show/hide toggles. These don't map to any handoff section. Keep current pattern; flag for review. | n/a | +| 11 | **Locale picker remains in Tweaks** (13 locales) — handoff doesn't address app-level locale switching. Keep as-is. | DESIGN.md doesn't mention. | +| 12 | **Sponsor / "Connect" call.** Final answer needed before §2.9 work begins. | MIGRATION.md "Risky areas". | +| 13 | **What-to-do with "Tweaks" as a brand word.** Rename to "Settings" matching handoff vocabulary, or keep "Tweaks" (existing affordance, localized in 13 languages)? | DESIGN.md §8.1 uses "Settings" in the drawer; existing string `bottom_nav_profile_tweaks` is "Tweaks". | +| 14 | **Announcements & WhatsNew** sit inside Profile today. Handoff doesn't address them. Keep, restyle. | n/a | + +--- + +## 8. Recommended sequencing (KMP/CMP-tailored, replaces MIGRATION.md order) + +MIGRATION.md is web-bottom-up. Tuned for our layered modules: + +| # | Phase | Concrete paths | Why | +|---|---|---|---| +| 1 | **Foundation — design tokens module** | New: `core/presentation/src/commonMain/.../tokens/` with `Palette.kt` (4 palettes × light/dark from tokens.json), `Status.kt` (freshness/wax/perm/trend hex), `Shapes.kt`, `Typography.kt`, `Motion.kt`. Rewrite: `core/presentation/.../theme/Color.kt`, `Theme.kt`, `Type.kt`. | Everything below consumes tokens. No screen work yet. | +| 2 | **Fonts** | Bundle Fraunces variable+italic + Inter Tight + Noto Sans CJK/Devanagari/Arabic into `core/presentation/.../composeResources/font/`. Rewrite `Type.kt` mapping (Fraunces for display/headline/title; Inter Tight for body/label; mono for explicit-mono slots). Migrate `FontTheme` enum: add `STORE` value, leave `CUSTOM` for legacy. | Phase-1 typography unblocks every label + heading change downstream. | +| 3 | **Shape primitives (Compose-specific)** | New: `core/presentation/src/commonMain/.../shape/AsymmetricSquircleShape.kt`, `WonkySquircleShape.kt`, `CookieShape.kt`, `SquiggleUnderline.kt` (Canvas composable). | Required before any card/CTA/sheet refactor. | +| 4 | **Silent Vocabulary primitives** | New: `core/presentation/src/commonMain/.../primitives/FreshnessRing.kt`, `Heartbeat.kt`, `StarTier.kt`, `WaxSeal.kt`, `VersionDelta.kt`, `VersionStack.kt`, `PermDot.kt`, `PlatformGlyph.kt`, `TopicGlyph.kt`, `SignalBars.kt`, `DownloadWeight.kt`, `LicensePosture.kt`. All take their data via typed inputs (days since release, star count, etc.) — no string parsing. | Bottom-up: the 12 primitives compose every screen below. Drop-in replacements for existing chips. | +| 5 | **Shared chrome — TopBar, BottomNav, SectionHeader, Banner, Toast** | Rewrite: `composeApp/.../app/navigation/BottomNavigation.kt` (Cookie-shape active tab, Fraunces italic label, VersionStack badge). Rewrite: `BottomNavigationUtils.kt` (drop Tweaks from items, add Library label). New: `core/presentation/.../components/SectionHeader.kt`, `Banner.kt`, `StoreToast.kt`. | Cross-cutting chrome lands before any screen. Bottom-nav reduction lives here. | +| 6 | **RepositoryCard rewrite** | Rewrite: `core/presentation/.../components/RepositoryCard.kt` + `ExpressiveCard.kt`. Now uses primitives + asymmetric squircle. | Single component change ripples to Home + Search + Favourites + Starred + Recently-viewed + DeveloperProfile. Biggest visual win per hour. | +| 7 | **HomeScreen** | Rewrite section order (Lead → Hot → Trending → Popular → Stars). Replace `HomeCategory` chips with time-window filter (Today/Week/Month/All) inside the state. Add lead-card with wonky squircle + radial bloom. Edit: `feature/home/presentation/HomeRoot.kt`, `HomeState.kt` (add `timeWindow`), `HomeViewModel.kt`. Keep `HomeRepository` interface intact. | High-traffic; sets the tone. The IA change (category becomes section, not filter) is the headline. | +| 8 | **AppsScreen → Library** | Edit: `feature/apps/presentation/AppsRoot.kt` (new layout per §9.5), `CompactAppRow.kt` (new primitives). Rename label `bottom_nav_apps_title` → `bottom_nav_library_title` (touch every 13 locales). Route name stays `AppsScreen` to avoid churn. | Establishes the list-row template (§7.4) used everywhere else. | +| 9 | **ProfileScreen** | Edit: `feature/profile/presentation/ProfileRoot.kt`, `components/sections/ProfileSection.kt`, `Options.kt`. Cookie-shape user mark in top bar. Identity hero card with gradient. Settings row → Tweaks (already wired). **Decide** sponsor card. | Required before bottom-nav reduction. | +| 10 | **Bottom-nav reduction** | Edit: `BottomNavigationUtils.kt:20–60` — drop Tweaks entry. Add settings-relocation coachmark in `TweaksRepository` + trigger from `ProfileRoot`. | Do AFTER Profile lands. Don't strand users. | +| 11 | **DetailsScreen** | Edit: `feature/details/presentation/DetailsRoot.kt`, `sections/Header.kt`, `Stats.kt`, `components/SmartInstallButton.kt`. WaxSeal card. Vital signs 2×2. Wonky-squircle CTA. Permission heat in `ApkInspectSheet.kt`. About / What's new as inner-screen takeover. | Longest screen; touches the most components. After bottom-up primitives stabilize. | +| 12 | **SearchScreen** | Edit: `feature/search/presentation/SearchRoot.kt` — wonky-squircle search input, restyled chips, restyled sheets. | Reuses primitives; quick. | +| 13 | **Tweaks** | Edit: `feature/tweaks/presentation/TweaksRoot.kt`, `components/sections/*.kt`. Section labels with Squiggle. Form rows with tinted icon shell + chevron. Feedback diagnostics card per §16.4. | Low priority — users hit Tweaks rarely. | +| 14 | **AuthenticationScreen** | Edit: `feature/auth/presentation/AuthenticationRoot.kt`. Full-screen sheet layout. Cookie · GitHub identity mark. Heartbeat polling indicator. Code-box wonky squircle. Adapt step copy for web OAuth path (Q #4 above). | Done late; rarely-seen post-signup. | +| 15 | **Net-new screens** | DeveloperProfile re-skin, Favourites/Starred/Recently-viewed unify to list-row template, HostTokens / Skipped / Hidden / Mirror / WhatsNew / Announcements re-skin. | All reuse phase-4–6 primitives. Mostly parallelizable. | +| 16 | **Sponsor decision** | Either remove `SponsorScreen.kt` + nav entry, or restyle. Coordinate with maintainer. | Last — depends on product call (Q #12). | +| 17 | **Polish** | Heartbeat reduce-motion guard. Theme-migration mapping (Q under §6 "Palette change"). Localization audit (Fraunces glyph coverage for ar / bn / hi). | Catch-up; ship behind the last bullet. | + +### 8.1 Per-phase exit criteria +- After Phase 4: a primitives gallery preview composable renders all 12 silent vocab items + 2 expressive shapes in a dev-only screen behind a debug flag. +- After Phase 6: Home + Search + Favourites + Starred render with new RepositoryCard and no other screen yet touched. Visual regression expected, no functional regression. +- After Phase 11: every primary user flow (discover → install → manage → settings) reads in the new vocabulary; remaining work is net-new screens + polish. + +### 8.2 Parallelization hints +- Phases 1–4 are strictly sequential (foundation). +- Phases 5–6 can fork once Phase 4 is on `main`. +- Phases 7–13 can fork once Phase 6 lands. +- Phase 14 (Auth) and Phase 15 (net-new) parallelize freely. +- One screen per session per MIGRATION.md principle 1 — still holds. + +--- + +## 9. Out-of-scope notes (flagged, not actioned) + +- `HomeRepositoryImpl.kt:571–604` local affinity score: stay as a sort-only proxy, never displayed. If backend supplies real `trendingScore` in a future API revision, this code can be deleted (open question DESIGN.md §14 OQ #1). +- `core/domain/.../util/EmojiShortcodes.kt` (290+ short-code → emoji map) is for **user content** (release notes, READMEs) only. Don't touch. +- `WhatsNewLoaderImpl.kt` per-version markdown is content, not chrome. Re-skin the renderer wrapper, not the markdown. +- `core/data/network/BackendApiClient.kt` calls out to `api.github-store.org` — unchanged by design refresh. +- Per-host PATs (`HostTokenRepository`, `HostTokensScreen`) — visual re-skin only; AES-256-GCM storage stays. + +--- + +End of audit. Path: `/Users/rainxchzed/Documents/development/kmp/GitHub-Store/.design/UX-AUDIT.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 78e14f33a..6929b9d15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ class XViewModel : ViewModel() { ### Navigation -`@Serializable` sealed interface `GithubStoreGraph` in `composeApp/.../app/navigation/`. Routes: `HomeScreen`, `SearchScreen`, `AuthenticationScreen`, `ProfileScreen`, `TweaksScreen`, `FavouritesScreen`, `StarredReposScreen`, `RecentlyViewedScreen`, `AppsScreen`, `SponsorScreen`, `ExternalImportScreen`, `MirrorPickerScreen`, `StarredPickerScreen`, `SkippedUpdatesScreen`, `HiddenRepositoriesScreen`, `WhatsNewHistoryScreen`, `AnnouncementsScreen`, `HostTokensScreen`, `DetailsScreen(repositoryId, owner, repo, isComingFromUpdate, sourceHost)`, `DeveloperProfileScreen(username)`. `DetailsScreen.sourceHost` is non-null for Codeberg / Forgejo / custom-forge repos — routes all `DetailsRepository` calls through `ForgejoClientRegistry` instead of the GitHub-backed default path. +`@Serializable` sealed interface `GithubStoreGraph` in `composeApp/.../app/navigation/`. Routes: `HomeScreen`, `SearchScreen`, `AuthenticationScreen`, `ProfileScreen`, `TweaksScreen`, `FavouritesScreen`, `StarredReposScreen`, `RecentlyViewedScreen`, `AppsScreen`, `OnboardingScreen`, `ExternalImportScreen`, `MirrorPickerScreen`, `StarredPickerScreen`, `SkippedUpdatesScreen`, `HiddenRepositoriesScreen`, `WhatsNewHistoryScreen`, `AnnouncementsScreen`, `HostTokensScreen`, `DetailsScreen(repositoryId, owner, repo, isComingFromUpdate, sourceHost)`, `DeveloperProfileScreen(username)`. `DetailsScreen.sourceHost` is non-null for Codeberg / Forgejo / custom-forge repos — routes all `DetailsRepository` calls through `ForgejoClientRegistry` instead of the GitHub-backed default path. ### DI diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c65abfdde..61f7cc1d5 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -60,8 +60,6 @@ kotlin { implementation(projects.feature.search.data) implementation(projects.feature.search.presentation) - implementation(projects.feature.profile.domain) - implementation(projects.feature.profile.data) implementation(projects.feature.profile.presentation) implementation(projects.feature.starred.domain) @@ -172,3 +170,27 @@ compose.desktop { } } } + +tasks.register("printRuntimeDependencies") { + description = "Lists all runtime dependencies for licenses.json maintenance." + group = "verification" + val coordinatesProvider: Provider> = + project.provider { + val cfg = + configurations.findByName("releaseRuntimeClasspath") + ?: configurations.findByName("androidReleaseRuntimeClasspath") + ?: error("No release runtime classpath configuration found.") + cfg.incoming.resolutionResult.allComponents + .asSequence() + .map { it.id } + .filterIsInstance() + .map { "${it.group}:${it.module}:${it.version}" } + .distinct() + .sorted() + .toList() + } + notCompatibleWithConfigurationCache("Resolves Android variant configurations at execution time.") + doLast { + coordinatesProvider.get().forEach { println(it) } + } +} diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt index 4d9488722..eb4ee1083 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt @@ -45,15 +45,8 @@ class MainActivity : ComponentActivity() { installSplashScreen() enableEdgeToEdge() - // Register activity result launcher for file picker (must be before STARTED) (shareManager as? AndroidShareManager)?.registerActivityResultLauncher(this) - // Apply the persisted language override BEFORE Compose kicks off - // so the very first frame resolves strings against the user's - // choice. `runBlocking` is acceptable here — DataStore reads are - // cheap and we only block once per Activity creation (including - // the post-language-swap recreate() path below). Without this, - // recreate() would briefly flash the old locale before settling. runBlocking { val tag = try { @@ -70,15 +63,6 @@ class MainActivity : ComponentActivity() { handleIncomingIntent(intent) - // Watch for runtime language changes from the Tweaks picker. - // Drop the initial emission (already applied above) and - // recreate() on any subsequent change — Android preserves - // `rememberSaveable` / ViewModel state through recreate, so - // scroll offsets, nav stack, and form fields all survive while - // every string re-resolves against the new locale. `key()` in - // the composition can't pull off the same trick: it changes - // the composite-key hash under it, which breaks - // `rememberSaveable` lookups and snaps LazyColumns back to 0. lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { tweaksRepository diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index 25ba9243f..44bc460c0 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -32,24 +32,10 @@ class GithubStoreApp : Application() { override fun onCreate() { super.onCreate() - // Synchronous: Koin must be ready before the first composition can - // resolve any singleton; everything else can run on the app scope. initKoin { androidContext(this@GithubStoreApp) } - // Deferred to the app scope so they don't block the first frame. - // Each step gets its own launch + runCatching so a failure in one - // (NotificationManager hiccup, broadcast-registration race, etc.) - // doesn't abort the others — matches the pattern used by the - // other startup blocks below. - // - notification channels are only consumed when a worker posts - // a notification (minutes later at the earliest) - // - dynamic PackageEventReceiver is the in-process fast path; the - // manifest-registered receiver still catches broadcasts during - // the millisecond gap until the dynamic registration lands - // - the download notification observer has nothing to observe - // until the user starts a download appScope.launch { runCatching { createNotificationChannels() } .onFailure { Logger.w(it) { "Notification-channel creation failed" } } @@ -179,20 +165,9 @@ class GithubStoreApp : Application() { val existing = repo.getAppByPackage(selfPackageName) if (existing != null) { - // After a self-update the old process is killed before - // ACTION_PACKAGE_REPLACED can be delivered to our own - // receiver, so isPendingInstall stays true. Resolve it - // here at the earliest startup opportunity. if (existing.isPendingInstall) { resolveSelfPendingInstall(existing, repo) } else { - // Backfill for #515: pre-fix releases left rows - // with `installedVersion` (tag) pinned to the - // pre-update tag even though the system already - // holds the new APK. Detect the drift on cold - // start and normalize so checkForUpdates stops - // re-flagging the row as updatable on every - // periodic sweep. normalizeSelfInstalledVersion(existing, repo) } return@launch @@ -250,27 +225,6 @@ class GithubStoreApp : Application() { } } - /** - * Resolves a stale `isPendingInstall` flag for the app's own - * database row. Called on every cold start when the row exists - * and still has the flag set — the typical scenario after a - * successful self-update where the broadcast path missed. - */ - - /** - * Self-heal stale `installedVersion` tags on the self-row carried - * over from before #515 was fixed. Only fires when: - * - The row exists and is not pending an install. - * - The system's package versionCode matches the row's - * `installedVersionCode` (so the user's *system* says the - * install is finished). - * - The row's tag (`installedVersion`) doesn't match - * `latestVersion` (which the pre-install update worker wrote - * to the intended new tag). - * Sets `installedVersion = latestVersion` and clears - * `isUpdateAvailable`. Cheap, idempotent, side-effect-free - * otherwise. - */ private suspend fun normalizeSelfInstalledVersion( existing: InstalledApp, repo: InstalledAppsRepository, @@ -302,11 +256,7 @@ class GithubStoreApp : Application() { val systemInfo = packageMonitor.getInstalledPackageInfo(packageName) if (systemInfo != null) { val latestVersionCode = existing.latestVersionCode ?: 0L - // Also pin `installedVersion` (the tag string) to the - // intended new release. Otherwise `checkForUpdates` - // compares the freshly-fetched matched-release tag to - // the *previous* tag still in the row and re-flags - // isUpdateAvailable on every periodic sweep (#515). + val resolvedTag = existing.latestVersion ?: systemInfo.versionName repo.updateApp( existing.copy( diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingPermissions.android.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingPermissions.android.kt new file mode 100644 index 000000000..a773a8ead --- /dev/null +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingPermissions.android.kt @@ -0,0 +1,101 @@ +package zed.rainxch.githubstore.app.onboarding + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner + +actual class OnboardingPermissionsController internal constructor( + private val context: android.content.Context, + private val notifications: MutableState, + private val installSources: MutableState, + private val launchNotifications: () -> Unit, +) { + actual val notificationsGranted: State = notifications + actual val installSourcesGranted: State = installSources + + actual fun requestNotifications() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + launchNotifications() + } else { + notifications.value = true + } + } + + actual fun requestInstallSources() { + val intent = + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + runCatching { context.startActivity(intent) } + } + + fun refresh() { + notifications.value = readNotificationsGranted(context) + installSources.value = readInstallSourcesGranted(context) + } +} + +@Composable +actual fun rememberOnboardingPermissionsController(): OnboardingPermissionsController { + val context = LocalContext.current + val notifications = remember { mutableStateOf(readNotificationsGranted(context)) } + val installSources = remember { mutableStateOf(readInstallSourcesGranted(context)) } + + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + notifications.value = granted + } + + val controller = + remember { + OnboardingPermissionsController( + context = context, + notifications = notifications, + installSources = installSources, + launchNotifications = { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + }, + ) + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) controller.refresh() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + return controller +} + +private fun readNotificationsGranted(context: android.content.Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED +} + +private fun readInstallSourcesGranted(context: android.content.Context): Boolean = context.packageManager.canRequestPackageInstalls() diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt index 029a5b697..0abc22c0e 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt @@ -10,6 +10,8 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import coil3.ImageLoader +import coil3.compose.setSingletonImageLoaderFactory import kotlinx.coroutines.delay import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.auth.presentation.AuthDeepLinkBus @@ -33,11 +35,8 @@ import zed.rainxch.githubstore.app.whatsnew.WhatsNewViewModel @Composable fun App(deepLinkUri: String? = null) { - // Wire Coil's singleton ImageLoader with the SVG decoder so README - // images that point to .svg URLs (shields.io badges, diagrams, - // hero images) render natively instead of failing silently. - coil3.compose.setSingletonImageLoaderFactory { context -> - coil3.ImageLoader + setSingletonImageLoaderFactory { context -> + ImageLoader .Builder(context) .components { add(coil3.svg.SvgDecoder.Factory()) } .build() @@ -49,6 +48,16 @@ fun App(deepLinkUri: String? = null) { val navController = rememberNavController() val currentScreen = navController.currentBackStackEntryAsState().value.getCurrentScreen() + LaunchedEffect(state.onboardingComplete) { + if (state.onboardingComplete == false && + currentScreen !is GithubStoreGraph.OnboardingScreen + ) { + navController.navigate(GithubStoreGraph.OnboardingScreen) { + popUpTo(0) { inclusive = true } + } + } + } + LaunchedEffect(deepLinkUri) { deepLinkUri?.let { uri -> when (val destination = DeepLinkParser.parse(uri)) { @@ -62,9 +71,6 @@ fun App(deepLinkUri: String? = null) { } DeepLinkDestination.Apps -> { - // Pending-install notification dropped us here. - // Navigate to the apps tab so the user can finish - // the deferred install from the row. navController.navigate(GithubStoreGraph.AppsScreen) { popUpTo(GithubStoreGraph.HomeScreen) { saveState = true @@ -96,8 +102,25 @@ fun App(deepLinkUri: String? = null) { } } + DeepLinkDestination.Tweaks -> { + navController.navigate(GithubStoreGraph.TweaksScreen) { + launchSingleTop = true + } + } + + DeepLinkDestination.About -> { + navController.navigate(GithubStoreGraph.AboutScreen) { + launchSingleTop = true + } + } + + DeepLinkDestination.TweaksLicenses -> { + navController.navigate(GithubStoreGraph.LicensesScreen) { + launchSingleTop = true + } + } + DeepLinkDestination.None -> { - // ignore unrecognized deep links } } } @@ -128,12 +151,6 @@ fun App(deepLinkUri: String? = null) { ) { ApplyAndroidSystemBars(state.isDarkTheme) - // Suppress the rate-limit dialog while the user is on the auth - // screen. They already accepted the prompt and are mid-sign-in; - // re-emitting the same dialog over the auth UI is noise that - // also blocks them from finishing the device-flow steps. Also - // flush any pending flag set by background API calls during - // auth, so it doesn't ghost back when the user returns home. val onAuthScreen = currentScreen is GithubStoreGraph.AuthenticationScreen LaunchedEffect(onAuthScreen, state.showRateLimitDialog) { if (onAuthScreen && state.showRateLimitDialog) { @@ -169,8 +186,6 @@ fun App(deepLinkUri: String? = null) { AppNavigation( navController = navController, - isScrollbarEnabled = state.isScrollbarEnabled, - contentWidth = state.contentWidth, ) val whatsNewViewModel: WhatsNewViewModel = koinViewModel() diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt index 8976ec784..b16fbdab7 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt @@ -10,10 +10,11 @@ data class MainState( val rateLimitInfo: RateLimitInfo? = null, val showRateLimitDialog: Boolean = false, val showSessionExpiredDialog: Boolean = false, - val currentColorTheme: AppTheme = AppTheme.OCEAN, + val currentColorTheme: AppTheme = AppTheme.NORD, val isAmoledTheme: Boolean = false, val isDarkTheme: Boolean? = null, val currentFontTheme: FontTheme = FontTheme.CUSTOM, val isScrollbarEnabled: Boolean = false, val contentWidth: ContentWidth = ContentWidth.COMPACT, + val onboardingComplete: Boolean? = null, ) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt index b51b6af55..1d7c81b52 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt @@ -5,18 +5,19 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.RateLimitRepository import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase class MainViewModel( private val tweaksRepository: TweaksRepository, private val installedAppsRepository: InstalledAppsRepository, - private val authenticationState: AuthenticationState, + private val userSessionRepository: UserSessionRepository, private val rateLimitRepository: RateLimitRepository, private val syncUseCase: SyncInstalledAppsUseCase, ) : ViewModel() { @@ -25,7 +26,7 @@ class MainViewModel( init { viewModelScope.launch(Dispatchers.IO) { - authenticationState + userSessionRepository .isUserLoggedIn() .collect { isLoggedIn -> _state.update { it.copy(isLoggedIn = isLoggedIn) } @@ -74,6 +75,11 @@ class MainViewModel( } } + viewModelScope.launch { + val complete = tweaksRepository.getOnboardingComplete().first() + _state.update { it.copy(onboardingComplete = complete) } + } + viewModelScope.launch { tweaksRepository.getScrollbarEnabled().collect { enabled -> _state.update { it.copy(isScrollbarEnabled = enabled) } @@ -101,7 +107,7 @@ class MainViewModel( } viewModelScope.launch { - authenticationState.sessionExpiredEvent.collect { + userSessionRepository.sessionExpiredEvent.collect { _state.update { it.copy(showSessionExpiredDialog = true) } } } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/announcements/AnnouncementsViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/announcements/AnnouncementsViewModel.kt index 5090e01bb..aeab87f76 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/announcements/AnnouncementsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/announcements/AnnouncementsViewModel.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalTime::class) + package zed.rainxch.githubstore.app.announcements import androidx.lifecycle.ViewModel @@ -15,9 +17,10 @@ import zed.rainxch.core.domain.model.Announcement import zed.rainxch.core.domain.model.AnnouncementCategory import zed.rainxch.core.domain.model.AnnouncementIconHint import zed.rainxch.core.domain.model.AnnouncementSeverity -import zed.rainxch.core.domain.repository.AnnouncementsFeedSnapshot +import zed.rainxch.core.domain.model.AnnouncementsFeedSnapshot import zed.rainxch.core.domain.repository.AnnouncementsRepository import zed.rainxch.core.domain.utils.BrowserHelper +import kotlin.time.ExperimentalTime import kotlin.time.Instant class AnnouncementsViewModel( diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt index d96572e82..01274a6e5 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt @@ -7,11 +7,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -20,6 +18,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.RateLimitInfo +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.rate_limit_close @@ -105,31 +106,28 @@ fun RateLimitDialog( }, confirmButton = { if (!isAuthenticated) { - Button(onClick = onSignIn) { - Text( - text = stringResource(Res.string.rate_limit_sign_in), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimary, - ) - } + GhsButton( + onClick = onSignIn, + label = stringResource(Res.string.rate_limit_sign_in), + variant = GhsButtonVariant.Primary, + size = GhsButtonSize.Sm, + ) } else { - Button(onClick = onDismiss) { - Text( - text = stringResource(Res.string.rate_limit_ok), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - } + GhsButton( + onClick = onDismiss, + label = stringResource(Res.string.rate_limit_ok), + variant = GhsButtonVariant.Primary, + size = GhsButtonSize.Sm, + ) } }, dismissButton = { - TextButton(onClick = onDismiss) { - Text( - text = stringResource(Res.string.rate_limit_close), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - } + GhsButton( + onClick = onDismiss, + label = stringResource(Res.string.rate_limit_close), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt index f666db85e..905290941 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt @@ -5,15 +5,16 @@ import androidx.compose.foundation.layout.Column import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import zed.rainxch.githubstore.core.presentation.res.* @Composable @@ -56,22 +57,20 @@ fun SessionExpiredDialog( } }, confirmButton = { - Button(onClick = onSignIn) { - Text( - text = stringResource(Res.string.sign_in_again), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimary, - ) - } + GhsButton( + onClick = onSignIn, + label = stringResource(Res.string.sign_in_again), + variant = GhsButtonVariant.Primary, + size = GhsButtonSize.Sm, + ) }, dismissButton = { - TextButton(onClick = onDismiss) { - Text( - text = stringResource(Res.string.continue_as_guest), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - } + GhsButton( + onClick = onDismiss, + label = stringResource(Res.string.continue_as_guest), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt index 8554f9182..cdb22380e 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt @@ -6,11 +6,6 @@ sealed interface DeepLinkDestination { val repo: String, ) : DeepLinkDestination - /** - * Deep link to the apps tab. Used by the pending-install - * notification to bring the user back to the row where they can - * complete a deferred install. - */ data object Apps : DeepLinkDestination data class AuthHandoff( @@ -23,6 +18,12 @@ sealed interface DeepLinkDestination { val state: String, ) : DeepLinkDestination + data object Tweaks : DeepLinkDestination + + data object About : DeepLinkDestination + + data object TweaksLicenses : DeepLinkDestination + data object None : DeepLinkDestination } @@ -71,10 +72,6 @@ object DeepLinkParser { fun parse(uri: String): DeepLinkDestination { return when { - // Pending-install notification opens the apps tab. No path - // segments — the deferred install is keyed by package name - // on the row itself, so the link only needs to bring the - // user to the right tab. uri == "githubstore://apps" || uri == "githubstore://apps/" || uri.startsWith("githubstore://apps?") -> { DeepLinkDestination.Apps } @@ -108,6 +105,18 @@ object DeepLinkParser { DeepLinkDestination.None } + uri == "githubstore://tweaks" || uri == "githubstore://tweaks/" -> { + DeepLinkDestination.Tweaks + } + + uri == "githubstore://about" || uri == "githubstore://tweaks/app-info" -> { + DeepLinkDestination.About + } + + uri == "githubstore://tweaks/licenses" -> { + DeepLinkDestination.TweaksLicenses + } + uri.startsWith("https://github-store.org/app/") -> { extractQueryParam(uri, "repo")?.let { encodedRepoParam -> val decoded = urlDecode(encodedRepoParam) @@ -121,10 +130,6 @@ object DeepLinkParser { } } - /** - * URL-decode a string, handling percent-encoded characters. - * Returns the original string if decoding fails. - */ private fun urlDecode(value: String): String = try { val result = StringBuilder() @@ -176,17 +181,6 @@ object DeepLinkParser { } } - /** - * Strictly validate owner and repo names to prevent injection attacks. - * Rejects: - * - Empty strings - * - Special characters that could be used for injection - * - Path traversal patterns - * - Control characters and whitespace - * - Excluded GitHub paths (like 'about', 'settings', etc.) - * - Names that exceed GitHub's length limits - * - Names that don't start with alphanumeric characters - */ private fun isStrictlyValidOwnerRepo( owner: String, repo: String, diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt index 8cdcb9c74..401c7b5df 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/SharedModules.kt @@ -13,7 +13,7 @@ val mainModule: Module = installedAppsRepository = get(), rateLimitRepository = get(), syncUseCase = get(), - authenticationState = get(), + userSessionRepository = get(), ) } } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt index 8199069cf..ce6af421a 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt @@ -2,17 +2,22 @@ package zed.rainxch.githubstore.app.di import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf +import org.koin.core.qualifier.named import org.koin.dsl.module import zed.rainxch.apps.presentation.AppsViewModel import zed.rainxch.apps.presentation.import.ExternalImportViewModel import zed.rainxch.apps.presentation.starred.StarredPickerViewModel import zed.rainxch.auth.presentation.AuthenticationViewModel import zed.rainxch.details.presentation.DetailsViewModel +import zed.rainxch.details.presentation.about.DetailsAboutViewModel +import zed.rainxch.details.presentation.whatsnew.DetailsWhatsNewViewModel import zed.rainxch.devprofile.presentation.DeveloperProfileViewModel import zed.rainxch.favourites.presentation.FavouritesViewModel import zed.rainxch.githubstore.app.announcements.AnnouncementsViewModel +import zed.rainxch.githubstore.app.onboarding.OnboardingViewModel import zed.rainxch.githubstore.app.whatsnew.WhatsNewViewModel import zed.rainxch.home.presentation.HomeViewModel +import zed.rainxch.home.presentation.categorylist.CategoryListViewModel import zed.rainxch.profile.presentation.ProfileViewModel import zed.rainxch.recentlyviewed.presentation.RecentlyViewedViewModel import zed.rainxch.search.presentation.SearchViewModel @@ -22,7 +27,6 @@ import zed.rainxch.tweaks.presentation.TweaksViewModel import zed.rainxch.tweaks.presentation.feedback.FeedbackViewModel import zed.rainxch.tweaks.presentation.hidden.HiddenRepositoriesViewModel import zed.rainxch.tweaks.presentation.hosttokens.HostTokensViewModel -import zed.rainxch.tweaks.presentation.mirror.AutoSuggestMirrorViewModel import zed.rainxch.tweaks.presentation.mirror.MirrorPickerViewModel import zed.rainxch.tweaks.presentation.skipped.SkippedUpdatesViewModel @@ -32,20 +36,13 @@ val viewModelsModule = viewModelOf(::ExternalImportViewModel) viewModelOf(::AuthenticationViewModel) viewModel { params -> - // Indexed access because `ownerParam` and `repoParam` are both - // Strings — positional `params.get()` would silently pick the - // first matching by type and could swap the two if Koin ever - // changes its resolution order. + DetailsViewModel( - repositoryId = params.get(0), - ownerParam = params.get(1), - repoParam = params.get(2), - isComingFromUpdate = params.get(3), - // Indexed access — `getOrNull()` would pick the - // first matching String (`ownerParam`) since type-based - // resolution doesn't disambiguate against the other - // String slots in this factory. - sourceHostParam = if (params.size() > 4) params.get(4) else null, + repositoryId = params[0], + ownerParam = params[1], + repoParam = params[2], + isComingFromUpdate = params[3], + sourceHostParam = if (params.size() > 4) params[4] else null, detailsRepository = get(), downloader = get(), installer = get(), @@ -64,12 +61,30 @@ val viewModelsModule = installationManager = get(), attestationVerifier = get(), downloadOrchestrator = get(), - telemetryRepository = get(), externalImportRepository = get(), apkInspector = get(), - authenticationState = get(), + userSessionRepository = get(), systemInstallSerializer = get(), - profileRepository = get(), + ) + } + viewModel { params -> + DetailsAboutViewModel( + repositoryId = params[0], + owner = params[1], + repo = params[2], + sourceHost = if (params.size() > 3) params[3] else null, + detailsRepository = get(), + translationRepository = get(), + ) + } + viewModel { params -> + DetailsWhatsNewViewModel( + repositoryId = params[0], + owner = params[1], + repo = params[2], + sourceHost = if (params.size() > 3) params[3] else null, + detailsRepository = get(), + translationRepository = get(), ) } viewModelOf(::DeveloperProfileViewModel) @@ -90,9 +105,8 @@ val viewModelsModule = tweaksRepository = get(), seenReposRepository = get(), searchHistoryRepository = get(), - telemetryRepository = get(), - profileRepository = get(), hiddenReposRepository = get(), + userSessionRepository = get(), initialPlatform = params.getOrNull(), ) } @@ -101,20 +115,25 @@ val viewModelsModule = viewModelOf(::FeedbackViewModel) viewModelOf(::StarredReposViewModel) viewModelOf(::StarredPickerViewModel) - viewModelOf(::AutoSuggestMirrorViewModel) viewModelOf(::SkippedUpdatesViewModel) viewModelOf(::HiddenRepositoriesViewModel) viewModelOf(::HostTokensViewModel) viewModelOf(::WhatsNewViewModel) viewModelOf(::AnnouncementsViewModel) + viewModelOf(::OnboardingViewModel) + viewModel { params -> + CategoryListViewModel( + category = params.get(), + homeRepository = get(), + ) + } viewModel { MirrorPickerViewModel( mirrorRepository = get(), testHttpClient = get( qualifier = - org.koin.core.qualifier - .named("test"), + named("test"), ), ) } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/initKoin.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/initKoin.kt index 859745de7..402f1cb1b 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/initKoin.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/initKoin.kt @@ -1,5 +1,6 @@ package zed.rainxch.githubstore.app.di +import kotlinx.coroutines.CoroutineScope import org.koin.core.context.startKoin import org.koin.dsl.KoinAppDeclaration import zed.rainxch.apps.data.di.appsModule @@ -8,30 +9,36 @@ import zed.rainxch.core.data.di.coreModule import zed.rainxch.core.data.di.corePlatformModule import zed.rainxch.core.data.di.databaseModule import zed.rainxch.core.data.di.networkModule +import zed.rainxch.core.data.network.ProxyManager +import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.details.data.di.detailsModule import zed.rainxch.devprofile.data.di.devProfileModule import zed.rainxch.home.data.di.homeModule -import zed.rainxch.profile.data.di.profileModule import zed.rainxch.search.data.di.searchModule fun initKoin(config: KoinAppDeclaration? = null) { - startKoin { - config?.invoke(this) - modules( - mainModule, - corePlatformModule, - coreModule, - networkModule, - databaseModule, - viewModelsModule, - whatsNewModule, - appsModule, - authModule, - detailsModule, - devProfileModule, - homeModule, - searchModule, - profileModule, - ) - } + val app = + startKoin { + config?.invoke(this) + modules( + mainModule, + corePlatformModule, + coreModule, + networkModule, + databaseModule, + viewModelsModule, + whatsNewModule, + appsModule, + authModule, + detailsModule, + devProfileModule, + homeModule, + searchModule, + ) + } + val koin = app.koin + ProxyManager.bootstrap( + repository = koin.get(), + appScope = koin.get(), + ) } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AdaptiveDetailPaneContent.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AdaptiveDetailPaneContent.kt new file mode 100644 index 000000000..09578c87f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AdaptiveDetailPaneContent.kt @@ -0,0 +1,190 @@ +package zed.rainxch.githubstore.app.navigation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.navigation.NavHostController +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import zed.rainxch.core.presentation.components.adaptive.AdaptiveDetailArgs +import zed.rainxch.details.presentation.DetailsRoot +import zed.rainxch.details.presentation.DetailsViewModel +import zed.rainxch.details.presentation.about.AboutRoot +import zed.rainxch.details.presentation.whatsnew.WhatsNewRoot +import zed.rainxch.search.presentation.mappers.toSearchPlatformUi + +private sealed interface DetailPaneRoute { + data object Main : DetailPaneRoute + + data class About( + val repositoryId: Long, + val owner: String, + val repo: String, + val sourceHost: String?, + ) : DetailPaneRoute + + data class WhatsNew( + val repositoryId: Long, + val owner: String, + val repo: String, + val sourceHost: String?, + ) : DetailPaneRoute +} + +@Composable +fun AdaptiveDetailPaneContent( + args: AdaptiveDetailArgs, + navController: NavHostController, + onCrossNavToRepo: (AdaptiveDetailArgs) -> Unit, + onClearPane: () -> Unit, +) { + key(args) { + var route: DetailPaneRoute by remember { mutableStateOf(DetailPaneRoute.Main) } + + AnimatedContent( + targetState = route, + transitionSpec = { + val forward = targetState !is DetailPaneRoute.Main + if (forward) { + slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(durationMillis = 280), + ) togetherWith + slideOutHorizontally( + targetOffsetX = { fullWidth -> -fullWidth / 4 }, + animationSpec = tween(durationMillis = 280), + ) + } else { + slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth / 4 }, + animationSpec = tween(durationMillis = 280), + ) togetherWith + slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(durationMillis = 280), + ) + } + }, + label = "detail-pane-route", + ) { current -> + when (current) { + DetailPaneRoute.Main -> { + MainDetailPane( + args = args, + navController = navController, + onCrossNavToRepo = onCrossNavToRepo, + onClearPane = onClearPane, + onOpenAbout = { repoId, owner, repo, sourceHost -> + route = DetailPaneRoute.About(repoId, owner, repo, sourceHost) + }, + onOpenWhatsNew = { repoId, owner, repo, sourceHost -> + route = DetailPaneRoute.WhatsNew(repoId, owner, repo, sourceHost) + }, + ) + } + + is DetailPaneRoute.About -> { + val aboutKey = + "about|${current.repositoryId}|${current.owner}|${current.repo}|${current.sourceHost.orEmpty()}" + AboutRoot( + repositoryId = current.repositoryId, + owner = current.owner, + repo = current.repo, + sourceHost = current.sourceHost, + onNavigateBack = { route = DetailPaneRoute.Main }, + viewModel = + koinViewModel(key = aboutKey) { + parametersOf( + current.repositoryId, + current.owner, + current.repo, + current.sourceHost, + ) + }, + ) + } + + is DetailPaneRoute.WhatsNew -> { + val whatsNewKey = + "whatsnew|${current.repositoryId}|${current.owner}|${current.repo}|${current.sourceHost.orEmpty()}" + WhatsNewRoot( + repositoryId = current.repositoryId, + owner = current.owner, + repo = current.repo, + sourceHost = current.sourceHost, + onNavigateBack = { route = DetailPaneRoute.Main }, + viewModel = + koinViewModel(key = whatsNewKey) { + parametersOf( + current.repositoryId, + current.owner, + current.repo, + current.sourceHost, + ) + }, + ) + } + } + } + } +} + +@Composable +private fun MainDetailPane( + args: AdaptiveDetailArgs, + navController: NavHostController, + onCrossNavToRepo: (AdaptiveDetailArgs) -> Unit, + onClearPane: () -> Unit, + onOpenAbout: (Long, String, String, String?) -> Unit, + onOpenWhatsNew: (Long, String, String, String?) -> Unit, +) { + val vmKey = + buildString { + append(args.repositoryId) + append('|') + append(args.owner.orEmpty()) + append('|') + append(args.repo.orEmpty()) + append('|') + append(args.sourceHost.orEmpty()) + } + val viewModel: DetailsViewModel = + koinViewModel(key = vmKey) { + parametersOf( + args.repositoryId, + args.owner.orEmpty(), + args.repo.orEmpty(), + args.isComingFromUpdate, + args.sourceHost, + ) + } + DetailsRoot( + onNavigateBack = onClearPane, + onOpenRepositoryInApp = { repoId -> + onCrossNavToRepo(AdaptiveDetailArgs(repositoryId = repoId)) + }, + onNavigateToDeveloperProfile = { username -> + navController.navigate( + GithubStoreGraph.DeveloperProfileScreen(username = username), + ) + }, + onNavigateToSearchByPlatform = { platform -> + navController.navigate( + GithubStoreGraph.SearchScreen( + initialPlatform = platform.toSearchPlatformUi().name, + ), + ) + }, + onNavigateToAbout = onOpenAbout, + onNavigateToWhatsNew = onOpenWhatsNew, + viewModel = viewModel, + ) +} diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index 0afd9742f..af3288b3a 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -2,6 +2,8 @@ package zed.rainxch.githubstore.app.navigation import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -24,41 +26,69 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.toRoute +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import zed.rainxch.apps.presentation.AppsRoot import zed.rainxch.apps.presentation.AppsViewModel +import zed.rainxch.apps.presentation.import.EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY import zed.rainxch.apps.presentation.import.ExternalImportRoot import zed.rainxch.auth.presentation.AuthenticationRoot +import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.model.ContentWidth +import zed.rainxch.core.domain.model.Platform +import zed.rainxch.core.presentation.components.adaptive.AdaptiveDetailArgs +import zed.rainxch.core.presentation.components.adaptive.AdaptiveListDetailScaffold +import zed.rainxch.core.presentation.components.adaptive.rememberAdaptiveListDetailState import zed.rainxch.core.presentation.components.announcements.AnnouncementsRoot import zed.rainxch.core.presentation.components.whatsnew.WhatsNewHistoryScreen import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalContentWidth import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled import zed.rainxch.details.presentation.DetailsRoot +import zed.rainxch.details.presentation.about.AboutRoot +import zed.rainxch.details.presentation.whatsnew.WhatsNewRoot import zed.rainxch.devprofile.presentation.DeveloperProfileRoot import zed.rainxch.favourites.presentation.FavouritesRoot import zed.rainxch.githubstore.app.announcements.AnnouncementsViewModel import zed.rainxch.githubstore.app.whatsnew.WhatsNewViewModel +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.adaptive_pick_repo_subtitle +import zed.rainxch.githubstore.core.presentation.res.adaptive_pick_repo_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_app_info +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_appearance +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_connection +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_install_method +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_language +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_privacy +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_sources +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_storage +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_translation +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_updates import zed.rainxch.home.presentation.HomeRoot import zed.rainxch.profile.presentation.ProfileRoot -import zed.rainxch.profile.presentation.SponsorScreen import zed.rainxch.recentlyviewed.presentation.RecentlyViewedRoot import zed.rainxch.search.presentation.SearchRoot import zed.rainxch.search.presentation.mappers.toSearchPlatformUi +import zed.rainxch.search.presentation.model.SearchPlatformUi import zed.rainxch.starred.presentation.StarredReposRoot import zed.rainxch.tweaks.presentation.TweaksRoot +import zed.rainxch.tweaks.presentation.appearance.TweaksAppearanceRoot +import zed.rainxch.tweaks.presentation.appinfo.TweaksAppInfoRoot +import zed.rainxch.tweaks.presentation.components.TweaksStubScreen +import zed.rainxch.tweaks.presentation.connection.TweaksConnectionRoot import zed.rainxch.tweaks.presentation.hidden.HiddenRepositoriesRoot import zed.rainxch.tweaks.presentation.hosttokens.HostTokensRoot -import zed.rainxch.tweaks.presentation.mirror.AutoSuggestMirrorViewModel +import zed.rainxch.tweaks.presentation.install.TweaksInstallRoot +import zed.rainxch.tweaks.presentation.language.TweaksLanguageRoot +import zed.rainxch.tweaks.presentation.licenses.LicensesRoot import zed.rainxch.tweaks.presentation.mirror.MirrorPickerRoot -import zed.rainxch.tweaks.presentation.mirror.components.AutoSuggestMirrorSheet +import zed.rainxch.tweaks.presentation.privacy.TweaksPrivacyRoot import zed.rainxch.tweaks.presentation.skipped.SkippedUpdatesRoot - -// Cross-screen "return result" key: set by the external-import wizard's -// "Add manually" path before navigateUp(), read once by the Apps screen. -private const val EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY = "external_import_open_link_sheet" +import zed.rainxch.tweaks.presentation.sources.TweaksSourcesRoot +import zed.rainxch.tweaks.presentation.storage.TweaksStorageRoot +import zed.rainxch.tweaks.presentation.translation.TweaksTranslationRoot +import zed.rainxch.tweaks.presentation.updates.TweaksUpdatesRoot @Composable fun AppNavigation( @@ -76,482 +106,941 @@ fun AppNavigation( val announcementsViewModel = koinViewModel() val announcementsUnreadCount by announcementsViewModel.unreadCount.collectAsStateWithLifecycle() + val isDesktop = getPlatform() != Platform.ANDROID + CompositionLocalProvider( LocalBottomNavigationHeight provides bottomNavigationHeight, LocalScrollbarEnabled provides isScrollbarEnabled, LocalContentWidth provides contentWidth, ) { - Box( - modifier = Modifier.fillMaxSize(), - ) { - NavHost( - navController = navController, - startDestination = GithubStoreGraph.HomeScreen, - modifier = Modifier.background(MaterialTheme.colorScheme.background), + Row(modifier = Modifier.fillMaxSize()) { + val desktopDrawerCurrent = + navController + .currentBackStackEntryAsState() + .value + .getCurrentScreen() + if (isDesktop && desktopDrawerCurrent != null) { + DesktopDrawer( + currentScreen = desktopDrawerCurrent, + onNavigate = { target -> + navController.navigate(target) { + popUpTo(GithubStoreGraph.HomeScreen) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + isUpdateAvailable = + appsState.apps.any { + it.installedApp.isUpdateAvailable + } || appsState.showImportProposalBanner, + hasUnreadAnnouncements = announcementsUnreadCount > 0, + ) + } + + Box( + modifier = + if (isDesktop) { + Modifier + .weight(1f) + .fillMaxHeight() + } else { + Modifier.fillMaxSize() + }, ) { - composable { - HomeRoot( - onNavigateToSearch = { - navController.navigate(GithubStoreGraph.SearchScreen()) - }, - onNavigateToSettings = { - navController.navigate(GithubStoreGraph.ProfileScreen) + androidx.compose.animation.SharedTransitionLayout { + val sharedScope = this + NavHost( + navController = navController, + startDestination = GithubStoreGraph.HomeScreen, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + enterTransition = { + val from = initialState.bottomNavIndex() + val to = targetState.bottomNavIndex() + if (from != null && to != null && from != to) { + val sign = if (to > from) 1 else -1 + androidx.compose.animation.slideInHorizontally( + initialOffsetX = { it * sign }, + animationSpec = + androidx.compose.animation.core + .tween(280), + ) + + androidx.compose.animation.fadeIn( + animationSpec = + androidx.compose.animation.core + .tween(220), + ) + } else { + androidx.compose.animation.slideInHorizontally( + initialOffsetX = { it / 6 }, + animationSpec = + androidx.compose.animation.core + .tween(280), + ) + + androidx.compose.animation.fadeIn( + animationSpec = + androidx.compose.animation.core + .tween(220), + ) + } }, - onNavigateToApps = { - navController.navigate(GithubStoreGraph.AppsScreen) + exitTransition = { + val from = initialState.bottomNavIndex() + val to = targetState.bottomNavIndex() + if (from != null && to != null && from != to) { + val sign = if (to > from) -1 else 1 + androidx.compose.animation.slideOutHorizontally( + targetOffsetX = { it * sign }, + animationSpec = + androidx.compose.animation.core + .tween(280), + ) + + androidx.compose.animation.fadeOut( + animationSpec = + androidx.compose.animation.core + .tween(220), + ) + } else { + androidx.compose.animation.fadeOut( + animationSpec = + androidx.compose.animation.core + .tween(180), + ) + } }, - onNavigateToDetails = { repoId -> - navController.navigate( - GithubStoreGraph.DetailsScreen( - repositoryId = repoId, - ), + popEnterTransition = { + androidx.compose.animation.fadeIn( + animationSpec = + androidx.compose.animation.core + .tween(220), ) }, - onNavigateToDeveloperProfile = { username -> - navController.navigate( - GithubStoreGraph.DeveloperProfileScreen( - username = username, - ), - ) + popExitTransition = { + androidx.compose.animation.slideOutHorizontally( + targetOffsetX = { it / 6 }, + animationSpec = + androidx.compose.animation.core + .tween(280), + ) + + androidx.compose.animation.fadeOut( + animationSpec = + androidx.compose.animation.core + .tween(220), + ) }, - ) - } + ) { + composable { + val animatedScope = this + CompositionLocalProvider( + zed.rainxch.core.presentation.locals.LocalSharedTransitionScope provides sharedScope, + zed.rainxch.core.presentation.locals.LocalAnimatedVisibilityScope provides animatedScope, + ) { + val listDetailState = rememberAdaptiveListDetailState() + val pickRepoTitle = stringResource(Res.string.adaptive_pick_repo_title) + val pickRepoSubtitle = stringResource(Res.string.adaptive_pick_repo_subtitle) + AdaptiveListDetailScaffold( + state = listDetailState, + emptyPaneTitle = pickRepoTitle, + emptyPaneSubtitle = pickRepoSubtitle, + list = { isExpanded -> + HomeRoot( + onNavigateToSearch = { + navController.navigate(GithubStoreGraph.SearchScreen()) + }, + onNavigateToSettings = { + navController.navigate(GithubStoreGraph.ProfileScreen) + }, + onNavigateToApps = { + navController.navigate(GithubStoreGraph.AppsScreen) + }, + onNavigateToDetails = { repoId -> + if (isExpanded) { + listDetailState.select( + AdaptiveDetailArgs(repositoryId = repoId), + ) + } else { + navController.navigate( + GithubStoreGraph.DetailsScreen(repositoryId = repoId), + ) + } + }, + onNavigateToDeveloperProfile = { username -> + navController.navigate( + GithubStoreGraph.DeveloperProfileScreen(username = username), + ) + }, + onNavigateToCategoryList = { category -> + navController.navigate( + GithubStoreGraph.CategoryListScreen(category.name), + ) + }, + onNavigateToStarredRepos = { + navController.navigate(GithubStoreGraph.StarredReposScreen) + }, + ) + }, + detail = { args -> + AdaptiveDetailPaneContent( + args = args, + navController = navController, + onCrossNavToRepo = { newArgs -> listDetailState.select(newArgs) }, + onClearPane = { listDetailState.clear() }, + ) + }, + ) + } + } - composable { backStackEntry -> - val args = backStackEntry.toRoute() - val initialPlatform = - args.initialPlatform?.let { name -> - runCatching { - zed.rainxch.search.presentation.model.SearchPlatformUi - .valueOf(name) - }.getOrNull() - } - SearchRoot( - onNavigateBack = { - navController.navigateUp() - }, - onNavigateToDetails = { repoId, sourceHost -> - navController.navigate( - GithubStoreGraph.DetailsScreen( - repositoryId = repoId, - sourceHost = sourceHost, - ), + composable { backStackEntry -> + val args = backStackEntry.toRoute() + val category = + runCatching { + zed.rainxch.home.domain.model.HomeCategory + .valueOf(args.category) + }.getOrDefault( + zed.rainxch.home.domain.model.HomeCategory.HOT_RELEASE, + ) + zed.rainxch.home.presentation.categorylist.CategoryListRoot( + category = category, + onNavigateBack = { navController.navigateUp() }, + onNavigateToDetails = { repoId -> + navController.navigate( + GithubStoreGraph.DetailsScreen(repositoryId = repoId), + ) + }, ) - }, - onNavigateToDetailsFromLink = { owner, repo -> - navController.navigate( - GithubStoreGraph.DetailsScreen( - owner = owner, - repo = repo, - ), + } + + composable { backStackEntry -> + val args = backStackEntry.toRoute() + val initialPlatform = + args.initialPlatform?.let { name -> + runCatching { + SearchPlatformUi.valueOf(name) + }.getOrNull() + } + val listDetailState = rememberAdaptiveListDetailState() + val pickRepoTitle = stringResource(Res.string.adaptive_pick_repo_title) + val pickRepoSubtitle = stringResource(Res.string.adaptive_pick_repo_subtitle) + val searchViewModel: zed.rainxch.search.presentation.SearchViewModel = + koinViewModel { + parametersOf(initialPlatform) + } + AdaptiveListDetailScaffold( + state = listDetailState, + emptyPaneTitle = pickRepoTitle, + emptyPaneSubtitle = pickRepoSubtitle, + list = { isExpanded -> + SearchRoot( + onNavigateBack = { + navController.navigateUp() + }, + onNavigateToDetails = { repoId, sourceHost -> + if (isExpanded) { + listDetailState.select( + AdaptiveDetailArgs( + repositoryId = repoId, + sourceHost = sourceHost, + ), + ) + } else { + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId, + sourceHost = sourceHost, + ), + ) + } + }, + onNavigateToDetailsFromLink = { owner, repo -> + if (isExpanded) { + listDetailState.select( + AdaptiveDetailArgs( + owner = owner, + repo = repo, + ), + ) + } else { + navController.navigate( + GithubStoreGraph.DetailsScreen( + owner = owner, + repo = repo, + ), + ) + } + }, + onNavigateToDeveloperProfile = { username -> + navController.navigate( + GithubStoreGraph.DeveloperProfileScreen( + username = username, + ), + ) + }, + viewModel = searchViewModel, + ) + }, + detail = { detailArgs -> + AdaptiveDetailPaneContent( + args = detailArgs, + navController = navController, + onCrossNavToRepo = { newArgs -> listDetailState.select(newArgs) }, + onClearPane = { listDetailState.clear() }, + ) + }, ) - }, - onNavigateToDeveloperProfile = { username -> - navController.navigate( - GithubStoreGraph.DeveloperProfileScreen( - username = username, - ), + } + + composable { backStackEntry -> + val animatedScope = this + CompositionLocalProvider( + zed.rainxch.core.presentation.locals.LocalSharedTransitionScope provides sharedScope, + zed.rainxch.core.presentation.locals.LocalAnimatedVisibilityScope provides animatedScope, + ) { + val args = backStackEntry.toRoute() + DetailsRoot( + onNavigateBack = { + navController.navigateUp() + }, + onOpenRepositoryInApp = { repoId -> + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId, + ), + ) + }, + onNavigateToDeveloperProfile = { username -> + navController.navigate( + GithubStoreGraph.DeveloperProfileScreen( + username = username, + ), + ) + }, + onNavigateToSearchByPlatform = { platform -> + navController.navigate( + GithubStoreGraph.SearchScreen( + initialPlatform = platform.toSearchPlatformUi().name, + ), + ) + }, + onNavigateToAbout = { repoId, owner, repo, sourceHost -> + navController.navigate( + GithubStoreGraph.DetailsAboutScreen( + repositoryId = repoId, + owner = owner, + repo = repo, + sourceHost = sourceHost, + ), + ) + }, + onNavigateToWhatsNew = { repoId, owner, repo, sourceHost -> + navController.navigate( + GithubStoreGraph.DetailsWhatsNewScreen( + repositoryId = repoId, + owner = owner, + repo = repo, + sourceHost = sourceHost, + ), + ) + }, + viewModel = + koinViewModel { + parametersOf( + args.repositoryId, + args.owner, + args.repo, + args.isComingFromUpdate, + args.sourceHost, + ) + }, + ) + } + } + + composable( + enterTransition = { + androidx.compose.animation.slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = + androidx.compose.animation.core + .tween(durationMillis = 280), + ) + }, + exitTransition = { + androidx.compose.animation.slideOutHorizontally( + targetOffsetX = { fullWidth -> -fullWidth / 4 }, + animationSpec = + androidx.compose.animation.core + .tween(durationMillis = 280), + ) + }, + popEnterTransition = { + androidx.compose.animation.slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth / 4 }, + animationSpec = + androidx.compose.animation.core + .tween(durationMillis = 280), + ) + }, + popExitTransition = { + androidx.compose.animation.slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = + androidx.compose.animation.core + .tween(durationMillis = 280), + ) + }, + ) { backStackEntry -> + val args = backStackEntry.toRoute() + AboutRoot( + repositoryId = args.repositoryId, + owner = args.owner, + repo = args.repo, + sourceHost = args.sourceHost, + onNavigateBack = { navController.navigateUp() }, ) - }, - viewModel = - koinViewModel { - parametersOf(initialPlatform) + } + + composable( + enterTransition = { + androidx.compose.animation.slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = + androidx.compose.animation.core + .tween(durationMillis = 280), + ) }, - ) - } + exitTransition = { + androidx.compose.animation.slideOutHorizontally( + targetOffsetX = { fullWidth -> -fullWidth / 4 }, + animationSpec = + androidx.compose.animation.core + .tween(durationMillis = 280), + ) + }, + popEnterTransition = { + androidx.compose.animation.slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth / 4 }, + animationSpec = + androidx.compose.animation.core + .tween(durationMillis = 280), + ) + }, + popExitTransition = { + androidx.compose.animation.slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = + androidx.compose.animation.core + .tween(durationMillis = 280), + ) + }, + ) { backStackEntry -> + val args = backStackEntry.toRoute() + WhatsNewRoot( + repositoryId = args.repositoryId, + owner = args.owner, + repo = args.repo, + sourceHost = args.sourceHost, + onNavigateBack = { navController.navigateUp() }, + ) + } - composable { backStackEntry -> - val args = backStackEntry.toRoute() - DetailsRoot( - onNavigateBack = { - navController.navigateUp() - }, - onOpenRepositoryInApp = { repoId -> - navController.navigate( - GithubStoreGraph.DetailsScreen( - repositoryId = repoId, - ), + composable { backStackEntry -> + val args = backStackEntry.toRoute() + DeveloperProfileRoot( + onNavigateBack = { + navController.navigateUp() + }, + onNavigateToDetails = { repoId -> + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId, + ), + ) + }, + onNavigateToUser = { username -> + navController.navigate( + GithubStoreGraph.DeveloperProfileScreen(username = username), + ) + }, + viewModel = + koinViewModel { + parametersOf(args.username) + }, ) - }, - onNavigateToDeveloperProfile = { username -> - navController.navigate( - GithubStoreGraph.DeveloperProfileScreen( - username = username, - ), + } + + composable { + AuthenticationRoot( + onNavigateToHome = { + navController.navigate(GithubStoreGraph.HomeScreen) { + popUpTo(0) { + inclusive = true + } + } + }, ) - }, - onNavigateToSearchByPlatform = { platform -> - navController.navigate( - GithubStoreGraph.SearchScreen( - initialPlatform = platform.toSearchPlatformUi().name, - ), + } + + composable { + zed.rainxch.githubstore.app.onboarding.OnboardingRoot( + onNavigateToSignIn = { + navController.navigate(GithubStoreGraph.AuthenticationScreen) + }, + onNavigateToHome = { + navController.navigate(GithubStoreGraph.HomeScreen) { + popUpTo(0) { inclusive = true } + } + }, ) - }, - viewModel = - koinViewModel { - parametersOf( - args.repositoryId, - args.owner, - args.repo, - args.isComingFromUpdate, - args.sourceHost, - ) - }, - ) - } + } - composable { backStackEntry -> - val args = backStackEntry.toRoute() - DeveloperProfileRoot( - onNavigateBack = { - navController.navigateUp() - }, - onNavigateToDetails = { repoId -> - navController.navigate( - GithubStoreGraph.DetailsScreen( - repositoryId = repoId, - ), + composable { + FavouritesRoot( + onNavigateBack = { + navController.navigateUp() + }, + onNavigateToDetails = { + navController.navigate(GithubStoreGraph.DetailsScreen(it)) + }, + onNavigateToDeveloperProfile = { username -> + navController.navigate( + GithubStoreGraph.DeveloperProfileScreen( + username = username, + ), + ) + }, ) - }, - viewModel = - koinViewModel { - parametersOf(args.username) - }, - ) - } + } - composable { - AuthenticationRoot( - onNavigateToHome = { - navController.navigate(GithubStoreGraph.HomeScreen) { - popUpTo(0) { - inclusive = true - } - } - }, - ) - } + composable { + StarredReposRoot( + onNavigateBack = { + navController.navigateUp() + }, + onNavigateToDetails = { repoId -> + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId, + ), + ) + }, + onNavigateToAuthentication = { + navController.navigate( + GithubStoreGraph.AuthenticationScreen, + ) + }, + onNavigateToDeveloperProfile = { username -> + navController.navigate( + GithubStoreGraph.DeveloperProfileScreen( + username = username, + ), + ) + }, + ) + } - composable { - FavouritesRoot( - onNavigateBack = { - navController.navigateUp() - }, - onNavigateToDetails = { - navController.navigate(GithubStoreGraph.DetailsScreen(it)) - }, - onNavigateToDeveloperProfile = { username -> - navController.navigate( - GithubStoreGraph.DeveloperProfileScreen( - username = username, - ), + composable { + zed.rainxch.apps.presentation.starred.StarredPickerRoot( + onNavigateBack = { navController.navigateUp() }, + onNavigateToDetails = { repoId, owner, repo -> + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId, + owner = owner, + repo = repo, + ), + ) + }, ) - }, - ) - } + } - composable { - StarredReposRoot( - onNavigateBack = { - navController.navigateUp() - }, - onNavigateToDetails = { repoId -> - navController.navigate( - GithubStoreGraph.DetailsScreen( - repositoryId = repoId, - ), + composable { + ProfileRoot( + onNavigateBack = { + navController.navigateUp() + }, + onNavigateToAuthentication = { + navController.navigate(GithubStoreGraph.AuthenticationScreen) + }, + onNavigateToStarredRepos = { + navController.navigate(GithubStoreGraph.StarredReposScreen) + }, + onNavigateToFavouriteRepos = { + navController.navigate(GithubStoreGraph.FavouritesScreen) + }, + onNavigateToRecentlyViewed = { + navController.navigate(GithubStoreGraph.RecentlyViewedScreen) + }, + onNavigateToDevProfile = { username -> + navController.navigate( + GithubStoreGraph.DeveloperProfileScreen( + username, + ), + ) + }, + onNavigateToWhatsNew = { + navController.navigate(GithubStoreGraph.WhatsNewHistoryScreen) + }, + onPreviewWhatsNewSheet = { + whatsNewViewModel.forceShowLatest() + navController.navigateUp() + }, + onNavigateToAnnouncements = { + navController.navigate(GithubStoreGraph.AnnouncementsScreen) + }, + onPreviewAnnouncements = { + announcementsViewModel.previewSampleAnnouncements() + navController.navigate(GithubStoreGraph.AnnouncementsScreen) + }, + onNavigateToTweaks = { + navController.navigate(GithubStoreGraph.TweaksScreen) + }, + onNavigateToAbout = { + navController.navigate(GithubStoreGraph.AboutScreen) + }, + hasUnreadAnnouncements = announcementsUnreadCount > 0, ) - }, - onNavigateToAuthentication = { - navController.navigate( - GithubStoreGraph.AuthenticationScreen, + } + + composable { + RecentlyViewedRoot( + onNavigateBack = { + navController.navigateUp() + }, + onNavigateToDetails = { repoId -> + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId, + ), + ) + }, + onNavigateToDeveloperProfile = { username -> + navController.navigate( + GithubStoreGraph.DeveloperProfileScreen( + username = username, + ), + ) + }, ) - }, - onNavigateToDeveloperProfile = { username -> - navController.navigate( - GithubStoreGraph.DeveloperProfileScreen( - username = username, - ), + } + + composable { + MirrorPickerRoot( + onNavigateBack = { navController.popBackStack() }, ) - }, - ) - } + } - composable { - zed.rainxch.apps.presentation.starred.StarredPickerRoot( - onNavigateBack = { navController.navigateUp() }, - onNavigateToDetails = { repoId, owner, repo -> - navController.navigate( - GithubStoreGraph.DetailsScreen( - repositoryId = repoId, - owner = owner, - repo = repo, - ), + composable { + val historyEntries by whatsNewViewModel.historyEntries.collectAsStateWithLifecycle() + WhatsNewHistoryScreen( + entries = historyEntries, + onNavigateBack = { navController.navigateUp() }, ) - }, - ) - } + } - composable { - ProfileRoot( - onNavigateBack = { - navController.navigateUp() - }, - onNavigateToAuthentication = { - navController.navigate(GithubStoreGraph.AuthenticationScreen) - }, - onNavigateToStarredRepos = { - navController.navigate(GithubStoreGraph.StarredReposScreen) - }, - onNavigateToFavouriteRepos = { - navController.navigate(GithubStoreGraph.FavouritesScreen) - }, - onNavigateToRecentlyViewed = { - navController.navigate(GithubStoreGraph.RecentlyViewedScreen) - }, - onNavigateToDevProfile = { username -> - navController.navigate(GithubStoreGraph.DeveloperProfileScreen(username)) - }, - onNavigateToSponsor = { - navController.navigate(GithubStoreGraph.SponsorScreen) - }, - onNavigateToWhatsNew = { - navController.navigate(GithubStoreGraph.WhatsNewHistoryScreen) - }, - onPreviewWhatsNewSheet = { - whatsNewViewModel.forceShowLatest() - navController.navigateUp() - }, - onNavigateToAnnouncements = { - navController.navigate(GithubStoreGraph.AnnouncementsScreen) - }, - onPreviewAnnouncements = { - announcementsViewModel.previewSampleAnnouncements() - navController.navigate(GithubStoreGraph.AnnouncementsScreen) - }, - hasUnreadAnnouncements = announcementsUnreadCount > 0, - ) - } + composable { + val feed by announcementsViewModel.feed.collectAsStateWithLifecycle() + val displayed by announcementsViewModel.displayedItems.collectAsStateWithLifecycle() + AnnouncementsRoot( + items = displayed, + acknowledgedIds = feed.acknowledgedIds, + mutedCategories = feed.mutedCategories, + refreshFailed = feed.lastRefreshFailed, + onNavigateBack = { navController.navigateUp() }, + onRefresh = { announcementsViewModel.refresh() }, + onCtaClick = { announcementsViewModel.openCta(it) }, + onDismissClick = { announcementsViewModel.dismiss(it) }, + onAcknowledgeClick = { announcementsViewModel.acknowledge(it) }, + onToggleMute = { category, muted -> + announcementsViewModel.setMuted(category, muted) + }, + onLeavingScreen = { announcementsViewModel.clearPreview() }, + onEnteringScreen = { announcementsViewModel.markRoutineItemsSeen() }, + ) + } - composable { - RecentlyViewedRoot( - onNavigateBack = { - navController.navigateUp() - }, - onNavigateToDetails = { repoId -> - navController.navigate( - GithubStoreGraph.DetailsScreen( - repositoryId = repoId, - ), + composable { + TweaksRoot( + onNavigateBack = { navController.popBackStack() }, + onNavigateToAppearance = { + navController.navigate(GithubStoreGraph.TweaksAppearanceScreen) { + launchSingleTop = true + } + }, + onNavigateToLanguage = { + navController.navigate(GithubStoreGraph.TweaksLanguageScreen) { + launchSingleTop = true + } + }, + onNavigateToConnection = { + navController.navigate(GithubStoreGraph.TweaksConnectionScreen) { + launchSingleTop = true + } + }, + onNavigateToSources = { + navController.navigate(GithubStoreGraph.TweaksSourcesScreen) { + launchSingleTop = true + } + }, + onNavigateToTranslation = { + navController.navigate(GithubStoreGraph.TweaksTranslationScreen) { + launchSingleTop = true + } + }, + onNavigateToInstallMethod = { + navController.navigate(GithubStoreGraph.TweaksInstallScreen) { + launchSingleTop = true + } + }, + onNavigateToUpdates = { + navController.navigate(GithubStoreGraph.TweaksUpdatesScreen) { + launchSingleTop = true + } + }, + onNavigateToStorage = { + navController.navigate(GithubStoreGraph.TweaksStorageScreen) { + launchSingleTop = true + } + }, + onNavigateToPrivacy = { + navController.navigate(GithubStoreGraph.TweaksPrivacyScreen) { + launchSingleTop = true + } + }, + onNavigateToHostTokens = { + navController.navigate(GithubStoreGraph.HostTokensScreen) { + launchSingleTop = true + } + }, ) - }, - onNavigateToDeveloperProfile = { username -> - navController.navigate( - GithubStoreGraph.DeveloperProfileScreen( - username = username, - ), + } + + composable { + TweaksAppearanceRoot( + onNavigateBack = { navController.popBackStack() }, ) - }, - ) - } + } - composable { - SponsorScreen( - onNavigateBack = { - navController.navigateUp() - }, - ) - } + composable { + TweaksLanguageRoot( + onNavigateBack = { navController.popBackStack() }, + ) + } - composable { - MirrorPickerRoot( - onNavigateBack = { navController.popBackStack() }, - ) - } + composable { + TweaksConnectionRoot( + onNavigateBack = { navController.popBackStack() }, + ) + } - composable { - val historyEntries by whatsNewViewModel.historyEntries.collectAsStateWithLifecycle() - WhatsNewHistoryScreen( - entries = historyEntries, - onNavigateBack = { navController.navigateUp() }, - ) - } + composable { + TweaksSourcesRoot( + onNavigateBack = { navController.popBackStack() }, + onNavigateToMirrorPicker = { + navController.navigate(GithubStoreGraph.MirrorPickerScreen) { + launchSingleTop = true + } + }, + ) + } - composable { - val feed by announcementsViewModel.feed.collectAsStateWithLifecycle() - val displayed by announcementsViewModel.displayedItems.collectAsStateWithLifecycle() - AnnouncementsRoot( - items = displayed, - acknowledgedIds = feed.acknowledgedIds, - mutedCategories = feed.mutedCategories, - refreshFailed = feed.lastRefreshFailed, - onNavigateBack = { navController.navigateUp() }, - onRefresh = { announcementsViewModel.refresh() }, - onCtaClick = { announcementsViewModel.openCta(it) }, - onDismissClick = { announcementsViewModel.dismiss(it) }, - onAcknowledgeClick = { announcementsViewModel.acknowledge(it) }, - onToggleMute = { category, muted -> - announcementsViewModel.setMuted(category, muted) - }, - onLeavingScreen = { announcementsViewModel.clearPreview() }, - onEnteringScreen = { announcementsViewModel.markRoutineItemsSeen() }, - ) - } + composable { + TweaksTranslationRoot( + onNavigateBack = { navController.popBackStack() }, + ) + } - composable { - TweaksRoot( - onNavigateToMirrorPicker = { - navController.navigate(GithubStoreGraph.MirrorPickerScreen) { - launchSingleTop = true - } - }, - onNavigateToSkippedUpdates = { - navController.navigate(GithubStoreGraph.SkippedUpdatesScreen) { - launchSingleTop = true - } - }, - onNavigateToHiddenRepositories = { - navController.navigate(GithubStoreGraph.HiddenRepositoriesScreen) { - launchSingleTop = true - } - }, - onNavigateToHostTokens = { - navController.navigate(GithubStoreGraph.HostTokensScreen) { - launchSingleTop = true - } - }, - ) - } + composable { + TweaksInstallRoot( + onNavigateBack = { navController.popBackStack() }, + ) + } - composable { - SkippedUpdatesRoot( - onNavigateBack = { navController.popBackStack() }, - ) - } + composable { + TweaksUpdatesRoot( + onNavigateBack = { navController.popBackStack() }, + onNavigateToSkippedUpdates = { + navController.navigate(GithubStoreGraph.SkippedUpdatesScreen) { + launchSingleTop = true + } + }, + ) + } - composable { - HiddenRepositoriesRoot( - onNavigateBack = { navController.popBackStack() }, - ) - } + composable { + TweaksStorageRoot( + onNavigateBack = { navController.popBackStack() }, + ) + } - composable { - HostTokensRoot( - onNavigateBack = { navController.popBackStack() }, - ) - } + composable { + TweaksPrivacyRoot( + onNavigateBack = { navController.popBackStack() }, + onNavigateToHiddenRepositories = { + navController.navigate(GithubStoreGraph.HiddenRepositoriesScreen) { + launchSingleTop = true + } + }, + ) + } - composable { backStackEntry -> - // Pick up the "open link sheet" flag set by ExternalImportRoot's - // "Add manually" path. We consume the flag once on entry so a - // later config change or back-stack rewind doesn't reopen the sheet. - LaunchedEffect(backStackEntry) { - val handle = backStackEntry.savedStateHandle - val openLinkSheet = handle.get(EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY) - if (openLinkSheet == true) { - handle.remove(EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY) - appsViewModel.onAction(zed.rainxch.apps.presentation.AppsAction.OnAddByLinkClick) + composable { + TweaksAppInfoRoot( + onNavigateBack = { navController.popBackStack() }, + onNavigateToLicenses = { + navController.navigate(GithubStoreGraph.LicensesScreen) { + launchSingleTop = true + } + }, + ) } - } - AppsRoot( - onNavigateBack = { - navController.navigateUp() - }, - onNavigateToRepo = { repoId, sourceHost, owner, repo -> - navController.navigate( - GithubStoreGraph.DetailsScreen( - repositoryId = repoId, - isComingFromUpdate = true, - sourceHost = sourceHost, - owner = owner.orEmpty(), - repo = repo.orEmpty(), - ), + + composable { + LicensesRoot( + onNavigateBack = { navController.popBackStack() }, ) - }, - onNavigateToExternalImport = { - navController.navigate(GithubStoreGraph.ExternalImportScreen) - }, - onNavigateToStarredPicker = { - navController.navigate(GithubStoreGraph.StarredPickerScreen) - }, - viewModel = appsViewModel, - state = appsState, - ) - } + } - composable { - ExternalImportRoot( - onNavigateBack = { - navController.navigateUp() - }, - onNavigateToDetails = { repoId -> - navController.navigate( - GithubStoreGraph.DetailsScreen( - repositoryId = repoId, - isComingFromUpdate = true, - ), + composable { + SkippedUpdatesRoot( + onNavigateBack = { navController.popBackStack() }, ) - }, - onAddManually = { - navController.previousBackStackEntry - ?.savedStateHandle - ?.set(EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY, true) - navController.navigateUp() - }, - ) - } - } + } - val currentScreen = - navController.currentBackStackEntryAsState().value.getCurrentScreen() + composable { + HiddenRepositoriesRoot( + onNavigateBack = { navController.popBackStack() }, + ) + } - currentScreen?.let { - BottomNavigation( - currentScreen = currentScreen, - onNavigate = { - navController.navigate(it) { - popUpTo(GithubStoreGraph.HomeScreen) { - saveState = true - } + composable { + HostTokensRoot( + onNavigateBack = { navController.popBackStack() }, + ) + } - launchSingleTop = true - restoreState = true + composable { backStackEntry -> + LaunchedEffect(backStackEntry) { + val handle = backStackEntry.savedStateHandle + val openLinkSheet = + handle.get(EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY) + if (openLinkSheet == true) { + handle.remove(EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY) + appsViewModel.onAction(zed.rainxch.apps.presentation.AppsAction.OnAddByLinkClick) + } + } + val listDetailState = rememberAdaptiveListDetailState() + val pickRepoTitle = stringResource(Res.string.adaptive_pick_repo_title) + val pickRepoSubtitle = stringResource(Res.string.adaptive_pick_repo_subtitle) + AdaptiveListDetailScaffold( + state = listDetailState, + emptyPaneTitle = pickRepoTitle, + emptyPaneSubtitle = pickRepoSubtitle, + list = { isExpanded -> + AppsRoot( + onNavigateBack = { + navController.navigateUp() + }, + onNavigateToRepo = { repoId, sourceHost, owner, repo -> + if (isExpanded) { + listDetailState.select( + AdaptiveDetailArgs( + repositoryId = repoId, + isComingFromUpdate = true, + sourceHost = sourceHost, + owner = owner, + repo = repo, + ), + ) + } else { + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId, + isComingFromUpdate = true, + sourceHost = sourceHost, + owner = owner.orEmpty(), + repo = repo.orEmpty(), + ), + ) + } + }, + onNavigateToExternalImport = { + navController.navigate(GithubStoreGraph.ExternalImportScreen) + }, + onNavigateToStarredPicker = { + navController.navigate(GithubStoreGraph.StarredPickerScreen) + }, + viewModel = appsViewModel, + state = appsState, + ) + }, + detail = { detailArgs -> + AdaptiveDetailPaneContent( + args = detailArgs, + navController = navController, + onCrossNavToRepo = { newArgs -> listDetailState.select(newArgs) }, + onClearPane = { listDetailState.clear() }, + ) + }, + ) } - }, - // Badge fires when either an update is waiting OR pending - // import candidates need review. The badge is a single dot - // — a union of the two conditions is honest "you have - // something to look at on this tab". - isUpdateAvailable = - appsState.apps.any { it.installedApp.isUpdateAvailable } || - appsState.showImportProposalBanner, - hasUnreadAnnouncements = announcementsUnreadCount > 0, - modifier = - Modifier - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(bottom = 24.dp) - .onGloballyPositioned { coordinates -> - bottomNavigationHeight = - with(density) { coordinates.size.height.toDp() } - }, - ) - } - val autoSuggestVm: AutoSuggestMirrorViewModel = koinViewModel() - val isAutoSuggestVisible by autoSuggestVm.isVisible.collectAsStateWithLifecycle() - if (isAutoSuggestVisible) { - AutoSuggestMirrorSheet( - onDismiss = autoSuggestVm::dismiss, - onPickOne = { - autoSuggestVm.onPickOneClicked() - navController.navigate(GithubStoreGraph.MirrorPickerScreen) { - launchSingleTop = true + composable { + ExternalImportRoot( + onNavigateBack = { + navController.navigateUp() + }, + onNavigateToDetails = { repoId -> + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId, + isComingFromUpdate = true, + ), + ) + }, + onAddManually = { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY, true) + navController.navigateUp() + }, + ) } - }, - onMaybeLater = autoSuggestVm::onMaybeLater, - onDontAskAgain = autoSuggestVm::onDontAskAgain, - ) + } + } + + val currentScreen = + navController.currentBackStackEntryAsState().value.getCurrentScreen() + + currentScreen?.takeIf { !isDesktop }?.let { + BottomNavigation( + currentScreen = currentScreen, + onNavigate = { + navController.navigate(it) { + popUpTo(GithubStoreGraph.HomeScreen) { + saveState = true + } + + launchSingleTop = true + restoreState = true + } + }, + isUpdateAvailable = + appsState.apps.any { it.installedApp.isUpdateAvailable } || + appsState.showImportProposalBanner, + hasUnreadAnnouncements = announcementsUnreadCount > 0, + modifier = + Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 24.dp) + .onGloballyPositioned { coordinates -> + bottomNavigationHeight = + with(density) { coordinates.size.height.toDp() } + }, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt index c34b29fae..15a284999 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt @@ -1,22 +1,21 @@ package zed.rainxch.githubstore.app.navigation -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -24,37 +23,24 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -import zed.rainxch.core.domain.getPlatform -import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.presentation.theme.GithubStoreTheme +import zed.rainxch.core.presentation.theme.geist +import zed.rainxch.core.presentation.vocabulary.CookieShape +import zed.rainxch.core.presentation.vocabulary.VersionStack @Composable fun BottomNavigation( @@ -67,57 +53,8 @@ fun BottomNavigation( val allowedScreens = BottomNavigationUtils.allowedScreens() if (allowedScreens.none { it.screen::class == currentScreen::class }) return - val selectedIndex = - allowedScreens.indexOfFirst { it.screen::class == currentScreen::class } - - val itemPositions = remember { mutableMapOf>() } - - var selectedItemPos by remember { mutableStateOf?>(null) } - - val rowHorizontalPaddingDp = 6.dp - val density = LocalDensity.current - val rowHorizontalPaddingPx = with(density) { rowHorizontalPaddingDp.toPx() } - - val indicatorHorizontalInsetPx = with(density) { 4.dp.toPx() } - - val indicatorX = remember { Animatable(0f) } - val indicatorWidth = remember { Animatable(0f) } - - LaunchedEffect(selectedIndex, selectedItemPos) { - val raw = selectedItemPos ?: itemPositions[selectedIndex] ?: return@LaunchedEffect - val targetX = raw.first + rowHorizontalPaddingPx - indicatorHorizontalInsetPx - val targetW = raw.second + indicatorHorizontalInsetPx * 2f - launch { - // E4.2: tab indicator animated via tween — bouncy spring on a - // high-frequency tap target overshoots on every tab change and - // reads as jittery (survey #16). Tween stays predictable. - indicatorX.animateTo( - targetValue = targetX, - animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), - ) - } - launch { - indicatorWidth.animateTo( - targetValue = targetW, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), - ) - } - } - - val isDarkTheme = - !MaterialTheme.colorScheme.background - .luminance() - .let { it > 0.5f } - - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - Box( + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Row( modifier = Modifier .clip(CircleShape) @@ -126,222 +63,50 @@ fun BottomNavigation( width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, shape = CircleShape, - ).pointerInput(Unit) { }, + ).padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, ) { - val glassHighColor = - if (isDarkTheme) { - Color.White.copy(alpha = .12f) - } else { - Color.White.copy(alpha = .30f) - } - val glassLowColor = - if (isDarkTheme) { - Color.White.copy(alpha = .04f) - } else { - Color.White.copy(alpha = .10f) - } - val specularColor = - if (isDarkTheme) { - Color.White.copy(alpha = .18f) - } else { - Color.White.copy(alpha = .45f) - } - val innerGlowColor = - if (isDarkTheme) { - Color.White.copy(alpha = .03f) - } else { - Color.White.copy(alpha = .08f) - } - val borderColor = - if (isDarkTheme) { - Color.White.copy(alpha = .08f) - } else { - Color.Transparent - } - - Box( - modifier = - Modifier - .matchParentSize() - .drawBehind { - if (indicatorWidth.value > 0f) { - if (isDarkTheme) { - drawRoundRect( - color = borderColor, - topLeft = - Offset( - indicatorX.value - .5.dp.toPx(), - 1.5.dp.toPx(), - ), - size = - Size( - indicatorWidth.value + 1.dp.toPx(), - size.height - 3.dp.toPx(), - ), - cornerRadius = CornerRadius(size.height / 2f), - style = Stroke(width = 1.dp.toPx()), - ) - } - - drawRoundRect( - brush = - Brush.verticalGradient( - colors = listOf(glassHighColor, glassLowColor), - ), - topLeft = Offset(indicatorX.value, 2.dp.toPx()), - size = Size(indicatorWidth.value, size.height - 4.dp.toPx()), - cornerRadius = CornerRadius(size.height / 2f), - ) - - drawRoundRect( - brush = - Brush.horizontalGradient( - colors = - listOf( - Color.Transparent, - specularColor, - Color.Transparent, - ), - startX = indicatorX.value + indicatorWidth.value * .15f, - endX = indicatorX.value + indicatorWidth.value * .85f, - ), - topLeft = - Offset( - indicatorX.value + indicatorWidth.value * .15f, - 3.dp.toPx(), - ), - size = Size(indicatorWidth.value * .7f, 1.5.dp.toPx()), - cornerRadius = CornerRadius(1.dp.toPx()), - ) - - drawRoundRect( - brush = - Brush.verticalGradient( - colors = listOf(Color.Transparent, innerGlowColor), - ), - topLeft = - Offset( - indicatorX.value + 4.dp.toPx(), - size.height - 8.dp.toPx(), - ), - size = Size(indicatorWidth.value - 8.dp.toPx(), 4.dp.toPx()), - cornerRadius = CornerRadius(2.dp.toPx()), - ) - } - }, - ) - - Row( - modifier = Modifier.padding(horizontal = rowHorizontalPaddingDp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - BottomNavigationUtils.allowedScreens().forEachIndexed { index, item -> - LiquidGlassTabItem( - item = item, - hasBadge = - (item.screen == GithubStoreGraph.AppsScreen && isUpdateAvailable) || - (item.screen == GithubStoreGraph.ProfileScreen && hasUnreadAnnouncements), - isSelected = item.screen::class == currentScreen::class, - onSelect = { onNavigate(item.screen) }, - onPositioned = { x, width -> - itemPositions[index] = x to width - if (index == selectedIndex) { - selectedItemPos = x to width - } - if (index == selectedIndex && indicatorWidth.value == 0f) { - val snapX = x + rowHorizontalPaddingPx - indicatorHorizontalInsetPx - val snapW = width + indicatorHorizontalInsetPx * 2f - indicatorX.snapTo(snapX) - indicatorWidth.snapTo(snapW) - } - }, - ) - } + allowedScreens.forEach { item -> + CookieTabItem( + item = item, + isSelected = item.screen::class == currentScreen::class, + onSelect = { onNavigate(item.screen) }, + showUpdateBadge = item.screen == GithubStoreGraph.AppsScreen && isUpdateAvailable, + hasUnreadDot = item.screen == GithubStoreGraph.ProfileScreen && hasUnreadAnnouncements, + ) } } } } @Composable -private fun LiquidGlassTabItem( +private fun CookieTabItem( item: BottomNavigationItem, isSelected: Boolean, onSelect: () -> Unit, - hasBadge: Boolean = false, - onPositioned: suspend (x: Float, width: Float) -> Unit, + showUpdateBadge: Boolean = false, + hasUnreadDot: Boolean = false, ) { - val scope = rememberCoroutineScope() - val density = LocalDensity.current val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val pressScale by animateFloatAsState( - targetValue = if (isPressed) 0.85f else 1f, + targetValue = 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), - label = "pressScale", - ) - - val iconScale by animateFloatAsState( - targetValue = if (isSelected) 1.15f else 1f, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), - label = "iconScale", - ) - - val iconOffsetY by animateDpAsState( - targetValue = if (isSelected) (-1).dp else 1.dp, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), - label = "iconOffsetY", + label = "tab-press-scale", ) - - val iconTint = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = .7f) - } - - val labelAlpha by animateFloatAsState( + val activeAlpha by animateFloatAsState( targetValue = if (isSelected) 1f else 0f, - animationSpec = - tween( - durationMillis = if (isSelected) 250 else 150, - easing = FastOutSlowInEasing, - ), - label = "labelAlpha", - ) - - val labelScale by animateFloatAsState( - targetValue = if (isSelected) 1f else 0.6f, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), - label = "labelScale", - ) - - val horizontalPadding by animateDpAsState( - targetValue = if (isSelected) 14.dp else 10.dp, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - label = "hPadding", + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "tab-active-alpha", ) + val cs = MaterialTheme.colorScheme + val cookieFill = cs.primary + val activeFg = cs.onPrimary + val inactiveFg = cs.onSurface.copy(alpha = 0.7f) Box( modifier = @@ -350,71 +115,74 @@ private fun LiquidGlassTabItem( .clickable( interactionSource = interactionSource, indication = null, - ) { onSelect() } - .onGloballyPositioned { coordinates -> - val x = coordinates.positionInParent().x - val width = coordinates.size.width.toFloat() - scope.launch { onPositioned(x, width) } - }.graphicsLayer { - scaleX = pressScale - scaleY = pressScale - }.padding(horizontal = horizontalPadding, vertical = 6.dp), + onClick = onSelect, + ).scale(pressScale) + .padding(horizontal = if (isSelected) 14.dp else 10.dp, vertical = 6.dp), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(1.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { - Icon( - imageVector = if (isSelected) item.iconFilled else item.iconOutlined, - contentDescription = stringResource(item.titleRes), - modifier = - Modifier - .size(22.dp) - .graphicsLayer { - scaleX = iconScale - scaleY = iconScale - translationY = with(density) { iconOffsetY.toPx() } - }, - tint = iconTint, - ) - Box( - modifier = - Modifier - .height(if (isSelected) 16.dp else 0.dp) - .graphicsLayer { - alpha = labelAlpha - scaleX = labelScale - scaleY = labelScale - }, + modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center, + ) { + if (activeAlpha > 0f) { + Box( + modifier = + Modifier + .size(32.dp) + .graphicsLayer { alpha = activeAlpha } + .clip(CookieShape) + .background(cookieFill), + ) + } + Icon( + imageVector = if (isSelected) item.iconFilled else item.iconOutlined, + contentDescription = stringResource(item.titleRes), + modifier = Modifier.size(20.dp), + tint = if (isSelected) activeFg else inactiveFg, + ) + } + + AnimatedVisibility( + visible = isSelected, + enter = fadeIn() + scaleIn(initialScale = 0.6f), + exit = fadeOut() + scaleOut(targetScale = 0.6f), ) { Text( text = stringResource(item.titleRes), + color = cs.primary, style = MaterialTheme.typography.labelSmall.copy( - fontSize = 10.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - lineHeight = 12.sp, + fontFamily = geist, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, ), - color = - if (isSelected) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = .7f) - }, maxLines = 1, ) } } - if (hasBadge) { + if (showUpdateBadge) { Box( - Modifier - .size(12.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.error) - .align(Alignment.TopEnd), + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 2.dp), + ) { + VersionStack(count = 1, widthDp = 8) + } + } + if (hasUnreadDot) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 4.dp, end = 2.dp) + .size(8.dp) + .clip(CircleShape) + .background(cs.error), ) } } @@ -426,8 +194,7 @@ fun BottomNavigationPreview() { GithubStoreTheme { BottomNavigation( currentScreen = GithubStoreGraph.HomeScreen, - onNavigate = { - }, + onNavigate = {}, isUpdateAvailable = true, ) } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigationUtils.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigationUtils.kt index 09e959bb5..bb7f67574 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigationUtils.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigationUtils.kt @@ -5,8 +5,6 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.ui.graphics.vector.ImageVector import org.jetbrains.compose.resources.StringResource -import zed.rainxch.core.domain.getPlatform -import zed.rainxch.core.domain.model.Platform import zed.rainxch.githubstore.core.presentation.res.* data class BottomNavigationItem( @@ -43,18 +41,7 @@ object BottomNavigationUtils { iconFilled = Icons.Filled.Person2, screen = GithubStoreGraph.ProfileScreen, ), - BottomNavigationItem( - titleRes = Res.string.bottom_nav_profile_tweaks, - iconOutlined = Icons.Outlined.Settings, - iconFilled = Icons.Filled.Settings, - screen = GithubStoreGraph.TweaksScreen, - ), ) - fun allowedScreens(): List = - items() - .filterNot { - getPlatform() != Platform.ANDROID && - it.screen == GithubStoreGraph.AppsScreen - } + fun allowedScreens(): List = items() } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/DesktopDrawer.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/DesktopDrawer.kt new file mode 100644 index 000000000..ceca3726a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/DesktopDrawer.kt @@ -0,0 +1,170 @@ +package zed.rainxch.githubstore.app.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.theme.geist +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.vocabulary.CookieShape +import zed.rainxch.core.presentation.vocabulary.VersionStack +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.app_name + +@Composable +fun DesktopDrawer( + currentScreen: GithubStoreGraph?, + onNavigate: (GithubStoreGraph) -> Unit, + isUpdateAvailable: Boolean, + hasUnreadAnnouncements: Boolean, + modifier: Modifier = Modifier, +) { + val cs = MaterialTheme.colorScheme + + val items = BottomNavigationUtils.items().filterNot { it.screen == GithubStoreGraph.AppsScreen } + Column( + modifier = + modifier + .fillMaxHeight() + .width(240.dp) + .background(cs.surface) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.Top, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(28.dp) + .clip(CookieShape) + .background(cs.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = "G", + color = Color.White, + style = + MaterialTheme.typography.titleMedium.copy( + fontFamily = geist, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ), + ) + } + Text( + text = stringResource(Res.string.app_name), + color = cs.onSurface, + style = + MaterialTheme.typography.titleMedium.copy( + fontFamily = geist, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, + fontWeight = FontWeight.SemiBold, + ), + ) + } + Spacer(Modifier.size(16.dp)) + items.forEach { item -> + val isSelected = item.screen::class == currentScreen?.let { it::class } + val badge: (@Composable () -> Unit)? = + when { + item.screen == GithubStoreGraph.AppsScreen && isUpdateAvailable -> { + { VersionStack(count = 1, widthDp = 8) } + } + + item.screen == GithubStoreGraph.ProfileScreen && hasUnreadAnnouncements -> { + { UnreadDot(cs.error) } + } + + else -> { + null + } + } + DrawerNavItem( + label = stringResource(item.titleRes), + iconImage = if (isSelected) item.iconFilled else item.iconOutlined, + isSelected = isSelected, + onClick = { onNavigate(item.screen) }, + trailing = badge, + ) + } + } +} + +@Composable +private fun DrawerNavItem( + label: String, + iconImage: androidx.compose.ui.graphics.vector.ImageVector, + isSelected: Boolean, + onClick: () -> Unit, + trailing: (@Composable () -> Unit)? = null, +) { + val cs = MaterialTheme.colorScheme + val bg = if (isSelected) cs.primaryContainer else Color.Transparent + val fg = if (isSelected) cs.primary else cs.onSurface + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + .clip(Radii.row) + .background(bg) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = iconImage, + contentDescription = label, + modifier = Modifier.size(20.dp), + tint = fg, + ) + Text( + text = label, + color = fg, + style = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + ), + modifier = Modifier.weight(1f), + ) + trailing?.invoke() + } +} + +@Composable +private fun UnreadDot(color: Color) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(color), + ) +} diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt index b284a3a0b..0bbc46548 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt @@ -9,11 +9,6 @@ sealed interface GithubStoreGraph { @Serializable data class SearchScreen( - // String over enum: Compose Navigation's Desktop (`nonAndroid.kt`) - // serializer needs an explicit NavType for non-primitive nav args, - // which enums don't have out of the box. Enum-as-name string keeps - // the contract type-safe at the caller / VM boundary while letting - // the route serialize on every target with no typeMap. val initialPlatform: String? = null, ) : GithubStoreGraph @@ -26,9 +21,6 @@ sealed interface GithubStoreGraph { val owner: String = "", val repo: String = "", val isComingFromUpdate: Boolean = false, - // Non-null when the repo lives on a non-GitHub forge (Codeberg / - // Forgejo / Gitea / custom). Drives the foreign-source branch in - // DetailsViewModel so we hit the Forgejo API instead of GitHub. val sourceHost: String? = null, ) : GithubStoreGraph @@ -43,6 +35,39 @@ sealed interface GithubStoreGraph { @Serializable data object TweaksScreen : GithubStoreGraph + @Serializable + data object TweaksAppearanceScreen : GithubStoreGraph + + @Serializable + data object TweaksLanguageScreen : GithubStoreGraph + + @Serializable + data object TweaksConnectionScreen : GithubStoreGraph + + @Serializable + data object TweaksSourcesScreen : GithubStoreGraph + + @Serializable + data object TweaksTranslationScreen : GithubStoreGraph + + @Serializable + data object TweaksInstallScreen : GithubStoreGraph + + @Serializable + data object TweaksUpdatesScreen : GithubStoreGraph + + @Serializable + data object TweaksStorageScreen : GithubStoreGraph + + @Serializable + data object TweaksPrivacyScreen : GithubStoreGraph + + @Serializable + data object AboutScreen : GithubStoreGraph + + @Serializable + data object LicensesScreen : GithubStoreGraph + @Serializable data object FavouritesScreen : GithubStoreGraph @@ -56,7 +81,7 @@ sealed interface GithubStoreGraph { data object AppsScreen : GithubStoreGraph @Serializable - data object SponsorScreen : GithubStoreGraph + data object OnboardingScreen : GithubStoreGraph @Serializable data object ExternalImportScreen : GithubStoreGraph @@ -81,4 +106,25 @@ sealed interface GithubStoreGraph { @Serializable data object HostTokensScreen : GithubStoreGraph + + @Serializable + data class CategoryListScreen( + val category: String, + ) : GithubStoreGraph + + @Serializable + data class DetailsAboutScreen( + val repositoryId: Long = -1L, + val owner: String = "", + val repo: String = "", + val sourceHost: String? = null, + ) : GithubStoreGraph + + @Serializable + data class DetailsWhatsNewScreen( + val repositoryId: Long = -1L, + val owner: String = "", + val repo: String = "", + val sourceHost: String? = null, + ) : GithubStoreGraph } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/NavigationUtils.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/NavigationUtils.kt index 0995fcc40..3f0783aef 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/NavigationUtils.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/NavigationUtils.kt @@ -3,6 +3,17 @@ package zed.rainxch.githubstore.app.navigation import androidx.navigation.NavBackStackEntry import androidx.navigation.toRoute +fun NavBackStackEntry?.bottomNavIndex(): Int? { + val route = this?.destination?.route ?: return null + return when { + route.contains("HomeScreen") -> 0 + route.contains("SearchScreen") -> 1 + route.contains("AppsScreen") -> 2 + route.contains("ProfileScreen") -> 3 + else -> null + } +} + fun NavBackStackEntry?.getCurrentScreen(): GithubStoreGraph? { if (this == null) return null val route = destination.route ?: return null diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingAction.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingAction.kt new file mode 100644 index 000000000..1010fcc00 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingAction.kt @@ -0,0 +1,24 @@ +package zed.rainxch.githubstore.app.onboarding + +import zed.rainxch.core.domain.model.AppTheme +import zed.rainxch.core.domain.model.ThemeMode + +sealed interface OnboardingAction { + data class OnPaletteSelected( + val palette: AppTheme, + ) : OnboardingAction + + data class OnModeSelected( + val mode: ThemeMode, + ) : OnboardingAction + + data object OnNextClick : OnboardingAction + + data object OnBackClick : OnboardingAction + + data object OnSkipStepClick : OnboardingAction + + data object OnSignInClick : OnboardingAction + + data object OnFinishClick : OnboardingAction +} diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingEvent.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingEvent.kt new file mode 100644 index 000000000..d3f20c0ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingEvent.kt @@ -0,0 +1,7 @@ +package zed.rainxch.githubstore.app.onboarding + +sealed interface OnboardingEvent { + data object NavigateToSignIn : OnboardingEvent + + data object NavigateToHome : OnboardingEvent +} diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingPermissions.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingPermissions.kt new file mode 100644 index 000000000..06765d925 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingPermissions.kt @@ -0,0 +1,16 @@ +package zed.rainxch.githubstore.app.onboarding + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State + +expect class OnboardingPermissionsController { + val notificationsGranted: State + val installSourcesGranted: State + + fun requestNotifications() + + fun requestInstallSources() +} + +@Composable +expect fun rememberOnboardingPermissionsController(): OnboardingPermissionsController diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingScreen.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingScreen.kt new file mode 100644 index 000000000..b2bc27801 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingScreen.kt @@ -0,0 +1,457 @@ +package zed.rainxch.githubstore.app.onboarding + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.DownloadForOffline +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.domain.model.AppTheme +import zed.rainxch.core.domain.model.ThemeMode +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.theme.geist +import zed.rainxch.core.presentation.theme.isDynamicColorAvailable +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.utils.ObserveAsEvents +import zed.rainxch.core.presentation.utils.constrainedContentWidth +import zed.rainxch.core.presentation.utils.primaryColor +import zed.rainxch.core.presentation.vocabulary.CookieShape +import zed.rainxch.core.presentation.vocabulary.Squiggle + +@Composable +fun OnboardingRoot( + onNavigateToSignIn: () -> Unit, + onNavigateToHome: () -> Unit, + viewModel: OnboardingViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsState() + ObserveAsEvents(viewModel.events) { event -> + when (event) { + OnboardingEvent.NavigateToSignIn -> onNavigateToSignIn() + OnboardingEvent.NavigateToHome -> onNavigateToHome() + } + } + OnboardingScreen(state = state, onAction = viewModel::onAction) +} + +@Composable +fun OnboardingScreen( + state: OnboardingState, + onAction: (OnboardingAction) -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .systemBarsPadding(), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = + Modifier + .constrainedContentWidth() + .fillMaxHeight() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + StepIndicator(total = state.steps.size, currentIndex = state.currentIndex) + Spacer(Modifier.height(32.dp)) + + val permissionsController = rememberOnboardingPermissionsController() + + AnimatedContent( + targetState = state.currentStep, + transitionSpec = { + ( + slideInHorizontally { it } + fadeIn() togetherWith + slideOutHorizontally { -it } + fadeOut() + ) + }, + label = "onboarding-step", + modifier = Modifier.weight(1f).fillMaxWidth(), + ) { step -> + when (step) { + OnboardingStep.PALETTE -> StepPalette(state, onAction) + OnboardingStep.SIGN_IN -> StepSignIn(onAction) + OnboardingStep.PERMISSIONS -> StepPermissions(permissionsController) + } + } + + ActionRow(state, onAction) + } + } +} + +@Composable +private fun StepIndicator( + total: Int, + currentIndex: Int, +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + repeat(total) { i -> + val active = i <= currentIndex + Box( + modifier = + Modifier + .size(width = if (i == currentIndex) 24.dp else 8.dp, height = 8.dp) + .clip(CircleShape) + .background( + if (active) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + ), + ) + } + } +} + +@Composable +private fun StepPalette( + state: OnboardingState, + onAction: (OnboardingAction) -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = "Pick your palette", + style = + MaterialTheme.typography.displaySmall.copy( + fontFamily = geist, + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Squiggle() + Text( + text = "Four palettes × Light, Dark, AMOLED, System.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + val dynamicAvailable = isDynamicColorAvailable() + AppTheme.entries + .filter { it != AppTheme.DYNAMIC || dynamicAvailable } + .forEach { palette -> + PaletteSwatch( + palette = palette, + isSelected = state.selectedPalette == palette, + onClick = { onAction(OnboardingAction.OnPaletteSelected(palette)) }, + ) + } + } + Spacer(Modifier.height(16.dp)) + ModeRow( + selected = state.selectedMode, + onSelect = { onAction(OnboardingAction.OnModeSelected(it)) }, + ) + } +} + +@Composable +private fun PaletteSwatch( + palette: AppTheme, + isSelected: Boolean, + onClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.clickable(onClick = onClick), + ) { + Box( + modifier = + Modifier + .size(64.dp) + .clip(CookieShape) + .background(palette.primaryColor) + .border( + width = if (isSelected) 3.dp else 0.dp, + color = MaterialTheme.colorScheme.onSurface, + shape = CookieShape, + ), + ) + Text( + text = palette.name.lowercase().replaceFirstChar { it.uppercaseChar() }, + color = + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + style = + MaterialTheme.typography.labelMedium.copy( + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + ), + ) + } +} + +@Composable +private fun ModeRow( + selected: ThemeMode, + onSelect: (ThemeMode) -> Unit, +) { + Row( + modifier = + Modifier + .clip(Radii.chip) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + ThemeMode.entries.forEach { mode -> + val isActive = mode == selected + Box( + modifier = + Modifier + .clip(Radii.chip) + .background(if (isActive) MaterialTheme.colorScheme.primary else Color.Transparent) + .clickable { onSelect(mode) } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = mode.name.lowercase().replaceFirstChar { it.uppercaseChar() }, + color = + if (isActive) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + }, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } +} + +@Composable +private fun StepSignIn(onAction: (OnboardingAction) -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Box( + modifier = + Modifier + .size(96.dp) + .clip(CookieShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = "G", + color = MaterialTheme.colorScheme.onPrimary, + fontFamily = geist, + fontWeight = FontWeight.Bold, + fontSize = 48.sp, + ) + } + Text( + text = "Sign in with GitHub", + style = + MaterialTheme.typography.headlineSmall.copy( + fontFamily = geist, + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Squiggle() + Text( + text = "Stars, profile, and rate-limit headroom. Optional — skip to browse anonymously.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + GhsButton( + onClick = { onAction(OnboardingAction.OnSignInClick) }, + label = "Sign in", + variant = GhsButtonVariant.Primary, + ) + } +} + +@Composable +private fun StepPermissions(controller: OnboardingPermissionsController) { + val notificationsGranted by controller.notificationsGranted + val installSourcesGranted by controller.installSourcesGranted + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Two quick prompts", + style = + MaterialTheme.typography.headlineSmall.copy( + fontFamily = geist, + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Squiggle() + Text( + text = "Both optional. You can flip these later in Tweaks.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + PermissionRow( + icon = Icons.Outlined.Notifications, + label = "Notifications", + description = "Update alerts and install progress", + isGranted = notificationsGranted, + onAllowClick = { controller.requestNotifications() }, + ) + PermissionRow( + icon = Icons.Outlined.DownloadForOffline, + label = "Install unknown apps", + description = "Required to install APKs outside Play", + isGranted = installSourcesGranted, + onAllowClick = { controller.requestInstallSources() }, + ) + } +} + +@Composable +private fun PermissionRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + description: String, + isGranted: Boolean, + onAllowClick: () -> Unit, +) { + val cs = MaterialTheme.colorScheme + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(Radii.card) + .background(cs.surfaceContainer) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background(if (isGranted) cs.tertiaryContainer else cs.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (isGranted) cs.tertiary else cs.primary, + modifier = Modifier.size(22.dp), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + color = cs.onSurface, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + ) + Text( + text = description, + color = cs.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + if (isGranted) { + Box( + modifier = + Modifier + .size(36.dp) + .clip(CircleShape) + .background(cs.tertiary), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Granted", + tint = cs.onPrimary, + modifier = Modifier.size(20.dp), + ) + } + } else { + GhsButton( + onClick = onAllowClick, + label = "Allow", + variant = GhsButtonVariant.Tonal, + ) + } + } +} + +@Composable +private fun ActionRow( + state: OnboardingState, + onAction: (OnboardingAction) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (state.currentStep == OnboardingStep.SIGN_IN || state.currentStep == OnboardingStep.PERMISSIONS) { + GhsButton( + onClick = { onAction(OnboardingAction.OnSkipStepClick) }, + label = "Skip", + variant = GhsButtonVariant.Outline, + ) + } else { + Spacer(Modifier.size(80.dp)) + } + GhsButton( + onClick = { onAction(OnboardingAction.OnNextClick) }, + label = if (state.isLast) "Get started" else "Next", + variant = GhsButtonVariant.Primary, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingState.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingState.kt new file mode 100644 index 000000000..8546b7bcd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingState.kt @@ -0,0 +1,20 @@ +package zed.rainxch.githubstore.app.onboarding + +import androidx.compose.runtime.Stable +import zed.rainxch.core.domain.model.AppTheme +import zed.rainxch.core.domain.model.ThemeMode + +enum class OnboardingStep { PALETTE, SIGN_IN, PERMISSIONS } + +@Stable +data class OnboardingState( + val steps: List = listOf(OnboardingStep.PALETTE, OnboardingStep.SIGN_IN), + val currentIndex: Int = 0, + val selectedPalette: AppTheme = AppTheme.NORD, + val selectedMode: ThemeMode = ThemeMode.SYSTEM, + val isAndroid: Boolean = false, +) { + val currentStep: OnboardingStep get() = steps[currentIndex] + val isFirst: Boolean get() = currentIndex == 0 + val isLast: Boolean get() = currentIndex == steps.lastIndex +} diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingViewModel.kt new file mode 100644 index 000000000..37911046b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingViewModel.kt @@ -0,0 +1,88 @@ +package zed.rainxch.githubstore.app.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import zed.rainxch.core.domain.getPlatform +import zed.rainxch.core.domain.model.Platform +import zed.rainxch.core.domain.repository.TweaksRepository + +class OnboardingViewModel( + private val tweaksRepository: TweaksRepository, +) : ViewModel() { + private val isAndroid = getPlatform() == Platform.ANDROID + + private val _state = + MutableStateFlow( + OnboardingState( + steps = + if (isAndroid) { + listOf(OnboardingStep.PALETTE, OnboardingStep.SIGN_IN, OnboardingStep.PERMISSIONS) + } else { + listOf(OnboardingStep.PALETTE, OnboardingStep.SIGN_IN) + }, + isAndroid = isAndroid, + ), + ) + val state = _state.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + fun onAction(action: OnboardingAction) { + when (action) { + is OnboardingAction.OnPaletteSelected -> { + _state.update { it.copy(selectedPalette = action.palette) } + viewModelScope.launch { tweaksRepository.setThemeColor(action.palette) } + } + + is OnboardingAction.OnModeSelected -> { + _state.update { it.copy(selectedMode = action.mode) } + viewModelScope.launch { tweaksRepository.setThemeMode(action.mode) } + } + + OnboardingAction.OnNextClick -> { + advance() + } + + OnboardingAction.OnBackClick -> { + retreat() + } + + OnboardingAction.OnSkipStepClick -> { + advance() + } + + OnboardingAction.OnSignInClick -> { + viewModelScope.launch { + _events.send(OnboardingEvent.NavigateToSignIn) + } + } + + OnboardingAction.OnFinishClick -> { + viewModelScope.launch { + tweaksRepository.setOnboardingComplete(true) + _events.send(OnboardingEvent.NavigateToHome) + } + } + } + } + + private fun advance() { + val s = _state.value + if (s.isLast) { + onAction(OnboardingAction.OnFinishClick) + } else { + _state.update { it.copy(currentIndex = it.currentIndex + 1) } + } + } + + private fun retreat() { + _state.update { if (it.isFirst) it else it.copy(currentIndex = it.currentIndex - 1) } + } +} diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewLoaderImpl.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewLoaderImpl.kt index fd6d40a91..bb380c281 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewLoaderImpl.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewLoaderImpl.kt @@ -44,10 +44,6 @@ class WhatsNewLoaderImpl( versionCode: Int, languageTag: String?, ): List { - // Explicit tag passed in wins over global Locale lookup — - // prevents the race with MainActivity's `setActiveLanguageTag` - // when both this VM and MainActivity subscribe to the same - // `getAppLanguage()` flow (#526 follow-up). val (full, primary) = if (!languageTag.isNullOrBlank()) { languageTag to languageTag.substringBefore('-') diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt index 43c8543c8..4129314b4 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/whatsnew/WhatsNewViewModel.kt @@ -35,14 +35,7 @@ class WhatsNewViewModel( private var lastLanguageTag: String? = null init { - // Re-load whenever the user's selected app language changes. - // The tag is threaded explicitly into the loader so we beat - // the race against MainActivity's setActiveLanguageTag — both - // collectors fan out from the same flow with no ordering - // guarantee, so reading Locale.getDefault() inside the loader - // can return the previous language. distinctUntilChanged - // guards against the initial replay-emit firing the load - // twice. + viewModelScope.launch { try { tweaksRepository diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt index bb51f2d73..147b0eaab 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt @@ -5,53 +5,16 @@ import java.awt.EventQueue import java.awt.Toolkit import java.util.concurrent.atomic.AtomicBoolean -/** - * Workaround for a Compose Multiplatform 1.10.x NPE on macOS where the native - * AX bridge (`sun.lwawt.macosx.CAccessible$AXChangeNotifier`) queries a - * Compose semantic node that has already been removed by Compose's own - * accessibility sync loop. The stack trace fingerprint is: - * - * androidx.compose.ui.platform.a11y.SemanticsOwnerAccessibility.accessibleParentOf - * -> sun.lwawt.macosx.CAccessible$AXChangeNotifier.propertyChange - * - * The NPE surfaces via two propagation paths and both are guarded here: - * - * 1. **EDT path** — NPE escapes `DispatchedTask.run` and propagates through - * the AWT event queue dispatch chain. [FilteringEventQueue] swallows it so - * the EDT keeps draining events. - * - * 2. **Coroutine-failure path** — `BaseContinuationImpl.resumeWith` catches the - * NPE and routes the coroutine failure through `handleCoroutineException` to - * the default uncaught-exception handler, bypassing [FilteringEventQueue]. - * The handler wrapper installed in [install] intercepts this path before it - * reaches [CrashReporter], preventing a spurious crash dump. - * - * Trade-off: macOS VoiceOver may miss updates on those removed nodes for the - * remainder of the session. Remove once the upstream fix lands (track against - * Compose MP 1.11+). - * - * See [GitHub-Store#330](https://github.com/OpenHub-Store/GitHub-Store/issues/330) - * and [GitHub-Store#640](https://github.com/OpenHub-Store/GitHub-Store/issues/640). - */ object A11yCrashGuard { - // Separate flags per path so each path logs its first suppression independently. private val warnedEdt = AtomicBoolean(false) private val warnedUncaught = AtomicBoolean(false) - // Must be called after CrashReporter.install() so the uncaught-exception handler - // chain is: A11yCrashGuard (filter) -> CrashReporter (log + dump) -> JVM default. fun install() { val osName = System.getProperty("os.name")?.lowercase().orEmpty() if (!osName.contains("mac")) return - // Path 1: NPE propagates out of the coroutine dispatcher and through the - // AWT EventQueue dispatch chain. Toolkit.getDefaultToolkit().systemEventQueue.push(FilteringEventQueue()) - // Path 2: NPE is intercepted by BaseContinuationImpl.resumeWith and - // forwarded to the default uncaught-exception handler via coroutine - // failure handling. Wrap the handler that CrashReporter already installed - // so all non-a11y exceptions still reach it. val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> if (isComposeA11yNpe(throwable)) { @@ -63,8 +26,7 @@ object A11yCrashGuard { } return@setDefaultUncaughtExceptionHandler } - // Forward to CrashReporter (or JVM default if previous is null, which - // would only happen if install() is called before CrashReporter.install()). + previous?.uncaughtException(thread, throwable) ?: throwable.printStackTrace(System.err) } @@ -76,7 +38,7 @@ object A11yCrashGuard { while (current != null) { if (current.stackTrace.any { frame -> frame.className.startsWith("androidx.compose.ui.platform.a11y") || - // Specific AX bridge inner class present in all known traces for this bug. + frame.className.startsWith("sun.lwawt.macosx.CAccessible\$AXChangeNotifier") } ) { diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index 1b66103a1..94147d19f 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type +import androidx.compose.ui.window.MenuBar import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import kotlinx.coroutines.flow.first @@ -26,46 +27,31 @@ import zed.rainxch.githubstore.app.di.initKoin import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.app_icon import zed.rainxch.githubstore.core.presentation.res.app_name +import zed.rainxch.githubstore.core.presentation.res.menubar_help_about +import zed.rainxch.githubstore.core.presentation.res.menubar_help_feedback +import zed.rainxch.githubstore.core.presentation.res.menubar_help_licenses +import zed.rainxch.githubstore.core.presentation.res.menubar_help_menu +import zed.rainxch.githubstore.core.presentation.res.menubar_help_privacy import java.awt.Desktop +import java.net.URI import kotlin.system.exitProcess +private const val PRIVACY_POLICY_URL = "https://github-store.org/privacy" + private const val LANGUAGE_PREF_READ_TIMEOUT_MS = 2000L fun main(args: Array) { - // Install first so anything that blows up during Koin init or - // resource loading leaves a diagnosable trail on disk (see - // `CrashReporter.resolveLogDir` for the per-OS path). CrashReporter.install() - // Guard the AWT EventDispatchThread against a known Compose MP 1.10.x NPE - // raised by the macOS accessibility bridge (see `A11yCrashGuard`). A11yCrashGuard.install() - // Skiko default backend on Linux is OpenGL, but on hybrid-GPU - // setups (e.g. AMD iGPU + Nvidia dGPU on Bazzite) the proprietary - // Nvidia driver path can SEGV the JVM before any Java exception - // handler runs (see issue #546). Honour an explicit - // `SKIKO_RENDER_API` env var if the user set one (escape hatch - // for reporters who can rescue the install with `SOFTWARE`), and - // otherwise leave Skiko to its default — pinning a specific API - // unconditionally would regress users who DO have a working - // accelerated path. selectLinuxRenderBackendIfRequested() - // Reduce JVM DNS cache TTL so network changes (VPN on/off) are picked up quickly. - // Default JVM caches positive lookups for 30s and negative lookups forever, - // which breaks connectivity when a VPN changes DNS/routing mid-session. java.security.Security.setProperty("networkaddress.cache.ttl", "30") java.security.Security.setProperty("networkaddress.cache.negative.ttl", "5") initKoin() - // Apply persisted UI language before any Compose code runs — same - // reasoning as on Android (see `MainActivity.onCreate`). Desktop - // Compose has no runtime `recreate()` equivalent, so mid-session - // language swaps surface as a "restart required" snackbar from the - // Tweaks screen; this block just covers the cold-start path so - // users see their chosen language immediately on next launch. runBlocking { val koin = GlobalContext.get() val tweaksRepo = koin.get() @@ -105,6 +91,13 @@ fun main(args: Array) { deepLinkUri = event.uri.toString() } } + if (desktop.isSupported(Desktop.Action.APP_ABOUT)) { + runCatching { + desktop.setAboutHandler { + deepLinkUri = "githubstore://tweaks/app-info" + } + } + } } } @@ -125,23 +118,39 @@ fun main(args: Array) { } }, ) { + MenuBar { + Menu(text = stringResource(Res.string.menubar_help_menu)) { + Item( + text = stringResource(Res.string.menubar_help_about), + onClick = { deepLinkUri = "githubstore://tweaks/app-info" }, + ) + Item( + text = stringResource(Res.string.menubar_help_feedback), + onClick = { deepLinkUri = "githubstore://tweaks" }, + ) + Item( + text = stringResource(Res.string.menubar_help_licenses), + onClick = { deepLinkUri = "githubstore://tweaks/licenses" }, + ) + Item( + text = stringResource(Res.string.menubar_help_privacy), + onClick = { + runCatching { + if (Desktop.isDesktopSupported() && + Desktop.getDesktop().isSupported(Desktop.Action.BROWSE) + ) { + Desktop.getDesktop().browse(URI(PRIVACY_POLICY_URL)) + } + } + }, + ) + } + } App(deepLinkUri = deepLinkUri) } } } -/** - * Honour `SKIKO_RENDER_API` (env) or `-Dskiko.renderApi` (system - * property) on Linux so users hitting the Nvidia hybrid-GPU SEGV - * (issue #546) can rescue the install via: - * - * SKIKO_RENDER_API=SOFTWARE ./GitHub-Store-x86_64.AppImage - * - * Skiko reads the system property internally; we copy the env var - * into the property when present so AppImage launchers don't need - * to construct `-D` flags. Other platforms are skipped — Mac/Win - * are unaffected by this class of bug. - */ private fun selectLinuxRenderBackendIfRequested() { val osName = System.getProperty("os.name", "").lowercase() if (!osName.contains("linux")) return diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt index 9599f6868..78aa94575 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt @@ -13,12 +13,6 @@ object DesktopDeepLink { private const val SCHEME = "githubstore" private const val DESKTOP_FILE_NAME = "github-store-deeplink" - /** - * On Windows and Linux, ensure the `githubstore://` protocol is registered. - * - Windows: Writes to HKCU registry. - * - Linux: Creates a `.desktop` file and registers via `xdg-mime`. - * No-op on macOS (handled via Info.plist in the packaged .app). - */ fun registerUriSchemeIfNeeded() { when { isWindows() -> registerWindows() @@ -102,11 +96,6 @@ object DesktopDeepLink { runCommand("xdg-mime", "default", "$DESKTOP_FILE_NAME.desktop", "x-scheme-handler/$SCHEME") } - /** - * Try to forward a deep link URI to an already-running instance. - * @return `true` if the URI was forwarded (this instance should exit), - * `false` if no existing instance is running. - */ fun tryForwardToRunningInstance(uri: String): Boolean = try { Socket("127.0.0.1", SINGLE_INSTANCE_PORT).use { socket -> @@ -117,10 +106,6 @@ object DesktopDeepLink { false } - /** - * Start listening for URIs forwarded from new instances. - * Calls [onUri] on the main thread when a URI is received. - */ fun startInstanceListener(onUri: (String) -> Unit) { val thread = Thread({ diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingPermissions.jvm.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingPermissions.jvm.kt new file mode 100644 index 000000000..41a8256fa --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/app/onboarding/OnboardingPermissions.jvm.kt @@ -0,0 +1,18 @@ +package zed.rainxch.githubstore.app.onboarding + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember + +actual class OnboardingPermissionsController { + actual val notificationsGranted: State = mutableStateOf(true) + actual val installSourcesGranted: State = mutableStateOf(true) + + actual fun requestNotifications() = Unit + + actual fun requestInstallSources() = Unit +} + +@Composable +actual fun rememberOnboardingPermissionsController(): OnboardingPermissionsController = remember { OnboardingPermissionsController() } diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 1b1cc6a0c..7d491d791 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -15,6 +15,7 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.datetime) implementation(projects.core.domain) @@ -23,22 +24,24 @@ kotlin { implementation(libs.touchlab.kermit) - implementation(libs.datastore) - implementation(libs.datastore.preferences) implementation(libs.ksafe) - implementation(libs.kotlinx.datetime) + implementation(libs.datastore) + implementation(libs.datastore.preferences) } } androidMain { dependencies { - implementation(libs.ktor.client.okhttp) - implementation(libs.androidx.work.runtime) implementation(libs.shizuku.api) implementation(libs.shizuku.provider) - implementation(libs.dhizuku.api) compileOnly(libs.hidden.api.stub) + + implementation(libs.dhizuku.api) + + implementation(libs.ktor.client.okhttp) + + implementation(libs.androidx.work.runtime) } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt index ddee6fd77..d8a547376 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt @@ -53,7 +53,6 @@ import zed.rainxch.core.domain.utils.ShareManager actual val corePlatformModule = module { - // Core single { AndroidDownloader( @@ -61,7 +60,6 @@ actual val corePlatformModule = ) } - // AndroidInstaller — registered by class so the wrapper can inject it single { AndroidInstaller( context = get(), @@ -69,30 +67,24 @@ actual val corePlatformModule = ) } - // ShizukuServiceManager — manages Shizuku lifecycle, permissions, service binding single { ShizukuServiceManager( context = androidContext(), ).also { it.initialize() } } - // DhizukuServiceManager — manages Dhizuku lifecycle, permissions, service binding single { DhizukuServiceManager( context = androidContext(), ).also { it.initialize() } } - // RootServiceManager — detects Magisk/KernelSU/APatch su binaries, probes - // for grant status, executes silent installs via `pm install` over `su`. single { RootServiceManager( scope = get(), ).also { it.initialize() } } - // Installer — SilentInstallerDispatcher routes through the user's selected - // silent backend (Shizuku, Dhizuku, Root) or falls back to the standard installer. single { SilentInstallerDispatcher( androidContext = androidContext(), @@ -107,7 +99,6 @@ actual val corePlatformModule = } } - // InstallerStatusProvider — exposes Shizuku, Dhizuku, and Root availability to UI single { AndroidInstallerStatusProvider( shizukuServiceManager = get(), @@ -169,8 +160,6 @@ actual val corePlatformModule = AndroidLocalizationManager() } - // Locals - single { initDatabase(androidContext()) } @@ -204,8 +193,6 @@ actual val corePlatformModule = ) } - // Utils - single { AndroidBrowserHelper(androidContext()) } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_10_11.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_10_11.kt index 17edc353b..593ae06be 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_10_11.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_10_11.kt @@ -3,18 +3,6 @@ package zed.rainxch.core.data.local.db.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -/** - * Adds per-app preferred-variant tracking to the installed_apps table: - * - preferredAssetVariant: stable identifier (e.g. "arm64-v8a") for the - * asset the user wants to install. Survives version bumps because it's - * derived from the part of the filename that doesn't change. - * - preferredVariantStale: flipped to true by checkForUpdates when the - * persisted variant cannot be matched in a fresh release; the UI then - * prompts the user to pick again. - * - * Both columns default to safe "no preference" values so existing rows - * keep their current auto-pick behaviour. - */ val MIGRATION_10_11 = object : Migration(10, 11) { override fun migrate(db: SupportSQLiteDatabase) { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_11_12.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_11_12.kt index cb7d55396..42b3b4894 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_11_12.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_11_12.kt @@ -3,23 +3,6 @@ package zed.rainxch.core.data.local.db.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -/** - * Adds the multi-layer variant fingerprint columns to `installed_apps`: - * - * - `preferredAssetTokens`: serialized token-set fingerprint (closed - * vocabulary of arch / flavor tokens, sorted, joined with `|`) - * - `assetGlobPattern`: glob-pattern fingerprint with version-shaped - * substrings replaced by `*` - * - `pickedAssetIndex`: zero-based index of the picked asset in the - * release's installable-asset list (same-position fallback) - * - `pickedAssetSiblingCount`: total installable assets in the picked - * release, pairs with `pickedAssetIndex` - * - * All four columns are nullable so existing rows keep their current - * single-layer behaviour: the resolver falls back through the layers - * in order, and an old row with only `preferredAssetVariant` set still - * works via the legacy substring-tail match. - */ val MIGRATION_11_12 = object : Migration(11, 12) { override fun migrate(db: SupportSQLiteDatabase) { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_12_13.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_12_13.kt index e3e15a463..fdd004d53 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_12_13.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_12_13.kt @@ -3,18 +3,6 @@ package zed.rainxch.core.data.local.db.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -/** - * Adds `pendingInstallFilePath` to `installed_apps`. - * - * Set by `DefaultDownloadOrchestrator` when an - * `InstallPolicy.InstallWhileForeground` download finishes after the - * foreground screen has been destroyed. The apps list shows a - * "Ready to install" row with a one-tap install action when this is - * non-null. Cleared on successful install. - * - * Nullable, no default — existing rows have `null` and behave the - * same as before. - */ val MIGRATION_12_13 = object : Migration(12, 13) { override fun migrate(db: SupportSQLiteDatabase) { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_13_14.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_13_14.kt index d6e77cde8..a175e94d4 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_13_14.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_13_14.kt @@ -3,23 +3,6 @@ package zed.rainxch.core.data.local.db.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -/** - * Adds version + asset name metadata for parked downloads to - * `installed_apps`: - * - * - `pendingInstallVersion`: release tag of the version represented - * by `pendingInstallFilePath`. Lets the Details screen detect - * "the parked file matches the currently-selected release" and - * skip re-downloading on install. - * - `pendingInstallAssetName`: original (unscoped) asset filename - * of the parked file. Pairs with `pendingInstallVersion` for the - * Details-screen "ready to install" match. - * - * Both nullable, no default — existing rows have `null` for both - * and continue working unchanged (the apps list still shows - * "Ready to install" based on `pendingInstallFilePath` alone; only - * the Details-screen short-circuit needs the version match). - */ val MIGRATION_13_14 = object : Migration(13, 14) { override fun migrate(db: SupportSQLiteDatabase) { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_9_10.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_9_10.kt index 6446d522a..8c87a71c6 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_9_10.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_9_10.kt @@ -3,14 +3,6 @@ package zed.rainxch.core.data.local.db.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -/** - * Adds per-app monorepo tracking fields to the installed_apps table: - * - assetFilterRegex: optional regex applied to asset (file) names - * - fallbackToOlderReleases: when true, the update checker walks backwards - * through past releases until it finds one whose assets match the filter - * - * Both columns default to nullable / false so existing rows are unaffected. - */ val MIGRATION_9_10 = object : Migration(9, 10) { override fun migrate(db: SupportSQLiteDatabase) { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt index 89c9e577a..110342263 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt @@ -20,10 +20,7 @@ actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient { } is ProxyConfig.System -> { - // java.net.ProxySelector.getDefault() does not read Android's - // per-network HTTP proxy. Android publishes the active proxy - // through standard system properties instead, which we resolve - // explicitly here so traffic actually flows through it. + proxy = resolveAndroidSystemProxy() } @@ -81,9 +78,7 @@ actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient { } internal fun resolveAndroidSystemProxy(): Proxy { - // System properties are user/OS-supplied, so guard against malformed - // values: InetSocketAddress(String, Int) throws IllegalArgumentException - // for ports outside 0..65535. + val httpsHost = System.getProperty("https.proxyHost")?.takeIf { it.isNotBlank() } val httpsPort = System.getProperty("https.proxyPort")?.toIntOrNull()?.takeIf { it in 1..65535 } if (httpsHost != null && httpsPort != null) { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidAggressiveOemDetector.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidAggressiveOemDetector.kt index 12abb1359..388c35e4f 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidAggressiveOemDetector.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidAggressiveOemDetector.kt @@ -24,12 +24,7 @@ class AndroidAggressiveOemDetector( } override fun openBatteryOptimizationSettings(): Boolean = - // The targeted intent (`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`) - // bypasses Play Store policy on standalone APKs and lands the user - // directly on the system whitelist toggle — but it's gated by the - // `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission, which Play - // restricts. Fall back to the per-app battery-optimization screen - // if the targeted action throws on a packaged build. + runCatching { val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { @@ -58,9 +53,7 @@ class AndroidAggressiveOemDetector( } private companion object { - // Substring matches against `Build.BRAND` / `Build.MANUFACTURER` — - // covers vendor sub-brands (BBK group: Oppo, OnePlus, Realme, - // vivo, iQOO; Xiaomi: Redmi, Poco; Huawei: Honor). + private val AGGRESSIVE_OEMS = listOf( "oppo", diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidApkInspector.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidApkInspector.kt index b67731cd7..533a3a5df 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidApkInspector.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidApkInspector.kt @@ -31,20 +31,12 @@ class AndroidApkInspector( Logger.w(TAG) { "inspectFile: PackageManager refused $filePath" } return@withContext null } - // PM doesn't auto-populate sourceDir for archive-loaded - // ApplicationInfo, so loadLabel/loadIcon return generics - // unless we patch them here. + info.applicationInfo?.apply { sourceDir = filePath publicSourceDir = filePath } - // Wide catch by design: PackageManager / Resources reads - // can throw a long tail of unchecked exceptions - // (DeadObjectException, SecurityException, NPE on exotic - // signing layouts, etc.) that are all ultimately the same - // outcome from the caller's perspective — "inspection - // failed". Letting them escape leaves the sheet stuck on - // its loading spinner because the VM never updates state. + try { buildInspection( info = info, @@ -67,10 +59,7 @@ class AndroidApkInspector( } catch (_: PackageManager.NameNotFoundException) { null } catch (t: Throwable) { - // Wider catch for SecurityException, DeadObjectException - // and other binder-side surprises so the coroutine - // never propagates a PM hiccup; the sheet renders the - // empty state instead of crashing. + Logger.w(TAG) { "inspectInstalled: PackageManager threw for $packageName: $t" } @@ -155,8 +144,7 @@ class AndroidApkInspector( granted: Boolean?, isInstalledPackage: Boolean, ): ApkPermission { - // PermissionInfo lookup is best-effort — system / OEM - // permissions sometimes vanish between OS versions. + val info = runCatching { pm.getPermissionInfo(name, 0) }.getOrNull() val protection = info?.let { resolveProtectionLevel(it) } ?: ProtectionLevel.UNKNOWN @@ -165,12 +153,7 @@ class AndroidApkInspector( ?: name.substringAfterLast('.').replace('_', ' ').lowercase() .replaceFirstChar { it.titlecase() } val description = info?.loadDescription(pm)?.toString()?.takeIf { it.isNotBlank() } - // Normal-protection permissions are auto-granted at install, - // so on an installed package treat them as granted=true even - // if the requestedPermissionsFlags array didn't surface the - // bit (some OEM ROMs omit it for non-dangerous entries). For - // file-based inspections there's no grant state yet — report - // `null` so the UI can render "to be granted on install". + val resolvedGranted = when { granted != null -> granted @@ -218,9 +201,6 @@ class AndroidApkInspector( private companion object { const val TAG = "AndroidApkInspector" - // Minimum flags to populate everything the inspector reports. - // GET_SIGNING_CERTIFICATES is what SigningFingerprint reads; - // the rest power the counts and labels. @Suppress("DEPRECATION") val FULL_FLAGS: Int = ( diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt index dfa6e6ed2..d8a1186bc 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloadProgressNotifier.kt @@ -13,26 +13,6 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import zed.rainxch.core.domain.system.DownloadProgressNotifier -/** - * Android implementation of [DownloadProgressNotifier]. - * - * # Behaviour - * - * - Ongoing notification on channel `app_downloads` (low-importance — - * no heads-up, no sound; long downloads shouldn't be noisy). - * - `setOnlyAlertOnce(true)` so repeated tick updates don't buzz. - * - `setOngoing(true)` prevents swipe-dismiss while downloading; - * cleared explicitly on completion / cancellation / failure. - * - Indeterminate spinner when the server omitted `Content-Length`. - * - "Cancel" action broadcasts [DownloadCancelReceiver.ACTION_CANCEL] - * with the package name; the receiver resolves the orchestrator - * via Koin and calls `cancel(packageName)`. - * - * # Permission gating - * - * POST_NOTIFICATIONS on Android 13+. If denied, silently skip — the - * orchestrator's in-app UI still reflects progress. - */ class AndroidDownloadProgressNotifier( private val context: Context, ) : DownloadProgressNotifier { @@ -47,12 +27,6 @@ class AndroidDownloadProgressNotifier( ) { if (!hasNotificationPermission()) return - // Encode the package in the Intent's data URI so PendingIntent - // identity (driven by Intent.filterEquals, which considers `data` - // but not extras) is uniquely per-package. Relying on - // packageName.hashCode() as requestCode alone risks a collision - // that would have FLAG_UPDATE_CURRENT overwrite another - // download's cancel extras. val cancelIntent = Intent(context, DownloadCancelReceiver::class.java).apply { action = DownloadCancelReceiver.ACTION_CANCEL @@ -108,11 +82,6 @@ class AndroidDownloadProgressNotifier( ) == PackageManager.PERMISSION_GRANTED } - /** - * Stable id in a range disjoint from the pending-install notifier - * (2000..2FFFFFF) and worker ids (1001..1005). Hash collisions are - * acceptable — worst case, two downloads share a single row. - */ private fun notificationIdFor(packageName: String): Int = NOTIFICATION_ID_BASE + (packageName.hashCode() and 0x00FFFFFF) @@ -144,7 +113,6 @@ class AndroidDownloadProgressNotifier( const val DOWNLOADS_CHANNEL_ID = "app_downloads" const val CANCEL_LABEL = "Cancel" - // Disjoint from PendingInstall (2000..) and workers (1001..). const val NOTIFICATION_ID_BASE = 3000 } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt index 314dc83f4..912bee680 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt @@ -49,9 +49,7 @@ class AndroidDownloader( } is ProxyConfig.System -> { - // ProxySelector.getDefault() does not honor Android's - // per-network HTTP proxy; resolve it explicitly so - // downloads also flow through the device proxy. + proxy(resolveAndroidSystemProxy()) } @@ -92,9 +90,7 @@ class AndroidDownloader( suggestedFileName: String?, bypassMirror: Boolean, ): Flow = - // bypassMirror is a no-op here: this downloader uses OkHttp directly, - // not Ktor, so it never traverses MirrorRewriteInterceptor. The caller - // (MultiSourceDownloader) already passes the resolved direct/mirror URL. + flow { val client = buildClient() @@ -117,11 +113,7 @@ class AndroidDownloader( val downloadId = UUID.randomUUID().toString() val destination = File(dir, safeName) - // Each attempt writes to its own temp file so MultiSourceDownloader's - // direct/mirror race cannot have two jobs trampling the same path - // (see issue: "File not ready after download" with custom mirror). - // Temp lives in the same dir so the final rename stays on one FS - // and ATOMIC_MOVE works. + val tempFile = File(dir, "$safeName.part-$downloadId") if (tempFile.exists()) tempFile.delete() @@ -173,12 +165,7 @@ class AndroidDownloader( emit(DownloadProgress(finalDownloaded, total, finalPercent)) } } catch (e: kotlin.coroutines.cancellation.CancellationException) { - // Cancellation is the normal MultiSourceDownloader race - // outcome (loser racer cancelled when winner emits first - // progress). Don't log it as an error — that floods - // Logcat with confusing red lines that look like real - // download failures. Clean up the temp file and rethrow - // so structured concurrency stays correct. + tempFile.delete() throw e } catch (e: Exception) { @@ -203,9 +190,7 @@ class AndroidDownloader( StandardCopyOption.ATOMIC_MOVE, ) } catch (_: AtomicMoveNotSupportedException) { - // Fallback for filesystems without atomic-move support — still - // safer than writing directly to target because the partial bytes - // were never visible at `target` until this step. + Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) } } @@ -253,9 +238,7 @@ class AndroidDownloader( override suspend fun cancelDownload(fileName: String): Boolean = withContext(Dispatchers.IO) { - // Cancel every in-flight download for this fileName — MultiSource - // races run direct + mirror in parallel under the same logical - // name, so a single-id lookup would leave one of them running. + val ids = idsByName.remove(fileName)?.toList().orEmpty() if (ids.isEmpty()) return@withContext false @@ -267,10 +250,7 @@ class AndroidDownloader( cancelled = true } } - // No destination delete: the flow's catch handles its own temp - // file. The final destination is only written via atomic-rename - // on success, so it's either a prior valid download (keep) or - // doesn't exist yet. + cancelled } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidFileLocationsProvider.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidFileLocationsProvider.kt index 0ba20306b..eb415f2cf 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidFileLocationsProvider.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidFileLocationsProvider.kt @@ -39,9 +39,7 @@ class AndroidFileLocationsProvider( } Log.w(TAG, "Downloads dir candidate unusable: ${dir.absolutePath}") } - // Last-resort fallback. context.filesDir always exists for an - // installed app; if even this fails the device is in an - // unrecoverable state and a thrown exception wouldn't help. + val fallback = File(context.filesDir, "downloads") fallback.mkdirs() return fallback.absolutePath @@ -65,7 +63,7 @@ class AndroidFileLocationsProvider( } override fun setExecutableIfNeeded(path: String) { - // No-op on Android + } override fun getCacheSizeBytes(): Long { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstallerInfoExtractor.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstallerInfoExtractor.kt index c7fac9f36..319cabfb4 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstallerInfoExtractor.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstallerInfoExtractor.kt @@ -59,11 +59,6 @@ class AndroidInstallerInfoExtractor( } } - /** - * Tries to parse the APK with full flags first (metadata + signing). - * If that fails, retries with minimal flags since some APKs / Android - * versions choke on GET_SIGNING_CERTIFICATES combined with other flags. - */ private fun parseApk( packageManager: PackageManager, filePath: String, @@ -80,8 +75,6 @@ class AndroidInstallerInfoExtractor( "Full-flag parse failed for $filePath, retrying with minimal flags" } - // Retry without signing — the fingerprint will be extracted - // separately in extractSigningFingerprint. val minimalFlags = PackageManager.GET_META_DATA val minimal = getPackageArchiveInfoCompat(packageManager, filePath, minimalFlags) if (minimal != null) return minimal @@ -108,27 +101,20 @@ class AndroidInstallerInfoExtractor( packageManager.getPackageArchiveInfo(filePath, flags) } - /** - * Extracts the signing fingerprint from an already-parsed PackageInfo - * if available, otherwise does a separate lightweight parse with only - * the signing flag. - */ private fun extractSigningFingerprint( packageManager: PackageManager, packageInfo: android.content.pm.PackageInfo, filePath: String, ): String? { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - // Try from the already-parsed info first (works when - // the full-flag parse succeeded). + val sigInfo = packageInfo.signingInfo if (sigInfo != null) { val cert = if (sigInfo.hasMultipleSigners()) { sigInfo.apkContentsSigners?.firstOrNull() } else { - // History is oldest→newest; last entry is the - // current signer after key rotation. + sigInfo.signingCertificateHistory?.lastOrNull() } cert?.toByteArray()?.let { certBytes -> @@ -136,8 +122,6 @@ class AndroidInstallerInfoExtractor( } } - // Signing info missing (minimal-flag fallback path) — - // do a separate parse with only the signing flag. val sigOnly = getPackageArchiveInfoCompat( packageManager, filePath, diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt index faf082422..5665088d8 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidLocalizationManager.kt @@ -3,11 +3,7 @@ package zed.rainxch.core.data.services import java.util.Locale class AndroidLocalizationManager : zed.rainxch.core.data.services.LocalizationManager { - /** - * Snapshot of the original JVM locale at construction time, so - * [setActiveLanguageTag] with a null argument can restore it even - * after prior overrides have modified `Locale.getDefault()`. - */ + private val systemDefault: Locale = Locale.getDefault() override fun getCurrentLanguageCode(): String { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPendingInstallNotifier.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPendingInstallNotifier.kt index 91559b5d2..1dcd6ec45 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPendingInstallNotifier.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPendingInstallNotifier.kt @@ -13,28 +13,6 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import zed.rainxch.core.domain.system.PendingInstallNotifier -/** - * Android implementation of [PendingInstallNotifier]. - * - * # Behaviour - * - * - Each pending install gets its own notification, keyed by the - * package name's hash code (so two pending installs of different - * apps don't replace each other in the shade). - * - Tapping the notification (or the explicit "Open" action) launches - * `MainActivity` via the `githubstore://apps` deep link, which the - * parser routes to the apps tab where the user finishes the install. - * - Channel `app_updates` (already declared in `GithubStoreApp`) is - * reused — pending installs are conceptually a flavour of update - * notification and don't deserve a separate channel. - * - * # Permission gating - * - * On Android 13+, posting requires `POST_NOTIFICATIONS`. We check it - * silently rather than throwing — if the user denied the permission, - * the install still completes, they just don't get notified. The apps - * row still shows the pending state, so this is a graceful degrade. - */ class AndroidPendingInstallNotifier( private val context: Context, ) : PendingInstallNotifier { @@ -48,21 +26,13 @@ class AndroidPendingInstallNotifier( ) { if (!hasNotificationPermission()) return - // Deep link straight to the *Details* page for this specific - // app. The existing `githubstore://repo/owner/name` route is - // already wired in `DeepLinkParser` and lands on Details with - // the right repository pre-loaded — the user can tap install - // immediately. The Details page also detects the parked file - // via `pendingInstallFilePath` and skips re-downloading. val safeOwner = sanitizeForUri(repoOwner) val safeRepo = sanitizeForUri(repoName) val uri = if (safeOwner.isNotEmpty() && safeRepo.isNotEmpty()) { "githubstore://repo/$safeOwner/$safeRepo" } else { - // Synthetic-key fallback (e.g. for fresh installs - // where we don't know the package's owner/repo - // yet) — open the apps tab as before. + FALLBACK_URI } val deepLinkIntent = @@ -109,24 +79,9 @@ class AndroidPendingInstallNotifier( ) == PackageManager.PERMISSION_GRANTED } - /** - * Stable per-package notification id. We hash the package name and - * mask off the high bits so it lands in a positive int range that - * doesn't collide with the existing notification ids used by the - * update workers (1004, 1005). Hash collisions are vanishingly - * rare in practice for package names; if they happen the worst - * case is that two pending installs share a notification, which - * is the same outcome as having only one notifier. - */ private fun notificationIdFor(packageName: String): Int = NOTIFICATION_ID_BASE + (packageName.hashCode() and 0x00FFFFFF) - /** - * Defensive sanitisation for the deep-link path components. - * `DeepLinkParser.isStrictlyValidOwnerRepo` already rejects - * weird input, so this just strips characters that would break - * URI parsing before they reach the parser. - */ private fun sanitizeForUri(input: String): String { if (input.isBlank()) return "" return input.filter { ch -> @@ -139,8 +94,6 @@ class AndroidPendingInstallNotifier( const val FALLBACK_URI = "githubstore://apps" const val SUBTEXT = "Ready to install" - // Existing worker notifications use 1001-1005; start the - // pending-install id space comfortably above that. const val NOTIFICATION_ID_BASE = 2000 } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt index 0c105b521..7be6bbc21 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt @@ -32,14 +32,6 @@ import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.SystemInstallSerializer -/** - * Background worker that automatically downloads and silently installs - * available updates via Shizuku. - * - * Only runs when auto-update is enabled AND Shizuku installer is selected and READY. - * Falls back gracefully: if Shizuku becomes unavailable mid-update, remaining apps - * are skipped and a notification is shown for manual update. - */ class AutoUpdateWorker( context: Context, params: WorkerParameters, @@ -165,7 +157,7 @@ class AutoUpdateWorker( } Logger.d { "AutoUpdateWorker: Downloading $assetName for ${app.appName}" } - downloader.download(assetUrl, assetName).collect { /* consume flow to completion */ } + downloader.download(assetUrl, assetName).collect { } val filePath = downloader.getDownloadedFilePath(assetName) @@ -174,7 +166,6 @@ class AutoUpdateWorker( val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) - // Validate package name matches (only when extraction succeeded) if (apkInfo != null && apkInfo.packageName != app.packageName) { Logger.e { "AutoUpdateWorker: Package name mismatch for ${app.appName}! " + diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/BootReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/BootReceiver.kt index e5e36537b..4d7572d71 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/BootReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/BootReceiver.kt @@ -9,10 +9,6 @@ import kotlinx.coroutines.runBlocking import org.koin.core.context.GlobalContext import zed.rainxch.core.domain.repository.TweaksRepository -/** - * Reschedules periodic update checks after device reboot. - * Registered statically in AndroidManifest.xml. - */ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return @@ -35,11 +31,7 @@ class BootReceiver : BroadcastReceiver() { UpdateScheduler.cancel(context) } } catch (t: Throwable) { - // Don't let scheduling failures crash the framework's - // broadcast pipeline — propagation triggers a second - // `sendFinished` from the platform after our own - // `pendingResult.finish()` and surfaces as - // `IllegalStateException: Broadcast already finished`. + Logger.e(t) { "BootReceiver: scheduling failed; dropped" } } finally { pendingResult.finish() diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadCancelReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadCancelReceiver.kt index d3f876cc7..ec71cdb4f 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadCancelReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadCancelReceiver.kt @@ -9,21 +9,6 @@ import kotlinx.coroutines.launch import org.koin.core.context.GlobalContext import zed.rainxch.core.domain.system.DownloadOrchestrator -/** - * Receives the "Cancel" action fired by the download progress - * notification (see [AndroidDownloadProgressNotifier]) and routes it to - * [DownloadOrchestrator.cancel]. - * - * Declared in the manifest so it fires even when the app is in the - * background and the process has been trimmed — static registration is - * what Android guarantees survival for post-notification callbacks. - * - * Koin's [GlobalContext] is used to resolve the orchestrator and the - * application-scoped [CoroutineScope] because a manifest-registered - * receiver has no injection point; the orchestrator is already a - * singleton so `GlobalContext.get()` returns the same instance the rest - * of the app uses. - */ class DownloadCancelReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action != ACTION_CANCEL) return @@ -33,9 +18,6 @@ class DownloadCancelReceiver : BroadcastReceiver() { return } - // goAsync keeps the BroadcastReceiver alive long enough for the - // suspend call to complete. Without it, the receiver returns as - // soon as onReceive exits and the coroutine may be killed. val pending = goAsync() val koin = GlobalContext.getOrNull() if (koin == null) { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadNotificationObserver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadNotificationObserver.kt index 1c2d41017..16b213e94 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadNotificationObserver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/DownloadNotificationObserver.kt @@ -10,48 +10,6 @@ import zed.rainxch.core.domain.system.DownloadProgressNotifier import zed.rainxch.core.domain.system.DownloadStage import zed.rainxch.core.domain.system.OrchestratedDownload -/** - * Single long-lived subscriber that translates - * [DownloadOrchestrator.downloads] state transitions into calls on - * [DownloadProgressNotifier]. - * - * # Why it's its own class - * - * The orchestrator stays platform-agnostic (common code, no Android - * imports) and doesn't know about notifications. The observer lives on - * `androidMain` alongside the Android notifier and is only started from - * [zed.rainxch.githubstore.app.GithubStoreApp], which means the whole - * feature is Android-only without any platform branches in shared code. - * - * # Transition rules - * - * - `Queued`, `Downloading` → post / update progress notification. - * - Anything else (`Installing`, `AwaitingInstall`, `Completed`, - * `Cancelled`, `Failed`) or entry removal → clear. `AwaitingInstall` - * is owned by [zed.rainxch.core.domain.system.PendingInstallNotifier] - * which posts its own "ready to install" row. - * - * # Throttling - * - * The orchestrator emits on every ~8KB chunk (hundreds of emissions - * per second on a fast link). Android silently drops notification - * updates posted faster than ~200ms for the same id, and every - * `NotificationManagerCompat.notify` is a Binder round-trip, so - * letting every emission through both wastes CPU and produces a stuck - * progress bar that jumps at the end. - * - * We coalesce in-stage ticks to at most one post per - * [PROGRESS_UPDATE_INTERVAL_MS] per package, but always flush - * immediately on stage transitions (`Queued → Downloading`, - * `Downloading → Completed`, etc.) and on 100%-percent emissions so - * the final frame is never skipped. - * - * # Lifecycle - * - * Started once from the Application's `onCreate` via [start], collected - * on the app-scoped coroutine scope (same one Koin provides). No - * explicit stop — the process going away ends the flow. - */ class DownloadNotificationObserver( private val orchestrator: DownloadOrchestrator, private val notifier: DownloadProgressNotifier, @@ -71,25 +29,19 @@ class DownloadNotificationObserver( try { reconcile(snapshot) } catch (t: Throwable) { - // Never let a NotificationManager hiccup - // collapse the whole flow — progress - // notifications are best-effort. + Logger.w(t) { "DownloadNotificationObserver: reconcile failed, continuing" } } } } finally { - // Reset so a subsequent start() on this process - // (e.g. after the caller's scope restarts) can - // resubscribe instead of silently no-op'ing on the - // `job?.isActive == true` guard. + job = null } } } private fun reconcile(snapshot: Map) { - // Clear notifications for entries that vanished from the map - // (e.g. dismissed after Completed, or cleared on Cancelled). + val removed = lastStages.keys - snapshot.keys for (pkg in removed) { clearProgressSafely(pkg) @@ -150,8 +102,7 @@ class DownloadNotificationObserver( } private companion object { - // Comfortably above Android's ~200ms internal drop threshold - // while still feeling live to the eye. + const val PROGRESS_UPDATE_INTERVAL_MS = 400L } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index 14faa9a22..54dee2742 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -20,15 +20,6 @@ import zed.rainxch.core.domain.system.SystemInstallSerializer import zed.rainxch.core.domain.util.VersionVerdict import zed.rainxch.core.domain.util.resolveExternalInstallVerdict -/** - * Listens for package install/replace/remove broadcasts to update tracked app state. - * - * Registered both statically (manifest — works when process is dead, e.g. after - * Shizuku silent install) and dynamically (GithubStoreApp — immediate in-process delivery). - * - * Uses [KoinComponent] for the no-arg constructor path (manifest-registered). - * The constructor with explicit dependencies is used for dynamic registration. - */ class PackageEventReceiver() : BroadcastReceiver(), KoinComponent { @@ -39,7 +30,6 @@ class PackageEventReceiver() : private val externalLinkDaoKoin: ExternalLinkDao by inject() private val systemInstallSerializerKoin: SystemInstallSerializer by inject() - // Explicitly provided dependencies (dynamic registration path) private var explicitRepository: InstalledAppsRepository? = null private var explicitMonitor: PackageMonitor? = null private var explicitExternalImport: ExternalImportRepository? = null @@ -47,11 +37,6 @@ class PackageEventReceiver() : private var explicitAppScope: CoroutineScope? = null private var explicitSystemInstallSerializer: SystemInstallSerializer? = null - // Local fallback scope for the manifest-registered path when - // `onReceive` fires but Koin somehow couldn't resolve the shared - // app scope (extremely unlikely — the Application installs Koin - // synchronously in onCreate). The async backstop below prefers - // the Koin scope via `getBackstopScope`. private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) constructor( @@ -84,18 +69,14 @@ class PackageEventReceiver() : explicitSystemInstallSerializer ?: systemInstallSerializerKoin private fun getBackstopScope(): CoroutineScope = - // Koin's app-scoped CoroutineScope outlives a manifest-registered - // receiver whose local `scope` would die with the instance. Fall - // back to the local scope only if Koin isn't initialized yet - // (shouldn't happen post-Application.onCreate, but defensive). + explicitAppScope ?: runCatching { appScopeKoin }.getOrElse { scope } override fun onReceive( context: Context?, intent: Intent?, ) { - // MY_PACKAGE_REPLACED has no data URI — the target is the - // receiving app itself. Fall back to the context's package name. + val packageName = intent?.data?.schemeSpecificPart ?: if (intent?.action == Intent.ACTION_MY_PACKAGE_REPLACED) { context?.packageName @@ -124,13 +105,6 @@ class PackageEventReceiver() : } } - /** - * Wipes the parked-install metadata on the row and deletes the - * APK file from disk now that the system holds the real package. - * Best-effort throughout — DB write failures are logged but never - * thrown so the broadcast handler can continue to do its other work - * (delta-scan, etc.). Safe to call when no parked file exists. - */ private suspend fun clearParkedInstall( repo: InstalledAppsRepository, packageName: String, @@ -150,9 +124,7 @@ class PackageEventReceiver() : } private suspend fun onPackageInstalled(packageName: String) { - // Release the system-install serializer gate so the next queued - // install (if any) can fire its ACTION_VIEW intent. Done at the - // very top so the gate clears even if downstream DB writes fail. + getSystemInstallSerializer().markCompleted(packageName) try { @@ -160,10 +132,6 @@ class PackageEventReceiver() : val monitor = getMonitor() val app = repo.getAppByPackage(packageName) - // First-time installs (app == null) skip the tracked-app branches - // but MUST still hit the backstop delta-scan launch below — that's - // how a freshly-installed GitHub app surfaces as a wizard candidate - // when the user installs it after the initial scan. if (app != null) { if (app.isPendingInstall) { val systemInfo = monitor.getInstalledPackageInfo(packageName) @@ -172,11 +140,7 @@ class PackageEventReceiver() : val versionCodeMatchesTarget = expectedVersionCode > 0L && systemInfo.versionCode >= expectedVersionCode - // When latestVersionCode is 0 (apkInfo extraction - // failed pre-download) the versionCode comparison - // can't decide. Fall back to versionName so the - // tag still gets written and the apps row stops - // rendering the stale "installed: vOld" subtext. + val versionNameChanged = !systemInfo.versionName.isNullOrBlank() && systemInfo.versionName != app.installedVersionName @@ -184,15 +148,6 @@ class PackageEventReceiver() : versionCodeMatchesTarget || (expectedVersionCode <= 0L && versionNameChanged) - // Source-of-truth for the tag the user just - // installed: `pendingInstallVersion` carries the - // exact release tag the orchestrator parked the - // file under (set during download flow). It's - // honest about explicit older-version installs; - // `app.latestVersion` is GHS's cached idea of - // "what GitHub says is latest" and would show - // the wrong tag if the user picked an older - // release from the version picker. val installedTag = app.pendingInstallVersion ?: app.latestVersion @@ -210,14 +165,7 @@ class PackageEventReceiver() : repo.updatePendingStatus(packageName, false) Logger.i { "Update confirmed via broadcast: $packageName (v${systemInfo.versionName}, tag=$installedTag)" } } else { - // Even on the "didn't reach target" branch the - // installedVersion tag must move forward when the - // user accepted some install — leaving it pinned - // to the old tag makes the apps row report the - // pre-install version forever. Use the parked - // tag so an explicit older-version install is - // honoured (issue: details + apps screens were - // showing latest when user picked older). + repo.updateApp( app.copy( isPendingInstall = false, @@ -241,10 +189,7 @@ class PackageEventReceiver() : repo.updatePendingStatus(packageName, false) Logger.i { "Resolved pending install via broadcast (no system info): $packageName" } } - // System has the package now — the parked APK on disk is - // dead weight. Clear the path on the row (otherwise the - // apps screen keeps rendering the "Install" CTA after a - // successful install) and delete the file to free space. + clearParkedInstall(repo, packageName, app.pendingInstallFilePath) } else { handleExternalInstall(packageName, app, repo, monitor) @@ -254,11 +199,6 @@ class PackageEventReceiver() : Logger.e { "PackageEventReceiver error for $packageName: ${e.message}" } } - // Fire a delta scan for previously-untracked installs so the - // import banner can pick up the new candidate. Guarded so we - // don't churn on apps the user already linked or asked us to - // ignore. Runs on the app scope — independent of the install - // path above. Always fires regardless of whether `app` was found. getBackstopScope().launch { runCatching { val rescan = shouldRescan(packageName) @@ -271,13 +211,6 @@ class PackageEventReceiver() : } } - // Skip re-scanning when (a) we already track the app in - // `installed_apps` (the user installed it through the store, or - // we already auto-linked it and materialized the row), or (b) the - // package is already MATCHED / NEVER_ASK in `external_links`. - // PENDING_REVIEW and SKIPPED are intentionally rescanned — - // metadata may have changed (label, fingerprint, installer) and - // the user hasn't given a permanent answer yet. private suspend fun shouldRescan(packageName: String): Boolean { val tracked = runCatching { getRepository().getAppByPackage(packageName) } .getOrNull() @@ -288,37 +221,6 @@ class PackageEventReceiver() : state != ExternalLinkState.NEVER_ASK.name } - /** - * Path taken when the broadcast fires for a tracked app that the - * user did NOT install from inside the store (sideload, browser - * download, Play Store update, F-Droid update of a shared - * package, etc.). The pending-install branch above handles the - * in-app install case. - * - * Strategy (GitHub-Store#378): - * - * 1. Refresh every version field from PackageManager — this is - * the strictest source of truth for what is actually on - * device right now. - * 2. Apply [resolveExternalInstallVerdict] for an immediate - * decision about `isUpdateAvailable`. The resolver uses a - * priority ladder (versionCode → versionName vs - * latestVersionName → versionName vs release tag) and only - * returns [VersionVerdict.UNKNOWN] when none of those - * produce a reliable answer. - * 3. Dispatch an async `checkForUpdates(packageName)` on the - * app-scoped coroutine scope. That call re-fetches the - * latest release list from GitHub and applies - * [zed.rainxch.core.domain.util.VersionMath] with the freshly - * updated `installedVersion`, so even an incorrect optimistic - * verdict is corrected within the RTT of a single GitHub - * API hit. - * - * The async backstop runs on the Koin-provided app scope so it - * survives the receiver instance being torn down after - * `onReceive` returns — critical for the manifest-registered - * path. - */ private suspend fun handleExternalInstall( packageName: String, app: zed.rainxch.core.domain.model.InstalledApp, @@ -347,17 +249,10 @@ class PackageEventReceiver() : when (verdict) { VersionVerdict.UP_TO_DATE -> false VersionVerdict.UPDATE_AVAILABLE -> true - // Preserve the current flag for UNKNOWN — the async - // checkForUpdates below is about to overwrite it with - // an authoritative answer anyway. + VersionVerdict.UNKNOWN -> app.isUpdateAvailable } - // Targeted column-only write: avoids clobbering sibling fields - // (download orchestrator metadata, variant pin, favourite - // toggle, checkForUpdates results…) that may have landed - // between `onPackageInstalled`'s initial `getAppByPackage` and - // this write. See `InstalledAppsRepository.updateInstalledVersion`. repo.updateInstalledVersion( packageName = packageName, installedVersion = systemInfo.versionName, @@ -373,8 +268,6 @@ class PackageEventReceiver() : "verdict=$verdict, updateAvailable=$newIsUpdateAvailable" } - // Authoritative re-validation against fresh GitHub release data. - // Runs on the app scope so it outlives this broadcast. getBackstopScope().launch { try { repo.checkForUpdates(packageName) @@ -390,8 +283,7 @@ class PackageEventReceiver() : } private suspend fun onPackageRemoved(packageName: String) { - // Mirror onPackageInstalled — release the install gate so a queued - // re-install for the same package isn't held up. + getSystemInstallSerializer().markCompleted(packageName) try { @@ -399,23 +291,13 @@ class PackageEventReceiver() : runCatching { getExternalImport().unlink(packageName) } .onFailure { initialError -> Logger.w(initialError) { "External link cleanup failed for $packageName; scheduling retry" } - // A failed unlink leaves a stale MATCHED/NEVER_ASK row that - // makes `shouldRescan` return false on a future reinstall — - // i.e., the user reinstalls a previously-tracked app and we - // silently fail to re-link it. Retry once on the app scope - // after a short backoff. If the retry also fails, the next - // periodic worker sweep gets a chance via `runPeriodicExternalDeltaScan`. + getBackstopScope().launch { kotlinx.coroutines.delay(UNLINK_RETRY_DELAY_MS) runCatching { getExternalImport().unlink(packageName) } .onSuccess { Logger.i { "External link cleanup retry succeeded for $packageName" } - // Recovery delta scan: a fast reinstall during the - // retry backoff window would have hit shouldRescan - // while the row was still MATCHED → no rescan - // queued. Now that the row is gone, evaluate - // shouldRescan again and fire if the package is - // currently installed. + runCatching { if (shouldRescan(packageName)) { getExternalImport().runDeltaScan(setOf(packageName)) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/SigningFingerprint.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/SigningFingerprint.kt index 98115b166..b85b69e1e 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/SigningFingerprint.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/SigningFingerprint.kt @@ -4,15 +4,6 @@ import android.content.pm.PackageInfo import android.os.Build import java.security.MessageDigest -/** - * Pulls the SHA-256 signing fingerprint out of a [PackageInfo], - * regardless of whether it was parsed with `GET_SIGNING_CERTIFICATES` - * (Android P+) or the legacy `GET_SIGNATURES` flag. Returns `null` - * when no signature data is reachable. - * - * Format: hex bytes joined with `:` separators, uppercase — same shape - * as `keytool -printcert` so users can paste-compare. - */ internal object SigningFingerprint { fun fromPackageInfo(info: PackageInfo): String? { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -21,8 +12,7 @@ internal object SigningFingerprint { if (sigInfo.hasMultipleSigners()) { sigInfo.apkContentsSigners?.firstOrNull() } else { - // signingCertificateHistory is oldest → newest; the - // last entry is the active signer after rotation. + sigInfo.signingCertificateHistory?.lastOrNull() } return cert?.toByteArray()?.let(::sha256) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt index c687a2569..32283c303 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt @@ -26,15 +26,6 @@ import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase -/** - * Periodic background worker that checks all tracked installed apps for available updates. - * - * Runs via WorkManager on a configurable schedule (default: every 6 hours). - * First syncs app state with the system package manager, then checks each - * tracked app's GitHub repository for new releases. - * Shows a notification when updates are found, or triggers auto-update - * if Shizuku silent install is enabled and auto-update preference is on. - */ class UpdateCheckWorker( context: Context, params: WorkerParameters, @@ -51,22 +42,19 @@ class UpdateCheckWorker( try { Logger.i { "UpdateCheckWorker: Starting periodic update check" } - // Run as foreground service to prevent OS from killing the worker setForeground(createForegroundInfo("Checking for updates...")) - // First sync installed apps state with system val syncResult = syncInstalledAppsUseCase() if (syncResult.isFailure) { Logger.w { "UpdateCheckWorker: Sync had issues: ${syncResult.exceptionOrNull()?.message}" } } - // Check all tracked apps for updates installedAppsRepository.checkAllForUpdates() val appsWithUpdates = installedAppsRepository.getAppsWithUpdates().first() if (appsWithUpdates.isNotEmpty()) { - // Check if auto-update via Shizuku is enabled + val autoUpdateEnabled = tweaksRepository.getAutoUpdateEnabled().first() val installerType = tweaksRepository.getInstallerType().first() @@ -77,7 +65,7 @@ class UpdateCheckWorker( } UpdateScheduler.scheduleAutoUpdate(applicationContext) } else { - // Show notification for manual update + showUpdateNotification(appsWithUpdates) } } else { @@ -97,9 +85,6 @@ class UpdateCheckWorker( } } - // Periodic best-effort: catch packages whose ACTION_PACKAGE_ADDED - // broadcast we missed (process killed, OEM app-standby, etc.). - // Cap at 50 so a 200-package device doesn't drag the worker. private suspend fun runPeriodicExternalDeltaScan() { try { val installed = packageMonitor.getAllInstalledPackageNames() @@ -151,9 +136,9 @@ class UpdateCheckWorker( } } - @SuppressLint("MissingPermission") // Permission checked at runtime before notify() + @SuppressLint("MissingPermission") private suspend fun showUpdateNotification(appsWithUpdates: List) { - // Check notification permission for API 33+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val granted = ContextCompat.checkSelfPermission( diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt index a80f4c8fc..b23f8c8ed 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt @@ -46,12 +46,6 @@ object UpdateScheduler { request = request, ) - // Note: an expedited request CANNOT carry an initial delay — - // WorkManager throws `IllegalArgumentException: Expedited jobs - // cannot be delayed` at build time. Drop the cold-start backoff - // and let the OS scheduler dispatch as soon as the network - // constraint is satisfied; on aggressive-OEM ROMs the expedited - // tier is what actually gets the work to run. val immediateRequest = OneTimeWorkRequestBuilder() .setConstraints(constraints) @@ -116,13 +110,7 @@ object UpdateScheduler { 15, TimeUnit.MINUTES, ) - // Intentionally NOT expedited: AutoUpdateWorker downloads - // multiple APKs and installs them sequentially, which can - // easily exceed the 10-minute expedited time limit and - // get the worker terminated mid-install. The worker - // already promotes itself to a foreground service via - // setForeground, which gives it the headroom it needs - // without the expedited time cap. + .build() WorkManager diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuInstallerServiceImpl.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuInstallerServiceImpl.kt index 934786060..eb58cac62 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuInstallerServiceImpl.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuInstallerServiceImpl.kt @@ -20,11 +20,7 @@ class DhizukuInstallerServiceImpl() : IDhizukuInstallerService.Stub() { private const val STATUS_SUCCESS = 0 private const val STATUS_FAILURE = -1 - // Surfaced to the AIDL caller (SilentInstallerDispatcher) so it can - // distinguish "Android 14+ update-ownership demanded user confirm" - // from a generic failure — the dispatcher uses this to retry the - // install without installerAttribution before falling back to the - // system installer dialog. + const val STATUS_PENDING_USER_ACTION_REQUIRED = -2 private const val INSTALL_TIMEOUT_SECONDS = 120L private const val UNINSTALL_TIMEOUT_SECONDS = 30L @@ -100,13 +96,7 @@ class DhizukuInstallerServiceImpl() : IDhizukuInstallerService.Stub() { when (status) { PackageInstaller.STATUS_SUCCESS -> resultRef.set(STATUS_SUCCESS) PackageInstaller.STATUS_PENDING_USER_ACTION -> { - // Android 14+ update-ownership enforcement: even - // with USER_ACTION_NOT_REQUIRED + DPC privilege, - // setting `installerPackageName` to a value that - // doesn't match the existing installer of record - // makes the platform demand user confirmation. - // Surface as a distinct error so the dispatcher - // can retry without attribution or fall back. + logW("install requires user action (Android 14+ update-ownership gate) — installerAttribution=$installerPackageName") resultRef.set(STATUS_PENDING_USER_ACTION_REQUIRED) } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuServiceManager.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuServiceManager.kt index deda4ee50..72e1c9606 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuServiceManager.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuServiceManager.kt @@ -34,12 +34,7 @@ class DhizukuServiceManager( private set fun initialize() { - // Eagerly call `Dhizuku.init(context)` here so the underlying binder - // is warmed up before the first install attempt. Without this, - // `refreshStatus()` racing the install dispatch can observe a - // not-yet-connected binder and report NOT_RUNNING, making the - // dispatcher fall back to the system installer dialog even though - // Dhizuku is set up and ready. + runCatching { Dhizuku.init(context) } .onFailure { Logger.w(TAG) { "warm-up Dhizuku.init() threw: ${it.message}" } } refreshStatus() diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/AndroidExternalAppScanner.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/AndroidExternalAppScanner.kt index eb55cee2e..b8001c43a 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/AndroidExternalAppScanner.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/AndroidExternalAppScanner.kt @@ -22,14 +22,10 @@ class AndroidExternalAppScanner( override suspend fun isPermissionGranted(): Boolean = withContext(Dispatchers.IO) { - // Android 11+: getInstalledPackages without QUERY_ALL_PACKAGES returns - // only the self-visible subset. We treat "saw something other than self - // and the declared packages" as a proxy for grant. - // On API < 30 the permission is not enforced — always granted. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return@withContext true val pkgs = listInstalledPackages(includeMeta = false) - // Heuristic: a typical device has 50+ visible packages. With QUERY_ALL_PACKAGES - // denied + our block, we usually see ~5-10. Treat >= 30 visible as granted. + pkgs.size >= GRANT_THRESHOLD } @@ -41,8 +37,7 @@ class AndroidExternalAppScanner( if (granted) { 0 } else { - // soft "we probably can't see ~150 more" estimate; the scanner UI - // marks this as approximate. + INVISIBLE_GUESS } VisiblePackageEstimate( @@ -76,8 +71,7 @@ class AndroidExternalAppScanner( private fun listInstalledPackages(includeMeta: Boolean): List { val baseFlags = if (includeMeta) PackageManager.GET_META_DATA.toLong() else 0L - // We deliberately do NOT request signing certificates here — we compute - // the fingerprint per-package on demand to keep the bulk listing cheap. + return runCatching { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(baseFlags)) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt index 0bf314e7b..c69b4eaff 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt @@ -18,9 +18,6 @@ class InstallerSourceClassifier( val flags = applicationInfo?.flags ?: 0 val isSystem = flags and ApplicationInfo.FLAG_SYSTEM != 0 - // FLAG_SYSTEM apps that received an OTA update get FLAG_UPDATED_SYSTEM_APP added - // *without* losing FLAG_SYSTEM. Treat any FLAG_SYSTEM app as SYSTEM regardless of - // update status — Samsung / Pixel / OEM-bundled apps almost never come from GitHub. if (isSystem) return InstallerKind.SYSTEM if (OEM_PACKAGE_PREFIXES.any { packageName.startsWith(it) }) return InstallerKind.STORE_OEM_OTHER @@ -107,9 +104,6 @@ class InstallerSourceClassifier( "com.google.android.packageinstaller", ) - // Catch-all for OEM apps that lost FLAG_SYSTEM after a self-update or - // were preloaded as not-quite-system (Samsung does both). These are - // never GitHub-published; the wizard surfacing them is just noise. private val OEM_PACKAGE_PREFIXES = listOf( "com.samsung.", diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/ManifestHintExtractor.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/ManifestHintExtractor.kt index eae19479f..b5ff10a05 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/ManifestHintExtractor.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/ManifestHintExtractor.kt @@ -71,7 +71,6 @@ class ManifestHintExtractor { val confidence: Double, ) - // declaration order = priority order private val URL_KEYS = listOf( UrlKey(KEY_FDROID_SOURCE_CODE, ManifestHintSource.META_FDROID_SOURCE_CODE, 0.85), @@ -83,7 +82,6 @@ class ManifestHintExtractor { private val OWNER_REGEX = Regex("^[\\w.-]{1,39}$") private val REPO_NAME_REGEX = Regex("^[\\w.-]{1,100}$") - // Matches https?://github.com//(/...)? — first two path segments only private val URL_REGEX = Regex( "(?i)^https?://(?:www\\.)?github\\.com/([\\w.-]+)/([\\w.-]+)(?:[/?#].*)?$", diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt index 07cee9b15..bec79c9cb 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt @@ -45,10 +45,7 @@ object SigningFingerprintComputer { private fun certBytes(info: PackageInfo): ByteArray? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val sigInfo = info.signingInfo ?: return null - // Prefer the active signer set. signingCertificateHistory is ordered with - // the original cert at index 0 and the current cert at the last index, so - // .firstOrNull() would return the pre-rotation cert for v3-rotated apps - // and break fingerprint matching against signed binaries. + val current = sigInfo.apkContentsSigners?.firstOrNull() val fallback = sigInfo.signingCertificateHistory?.lastOrNull() (current ?: fallback)?.toByteArray() diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/installer/SilentInstallerDispatcher.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/installer/SilentInstallerDispatcher.kt index bba46f021..be4db240c 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/installer/SilentInstallerDispatcher.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/installer/SilentInstallerDispatcher.kt @@ -141,11 +141,7 @@ class SilentInstallerDispatcher( withContext(Dispatchers.IO) { runInstall(file, backend, installerAttribution, expectedPkg, expectedVc) } - // Android 14+ may reject a session that carries a non-matching - // installerPackageName with STATUS_PENDING_USER_ACTION even when - // USER_ACTION_NOT_REQUIRED is set (update-ownership enforcement). - // Retry once without attribution — the install stays silent, the - // app just loses its "installed by Play Store / F-Droid" label. + val resolved = if ( backend == Backend.DHIZUKU && @@ -197,15 +193,11 @@ class SilentInstallerDispatcher( service.installPackage(pfd, file.length(), expectedPkg, expectedVc, installerAttribution) } Backend.ROOT -> - // Root path streams the APK directly into `pm install`'s - // stdin from the app's own process, so no ParcelFileDescriptor - // dance is needed — the file lives on app-private storage and - // we read it back via the standard FileInputStream. + rootServiceManager.installPackage(file, installerAttribution) Backend.DEFAULT -> null } - private fun readApkIdentity(filePath: String): Pair { val info = try { androidContext.packageManager.getPackageArchiveInfo(filePath, 0) @@ -256,13 +248,7 @@ class SilentInstallerDispatcher( if (dhizukuServiceManager.status.value == DhizukuStatus.READY) Backend.DHIZUKU else Backend.DEFAULT } InstallerType.ROOT -> { - // Root grants are sticky once accepted by Magisk/KernelSU/APatch, - // so a READY cached status is trustworthy on the hot path. When - // the user is still on PERMISSION_NEEDED, re-probe (async) so a - // fresh grant since app start can flip the status without forcing - // them back to Tweaks. The probe shells out and is slow; gating - // it on `!= READY` keeps install latency unaffected once root has - // been granted once. + if (rootServiceManager.status.value != RootStatus.READY) { rootServiceManager.refreshStatus() } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt index 8cc7168c3..195c66e86 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt @@ -25,10 +25,7 @@ class RootServiceManager( private var cachedSuPath: String? = null fun initialize() { - // First-launch detection runs off the main thread because `su -c id` - // can block for several seconds when the root manager (Magisk / - // KernelSU / APatch) is showing its grant dialog or doing initial - // bookkeeping. We don't want to stall app cold-start. + scope.launch(Dispatchers.IO) { refreshStatusBlocking() } @@ -38,16 +35,6 @@ class RootServiceManager( scope.launch(Dispatchers.IO) { refreshStatusBlocking() } } - /** - * Triggers the root manager's per-app authorization dialog without - * actually performing a privileged action. Idiomatic across Magisk / - * KernelSU / APatch — running `su -c true` is enough to make the - * manager pop its allow/deny prompt; the grant is then cached and - * subsequent calls run silently. - * - * No-op when the su binary isn't on the device. Re-runs status - * detection afterwards so the UI reflects the user's choice. - */ fun requestPermission() { scope.launch(Dispatchers.IO) { val su = cachedSuPath ?: locateSuBinary()?.path ?: run { @@ -70,20 +57,6 @@ class RootServiceManager( } } - /** - * Pipes [apkFile] into `pm install -S -i -` over `su`. - * - * Stdin streaming is deliberate: invoking `pm install ` against - * an app-private APK location fails because the shell process — even - * running as UID 0 — is denied by SELinux on `app_data_files`. Reading - * the bytes from app process and writing them to `pm`'s stdin sidesteps - * the policy. - * - * Returns `0` on success, non-zero on failure, `null` when no su binary - * is reachable. Mirrors the AIDL contract of the Shizuku / Dhizuku - * services so [SilentInstallerDispatcher] can treat all silent backends - * uniformly. - */ suspend fun installPackage( apkFile: File, installerPackageName: String?, @@ -93,10 +66,7 @@ class RootServiceManager( Logger.w(TAG) { "installPackage() — no su binary available" } return@withContext null } - // The whole string gets injected into a `su -c ''` shell — - // refuse anything that isn't a strictly conformant Android - // package name so a malformed user-supplied installer - // attribution can't smuggle shell metacharacters past us. + val safeInstaller = installerPackageName?.takeIf { it.isNotBlank() } if (safeInstaller != null && !PACKAGE_NAME_PATTERN.matches(safeInstaller)) { Logger.w(TAG) { @@ -105,10 +75,7 @@ class RootServiceManager( return@withContext STATUS_FAILURE } val pm = "/system/bin/pm" - // Always shell out the full pm path — some Magisk modules / - // KernelSU configurations strip `/system/bin` from the minimal - // PATH that `su -c ` runs against, and the resulting - // `pm: not found` (exit 127) is a silent class of bug. + val command = buildString { append(pm).append(" install ") if (safeInstaller != null) append("-i ").append(safeInstaller).append(' ') @@ -123,12 +90,6 @@ class RootServiceManager( return@withContext null } - // Drain stdout and stderr concurrently with the stdin pump - // because `readText()` blocks until EOF — if pm keeps the pipes - // open while waiting for stdin, calling `readText()` BEFORE - // `waitFor()` deadlocks the process and the install timeout - // never fires (the read just hangs forever inside the read - // syscall). val stdoutBuf = StringBuilder() val stderrBuf = StringBuilder() val stdoutThread = Thread { @@ -182,9 +143,7 @@ class RootServiceManager( stderrThread.join(READER_DRAIN_TIMEOUT_MS) return@withContext STATUS_FAILURE } - // Reader threads will see EOF now that the process is gone; - // wait for them with a short cap so a stuck pipe never blocks - // us indefinitely. + stdoutThread.join(READER_DRAIN_TIMEOUT_MS) stderrThread.join(READER_DRAIN_TIMEOUT_MS) @@ -260,25 +219,8 @@ class RootServiceManager( } } - /** - * Locate a working `su` binary by exec-probing each candidate. - * - * Why exec-probe instead of `File.exists()`: on Android 13+ the - * `untrusted_app` SELinux profile blocks stat'ing `/data/adb/...` - * paths, so `File("/data/adb/magisk/su").exists()` returns false - * even when Magisk is installed and would grant root. Magisk / - * KernelSU / APatch hook the exec syscall regardless of whether - * the app can stat the path, so a direct exec is the only reliable - * probe on modern Android. - */ private fun locateSuBinary(): SuProbe? { - // A device can have multiple su binaries — e.g. a ROM-baked - // `/system/bin/su` that's denied alongside a Magisk install at - // `/data/adb/magisk/su` that would grant. Always scan all paths; - // prefer a confirmed UID_ZERO over a NOT_ZERO so the first denied - // candidate doesn't shadow a working one. Fall back to the first - // NOT_ZERO so the grant-prompt path still surfaces when none of - // the binaries is currently granted. + var firstNotZero: SuProbe? = null for (path in SU_PATHS) { val result = probeSu(path) ?: continue @@ -312,9 +254,7 @@ class RootServiceManager( } } } catch (_: java.io.IOException) { - // exec failed — binary doesn't exist at this path on this device. - // Skip to next candidate. Not logged at warn level because this is - // the expected outcome for most paths in the SU_PATHS list. + null } catch (e: Exception) { Logger.w(TAG) { "probeSu($path) threw: ${e.javaClass.simpleName}: ${e.message}" } @@ -341,9 +281,6 @@ class RootServiceManager( private const val PROMPT_TIMEOUT_SECONDS = 60L private const val READER_DRAIN_TIMEOUT_MS = 1_000L - // Strict Android package-name shape — letters, digits, underscores - // separated by dots, must start with a letter. Used to reject - // anything that could carry shell metacharacters into `su -c`. private val PACKAGE_NAME_PATTERN = Regex("""^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$""") diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerServiceImpl.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerServiceImpl.kt index c2225fd90..a6062cff9 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerServiceImpl.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerServiceImpl.kt @@ -5,19 +5,6 @@ import java.io.BufferedReader import java.io.InputStreamReader import java.util.concurrent.TimeUnit -/** - * Shizuku UserService implementation that runs in a privileged process (shell/root). - * Provides silent package install/uninstall via `pm` shell commands. - * - * This class runs in Shizuku's process, NOT in the app's process. - * It has shell-level (UID 2000) or root-level (UID 0) privileges. - * - * Uses `pm install` with stdin pipe for install, `pm uninstall` for uninstall. - * This is the most reliable approach — avoids fragile reflection on hidden - * IPackageInstaller/IPackageInstallerSession/IIntentSender APIs. - * - * MUST have a default no-arg constructor for Shizuku's UserService framework. - */ class ShizukuInstallerServiceImpl() : IShizukuInstallerService.Stub() { companion object { @@ -52,7 +39,6 @@ class ShizukuInstallerServiceImpl() : IShizukuInstallerService.Stub() { val process = Runtime.getRuntime().exec(command) - // Pipe the APK from the ParcelFileDescriptor to pm's stdin val writeThread = Thread { try { ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input -> @@ -74,7 +60,6 @@ class ShizukuInstallerServiceImpl() : IShizukuInstallerService.Stub() { } writeThread.start() - // Read stdout/stderr for result val stdout = BufferedReader(InputStreamReader(process.inputStream)).readText().trim() val stderr = BufferedReader(InputStreamReader(process.errorStream)).readText().trim() diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt index 888537ae5..7f884f6f9 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt @@ -97,7 +97,7 @@ class AndroidShareManager( } launcher.launch(intent) } else { - // Fallback: try with ACTION_GET_CONTENT + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = mimeType @@ -107,7 +107,7 @@ class AndroidShareManager( context.startActivity(Intent.createChooser(intent, null).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - // Note: fallback won't deliver result without launcher + onResult(null) } catch (e: Exception) { onResult(null) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt index 44336a0f6..3ed3f3674 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt @@ -24,11 +24,6 @@ class CacheManager( encodeDefaults = true } - // Guarded by [memoryCacheMutex]. Multiple repositories read/write - // this map from concurrent ViewModel scopes — without the mutex a - // structural HashMap mutation can corrupt internal state on rehash. - // `@PublishedApi internal` so the inline reified `get` / `put` can - // touch them without leaking the map to outside callers. @PublishedApi internal val memoryCache = HashMap>() @@ -37,9 +32,6 @@ class CacheManager( init { appScope?.launch { - // Periodic janitor: in-memory cache grows otherwise unbounded - // over a long session. Run once an hour off the critical path; - // mutex serialises with foreground reads/writes. while (true) { delay(CLEANUP_INTERVAL_MS) runCatching { cleanupExpired() } @@ -59,11 +51,6 @@ class CacheManager( return try { json.decodeFromString(serializer(), jsonData) } catch (_: Exception) { - // Guarded eviction: only remove if no concurrent - // `put` has replaced our snapshot. Without the - // equality check, a fresh value written between - // the snapshot read and this decode failure would - // be wrongly evicted. memoryCacheMutex.withLock { if (memoryCache[key] == cached) memoryCache.remove(key) } @@ -85,9 +72,6 @@ class CacheManager( return try { json.decodeFromString(serializer(), entry.jsonData) } catch (_: Exception) { - // Same race rationale as the in-memory branch — use the - // row's cachedAt as a version stamp so a fresh `put` that - // raced in between getValid and now is preserved. cacheDao.deleteIfMatches(key, entry.cachedAt) memoryCacheMutex.withLock { if (memoryCache[key] == snapshot) memoryCache.remove(key) @@ -133,14 +117,6 @@ class CacheManager( cacheDao.delete(key) } - suspend fun invalidateByPrefix(prefix: String) { - memoryCacheMutex.withLock { - val keysToRemove = memoryCache.keys.filter { it.startsWith(prefix) } - keysToRemove.forEach { memoryCache.remove(it) } - } - cacheDao.deleteByPrefix(prefix) - } - suspend fun clearAll() { memoryCacheMutex.withLock { memoryCache.clear() } cacheDao.deleteAll() @@ -159,8 +135,6 @@ class CacheManager( } companion object CacheTtl { - // Tuned per E4 perf-pass spec: home grid is browsed often + tolerates - // stale data well; longer TTL cuts cold-start network round-trips. val HOME_REPOS = 24.hours.inWholeMilliseconds val REPO_DETAILS = 6.hours.inWholeMilliseconds val RELEASES = 6.hours.inWholeMilliseconds diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt index 8316bdf16..6b52a0fd3 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt @@ -70,11 +70,7 @@ class DefaultTokenStore( override suspend fun clear() { migrationDeferred.await() - // safeDelete swallows KSafe failures (returns false). Legacy cleanup - // runs unconditionally so a sign-out never leaves a stale plaintext - // token. If KSafe delete silently failed, the encrypted token row - // remains but is unreachable without a re-issued auth — acceptable - // trade vs crashing the sign-out flow. + ksafe.safeDelete(tokenKey) runCatching { legacyDataStore.edit { it.remove(legacyKey) } } } @@ -100,16 +96,13 @@ class DefaultTokenStore( val parsed = runCatching { json.decodeFromString(GithubDeviceTokenSuccessDto.serializer(), legacyRaw) }.getOrNull() ?: run { - // Legacy value present but unparseable — leave it in DataStore - // (don't drop the only copy) and bail out without marking - // migrated, so a code change that fixes the parse can retry. + return@withLock } val putOk = ksafe.safePut(tokenKey, parsed) if (!putOk) { - // Never drop the legacy entry if we couldn't persist into KSafe. - // The user's token stays readable via the slow path next launch. + return@withLock } runCatching { legacyDataStore.edit { it.remove(legacyKey) } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index eadd97408..f7f6ce441 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -8,12 +8,6 @@ import io.ktor.http.HttpHeaders import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.koin.core.qualifier.named import org.koin.dsl.module import zed.rainxch.core.data.network.createPlatformHttpClient @@ -47,23 +41,21 @@ import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.data.network.MirrorApiClient import zed.rainxch.core.data.network.MockExternalMatchApi import zed.rainxch.core.data.network.ProxyManager -import zed.rainxch.core.data.network.ProxyManagerSeeding import zed.rainxch.core.data.network.ProxyTesterImpl import zed.rainxch.core.data.network.TranslationClientProvider -import zed.rainxch.core.data.repository.AuthenticationStateImpl +import zed.rainxch.core.data.repository.UserSessionRepositoryImpl import zed.rainxch.core.data.repository.ExternalImportRepositoryImpl import zed.rainxch.core.data.repository.FavouritesRepositoryImpl import zed.rainxch.core.data.repository.InstalledAppsRepositoryImpl import zed.rainxch.core.data.repository.ProxyRepositoryImpl -import zed.rainxch.core.data.repository.DeviceIdentityRepositoryImpl import zed.rainxch.core.data.repository.RateLimitRepositoryImpl -import zed.rainxch.core.data.repository.SearchHistoryRepositoryImpl -import zed.rainxch.core.data.repository.TelemetryRepositoryImpl import zed.rainxch.core.data.repository.HiddenReposRepositoryImpl import zed.rainxch.core.data.repository.SeenReposRepositoryImpl import zed.rainxch.core.data.repository.StarredRepositoryImpl import zed.rainxch.core.data.repository.AnnouncementsCacheStoreImpl import zed.rainxch.core.data.repository.AnnouncementsRepositoryImpl +import zed.rainxch.core.data.repository.CacheRepositoryImpl +import zed.rainxch.core.data.repository.HostTokenRepositoryImpl import zed.rainxch.core.data.repository.TweaksRepositoryImpl import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.logging.GitHubStoreLogger @@ -79,19 +71,18 @@ import zed.rainxch.core.domain.system.ExternalAppScanner import zed.rainxch.core.domain.system.MultiSourceDownloader import zed.rainxch.core.domain.repository.AnnouncementsCacheStore import zed.rainxch.core.domain.repository.AnnouncementsRepository -import zed.rainxch.core.domain.repository.AuthenticationState -import zed.rainxch.core.domain.repository.DeviceIdentityRepository +import zed.rainxch.core.domain.repository.CacheRepository +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.MirrorRepository import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.RateLimitRepository -import zed.rainxch.core.domain.repository.SearchHistoryRepository import zed.rainxch.core.domain.repository.HiddenReposRepository +import zed.rainxch.core.domain.repository.HostTokenRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository -import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase @@ -109,9 +100,12 @@ val coreModule = getPlatform() } - single { - AuthenticationStateImpl( + single { + UserSessionRepositoryImpl( tokenStore = get(), + cacheManager = get(), + httpClientProvider = { get().client }, + logger = get(), ) } @@ -122,12 +116,20 @@ val coreModule = ) } - single { + single { ForgejoClientRegistry( proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY), ) } + single { + CacheRepositoryImpl( + logger = get(), + fileLocationsProvider = get(), + cacheManager = get() + ) + } + single { InstalledAppsRepositoryImpl( database = get(), @@ -152,7 +154,7 @@ val coreModule = single { TweaksRepositoryImpl( - ksafe = get(qualifier = org.koin.core.qualifier.named("prefs")), + ksafe = get(qualifier = named("prefs")), legacyDataStore = get(), ) } @@ -163,8 +165,8 @@ val coreModule = single { AnnouncementsCacheStoreImpl( - ksafe = get(qualifier = org.koin.core.qualifier.named("announcements_cache")), - legacyDataStore = get(qualifier = org.koin.core.qualifier.named("announcements")), + ksafe = get(qualifier = named("announcements_cache")), + legacyDataStore = get(qualifier = named("announcements")), ) } @@ -187,14 +189,15 @@ val coreModule = single { val repo = MirrorRepositoryImpl( - ksafe = get(qualifier = org.koin.core.qualifier.named("prefs")), + ksafe = get(qualifier = named("prefs")), legacyDataStore = get(), apiClient = get(), appScope = get(), ) - // Kick off the ProxyManager mirror-template snapshot collector - // so the Ktor interceptor can resolve the template synchronously. - ProxyManager.startMirrorCollector(repo, get()) + ProxyManager.startMirrorCollector( + repository = repo, + scope = get() + ) repo } @@ -210,15 +213,9 @@ val coreModule = ) } - single { - SearchHistoryRepositoryImpl( - searchHistoryDao = get(), - ) - } - single { ProxyRepositoryImpl( - ksafe = get(qualifier = org.koin.core.qualifier.named("prefs")), + ksafe = get(qualifier = named("prefs")), legacyDataStore = get(), logger = get(), ) @@ -238,47 +235,18 @@ val coreModule = } single { - CacheManager(cacheDao = get(), appScope = get()) + CacheManager( + cacheDao = get(), + appScope = get() + ) } single { - // Request the seeding sentinel so Koin guarantees ProxyManager - // has the user's persisted config loaded before we snapshot - // the discovery flow for the initial client build. - get() BackendApiClient( proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY), tokenStore = get(), ) } - // NOTE: the reviewer asked for a Koin onClose hook to call - // BackendApiClient.close()/GitHubClientProvider.close()/ - // TranslationClientProvider.close() at Koin shutdown. Koin 4.x - // (4.1.1 on this project) doesn't expose that hook at the - // module DSL level — it existed in 3.x and was removed — and - // there's no clean replacement short of wrapping each provider - // in a Koin scope. On Android/Desktop the process exit - // releases these resources anyway, so we intentionally leave - // the hooks off rather than fake them with an API that doesn't - // fit. Revisit if we upgrade Koin or adopt scope-based DI. - - single { - DeviceIdentityRepositoryImpl( - ksafe = get(qualifier = org.koin.core.qualifier.named("prefs")), - legacyDataStore = get(), - ) - } - - single { - TelemetryRepositoryImpl( - backendApiClient = get(), - deviceIdentity = get(), - tweaksRepository = get(), - platform = get(), - appScope = get(), - logger = get(), - ) - } single { BackendExternalMatchApi(get()) } @@ -298,11 +266,10 @@ val coreModule = scanner = get(), externalLinkDao = get(), signingFingerprintDao = get(), - ksafe = get(qualifier = org.koin.core.qualifier.named("prefs")), + ksafe = get(qualifier = named("prefs")), legacyDataStore = get(), externalMatchApi = get(), backendClient = get(), - telemetry = get(), forgejoClientRegistry = get(), tweaksRepository = get(), ) @@ -316,14 +283,11 @@ val coreModule = single { SlowDownloadDetectorImpl( - ksafe = get(qualifier = org.koin.core.qualifier.named("prefs")), + ksafe = get(qualifier = named("prefs")), appScope = get(), ) } - // Application-scoped download / install orchestrator. Lives - // for the process lifetime so downloads survive screen - // navigation. ViewModels are observers, never owners. single { DefaultDownloadOrchestrator( downloader = get(), @@ -338,12 +302,6 @@ val coreModule = ) } - // Single-flight gate over `installer.install` calls that - // delegate to the system installer dialog. Without this, - // sequential update flows fire stacked ACTION_VIEW intents - // and OEM PackageInstaller activities (Xiaomi, OPPO, vivo, - // Honor, Samsung variants) routinely fail to refresh — the - // second dialog keeps showing the first APK. single { DefaultSystemInstallSerializer() } @@ -351,51 +309,16 @@ val coreModule = val networkModule = module { - // Seed [ProxyManager] from persisted per-scope configs *before* - // any HTTP client is constructed. Registered as its own - // [ProxyManagerSeeding] sentinel so client providers can depend - // on it explicitly — without this the seeding would live inside - // one provider's factory and silently race with others. - // - // Reads run in parallel under a single 1.5s budget (was 1.5s × 3 - // sequential). On timeout / DataStore failure we fall back to the - // in-memory defaults — we'd rather the app network with the - // System proxy than stall at launch on a slow disk. - single(createdAtStart = true) { - val repository = get() - runBlocking { - runCatching { - withTimeout(1_500L) { - coroutineScope { - ProxyScope.entries - .map { scope -> - async { - scope to repository.getProxyConfig(scope).first() - } - }.awaitAll() - } - } - }.onSuccess { results -> - results.forEach { (scope, config) -> - ProxyManager.setConfig(scope, config) - } - } - } - ProxyManagerSeeding() - } - - single(createdAtStart = true) { - get() + single { GitHubClientProvider( tokenStore = get(), rateLimitRepository = get(), - authenticationState = get(), + userSessionRepository = get(), proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY), ) } - single(createdAtStart = true) { - get() + single { TranslationClientProvider( proxyConfigFlow = ProxyManager.configFlow(ProxyScope.TRANSLATION), ) @@ -403,7 +326,7 @@ val networkModule = single { DefaultTokenStore( - ksafe = get(qualifier = org.koin.core.qualifier.named("tokens")), + ksafe = get(qualifier = named("tokens")), legacyDataStore = get(), ) } @@ -412,10 +335,10 @@ val networkModule = RateLimitRepositoryImpl() } - single { - zed.rainxch.core.data.repository.HostTokenRepositoryImpl( - ksafe = get(qualifier = org.koin.core.qualifier.named("tokens")), - httpClient = get(qualifier = org.koin.core.qualifier.named("test")), + single { + HostTokenRepositoryImpl( + ksafe = get(qualifier = named("tokens")), + httpClient = get(qualifier = named("test")), ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/MultiSourceDownloaderImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/MultiSourceDownloaderImpl.kt index 70945f198..f37a07011 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/MultiSourceDownloaderImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/MultiSourceDownloaderImpl.kt @@ -24,9 +24,6 @@ class MultiSourceDownloaderImpl( suggestedFileName: String?, ): Flow { val active = ProxyManager.currentMirror() - // The multi-source race targets release-asset downloads. A mirror that - // doesn't list RELEASE_ASSET (e.g. jsDelivr, raw-files only) can't - // serve these URLs — fall through to a Direct download. if (active == null || TrafficKind.RELEASE_ASSET !in active.trafficKinds) { return downloader.download(githubUrl, suggestedFileName) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt index f0e85fa7f..3332b4c16 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/download/SlowDownloadDetectorImpl.kt @@ -70,10 +70,6 @@ class SlowDownloadDetectorImpl( val active = ProxyManager.currentMirror() if (active != null && TrafficKind.RELEASE_ASSET in active.trafficKinds) return - // Mirror's KSafe migration runs on init in MirrorRepositoryImpl. Until - // it flips this marker, K_SUGGEST_* read as defaults — which would - // re-prompt users who'd already dismissed permanently. Bail out - // silently until migration is observed complete. val migrationDone = runCatching { ksafe.safeGet(MIRROR_MIGRATION_MARKER, false) }.getOrDefault(false) if (!migrationDone) return @@ -87,8 +83,6 @@ class SlowDownloadDetectorImpl( } private companion object { - // Same keys MirrorRepositoryImpl writes — shared across both files - // to avoid coupling on a constants object that no longer exists. const val K_SUGGEST_DISMISSED = "mirror_auto_suggest_dismissed" const val K_SUGGEST_SNOOZE = "mirror_auto_suggest_snooze_until" const val MIRROR_MIGRATION_MARKER = "__migrated_mirror_v1__" diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/AssetNetwork.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/AssetNetwork.kt index 1f5f90015..4192e0685 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/AssetNetwork.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/AssetNetwork.kt @@ -7,9 +7,7 @@ import kotlinx.serialization.Serializable data class AssetNetwork( @SerialName("id") val id: Long, @SerialName("name") val name: String, - // Optional: GitHub always emits this, Forgejo / Codeberg / Gitea - // releases payload omits it entirely. Defaulting to null lets the - // shared DTO deserialize against both shapes. + @SerialName("content_type") val contentType: String? = null, @SerialName("size") val size: Long, @SerialName("browser_download_url") val downloadUrl: String, diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt deleted file mode 100644 index 3ff0b1c16..000000000 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package zed.rainxch.core.data.dto - -import kotlinx.serialization.Serializable - -@Serializable -data class EventRequest( - val deviceId: String, - val platform: String, - val appVersion: String? = null, - val eventType: String, - val repoId: Long? = null, - val resultCount: Int? = null, - val success: Boolean? = null, - val errorCode: String? = null, - // ── E1 external-import props (all bucketed enums or counts) ── - val trigger: String? = null, - val strategy: String? = null, - val confidenceBucket: String? = null, - val countBucket: String? = null, - val candidateCountBucket: String? = null, - val durationMsBucket: String? = null, - val rowsAddedBucket: String? = null, - val statusCodeBucket: String? = null, - val sdkIntBucket: String? = null, - val source: String? = null, - val persisted: String? = null, - val granted: Boolean? = null, - val retried: Boolean? = null, -) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubReadmeResponseDto.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubReadmeResponseDto.kt index ec49711e5..d6ff640a2 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubReadmeResponseDto.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubReadmeResponseDto.kt @@ -7,10 +7,7 @@ import kotlinx.serialization.Serializable data class GithubReadmeResponseDto( @SerialName("name") val name: String? = null, @SerialName("path") val path: String? = null, - // Directory-listing entries (Forgejo `/contents/` with no filepath) - // have no `content` of their own — the field is omitted. Make it - // nullable so the same DTO can be reused for both single-file and - // listing responses. + @SerialName("content") val content: String? = null, @SerialName("encoding") val encoding: String? = null, @SerialName("type") val type: String? = null, diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubRepoNetworkModel.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubRepoNetworkModel.kt index 789d6c079..d97d1e0e7 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubRepoNetworkModel.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubRepoNetworkModel.kt @@ -21,6 +21,5 @@ data class GithubRepoNetworkModel( @SerialName("fork") val fork: Boolean = false, @SerialName("archived") val archived: Boolean = false, @SerialName("open_issues_count") val openIssuesCount: Int = 0, - @SerialName("pushed_at") val pushedAt: String? = null, @SerialName("has_downloads") val hasDownloads: Boolean = false, ) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt index 7232fb602..a969e76b4 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt @@ -12,10 +12,7 @@ data class SigningFingerprintSeedResponse( val fingerprint: String, val owner: String, val repo: String, - // Epoch milliseconds. Backend contract (E1 plan §7.4); the same value is - // forwarded as `since` on the next sync — any unit drift between client - // and backend would show up as either re-fetched pages or a hard skip, - // which the seed-sync telemetry will surface. + val observedAt: Long, ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/CacheDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/CacheDao.kt index 22374565b..7a608f1e1 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/CacheDao.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/CacheDao.kt @@ -29,13 +29,6 @@ interface CacheDao { @Query("DELETE FROM cache_entries WHERE `key` = :key") suspend fun delete(key: String) - /** - * Conditional delete used by CacheManager when a row decoded as a - * malformed payload — guards against evicting a freshly-put row that - * raced in between the read and the decode-failure delete. Compares - * `cachedAt` because (key, cachedAt) is effectively the row's version - * stamp on this schema. - */ @Query("DELETE FROM cache_entries WHERE `key` = :key AND cachedAt = :cachedAt") suspend fun deleteIfMatches(key: String, cachedAt: Long) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/FavoriteRepoDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/FavoriteRepoDao.kt index c88ae6cf1..876a0ccd2 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/FavoriteRepoDao.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/FavoriteRepoDao.kt @@ -33,9 +33,9 @@ interface FavoriteRepoDao { @Query( """ - UPDATE favorite_repos - SET isInstalled = :installed, - installedPackageName = :packageName + UPDATE favorite_repos + SET isInstalled = :installed, + installedPackageName = :packageName WHERE repoId = :repoId """, ) @@ -47,7 +47,7 @@ interface FavoriteRepoDao { @Query( """ - UPDATE favorite_repos + UPDATE favorite_repos SET latestVersion = :version, latestReleaseUrl = :releaseUrl, lastSyncedAt = :timestamp diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt index ff33fd8af..597818bf9 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt @@ -49,8 +49,8 @@ interface InstalledAppDao { @Query( """ - UPDATE installed_apps - SET isUpdateAvailable = :available, + UPDATE installed_apps + SET isUpdateAvailable = :available, latestVersion = :version, latestAssetName = :assetName, latestAssetUrl = :assetUrl, @@ -97,16 +97,6 @@ interface InstalledAppDao { fallback: Boolean, ) - /** - * Sets the user's preferred asset variant along with its multi-layer - * fingerprint (token set, glob pattern, same-position metadata). - * Always clears the "stale" flag in the same write because the user - * has just made an explicit choice — whatever was stored before is - * no longer stale, even if the new variant is the same value. - * - * Pass `null` for [variant] (and the other fingerprint fields) to - * unpin and fall back to the platform auto-picker. - */ @Query( """ UPDATE installed_apps @@ -128,11 +118,6 @@ interface InstalledAppDao { siblingCount: Int?, ) - /** - * Sets `preferredVariantStale` for [packageName]. Used by - * `checkForUpdates` when the persisted variant cannot be matched - * against the assets in a fresh release. - */ @Query( """ UPDATE installed_apps @@ -151,17 +136,6 @@ interface InstalledAppDao { timestamp: Long, ) - /** - * Sets (or clears) [InstalledAppEntity.skippedReleaseTag] for - * [packageName]. Pair-writes `isUpdateAvailable = 0` when [tag] is - * non-null so the apps list drops the badge atomically with the - * skip — without the second column the UI would still show "Update" - * for a frame until the next periodic check ran. - * - * Pass `null` for [tag] to unskip; the row's update badge stays - * untouched in that branch and the next `checkForUpdates` will - * recompute it normally. - */ @Query( """ UPDATE installed_apps @@ -184,14 +158,6 @@ interface InstalledAppDao { ) fun getAppsWithSkippedReleaseTag(): Flow> - /** - * Atomically writes the installed-version columns and the - * `isUpdateAvailable` flag for [packageName]. Used by the external - * install / sideload path (`PackageEventReceiver.handleExternalInstall`) - * where a stale snapshot + full-row update could otherwise clobber - * concurrent writes to sibling columns (download orchestrator, - * variant pin, favourite toggle, `checkForUpdates`, etc.). - */ @Query( """ UPDATE installed_apps @@ -210,15 +176,6 @@ interface InstalledAppDao { isUpdateAvailable: Boolean, ) - /** - * Sets the path + version + asset name of a - * downloaded-but-not-yet-installed asset. Pass all `null` to - * clear (e.g. after the user installs the file). - * - * The version + asset name pair is what the Details screen uses - * to detect "the parked file matches the currently-selected - * release" and skip the redundant re-download. - */ @Query( """ UPDATE installed_apps @@ -235,14 +192,6 @@ interface InstalledAppDao { assetName: String?, ) - /** - * Atomically clears the "update available" badge and any cached - * latest-release metadata for [packageName], while bumping - * `lastCheckedAt`. Used by `checkForUpdates` whenever the current - * filter / release window yields no match: without this, a user who - * tightens their asset filter would keep the stale badge and the - * download button would point at an asset that no longer matches. - */ @Query( """ UPDATE installed_apps diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/StarredRepoDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/StarredRepoDao.kt index 13f0cc2ee..fbdaa8c28 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/StarredRepoDao.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/StarredRepoDao.kt @@ -39,9 +39,9 @@ interface StarredRepoDao { @Query( """ - UPDATE starred_repos - SET isInstalled = :installed, - installedPackageName = :packageName + UPDATE starred_repos + SET isInstalled = :installed, + installedPackageName = :packageName WHERE repoId = :repoId """, ) @@ -53,7 +53,7 @@ interface StarredRepoDao { @Query( """ - UPDATE starred_repos + UPDATE starred_repos SET latestVersion = :version, latestReleaseUrl = :releaseUrl, lastSyncedAt = :timestamp diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt index 9a5251792..cc02cb679 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt @@ -40,131 +40,31 @@ data class InstalledAppEntity( val latestVersionCode: Long? = null, val latestReleasePublishedAt: String? = null, val includePreReleases: Boolean = false, - /** - * Per-app regex applied to asset (file) names. When non-null, only assets - * whose name matches the pattern are considered installable for this app. - * Used to track a single app inside a monorepo that ships multiple apps - * (e.g. `ente-auth.*` against `ente-io/ente`). - */ + val assetFilterRegex: String? = null, - /** - * When true, the update checker walks backward through past releases until - * it finds one whose assets match [assetFilterRegex]. Required for - * monorepos where the latest release is for a *different* app. - * - * `@ColumnInfo(defaultValue = "0")` matches `MIGRATION_9_10`'s - * `DEFAULT 0` clause so Room's schema validator doesn't flag the - * column on freshly-built databases. - */ + @ColumnInfo(defaultValue = "0") val fallbackToOlderReleases: Boolean = false, - /** - * Stable identifier for the asset variant the user wants to track — - * for example `arm64-v8a`, `universal`, or `x86_64`. Derived from the - * picked asset filename by stripping the version segment, so it - * survives release-to-release version bumps. - * - * `null` means "use the platform installer's automatic picker" - * (today's behaviour). When non-null, [checkForUpdates] resolves the - * matching asset on every check; if no asset in the new release - * matches the variant, [preferredVariantStale] is flipped to true. - */ + val preferredAssetVariant: String? = null, - /** - * Set to true by the update checker when the persisted - * [preferredAssetVariant] cannot be found in the latest release's - * assets — typically because the maintainer renamed or restructured - * the artefacts. The UI surfaces this with a "variant changed — - * pick again" prompt and clears it once the user picks a new variant. - * - * `@ColumnInfo(defaultValue = "0")` matches `MIGRATION_10_11`'s - * `DEFAULT 0` clause so Room's schema validator doesn't flag a - * mismatch between the migrated table and the freshly-created one. - */ + @ColumnInfo(defaultValue = "0") val preferredVariantStale: Boolean = false, - /** - * Token-set fingerprint of the picked asset, serialized as - * `token1|token2|…` (alphabetically sorted so equal sets always - * serialize to identical strings — letting the resolver compare - * with plain string equality instead of parsing). - * - * The token vocabulary is closed (see `AssetVariant.kt`); only - * recognised arch / flavor tokens are stored. `null` means - * "no token fingerprint available — fall back to glob or tail". - */ + val preferredAssetTokens: String? = null, - /** - * Glob-pattern fingerprint of the picked asset (e.g. - * `app-*-arm64-v8a.apk`). Used as a secondary identity layer when - * the token vocabulary doesn't recognise anything in the filename - * — the most common case being custom flavor names. - * - * `null` means the picked filename had no version-shaped substring - * to wildcard, so the glob would just equal the filename and - * provides no rescue value. - */ + val assetGlobPattern: String? = null, - /** - * Zero-based index of the picked asset in the original release's - * installable-asset list. Used by the same-position fallback — - * when none of the fingerprint layers match in a fresh release - * but the new release has exactly the same number of installable - * assets, the asset at this index is preferred. - * - * Weak signal, only consulted as a last resort. `null` for older - * rows pinned before this column existed. - */ + val pickedAssetIndex: Int? = null, - /** - * Total installable assets in the release the user picked from. - * Pairs with [pickedAssetIndex] for same-position fallback. - */ + val pickedAssetSiblingCount: Int? = null, - /** - * Absolute path to a downloaded but not-yet-installed APK / asset. - * - * Set by `DefaultDownloadOrchestrator` when an - * `InstallPolicy.InstallWhileForeground` download finishes after - * the foreground screen has been destroyed (the user navigated - * away mid-download). The apps list shows a "Ready to install" - * row with a one-tap install action when this is non-null. - * - * Cleared by the orchestrator on successful install or by the - * user dismissing the row. - * - * Survives process restarts: the file lives on disk and the - * column points back at it. Both are cleaned up if the file is - * missing on next read. - */ + val pendingInstallFilePath: String? = null, - /** - * Release tag of the version represented by [pendingInstallFilePath]. - * Used by the Details screen to detect "the parked file matches - * the currently-selected release" — when both [pendingInstallVersion] - * and [pendingInstallAssetName] match the user's selection, the - * install button skips the download phase and dispatches the - * existing install dialog flow on the parked file directly. - */ + val pendingInstallVersion: String? = null, - /** - * Asset filename (the original, not the scoped form) of the - * parked file. Pairs with [pendingInstallVersion] for - * Details-screen "ready to install" detection. - */ + val pendingInstallAssetName: String? = null, - /** - * Tag of an upstream release the user has chosen to ignore. Set when - * the user taps "Skip this version" on the apps row. While non-null - * and equal to the matched release tag, [checkForUpdates] suppresses - * the update badge for [packageName]. The flag auto-clears the - * moment a strictly newer release lands so the user gets re-notified - * the next cycle without having to unskip manually. - * - * `@ColumnInfo(defaultValue = "NULL")` matches `MIGRATION_15_16` so - * Room's schema validator doesn't flag a mismatch between the - * migrated table and the freshly-created one. - */ + @ColumnInfo(defaultValue = "NULL") val skippedReleaseTag: String? = null, @ColumnInfo(defaultValue = "NULL") diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/AssetNetwork.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/AssetNetwork.kt index 0d8c46ac5..12814d3a2 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/AssetNetwork.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/AssetNetwork.kt @@ -8,9 +8,7 @@ fun AssetNetwork.toDomain(): GithubAsset = GithubAsset( id = id, name = name, - // Forgejo / Codeberg omits `content_type` on release assets; - // fall back to a reasonable default so installer / MIME-based - // classifiers don't NPE. + contentType = contentType ?: "application/octet-stream", size = size, downloadUrl = downloadUrl, diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/mappers/UserProfileMappers.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/UserProfileMappers.kt similarity index 79% rename from feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/mappers/UserProfileMappers.kt rename to core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/UserProfileMappers.kt index 3e0df17c5..08a4dc958 100644 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/mappers/UserProfileMappers.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/UserProfileMappers.kt @@ -1,7 +1,7 @@ -package zed.rainxch.profile.data.mappers +package zed.rainxch.core.data.mappers import zed.rainxch.core.data.dto.UserProfileNetwork -import zed.rainxch.profile.domain.model.UserProfile +import zed.rainxch.core.domain.model.UserProfile fun UserProfileNetwork.toUserProfile(): UserProfile = UserProfile( diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt index fc39e5020..c338e3371 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mirror/MirrorRepositoryImpl.kt @@ -3,7 +3,6 @@ package zed.rainxch.core.data.mirror import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import eu.anifantakis.lib.ksafe.KSafe @@ -33,12 +32,12 @@ import zed.rainxch.core.domain.model.MirrorPreference import zed.rainxch.core.domain.model.MirrorStatus import zed.rainxch.core.domain.model.MirrorType import zed.rainxch.core.domain.model.TrafficKind -import zed.rainxch.core.domain.repository.MirrorRemoved import zed.rainxch.core.domain.repository.MirrorRepository import zed.rainxch.core.data.secure.safeDelete import zed.rainxch.core.data.secure.safeGet import zed.rainxch.core.data.secure.safeGetFlow import zed.rainxch.core.data.secure.safePut +import zed.rainxch.core.domain.model.MirrorRemoved class MirrorRepositoryImpl( private val ksafe: KSafe, diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt index ccbc93cd5..00298afd7 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt @@ -36,7 +36,6 @@ import zed.rainxch.core.data.dto.AnnouncementsResponseDto import zed.rainxch.core.data.dto.BackendExploreResponse import zed.rainxch.core.data.dto.BackendRepoResponse import zed.rainxch.core.data.dto.BackendSearchResponse -import zed.rainxch.core.data.dto.EventRequest import zed.rainxch.core.data.dto.ExternalMatchRequest import zed.rainxch.core.data.dto.ExternalMatchResponse import zed.rainxch.core.data.dto.GithubReadmeResponseDto @@ -48,12 +47,6 @@ import zed.rainxch.core.data.dto.UserProfileNetwork import zed.rainxch.core.domain.model.ProxyConfig import kotlin.coroutines.cancellation.CancellationException -/** - * Client for GitHub Store's own backend (trending/popular/search). - * Treated as *discovery* traffic — routes through the discovery-scope - * proxy so users configuring a proxy for GitHub browsing also have - * their backend discovery requests proxied consistently. - */ class BackendApiClient( proxyConfigFlow: StateFlow, private val tokenStore: TokenStore, @@ -224,7 +217,6 @@ class BackendApiClient( parameter("page", page) parameter("per_page", perPage) if (token != null) header(X_GITHUB_TOKEN_HEADER, token) - // Cold path: backend goes to GitHub + paginates. 15s covers p99. timeout { requestTimeoutMillis = 15_000 socketTimeoutMillis = 15_000 @@ -284,29 +276,6 @@ class BackendApiClient( } } - suspend fun getUserStarred( - username: String, - page: Int = 1, - perPage: Int = 30, - ): Result> = - safeCall { - val token = currentUserGithubToken() - val response = httpClient.get("users/$username/starred") { - parameter("page", page) - parameter("per_page", perPage) - if (token != null) header(X_GITHUB_TOKEN_HEADER, token) - timeout { - requestTimeoutMillis = 15_000 - socketTimeoutMillis = 15_000 - } - } - when { - response.status.isSuccess() -> Result.success(response.body()) - response.status == HttpStatusCode.TooManyRequests -> Result.failure(buildRateLimited(response)) - else -> Result.failure(BackendException(response.status.value)) - } - } - suspend fun getUser(username: String): Result = safeCall { val token = currentUserGithubToken() @@ -381,22 +350,6 @@ class BackendApiClient( } } - suspend fun postEvents(events: List): Result = - safeCall { - val response = httpClient.post("events") { - contentType(ContentType.Application.Json) - setBody(events) - } - when { - response.status == HttpStatusCode.NoContent || response.status.isSuccess() -> - Result.success(Unit) - response.status == HttpStatusCode.TooManyRequests -> - Result.failure(RateLimitedException()) - else -> - Result.failure(BackendException(response.status.value)) - } - } - private suspend fun buildRateLimited(response: io.ktor.client.statement.HttpResponse): RateLimitedException { BackendRateLimitTracker.record(response) val headerRetryAfter = response.headers["Retry-After"]?.toLongOrNull() diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendFallbackPolicy.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendFallbackPolicy.kt index 291d9d805..015b6bae3 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendFallbackPolicy.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendFallbackPolicy.kt @@ -5,12 +5,6 @@ import zed.rainxch.core.domain.model.RateLimitInfo import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock -/** - * Centralized policy for backend → direct-GitHub fallback. **Side - * effect:** rethrows `CancellationException` so structured concurrency - * is preserved, and converts a backend rate-limit into a domain - * [RateLimitException] so callers can surface retry-after to the user. - */ fun shouldFallbackToGithubOrRethrow(cause: Throwable): Boolean = when (cause) { is CancellationException -> throw cause diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt index 72f354ec9..4b677e6d5 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ExternalMatchApi.kt @@ -8,7 +8,6 @@ import zed.rainxch.core.data.dto.ExternalMatchResponse import zed.rainxch.core.domain.repository.TweaksRepository interface ExternalMatchApi { - // NOTE: chunking lives in the repo; impls receive whatever the repo passes suspend fun match(request: ExternalMatchRequest): Result } @@ -54,10 +53,12 @@ class ExternalMatchApiSelector( tweaks: TweaksRepository, scope: CoroutineScope, ) : ExternalMatchApi { - // Cache the flag in a hot StateFlow so `match()` can read it - // synchronously instead of round-tripping DataStore on every call. private val flagState = tweaks.getExternalMatchSearchEnabled() - .stateIn(scope, SharingStarted.Eagerly, initialValue = false) + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = false + ) override suspend fun match(request: ExternalMatchRequest): Result = if (flagState.value) { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ForgejoApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ForgejoApiClient.kt index d3037c307..0b59f6af7 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ForgejoApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ForgejoApiClient.kt @@ -23,8 +23,8 @@ import zed.rainxch.core.domain.model.ProxyConfig import java.io.IOException class ForgejoApiClient( - private val host: String, - private val proxyConfig: ProxyConfig = ProxyConfig.System, + host: String, + proxyConfig: ProxyConfig = ProxyConfig.System, ) { private val baseUrl: String = "https://$host/api/v1" @@ -92,20 +92,6 @@ class ForgejoApiClient( } } - /** - * Forgejo does **not** expose GitHub's `/readme` convenience - * endpoint (verified live against codeberg.org — 404 across every - * repo). Use the `/contents/{filepath}` route, which returns the - * exact same `{name, path, content, encoding}` shape that the - * GitHub readme decoder already handles. The README filename has - * to be supplied explicitly; callers typically try `README.md` - * first and fall back to listing `/contents/` for `^README(\..+)?$` - * matches when that 404s. - * - * `ref` defaults to the repo's default branch when omitted, but we - * pass it explicitly so newly-created repos with a non-`main` HEAD - * resolve correctly. - */ suspend fun getContentsFile( owner: String, repo: String, @@ -129,13 +115,6 @@ class ForgejoApiClient( } } - /** - * Release the underlying Ktor HttpClient + its engine. Called by - * [ForgejoClientRegistry] when the registry shuts down OR when the - * cached client is invalidated (proxy config change). Without this - * the engine's connection pool + dispatcher stay alive for the rest - * of the process. - */ fun close() { runCatching { client.close() } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ForgejoClientRegistry.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ForgejoClientRegistry.kt index 726274a3f..f64c4738f 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ForgejoClientRegistry.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ForgejoClientRegistry.kt @@ -25,10 +25,6 @@ class ForgejoClientRegistry( .drop(1) .distinctUntilChanged() .onEach { _ -> - // Invalidate cached clients so the next clientFor() - // rebuilds them against the new proxy config. Close - // each before dropping the reference — otherwise their - // engines, dispatchers, and connection pools leak. mutex.withLock { clients.values.forEach { it.close() } clients.clear() diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt index c42c34937..2d2b4dcc2 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt @@ -14,13 +14,13 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.domain.model.ProxyConfig -import zed.rainxch.core.domain.repository.AuthenticationState +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.core.domain.repository.RateLimitRepository class GitHubClientProvider( private val tokenStore: TokenStore, private val rateLimitRepository: RateLimitRepository, - private val authenticationState: AuthenticationState, + private val userSessionRepository: UserSessionRepository, proxyConfigFlow: StateFlow, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -31,7 +31,7 @@ class GitHubClientProvider( createGitHubHttpClient( tokenStore = tokenStore, rateLimitRepository = rateLimitRepository, - authenticationState = authenticationState, + userSessionRepository = userSessionRepository, proxyConfig = proxyConfigFlow.value, ) @@ -41,14 +41,11 @@ class GitHubClientProvider( .distinctUntilChanged() .onEach { proxyConfig -> mutex.withLock { - // Replace-then-close: readers of [client] always see - // a live client. Closing first opens a window where - // an in-flight call could touch a closed engine. val replacement = createGitHubHttpClient( tokenStore = tokenStore, rateLimitRepository = rateLimitRepository, - authenticationState = authenticationState, + userSessionRepository = userSessionRepository, proxyConfig = proxyConfig, ) val previous = currentClient diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt index afcae952d..924dc2ed3 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt @@ -10,14 +10,13 @@ import io.ktor.client.statement.HttpResponse import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.util.network.UnresolvedAddressException -import kotlinx.coroutines.flow.Flow import kotlinx.serialization.json.Json import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.network.interceptor.RateLimitInterceptor import zed.rainxch.core.data.network.interceptor.UnauthorizedInterceptor import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.RateLimitException -import zed.rainxch.core.domain.repository.AuthenticationState +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.core.domain.repository.RateLimitRepository import java.io.IOException import kotlin.coroutines.cancellation.CancellationException @@ -27,7 +26,7 @@ expect fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient fun createGitHubHttpClient( tokenStore: TokenStore, rateLimitRepository: RateLimitRepository, - authenticationState: AuthenticationState? = null, + userSessionRepository: UserSessionRepository? = null, proxyConfig: ProxyConfig = ProxyConfig.System, ): HttpClient { val json = @@ -41,9 +40,9 @@ fun createGitHubHttpClient( this.rateLimitRepository = rateLimitRepository } - if (authenticationState != null) { + if (userSessionRepository != null) { install(UnauthorizedInterceptor) { - this.authenticationState = authenticationState + this.userSessionRepository = userSessionRepository } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorApiClient.kt index 89f7ce500..1833b8ae4 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorApiClient.kt @@ -5,12 +5,6 @@ import zed.rainxch.core.data.dto.MirrorListResponse class MirrorApiClient( private val backendApiClient: BackendApiClient, ) { - /** - * Fetches the mirror catalog from the backend. Delegates to - * [BackendApiClient] — which routes through the discovery proxy - * scope but never through MirrorRewriteInterceptor (the - * interceptor lives on the GitHub-bound client only). - */ suspend fun fetchList(): Result = backendApiClient.getMirrorList() } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriteInterceptor.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriteInterceptor.kt index ffa8f2011..e0afc061c 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriteInterceptor.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriteInterceptor.kt @@ -25,8 +25,6 @@ fun HttpClient.installMirrorRewrite() { if (rewritten != null) { val originalHost = request.url.host request.url.takeFrom(rewritten) - // Host changed — strip the user's GitHub bearer token so we - // never send it to a community mirror. if (rewritten.host != originalHost) { request.headers.remove(HttpHeaders.Authorization) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriter.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriter.kt index e0cf94d96..65436d750 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriter.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/MirrorRewriter.kt @@ -31,17 +31,6 @@ object MirrorRewriter { } }.getOrNull() - /** - * Two template styles supported: - * - * - `{url}` (whole-URL proxy): substituted with the full GitHub URL. - * Used by ghfast.top, gh-proxy.com, etc. - * - * - `{owner}/{repo}@{ref}/{path}` (path-decomposed): used by jsDelivr's - * `/gh/` endpoint and similar CDN-style mirrors. Parses the source - * GitHub URL to extract the four placeholders. Returns null if the - * source URL doesn't decompose cleanly. - */ fun applyTemplate( template: String, githubUrl: String, @@ -77,7 +66,6 @@ object MirrorRewriter { val segments = parsed.encodedPath.trimStart('/').split('/').filter { it.isNotEmpty() } return when (host) { "raw.githubusercontent.com" -> { - // /{owner}/{repo}/{ref}/{path...} if (segments.size < 4) return null Decomposed( owner = segments[0], @@ -87,7 +75,6 @@ object MirrorRewriter { ) } "github.com" -> { - // /{owner}/{repo}/raw/{ref}/{path...} or /{owner}/{repo}/blob/{ref}/{path...} if (segments.size < 5) return null val verb = segments[2] if (verb != "raw" && verb != "blob") return null diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt index eab4d0f7f..fe495799a 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.kt @@ -8,12 +8,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import zed.rainxch.core.domain.model.MirrorPreference import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.model.TrafficKind import zed.rainxch.core.domain.repository.MirrorRepository +import zed.rainxch.core.domain.repository.ProxyRepository data class MirrorActive( val template: String, @@ -27,8 +31,28 @@ object ProxyManager { private val mirror = AtomicReference(null) private var mirrorCollectorJob: Job? = null + private val seedMutex = Mutex() + @Volatile private var seedJob: Job? = null + fun configFlow(scope: ProxyScope): StateFlow = flows.getValue(scope).asStateFlow() + fun bootstrap(repository: ProxyRepository, appScope: CoroutineScope) { + if (seedJob != null) return + appScope.launch { + seedMutex.withLock { + if (seedJob != null) return@withLock + seedJob = launch { + ProxyScope.entries.forEach { scope -> + runCatching { + val cfg = repository.getProxyConfig(scope).first() + flows.getValue(scope).value = cfg + } + } + } + } + } + } + fun currentConfig(scope: ProxyScope): ProxyConfig = flows.getValue(scope).value fun setConfig( @@ -38,18 +62,8 @@ object ProxyManager { flows.getValue(scope).value = config } - /** - * Resolved active mirror — template plus the traffic kinds it can serve. - * Read on every outbound GitHub request; must be hot-path safe (atomic, no I/O). - * Null when preference is Direct or the catalog has no template for the - * selected mirror. - */ fun currentMirror(): MirrorActive? = mirror.get() - /** - * Convenience accessor for callers that only need the template string and - * don't gate by traffic kind. Prefer [currentMirror] for new code. - */ fun currentMirrorTemplate(): String? = mirror.get()?.template fun startMirrorCollector( diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManagerSeeding.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManagerSeeding.kt deleted file mode 100644 index 6419f6825..000000000 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManagerSeeding.kt +++ /dev/null @@ -1,11 +0,0 @@ -package zed.rainxch.core.data.network - -/** - * Marker type that represents "the [ProxyManager] has been seeded - * with the user's persisted per-scope proxy configs." Any component - * that constructs an HTTP client whose proxy is read from - * [ProxyManager] at creation time must inject this so Koin resolves - * the seeding step first — it forces the DI graph dependency to be - * explicit instead of depending on registration order. - */ -class ProxyManagerSeeding internal constructor() diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyTesterImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyTesterImpl.kt index a3b8a1156..1535ba225 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyTesterImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyTesterImpl.kt @@ -16,7 +16,10 @@ import kotlin.coroutines.cancellation.CancellationException import kotlin.time.TimeSource class ProxyTesterImpl : ProxyTester { - override suspend fun test(config: ProxyConfig): ProxyTestOutcome { + override suspend fun test(config: ProxyConfig): ProxyTestOutcome = + test(config, TEST_URL) + + override suspend fun test(config: ProxyConfig, url: String): ProxyTestOutcome { val client = createPlatformHttpClient(config).config { install(HttpTimeout) { @@ -29,7 +32,7 @@ class ProxyTesterImpl : ProxyTester { return try { val started = TimeSource.Monotonic.markNow() - val response: HttpResponse = client.get(TEST_URL) + val response: HttpResponse = client.get(url) val elapsed = started.elapsedNow().inWholeMilliseconds when { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt index bfbbba42a..949f0ac1a 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt @@ -14,15 +14,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import zed.rainxch.core.domain.model.ProxyConfig -/** - * Reactive holder for the HTTP client used by translation requests. - * Rebuilds the underlying client whenever the translation-scope - * proxy config changes so README translation picks up proxy updates - * without requiring an app restart. Mirrors [GitHubClientProvider] - * but keeps translation on its own client — translation endpoints - * (e.g. Google Translate) don't need any of the GitHub-specific - * interceptors, auth headers, or base URL defaults. - */ class TranslationClientProvider( proxyConfigFlow: StateFlow, ) { @@ -38,9 +29,7 @@ class TranslationClientProvider( .distinctUntilChanged() .onEach { config -> mutex.withLock { - // Build the replacement *before* closing the old one - // so the volatile read from [client] never observes a - // closed-but-not-yet-reassigned client. + val replacement = createPlatformHttpClient(config) val previous = currentClient currentClient = replacement diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/HostTokenInterceptor.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/HostTokenInterceptor.kt deleted file mode 100644 index 3e0bfd558..000000000 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/HostTokenInterceptor.kt +++ /dev/null @@ -1,36 +0,0 @@ -package zed.rainxch.core.data.network.interceptor - -import io.ktor.client.plugins.api.createClientPlugin -import io.ktor.client.request.header -import io.ktor.http.HttpHeaders -import kotlin.coroutines.cancellation.CancellationException -import zed.rainxch.core.domain.model.HostNames -import zed.rainxch.core.domain.repository.HostTokenRepository - -class HostTokenInterceptorConfig { - lateinit var repository: HostTokenRepository -} - -val HostTokenInterceptor = createClientPlugin("HostTokenInterceptor", ::HostTokenInterceptorConfig) { - val repo = pluginConfig.repository - onRequest { request, _ -> - val existing = request.headers[HttpHeaders.Authorization] - if (!existing.isNullOrBlank()) return@onRequest - // `api.github.com` requests must map back to the `github.com` - // storage key, otherwise the PAT is never attached. Forgejo / - // Codeberg / Gitea APIs share the host with storage, so the - // mapping is a no-op for them. - val host = HostNames.apiHostToTokenHost(request.url.host) - if (host.isBlank()) return@onRequest - val token = try { - repo.get(host)?.token - } catch (ce: CancellationException) { - throw ce - } catch (_: Throwable) { - null - } - if (!token.isNullOrBlank()) { - request.header(HttpHeaders.Authorization, "Bearer $token") - } - } -} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt index 3d371c5d5..e51383704 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt @@ -5,13 +5,13 @@ import io.ktor.client.plugins.HttpClientPlugin import io.ktor.client.statement.HttpReceivePipeline import io.ktor.http.HttpHeaders import io.ktor.util.AttributeKey -import zed.rainxch.core.domain.repository.AuthenticationState +import zed.rainxch.core.domain.repository.UserSessionRepository class UnauthorizedInterceptor( - private val authenticationState: AuthenticationState, + private val userSessionRepository: UserSessionRepository, ) { class Config { - var authenticationState: AuthenticationState? = null + var userSessionRepository: UserSessionRepository? = null } companion object Plugin : HttpClientPlugin { @@ -21,8 +21,8 @@ class UnauthorizedInterceptor( override fun prepare(block: Config.() -> Unit): UnauthorizedInterceptor { val config = Config().apply(block) return UnauthorizedInterceptor( - authenticationState = - requireNotNull(config.authenticationState) { + userSessionRepository = + requireNotNull(config.userSessionRepository) { "AuthenticationState must be provided" }, ) @@ -35,9 +35,9 @@ class UnauthorizedInterceptor( scope.receivePipeline.intercept(HttpReceivePipeline.After) { val tokenKey = extractBearerToken(subject.call.request.headers[HttpHeaders.Authorization]) if (subject.status.value == 401) { - plugin.authenticationState.notifySessionExpired(tokenKey) + plugin.userSessionRepository.notifySessionExpired(tokenKey) } else { - plugin.authenticationState.notifyRequestSucceeded(tokenKey) + plugin.userSessionRepository.notifyRequestSucceeded(tokenKey) } proceedWith(subject) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsCacheStoreImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsCacheStoreImpl.kt index eef9d7b77..959a0147e 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsCacheStoreImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsCacheStoreImpl.kt @@ -68,7 +68,7 @@ class AnnouncementsCacheStoreImpl( if (!legacy.isNullOrEmpty()) { val putOk = ksafe.safePut(K_CACHED_PAYLOAD, legacy) if (!putOk) { - // Don't drop the only copy if write failed; retry next launch. + return } runCatching { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsRepositoryImpl.kt index a1a7ab76e..e8a70a7f4 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AnnouncementsRepositoryImpl.kt @@ -14,9 +14,9 @@ import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.model.Announcement import zed.rainxch.core.domain.model.AnnouncementCategory +import zed.rainxch.core.domain.model.AnnouncementsFeedSnapshot import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.repository.AnnouncementsCacheStore -import zed.rainxch.core.domain.repository.AnnouncementsFeedSnapshot import zed.rainxch.core.domain.repository.AnnouncementsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.AppVersionInfo diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/CacheRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/CacheRepositoryImpl.kt new file mode 100644 index 000000000..22f0a0c70 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/CacheRepositoryImpl.kt @@ -0,0 +1,28 @@ +package zed.rainxch.core.data.repository + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import zed.rainxch.core.data.cache.CacheManager +import zed.rainxch.core.data.services.FileLocationsProvider +import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.repository.CacheRepository + +class CacheRepositoryImpl ( + private val logger: GitHubStoreLogger, + private val fileLocationsProvider: FileLocationsProvider, + private val cacheManager: CacheManager, +) : CacheRepository { + override fun observeCacheSize(): Flow = + flow { + val sizeBytes = fileLocationsProvider.getCacheSizeBytes() + emit(sizeBytes) + }.flowOn(Dispatchers.IO) + + override suspend fun clearCache() { + fileLocationsProvider.clearCacheFiles() + cacheManager.clearAll() + logger.debug("Cache cleared successfully") + } +} \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt deleted file mode 100644 index ee136b5ac..000000000 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/DeviceIdentityRepositoryImpl.kt +++ /dev/null @@ -1,87 +0,0 @@ -package zed.rainxch.core.data.repository - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import eu.anifantakis.lib.ksafe.KSafe -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid -import zed.rainxch.core.domain.repository.DeviceIdentityRepository -import zed.rainxch.core.data.secure.safeGet -import zed.rainxch.core.data.secure.safePut - -@OptIn(ExperimentalUuidApi::class) -class DeviceIdentityRepositoryImpl( - private val ksafe: KSafe, - private val legacyDataStore: DataStore, -) : DeviceIdentityRepository { - - private val deviceIdMutex = Mutex() - - @Volatile private var migrated: Boolean = false - - override suspend fun getDeviceId(): String = - deviceIdMutex.withLock { - migrateIfNeeded() - // Let read failures surface — a transient Keystore / DataStore - // hiccup must not silently mint a new ID and orphan telemetry. - val existing = ksafe.safeGet(DEVICE_ID_KEY, "") - if (existing.isNotBlank()) return existing - - val generated = Uuid.random().toString() - ksafe.safePut(DEVICE_ID_KEY, generated) - generated - } - - override suspend fun resetDeviceId(): String = - deviceIdMutex.withLock { - // Migrate first so any pre-existing legacy entry is removed even - // if the user resets before the first getDeviceId() call. - migrateIfNeeded() - val next = Uuid.random().toString() - ksafe.safePut(DEVICE_ID_KEY, next) - // Reset semantics: any legacy copy must die too. - runCatching { - legacyDataStore.edit { it.remove(stringPreferencesKey("anonymous_device_id")) } - } - next - } - - private suspend fun migrateIfNeeded() { - if (migrated) return - val existing = runCatching { ksafe.safeGet(DEVICE_ID_KEY, "") }.getOrDefault("") - if (existing.isNotBlank()) { - migrated = true - return - } - val legacy = runCatching { - legacyDataStore.data.first()[stringPreferencesKey("anonymous_device_id")] - }.getOrNull() - if (legacy.isNullOrBlank()) { - // Nothing to migrate; first-run install or already cleaned. - migrated = true - return - } - val putOk = ksafe.safePut(DEVICE_ID_KEY, legacy) - if (!putOk) { - // Keep legacy intact for next attempt; do not flip marker. - return - } - val deleteResult = runCatching { - legacyDataStore.edit { it.remove(stringPreferencesKey("anonymous_device_id")) } - } - if (deleteResult.isFailure) { - // Plaintext copy still on disk — retry cleanup next launch. - return - } - migrated = true - } - - private companion object { - const val DEVICE_ID_KEY = "anonymous_device_id" - } -} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index 1fce12802..d8e1d23b0 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -24,11 +24,9 @@ import zed.rainxch.core.data.local.db.entities.SigningFingerprintEntity import zed.rainxch.core.data.mappers.toRepoMatchResults import zed.rainxch.core.data.mappers.toRequestItem import zed.rainxch.core.data.network.BackendApiClient -import zed.rainxch.core.data.network.BackendException import zed.rainxch.core.data.network.ExternalMatchApi -import zed.rainxch.core.data.network.RateLimitedException +import zed.rainxch.core.data.network.ForgejoClientRegistry import zed.rainxch.core.domain.repository.ExternalImportRepository -import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalAppScanner import zed.rainxch.core.domain.system.ExternalDecisionSnapshot @@ -39,6 +37,8 @@ import zed.rainxch.core.domain.system.RepoMatchSuggestion import zed.rainxch.core.domain.system.ScanResult import zed.rainxch.core.data.secure.safeGet import zed.rainxch.core.data.secure.safePut +import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.system.InstallerKind class ExternalImportRepositoryImpl( private val scanner: ExternalAppScanner, @@ -48,17 +48,11 @@ class ExternalImportRepositoryImpl( private val legacyDataStore: DataStore, private val externalMatchApi: ExternalMatchApi, private val backendClient: BackendApiClient, - private val telemetry: TelemetryRepository, - private val forgejoClientRegistry: zed.rainxch.core.data.network.ForgejoClientRegistry, - private val tweaksRepository: zed.rainxch.core.domain.repository.TweaksRepository, + private val forgejoClientRegistry: ForgejoClientRegistry, + private val tweaksRepository: TweaksRepository, ) : ExternalImportRepository { - // Snapshot cache survives only for the lifetime of the process. Decisions - // (linked / skipped / never-ask) are persisted in `external_links`; the - // raw candidate metadata (label, fingerprint, hint) is regenerated on the - // next scan rather than persisted to keep the schema small. private val candidateSnapshot = MutableStateFlow>(emptyMap()) - override fun pendingCandidatesFlow(): Flow> = combine( candidateSnapshot, @@ -76,10 +70,6 @@ class ExternalImportRepositoryImpl( ksafe.safeGet(K_INITIAL_SCAN_AT, null) }.getOrNull() == null runCatching { - if (firstLaunch) { - runCatching { telemetry.importScanStarted(trigger = "first_launch") } - .onFailure { Logger.d { "telemetry importScanStarted failed: ${it.message}" } } - } runFullScan() }.onSuccess { if (firstLaunch) markInitialScanComplete() @@ -119,12 +109,6 @@ class ExternalImportRepositoryImpl( .onFailure { Logger.d { "prune pending failed: ${it.message}" } } val durationMs = nowMillis() - started - runCatching { - telemetry.importScanCompleted( - candidateCountBucket = bucketCandidateCount(candidates.size), - durationMsBucket = bucketDurationMs(durationMs), - ) - }.onFailure { Logger.d { "telemetry importScanCompleted failed: ${it.message}" } } return ScanResult( totalCandidates = candidates.size, @@ -149,7 +133,6 @@ class ExternalImportRepositoryImpl( val existing = externalLinkDao.get(pkg) if (rawCandidate == null) { - // Package is genuinely gone (uninstalled). Hard-delete the row. if (existing != null) { externalLinkDao.deleteByPackageName(pkg) } @@ -157,21 +140,14 @@ class ExternalImportRepositoryImpl( } if (!hasPositiveEvidence(rawCandidate)) { - // Package exists but currently has no positive evidence. Only - // drop PENDING_REVIEW rows here — preserved decisions - // (MATCHED / NEVER_ASK / SKIPPED) must survive a transient - // evidence miss (e.g., F-Droid seed not yet synced, manifest - // hint changed across reinstall). Deleting them on a single - // transient miss would silently wipe the user's decisions. if (existing?.state == ExternalLinkState.PENDING_REVIEW.name) { externalLinkDao.deleteByPackageName(pkg) } return@forEach } - val candidate = rawCandidate - deltaCandidates += candidate - val updated = mergeCandidate(existing, candidate, now) + deltaCandidates += rawCandidate + val updated = mergeCandidate(existing, rawCandidate, now) if (existing == null) newCandidates++ if (updated.state == ExternalLinkState.PENDING_REVIEW.name) pendingReview++ externalLinkDao.upsert(updated) @@ -198,9 +174,6 @@ class ExternalImportRepositoryImpl( override suspend fun resolveMatches(candidates: List): List { if (candidates.isEmpty()) return emptyList() - // Strategy 3: signing-fingerprint lookup against the local seed - // table. Hits are the strongest non-manifest signal we have — - // signature equality is cryptographic, no string fuzzing. val fingerprintHits = mutableMapOf() candidates.forEach { candidate -> val fp = candidate.signingFingerprint ?: return@forEach @@ -232,45 +205,16 @@ class ExternalImportRepositoryImpl( } }.onFailure { error -> Logger.w(error) { "external-match batch failed; continuing" } - runCatching { - telemetry.externalMatchApiFailure( - statusCodeBucket = bucketApiFailure(error), - retried = false, - ) - }.onFailure { Logger.d { "telemetry externalMatchApiFailure failed: ${it.message}" } } } } - // Strategy 4: query each configured Forgejo / Codeberg host using - // the app label as a free-text search term. Strict bounds: - // - // * Only run for candidates with no high-confidence existing - // hit (manifest / fingerprint / backend ≥ 0.7). Most installed - // apps either match by fingerprint or have nothing useful to - // match against; running a 5-host fanout per candidate - // blew up scan time from seconds to minutes for users with - // larger installed-app lists. - // * Cap the total number of candidates that trigger a Forgejo - // search per scan invocation, so even worst-case unmatched - // populations don't burn through Codeberg's rate limit - // (2000 req / 300s per IP). - // * The pricier `resolveMatches` callers (full scan) are - // already off the main thread; smart-match's single- - // candidate variant skips the cap. val forgejoHits = mutableMapOf>() val forgejoHostList = forgejoSearchHosts() - // When the user has explicitly added custom forges, they care - // about forge results enough to warrant a wider sweep — double - // the budget so more apps actually get cross-checked against - // the user's hosts before we cap out. val hasUserHosts = runCatching { tweaksRepository.getCustomForgeHosts().first().isNotEmpty() }.getOrDefault(false) if (forgejoHostList.isNotEmpty()) { val totalBudget = if (hasUserHosts) FORGEJO_SEARCH_CANDIDATE_BUDGET * 2 else FORGEJO_SEARCH_CANDIDATE_BUDGET - // Build the list of (candidate, query) pairs eligible for - // Forgejo search, applying the skip-threshold filter and - // candidate budget *before* we issue any HTTP. val eligible = candidates.asSequence() .filter { candidate -> val existing = listOfNotNull( @@ -287,12 +231,6 @@ class ExternalImportRepositoryImpl( .take(totalBudget) .toList() - // Cross-product (eligibleCandidate × host) flattened into a - // single list, then fanned out in parallel using a bounded - // semaphore so we don't open more than N concurrent HTTP - // sockets at once. Per-call timeout caps tail latency from - // a slow host so a single dead instance can't drag the - // whole scan past the user's patience threshold. data class SearchTask( val packageName: String, val host: String, @@ -341,25 +279,9 @@ class ExternalImportRepositoryImpl( backendResults[candidate.packageName]?.let { suggestions += it } forgejoHits[candidate.packageName]?.let { suggestions += it } val deduped = suggestions - // Dedup across hosts too — a repo can legitimately exist - // on both Codeberg and a mirror, but for suggestions we - // only want one row per logical {host, owner, repo}. .distinctBy { "${it.sourceHost ?: "github"}|${it.owner}/${it.repo}" } .sortedByDescending { it.confidence } - // Emit one `import_match_attempted` per strategy that - // produced a hit for this candidate. Bucketed confidence - // only — never owner/repo/package name. - deduped.groupBy { it.source }.forEach { (source, hits) -> - val top = hits.maxByOrNull { it.confidence } ?: return@forEach - runCatching { - telemetry.importMatchAttempted( - strategy = source.telemetryStrategy(), - confidenceBucket = bucketConfidence(top.confidence), - ) - }.onFailure { Logger.d { "telemetry importMatchAttempted failed: ${it.message}" } } - } - RepoMatchResult(packageName = candidate.packageName, suggestions = deduped) } } @@ -496,9 +418,6 @@ class ExternalImportRepositoryImpl( RepoMatchSuggestion( owner = item.owner.login, repo = item.name, - // Search is the user-driven override path. The - // 0.5 confidence is a placeholder — UX is "I'll - // pick this myself", not a confidence bet. confidence = SEARCH_OVERRIDE_CONFIDENCE, source = RepoMatchSource.SEARCH, stars = item.stargazersCount, @@ -509,7 +428,6 @@ class ExternalImportRepositoryImpl( } override suspend fun syncSigningFingerprintSeed() { - val started = nowMillis() var rowsAdded = 0 try { val lastObservedAt = runCatching { signingFingerprintDao.lastSyncTimestamp() } @@ -551,16 +469,6 @@ class ExternalImportRepositoryImpl( } catch (e: Exception) { Logger.w(e) { "signing-seeds sync aborted" } } - emitSeedSyncTelemetry(rowsAdded, nowMillis() - started) - } - - private suspend fun emitSeedSyncTelemetry(rowsAdded: Int, durationMs: Long) { - runCatching { - telemetry.signingSeedSyncCompleted( - rowsAddedBucket = bucketSeedRowsAdded(rowsAdded), - durationMsBucket = bucketDurationMs(durationMs), - ) - }.onFailure { Logger.d { "telemetry signingSeedSyncCompleted failed: ${it.message}" } } } override suspend fun pruneExpiredSkips() { @@ -601,7 +509,6 @@ class ExternalImportRepositoryImpl( if (existing != null && shouldPreserveDecision(existing, now)) { return existing.copy( signingFingerprint = candidate.signingFingerprint ?: existing.signingFingerprint, - // installerKind is authoritative per-scan from PackageManager; signingFingerprint may briefly be null on extraction failure, so we hold the previous value. installerKind = candidate.installerKind.name, ) } @@ -635,69 +542,7 @@ class ExternalImportRepositoryImpl( private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds() - private fun bucketCount(n: Int): String = - when { - n <= 0 -> "0" - n <= 2 -> "1-2" - n <= 9 -> "3-9" - n <= 49 -> "10-49" - else -> "50+" - } - - private fun bucketCandidateCount(n: Int): String = - when { - n <= 0 -> "0" - n <= 9 -> "1-9" - n <= 49 -> "10-49" - n <= 199 -> "50-199" - else -> "200+" - } - - private fun bucketDurationMs(ms: Long): String = - when { - ms < 500L -> "<500" - ms < 2_000L -> "500-2000" - ms < 5_000L -> "2000-5000" - else -> ">5000" - } - - private fun bucketConfidence(c: Double): String = - when { - c < 0.5 -> "<0.5" - c < 0.85 -> "0.5-0.85" - else -> ">=0.85" - } - - private fun bucketSeedRowsAdded(n: Int): String = - when { - n <= 0 -> "0" - n <= 99 -> "1-99" - n <= 999 -> "100-999" - else -> "1000+" - } - - private fun bucketApiFailure(error: Throwable): String = - when (error) { - is BackendException -> { - val code = error.statusCode - if (code in 400..499) "4xx" else "5xx" - } - is RateLimitedException -> "4xx" - else -> "network" - } - - /** - * Canonical Forgejo hosts we always probe (Codeberg + the public - * Gitea/Forgejo demo instances) merged with whatever the user added - * under Tweaks → Network → Custom forges. We cap the merged set at - * 5 hosts to bound concurrent fanout per match attempt — extra - * hosts are dropped silently rather than queued. - */ private suspend fun forgejoSearchHosts(): List { - // Mirror the known-Forgejo set in RepositoryUrlParser so the - // URL-parse path and the smart-match path stay aligned — a URL - // we accept as a Forgejo link is also a host we'll proactively - // search during auto-import. val canonical = listOf("codeberg.org", "gitea.com", "git.disroot.org") val user = runCatching { tweaksRepository.getCustomForgeHosts().first() } .getOrNull() @@ -717,14 +562,8 @@ class ExternalImportRepositoryImpl( limit = FORGEJO_SEARCH_LIMIT, ).getOrNull() ?: return emptyList() response.data - .orEmpty() .take(FORGEJO_SEARCH_LIMIT) .mapIndexed { index, repo -> - // Confidence falls off with rank so the top hit on - // a given host outranks lower hits — but stays - // below the high-confidence GitHub strategies so - // manifest / fingerprint / backend matches still - // sort above when present. val confidence = FORGEJO_SEARCH_BASE_CONFIDENCE - (index * FORGEJO_SEARCH_RANK_DECAY) RepoMatchSuggestion( @@ -745,55 +584,21 @@ class ExternalImportRepositoryImpl( } } - private fun RepoMatchSource.telemetryStrategy(): String = - when (this) { - RepoMatchSource.MANIFEST -> "manifest" - RepoMatchSource.SEARCH -> "search" - RepoMatchSource.FINGERPRINT -> "fingerprint" - RepoMatchSource.MANUAL -> "manual" - RepoMatchSource.FORGEJO_SEARCH -> "forgejo_search" - } - companion object { private const val K_INITIAL_SCAN_AT = "external_import_initial_scan_at" private const val SKIP_TTL_MILLIS: Long = 7L * 24 * 60 * 60 * 1000 - // Bounds the per-match fanout so a user with many custom forges - // doesn't trigger a many-way HTTP burst on every smart-match. private const val FORGEJO_SEARCH_MAX_HOSTS = 5 private const val FORGEJO_SEARCH_LIMIT = 5 - // Hard cap on how many candidates trigger a Forgejo fanout per - // resolveMatches call. Smart-match always sends a single - // candidate so this only matters for the import-scan path with - // dozens to hundreds of installed apps. 12 unmatched candidates - // × 5 hosts = at most 60 HTTP calls per scan — well under - // Codeberg's rate limit and finishes in seconds. private const val FORGEJO_SEARCH_CANDIDATE_BUDGET = 12 - // Confidence threshold above which we DON'T bother running the - // Forgejo search — the GitHub-side hit will dominate the - // suggestion list and a remote search would be pure waste. 0.7 - // matches the backend's "confident" bucket. private const val FORGEJO_SEARCH_SKIP_THRESHOLD = 0.7 - // Concurrency cap for the parallel fanout — each permit holds - // one open HTTP socket. 8 keeps us well below mobile network - // stacks' typical 10–16 socket cap and the per-host Forgejo - // rate limit (2000 / 300s) at 26 req/s burst headroom even if - // every permit hits the same host (which they won't, since - // tasks alternate hosts in interleaved order). private const val FORGEJO_SEARCH_CONCURRENCY = 8 - // Hard per-call timeout. The shared HttpClient still has a 60s - // request timeout for the install path; this is a tighter - // wrapper just for smart-match so a single slow / dead host - // doesn't drag the scan latency to the floor. private const val FORGEJO_SEARCH_PER_CALL_TIMEOUT_MS = 4_000L - // Sits below the high-confidence GitHub strategies (manifest / - // fingerprint / backend) but above zero so Forgejo hits still - // surface when nothing else matched. private const val FORGEJO_SEARCH_BASE_CONFIDENCE = 0.55 private const val FORGEJO_SEARCH_RANK_DECAY = 0.08 private const val MATCH_BATCH_SIZE = 25 @@ -804,12 +609,10 @@ class ExternalImportRepositoryImpl( private const val MAX_SEED_PAGES = 50 private const val SEED_SOURCE_BACKEND = "backend_seed" - // Stores whose entire catalog is sourced from GitHub releases — apps installed - // through them are surfaced even without a manifest hint or fingerprint match. private val TRUSTED_GITHUB_INSTALLERS = setOf( - zed.rainxch.core.domain.system.InstallerKind.STORE_OBTAINIUM, - zed.rainxch.core.domain.system.InstallerKind.STORE_FDROID, + InstallerKind.STORE_OBTAINIUM, + InstallerKind.STORE_FDROID, ) } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/HostTokenRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/HostTokenRepositoryImpl.kt index 5803cf126..60d74c303 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/HostTokenRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/HostTokenRepositoryImpl.kt @@ -25,8 +25,8 @@ import kotlinx.serialization.json.Json import kotlin.time.Clock import zed.rainxch.core.domain.model.HostNames import zed.rainxch.core.domain.model.HostToken +import zed.rainxch.core.domain.model.TokenValidation import zed.rainxch.core.domain.repository.HostTokenRepository -import zed.rainxch.core.domain.repository.TokenValidation class HostTokenRepositoryImpl( private val ksafe: KSafe, @@ -43,15 +43,10 @@ class HostTokenRepositoryImpl( private val json = Json { ignoreUnknownKeys = true } init { - // Kick off the initial KSafe read so the first collector of - // `observeAll()` already sees the persisted list rather than an - // empty value emitted from a fresh `MutableStateFlow`. + initScope.launch { runCatching { ensureLoaded() } } } - // `onStart` guarantees the load runs before the first emission, so - // collectors that subscribe before `init { … }` lands still get the - // persisted list as their initial value. override fun observeAll(): Flow> = cache.asStateFlow().onStart { ensureLoaded() } @@ -148,10 +143,6 @@ class HostTokenRepositoryImpl( loaded = true } - // Persists then promotes the in-memory cache. If KSafe write fails, - // the in-memory value is rolled back and the exception is thrown so - // the caller can surface the failure to the UI instead of pretending - // the save succeeded. private suspend fun persistOrThrow(list: List) { val previous = cache.value val encoded = json.encodeToString(ListSerializer(HostToken.serializer()), list) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 1f3b20a1b..42f83e077 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -25,8 +25,8 @@ import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp +import zed.rainxch.core.domain.model.MatchingPreview import zed.rainxch.core.domain.repository.InstalledAppsRepository -import zed.rainxch.core.domain.repository.MatchingPreview import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.model.isEffectivelyPreRelease import zed.rainxch.core.domain.util.AssetFilter @@ -42,25 +42,11 @@ class InstalledAppsRepositoryImpl( private val backendApiClient: zed.rainxch.core.data.network.BackendApiClient, private val forgejoClientRegistry: zed.rainxch.core.data.network.ForgejoClientRegistry, ) : InstalledAppsRepository { - // Reads the current Ktor client at every call site so any proxy - // change (ProxyManager rebuilds the client via [clientProvider]) - // is picked up immediately on the next request without requiring - // the repository itself to be reconstructed. + private val httpClient: HttpClient get() = clientProvider.client private companion object { - /** - * How many releases the update checker fetches in one request. - * Picked to balance: - * - Monorepos that ship multiple sibling apps in close succession - * (need a few releases of headroom to find a match for the - * targeted app via [InstalledApp.fallbackToOlderReleases]) - * - Avoiding unnecessary GitHub API quota burn for the common case - * of a single-app repo where 1 release is enough. - * - * 50 is the GitHub API per_page maximum that doesn't require - * pagination, and is enough to cover ~3 months of daily releases. - */ + const val RELEASE_WINDOW = 50 } @@ -109,20 +95,6 @@ class InstalledAppsRepositoryImpl( installedAppsDao.deleteByPackageName(packageName) } - /** - * Fetches up to [RELEASE_WINDOW] releases for [owner]/[repo], filters - * out drafts, applies the pre-release policy, and returns them sorted - * by `publishedAt` descending. Empty list on failure (logged at error). - * - * Pre-release policy: a release is filtered out when - * `includePreReleases = false` AND either the GitHub `prerelease` - * flag is `true` OR the tag/name contains a recognised pre-release - * marker (see [GithubRelease.isEffectivelyPreRelease]). The tag - * heuristic catches the common maintainer mistake of tagging - * `v2.0.0-rc.1` with `prerelease: false`. Whenever the flag and - * heuristic disagree we emit a diagnostic so the drift is - * traceable in session logs. - */ private suspend fun fetchReleaseWindow( owner: String, repo: String, @@ -184,8 +156,7 @@ class InstalledAppsRepositoryImpl( .filter { includePreReleases || !it.isEffectivelyPreRelease() } .toList() } catch (e: CancellationException) { - // Structured concurrency: cancellation must propagate. Never - // silently convert a cancelled fetch into an empty result. + throw e } catch (e: Exception) { Logger.e { "Failed to fetch releases for $owner/$repo: ${e.message}" } @@ -193,51 +164,12 @@ class InstalledAppsRepositoryImpl( } } - /** - * Result of [resolveTrackedRelease] — a candidate release plus the asset - * the installer should download for it. `null` when no release in the - * window contains a usable asset (after filter + arch matching). - * - * [variantWasLost] is true when the user has a [InstalledApp.preferredAssetVariant] - * set but none of this release's assets matched it. The caller flips - * `preferredVariantStale` based on this so the UI can prompt the user - * to pick a new variant. - */ private data class ResolvedRelease( val release: GithubRelease, val primaryAsset: GithubAsset, val variantWasLost: Boolean, ) - /** - * Walks [releases] (already in newest-first order) and returns the first - * release whose installable asset list — after applying [filter] — yields - * a usable asset. The picker tries, in order: - * - * 1. **Token-set match** — pinned token fingerprint equals the asset's - * 2. **Glob match** — pinned glob pattern equals the asset's - * 3. **Tail-string match** — legacy substring-tail equality - * 4. **Same-position fallback** — same index, same total count of - * installable assets as when the user originally pinned - * 5. **Platform auto-pick** — architecture-aware default - * - * Layers 1–3 are wrapped behind [AssetVariant.resolvePreferredAsset]. - * Layer 4 is consulted only when 1–3 all miss but the new release - * has exactly the same number of installable assets as the picked - * release. Layer 5 keeps updates flowing even when the variant is - * completely lost — the caller flips `variantWasLost` so the UI - * can surface the discrepancy. - * - * When [filter] is null, only the first release in the window is - * considered: this preserves the pre-existing behaviour for apps that - * don't track a monorepo. - * - * When [filter] is non-null and [fallbackToOlderReleases] is false, the - * walker still only inspects the first release. The semantics are: - * "Apply the filter to the latest release, but don't dig further." - * This matches Obtainium's defaults and avoids accidental downgrades for - * apps where the user just wants a stricter asset picker. - */ private fun resolveTrackedRelease( releases: List, filter: AssetFilter?, @@ -259,10 +191,6 @@ class InstalledAppsRepositoryImpl( releases.take(1) } - // "Has any pin" tracks whether the user has *something* stored - // for variant identity — used to decide whether the - // `variantWasLost` flag should flip on. Without this, an app - // that's never been pinned would always look "lost". val hasAnyPin = preferredVariant != null || preferredTokens.isNotEmpty() || @@ -277,7 +205,6 @@ class InstalledAppsRepositoryImpl( if (installableForApp.isEmpty()) continue - // Layers 1–3: token set, glob, then legacy tail string. val fingerprintMatch = AssetVariant.resolvePreferredAsset( assets = installableForApp, @@ -286,9 +213,6 @@ class InstalledAppsRepositoryImpl( pinnedGlob = preferredGlob, ) - // Layer 4: same-position fallback. Only consulted when no - // fingerprint matched and the user actually pinned - // *something* (otherwise the index is meaningless). val positionMatch = if (fingerprintMatch == null && hasAnyPin) { AssetVariant.resolveBySamePosition( @@ -300,26 +224,6 @@ class InstalledAppsRepositoryImpl( null } - // Layer 5: platform auto-pick (last resort, never null - // unless the platform installer can't pick anything). - // - // Scope the auto-pick input by package flavor: when the - // tracked app's package id has no flavor token (e.g. plain - // `com.foo.bar`), drop release assets that look like flavor - // variants (`-fdroid.apk`, `-foss.apk`) so the picker - // doesn't silently swap the user's installed APK for a - // sibling artifact that ships under a *different* package - // id (`com.foo.bar.fdroid`). The mirror also applies: a - // tracked `.fdroid` package keeps fdroid-named assets. - // Pinned variants are unaffected — fingerprintMatch / - // positionMatch run against the full installable set above. - // Stem filter (issue #591): when the tracked app has a known - // prior asset name, restrict the auto-pick pool to release - // assets that share its base-name stem. Stops sibling apps - // shipped from the same repo (`AppA-1.10.apk` vs - // `AppB-2.20.apk`) from cross-pollinating: without this, - // `choosePrimaryAsset` would pick the highest numeric - // version regardless of the filename prefix. val installedStem = installedAssetName ?.let { AssetVariant.extractBaseStem(it) } @@ -335,11 +239,7 @@ class InstalledAppsRepositoryImpl( pool.filter { AssetVariant.extractBaseStem(it.name) == installedStem } - // Only honour the stem filter when it - // actually keeps something; otherwise fall - // back to the broader pool. A maintainer - // legitimately renaming the binary should - // not strand the update check. + if (matching.isNotEmpty()) matching else pool } } @@ -348,11 +248,6 @@ class InstalledAppsRepositoryImpl( ?: installer.choosePrimaryAsset(autoPickPool) ?: continue - // The variant is "lost" when the user had a pin but neither - // a fingerprint nor a same-position match recovered it. - // Same-position rescues silently (it's a confidence-trick - // — the user can't tell anything went wrong) so we don't - // flag it as lost; otherwise the UI would nag every check. val variantWasLost = hasAnyPin && fingerprintMatch == null && positionMatch == null @@ -379,16 +274,11 @@ class InstalledAppsRepositoryImpl( ) if (releases.isEmpty()) { - // The repo has no visible releases (or the fetch failed - // softly). Drop any stale update metadata so the badge - // doesn't outlive the release that set it. + installedAppsDao.clearUpdateMetadata(packageName, System.currentTimeMillis()) return false } - // Compile the per-app filter once. Invalid regexes are treated as - // "no filter" so we don't break the app silently — the user is - // told about the syntax error in the advanced settings sheet. val compiledFilter = AssetFilter.parse(app.assetFilterRegex) ?.onFailure { error -> @@ -417,21 +307,13 @@ class InstalledAppsRepositoryImpl( "No matching release found for ${app.appName} in window of ${releases.size}; " + "filter=${app.assetFilterRegex}, fallback=${app.fallbackToOlderReleases}" } - // Filter matches nothing in the fetched window — clear - // any cached latest-release metadata so the UI doesn't - // keep pointing at an asset that no longer matches. + installedAppsDao.clearUpdateMetadata(packageName, System.currentTimeMillis()) return false } val (matchedRelease, primaryAsset, variantWasLost) = resolved - // Canary: when installedVersionCode and latestVersionCode are - // both known and equal, the user already has the matched - // release. The tag-string compare below can still flip true - // (#515) if `installedVersion` got out of sync — e.g. after - // a self-update where the resolver path missed updating the - // tag. Trust the version-code parity over the string compare. val installedCode = app.installedVersionCode val latestCode = app.latestVersionCode val codesAlreadyMatch = @@ -440,10 +322,6 @@ class InstalledAppsRepositoryImpl( latestCode > 0L && installedCode == latestCode - // Auto-unskip when a strictly newer release than the one - // the user skipped lands. We bias toward re-notifying so a - // stale skip can't pin the badge off forever — users can - // re-skip the new tag if they don't want it either. val skippedTag = app.skippedReleaseTag val matchesSkipped = skippedTag != null && @@ -490,12 +368,6 @@ class InstalledAppsRepositoryImpl( latestReleasePublishedAt = matchedRelease.publishedAt, ) - // Backfill: when codes already match but the row's - // `installedVersion` (tag) drifted from the matched release - // (typical for rows written before #515 was fixed — e.g. - // installedVersion="1.7.0" left over from before a - // self-update completed), pin the tag to the matched - // release so subsequent checks don't keep re-flagging. if (codesAlreadyMatch && app.installedVersion != matchedRelease.tagName ) { @@ -508,10 +380,6 @@ class InstalledAppsRepositoryImpl( ) } - // Sync the staleness flag with what the resolver actually - // observed: flip on when the user's pinned variant has - // disappeared from the latest matching release, flip off - // (and only when previously set) when it's back in business. if (variantWasLost != app.preferredVariantStale) { installedAppsDao.updateVariantStaleness(packageName, variantWasLost) } @@ -568,14 +436,6 @@ class InstalledAppsRepositoryImpl( ), ) - // Recompute `isUpdateAvailable` against the EXISTING upstream - // `latestVersion` snapshot, not the freshly installed tag. When - // the user explicitly picks an older release (#542 reporter - // scenario) `newTag` < `latestVersion`, so the row still has - // an upstream update pending and the badge must NOT be cleared. - // The previous write stamped `latest* = new*` which then poisoned - // the `checkForUpdates` versionCode-parity canary into reporting - // "up to date" forever. val snapshotLatestVersion = app.latestVersion val isUpdateStillAvailable = !snapshotLatestVersion.isNullOrBlank() && @@ -593,13 +453,7 @@ class InstalledAppsRepositoryImpl( lastUpdatedAt = System.currentTimeMillis(), lastCheckedAt = System.currentTimeMillis(), signingFingerprint = signingFingerprint, - // When this update settled (isPendingInstall = false), - // wipe any leftover parked-install pointer so the apps - // screen stops advertising an Install CTA on a file - // the system already swapped under the row. Caller - // (DetailsViewModel.saveInstalledAppToDatabase) re-sets - // these explicitly via setPendingInstallFilePath - // afterwards on the still-pending path. + pendingInstallFilePath = if (isPendingInstall) app.pendingInstallFilePath else null, pendingInstallVersion = @@ -677,9 +531,6 @@ class InstalledAppsRepositoryImpl( fallback = fallbackToOlderReleases, ) - // Persisting is the authoritative operation — if the follow-up - // re-check fails (network down, rate limited, cancelled) we still - // keep the new filter. The next periodic worker run will catch up. try { checkForUpdates(packageName) } catch (e: CancellationException) { @@ -712,9 +563,6 @@ class InstalledAppsRepositoryImpl( siblingCount = siblingCount?.takeIf { it > 0 }, ) - // Re-run the update check so cached `latestAsset*` columns point - // at the variant the user just chose. Failures here are - // non-fatal: persistence is the authoritative step. try { checkForUpdates(packageName) } catch (e: CancellationException) { @@ -810,11 +658,6 @@ class InstalledAppsRepositoryImpl( ) } - // Version normalization + comparison lives in - // `core.domain.util.VersionMath` so the periodic update check, - // the external-install verdict in `PackageEventReceiver`, and any - // future surfaces all share one comparator. See #378. - private suspend fun fetchForgejoReleaseWindow( host: String, owner: String, diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt index 13b80047d..ca9acb2b4 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt @@ -43,6 +43,7 @@ class ProxyRepositoryImpl( init { migrationScope.launch { runCatching { migrateIfNeeded() } + runCatching { migrateMasterV2IfNeeded() } migrationDeferred.complete(Unit) } } @@ -70,6 +71,21 @@ class ProxyRepositoryImpl( ) } + private object MasterKeys { + const val TYPE = "master_proxy_type" + const val HOST = "master_proxy_host" + const val PORT = "master_proxy_port" + const val USERNAME = "master_proxy_username" + const val PASSWORD = "master_proxy_password" + } + + private fun useMasterKeyFor(scope: ProxyScope): String = + when (scope) { + ProxyScope.DISCOVERY -> "discovery_proxy_use_master" + ProxyScope.DOWNLOAD -> "download_proxy_use_master" + ProxyScope.TRANSLATION -> "translation_proxy_use_master" + } + override fun getProxyConfig(scope: ProxyScope): Flow = flow { migrationDeferred.await() val keys = keysFor(scope) @@ -155,6 +171,61 @@ class ProxyRepositoryImpl( if (value != null) ksafe.safePut(key, value) else ksafe.safeDelete(key) } + override fun getMasterProxyConfig(): Flow = flow { + migrationDeferred.await() + emitAll( + combine( + ksafe.safeGetFlow(MasterKeys.TYPE, null), + ksafe.safeGetFlow(MasterKeys.HOST, null), + ksafe.safeGetFlow(MasterKeys.PORT, null), + ksafe.safeGetFlow(MasterKeys.USERNAME, null), + ksafe.safeGetFlow(MasterKeys.PASSWORD, null), + ) { type, host, port, user, pass -> + if (type == null) null else parseConfig(type, host, port, user, pass) + }, + ) + } + + override suspend fun setMasterProxyConfig(config: ProxyConfig) { + migrationDeferred.await() + when (config) { + is ProxyConfig.None -> { + ksafe.safePut(MasterKeys.TYPE, "none") + ksafe.safeDelete(MasterKeys.HOST); ksafe.safeDelete(MasterKeys.PORT) + ksafe.safeDelete(MasterKeys.USERNAME); ksafe.safeDelete(MasterKeys.PASSWORD) + } + is ProxyConfig.System -> { + ksafe.safePut(MasterKeys.TYPE, "system") + ksafe.safeDelete(MasterKeys.HOST); ksafe.safeDelete(MasterKeys.PORT) + ksafe.safeDelete(MasterKeys.USERNAME); ksafe.safeDelete(MasterKeys.PASSWORD) + } + is ProxyConfig.Http -> { + ksafe.safePut(MasterKeys.TYPE, "http") + ksafe.safePut(MasterKeys.HOST, config.host) + ksafe.safePut(MasterKeys.PORT, config.port) + writeOrClear(MasterKeys.USERNAME, config.username) + writeOrClear(MasterKeys.PASSWORD, config.password) + } + is ProxyConfig.Socks -> { + ksafe.safePut(MasterKeys.TYPE, "socks") + ksafe.safePut(MasterKeys.HOST, config.host) + ksafe.safePut(MasterKeys.PORT, config.port) + writeOrClear(MasterKeys.USERNAME, config.username) + writeOrClear(MasterKeys.PASSWORD, config.password) + } + } + } + + override fun getUseMaster(scope: ProxyScope): Flow = flow { + migrationDeferred.await() + emitAll(ksafe.safeGetFlow(useMasterKeyFor(scope), false)) + } + + override suspend fun setUseMaster(scope: ProxyScope, useMaster: Boolean) { + migrationDeferred.await() + ksafe.safePut(useMasterKeyFor(scope), useMaster) + } + private suspend fun migrateIfNeeded() { if (migrated) return migrationLock.withLock { @@ -166,7 +237,7 @@ class ProxyRepositoryImpl( } val snapshot = runCatching { legacyDataStore.data.first() }.getOrNull() if (snapshot == null) { - // Don't mark complete — retry on next launch. + return } @@ -193,7 +264,6 @@ class ProxyRepositoryImpl( val user = snapshot[userLegacyKey] ?: snapshot[stringPreferencesKey("proxy_username")] val pass = snapshot[passLegacyKey] ?: snapshot[stringPreferencesKey("proxy_password")] - // Per-field tracking — only enqueue legacy delete if KSafe write succeeded. val typeOk = ksafe.safePut(keys.type, type) if (!typeOk) { anyFailure = true; return@forEach } scopeType?.let { keysToClear += typeLegacyKey } @@ -216,9 +286,6 @@ class ProxyRepositoryImpl( } } - // Drop the unscoped legacy keys only if we actually transferred them - // into at least one scope above. We don't know which scope owns them, - // but their fallback role is exhausted once any scope is populated. val anyScopeTouched = keysToClear.isNotEmpty() if (anyScopeTouched) { keysToClear += stringPreferencesKey("proxy_type") @@ -244,7 +311,48 @@ class ProxyRepositoryImpl( } } + private suspend fun migrateMasterV2IfNeeded() { + val alreadyDone = runCatching { + ksafe.safeGet(MIGRATION_MARKER_MASTER_V2, false) + }.getOrDefault(false) + if (alreadyDone) return + + val configs = ProxyScope.entries.map { scope -> + scope to readScopeConfigDirect(scope) + } + + // Plurality vote: most common scope config becomes the master. + // Tie-break: Download > Discovery > Translation (most-common scope usage order). + val tieBreakOrder = listOf(ProxyScope.DOWNLOAD, ProxyScope.DISCOVERY, ProxyScope.TRANSLATION) + val counts = configs.groupBy { it.second }.mapValues { it.value.size } + val maxCount = counts.values.maxOrNull() ?: 0 + val winners = counts.filter { it.value == maxCount }.keys + val winnerConfig = tieBreakOrder.firstNotNullOfOrNull { scope -> + configs.firstOrNull { it.first == scope && it.second in winners }?.second + } ?: configs.first().second + + runCatching { setMasterProxyConfig(winnerConfig) } + + configs.forEach { (scope, config) -> + val matches = config == winnerConfig + runCatching { setUseMaster(scope, matches) } + } + + runCatching { ksafe.safePut(MIGRATION_MARKER_MASTER_V2, true) } + } + + private suspend fun readScopeConfigDirect(scope: ProxyScope): ProxyConfig { + val keys = keysFor(scope) + val type = runCatching { ksafe.safeGet(keys.type, null) }.getOrNull() + val host = runCatching { ksafe.safeGet(keys.host, null) }.getOrNull() + val port = runCatching { ksafe.safeGet(keys.port, null) }.getOrNull() + val user = runCatching { ksafe.safeGet(keys.username, null) }.getOrNull() + val pass = runCatching { ksafe.safeGet(keys.password, null) }.getOrNull() + return parseConfig(type, host, port, user, pass) + } + private companion object { const val MIGRATION_MARKER = "__migrated_proxy_v1__" + const val MIGRATION_MARKER_MASTER_V2 = "__migrated_proxy_master_v2__" } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt index 4fdfe3957..4f6734708 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/StarredRepositoryImpl.kt @@ -44,7 +44,7 @@ class StarredRepositoryImpl( private val httpClient: HttpClient get() = clientProvider.client companion object { - private const val SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000L // 24 hours + private const val SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000L } override fun getAllStarred(): Flow> = diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt deleted file mode 100644 index 9a720a33f..000000000 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt +++ /dev/null @@ -1,335 +0,0 @@ -package zed.rainxch.core.data.repository - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import zed.rainxch.core.data.BuildKonfig -import zed.rainxch.core.data.dto.EventRequest -import zed.rainxch.core.data.network.BackendApiClient -import zed.rainxch.core.domain.logging.GitHubStoreLogger -import zed.rainxch.core.domain.model.Platform -import zed.rainxch.core.domain.repository.DeviceIdentityRepository -import zed.rainxch.core.domain.repository.TelemetryRepository -import zed.rainxch.core.domain.repository.TweaksRepository - -class TelemetryRepositoryImpl( - private val backendApiClient: BackendApiClient, - private val deviceIdentity: DeviceIdentityRepository, - private val tweaksRepository: TweaksRepository, - private val platform: Platform, - private val appScope: CoroutineScope, - private val logger: GitHubStoreLogger, -) : TelemetryRepository { - - private val bufferMutex = Mutex() - private val buffer = ArrayDeque() - - init { - appScope.launch { - while (true) { - delay(FLUSH_INTERVAL_MS) - runCatching { flushPending() } - .onFailure { logger.debug("Telemetry flush error: ${it.message}") } - } - } - } - - // ── recording (fire-and-forget, guarded by opt-in) ────────────── - - override fun recordSearchPerformed(resultCount: Int) { - // Query intentionally dropped: a 16-hex SHA-256 prefix of a - // lowercased repo-name search is rainbow-table-trivial. The - // count-only signal here is what survives. - enqueue( - eventType = "search_performed", - resultCount = resultCount, - ) - } - - override fun recordSearchResultClicked(repoId: Long) { - enqueue(eventType = "search_result_clicked", repoId = repoId) - } - - override fun recordRepoViewed(repoId: Long) { - enqueue(eventType = "repo_viewed", repoId = repoId) - } - - override fun recordReleaseDownloaded(repoId: Long) { - enqueue(eventType = "release_downloaded", repoId = repoId) - } - - override fun recordInstallStarted(repoId: Long) { - enqueue(eventType = "install_started", repoId = repoId) - } - - override fun recordInstallSucceeded(repoId: Long) { - enqueue(eventType = "install_succeeded", repoId = repoId, success = true) - } - - override fun recordInstallFailed(repoId: Long, errorCode: String?) { - enqueue( - eventType = "install_failed", - repoId = repoId, - success = false, - errorCode = errorCode, - ) - } - - override fun recordAppOpenedAfterInstall(repoId: Long) { - enqueue(eventType = "app_opened_after_install", repoId = repoId) - } - - override fun recordUninstalled(repoId: Long) { - enqueue(eventType = "uninstalled", repoId = repoId) - } - - override fun recordFavorited(repoId: Long) { - enqueue(eventType = "favorited", repoId = repoId) - } - - override fun recordUnfavorited(repoId: Long) { - enqueue(eventType = "unfavorited", repoId = repoId) - } - - // ── E1 external-import events ─────────────────────────────────── - // Privacy invariant: never pass package names, repo names, app - // labels, or signing fingerprints — only bucketed strings, enums, - // and counts. Enforced in CI by `PrivacyAuditTest` (E6). - - override suspend fun importScanStarted(trigger: String) { - enqueueExt(eventType = "import_scan_started", trigger = trigger) - } - - override suspend fun importScanCompleted( - candidateCountBucket: String, - durationMsBucket: String, - ) { - enqueueExt( - eventType = "import_scan_completed", - candidateCountBucket = candidateCountBucket, - durationMsBucket = durationMsBucket, - ) - } - - override suspend fun importMatchAttempted(strategy: String, confidenceBucket: String) { - enqueueExt( - eventType = "import_match_attempted", - strategy = strategy, - confidenceBucket = confidenceBucket, - ) - } - - override suspend fun importAutoLinked(countBucket: String) { - enqueueExt(eventType = "import_auto_linked", countBucket = countBucket) - } - - override suspend fun importManuallyLinked(countBucket: String, source: String) { - enqueueExt( - eventType = "import_manually_linked", - countBucket = countBucket, - source = source, - ) - } - - override suspend fun importSkipped(countBucket: String, persisted: String) { - enqueueExt( - eventType = "import_skipped", - countBucket = countBucket, - persisted = persisted, - ) - } - - override suspend fun importUnlinkedFromDetails() { - enqueueExt(eventType = "import_unlinked_from_details") - } - - override suspend fun importPermissionRequested() { - enqueueExt(eventType = "import_permission_requested") - } - - override suspend fun importPermissionOutcome(granted: Boolean, sdkIntBucket: String) { - enqueueExt( - eventType = "import_permission_outcome", - granted = granted, - sdkIntBucket = sdkIntBucket, - ) - } - - override suspend fun importSearchOverrideUsed() { - enqueueExt(eventType = "import_search_override_used") - } - - override suspend fun importSearchOverrideNoResults() { - enqueueExt(eventType = "import_search_override_no_results") - } - - override suspend fun signingSeedSyncCompleted( - rowsAddedBucket: String, - durationMsBucket: String, - ) { - enqueueExt( - eventType = "signing_seed_sync_completed", - rowsAddedBucket = rowsAddedBucket, - durationMsBucket = durationMsBucket, - ) - } - - override suspend fun externalMatchApiFailure(statusCodeBucket: String, retried: Boolean) { - enqueueExt( - eventType = "external_match_api_failure", - statusCodeBucket = statusCodeBucket, - retried = retried, - ) - } - - // ── batching ──────────────────────────────────────────────────── - - override suspend fun flushPending() { - // Re-check consent: the user may have disabled telemetry between - // when these events were enqueued and now. Respect the current - // setting — withdrawn consent means the buffered events must - // never leave the device. - if (!telemetryEnabled()) { - bufferMutex.withLock { buffer.clear() } - return - } - - val pending = bufferMutex.withLock { - if (buffer.isEmpty()) return - val take = minOf(buffer.size, MAX_BATCH_SIZE) - val batch = (0 until take).map { buffer.removeFirst() } - batch - } - - val result = withContext(Dispatchers.IO) { - backendApiClient.postEvents(pending) - } - - if (result.isFailure) { - // Put events back at the front for retry next tick (bounded). - // If consent was revoked during the round-trip, drop them - // instead — the flight was already in-progress under the old - // consent, but re-adding would leak past the withdrawal. - if (telemetryEnabled()) { - bufferMutex.withLock { - for (i in pending.indices.reversed()) { - if (buffer.size < MAX_BUFFER_SIZE) buffer.addFirst(pending[i]) - } - } - } else { - bufferMutex.withLock { buffer.clear() } - } - logger.debug("Telemetry batch failed: ${result.exceptionOrNull()?.message}") - } - } - - override suspend fun clearPending() { - bufferMutex.withLock { buffer.clear() } - } - - private suspend fun telemetryEnabled(): Boolean = - runCatching { tweaksRepository.getTelemetryEnabled().first() } - .getOrDefault(false) - - // ── helpers ───────────────────────────────────────────────────── - - private fun enqueue( - eventType: String, - repoId: Long? = null, - resultCount: Int? = null, - success: Boolean? = null, - errorCode: String? = null, - ) { - appScope.launch { - if (!telemetryEnabled()) return@launch - - val deviceId = runCatching { deviceIdentity.getDeviceId() }.getOrNull() ?: return@launch - - val event = EventRequest( - deviceId = deviceId, - platform = platformSlug(), - appVersion = BuildKonfig.VERSION_NAME, - eventType = eventType, - repoId = repoId, - resultCount = resultCount, - success = success, - errorCode = errorCode, - ) - - bufferMutex.withLock { - if (buffer.size >= MAX_BUFFER_SIZE) { - buffer.removeFirst() - } - buffer.add(event) - } - } - } - - private fun enqueueExt( - eventType: String, - trigger: String? = null, - strategy: String? = null, - confidenceBucket: String? = null, - countBucket: String? = null, - candidateCountBucket: String? = null, - durationMsBucket: String? = null, - rowsAddedBucket: String? = null, - statusCodeBucket: String? = null, - sdkIntBucket: String? = null, - source: String? = null, - persisted: String? = null, - granted: Boolean? = null, - retried: Boolean? = null, - ) { - appScope.launch { - if (!telemetryEnabled()) return@launch - - val deviceId = runCatching { deviceIdentity.getDeviceId() }.getOrNull() ?: return@launch - - val event = EventRequest( - deviceId = deviceId, - platform = platformSlug(), - appVersion = BuildKonfig.VERSION_NAME, - eventType = eventType, - trigger = trigger, - strategy = strategy, - confidenceBucket = confidenceBucket, - countBucket = countBucket, - candidateCountBucket = candidateCountBucket, - durationMsBucket = durationMsBucket, - rowsAddedBucket = rowsAddedBucket, - statusCodeBucket = statusCodeBucket, - sdkIntBucket = sdkIntBucket, - source = source, - persisted = persisted, - granted = granted, - retried = retried, - ) - - bufferMutex.withLock { - if (buffer.size >= MAX_BUFFER_SIZE) { - buffer.removeFirst() - } - buffer.add(event) - } - } - } - - private fun platformSlug(): String = when (platform) { - Platform.ANDROID -> "android" - Platform.MACOS -> "desktop-macos" - Platform.WINDOWS -> "desktop-windows" - Platform.LINUX -> "desktop-linux" - } - - private companion object { - private const val FLUSH_INTERVAL_MS = 30_000L - private const val MAX_BATCH_SIZE = 50 - private const val MAX_BUFFER_SIZE = 500 - } -} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt index bb94bbde7..67d418322 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt @@ -28,8 +28,11 @@ import zed.rainxch.core.domain.model.ContentWidth import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType +import zed.rainxch.core.domain.model.RestartReason +import zed.rainxch.core.domain.model.ThemeMode import zed.rainxch.core.domain.model.TranslationProvider import zed.rainxch.core.domain.repository.TweaksRepository +import kotlinx.coroutines.flow.combine import zed.rainxch.core.data.secure.safeDelete import zed.rainxch.core.data.secure.safeGet import zed.rainxch.core.data.secure.safeGetFlow @@ -82,6 +85,32 @@ class TweaksRepositoryImpl( override fun getAmoledTheme(): Flow = gatedGetFlow(K_AMOLED, false) override suspend fun setAmoledTheme(enabled: Boolean) { migrationDeferred.await(); ksafe.safePut(K_AMOLED, enabled) } + override fun getThemeMode(): Flow = + combine(getIsDarkTheme(), getAmoledTheme()) { isDark, amoled -> + when { + isDark == null -> ThemeMode.SYSTEM + !isDark -> ThemeMode.LIGHT + amoled -> ThemeMode.AMOLED + else -> ThemeMode.DARK + } + } + + override suspend fun setThemeMode(mode: ThemeMode) { + when (mode) { + ThemeMode.SYSTEM -> setDarkTheme(null) + ThemeMode.LIGHT -> { setDarkTheme(false); setAmoledTheme(false) } + ThemeMode.DARK -> { setDarkTheme(true); setAmoledTheme(false) } + ThemeMode.AMOLED -> { setDarkTheme(true); setAmoledTheme(true) } + } + } + + override fun getOnboardingComplete(): Flow = gatedGetFlow(K_ONBOARDING_COMPLETE, false) + + override suspend fun setOnboardingComplete(complete: Boolean) { + migrationDeferred.await() + ksafe.safePut(K_ONBOARDING_COMPLETE, complete) + } + override fun getFontTheme(): Flow = gatedGetFlow(K_FONT, "").map { FontTheme.fromName(it.ifEmpty { null }) } @@ -188,9 +217,6 @@ class TweaksRepositoryImpl( ksafe.safePut(K_CONTENT_WIDTH, width.name) } - override fun getTelemetryEnabled(): Flow = gatedGetFlow(K_TELEMETRY_ENABLED, false) - override suspend fun setTelemetryEnabled(enabled: Boolean) { migrationDeferred.await(); ksafe.safePut(K_TELEMETRY_ENABLED, enabled) } - override fun getTranslationProvider(): Flow = gatedGetFlow(K_TRANSLATION_PROVIDER, "").map { TranslationProvider.fromName(it.ifEmpty { null }) } @@ -388,7 +414,6 @@ class TweaksRepositoryImpl( MigrationEntry(booleanPreferencesKey("include_pre_releases"), K_INCLUDE_PRE_RELEASES), MigrationEntry(booleanPreferencesKey("hide_seen_enabled"), K_HIDE_SEEN_ENABLED), MigrationEntry(booleanPreferencesKey("scrollbar_enabled"), K_SCROLLBAR_ENABLED), - MigrationEntry(booleanPreferencesKey("telemetry_enabled"), K_TELEMETRY_ENABLED), MigrationEntry(stringPreferencesKey("translation_provider"), K_TRANSLATION_PROVIDER), MigrationEntry(stringPreferencesKey("youdao_app_key"), K_YOUDAO_APP_KEY), MigrationEntry(stringPreferencesKey("youdao_app_secret"), K_YOUDAO_APP_SECRET), @@ -440,6 +465,29 @@ class TweaksRepositoryImpl( } } + override fun getNeedsRestartReasons(): Flow> = + gatedGetFlow>(K_RESTART_REASONS, emptyList()).map { stored -> + stored.mapNotNull { name -> + runCatching { RestartReason.valueOf(name) }.getOrNull() + }.toSet() + } + + override suspend fun addRestartReason(reason: RestartReason) { + migrationDeferred.await() + rmwLock.withLock { + val current = ksafe.safeGet>(K_RESTART_REASONS, emptyList()).toSet() + if (reason.name in current) return@withLock + ksafe.safePut(K_RESTART_REASONS, (current + reason.name).toList()) + } + } + + override suspend fun clearRestartReasons() { + migrationDeferred.await() + rmwLock.withLock { + ksafe.safePut(K_RESTART_REASONS, emptyList()) + } + } + companion object { private const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L private const val MIGRATION_MARKER = "__migrated_from_datastore_v1__" @@ -458,7 +506,6 @@ class TweaksRepositoryImpl( private const val K_INCLUDE_PRE_RELEASES = "include_pre_releases" private const val K_HIDE_SEEN_ENABLED = "hide_seen_enabled" private const val K_SCROLLBAR_ENABLED = "scrollbar_enabled" - private const val K_TELEMETRY_ENABLED = "telemetry_enabled" private const val K_TRANSLATION_PROVIDER = "translation_provider" private const val K_YOUDAO_APP_KEY = "youdao_app_key" private const val K_YOUDAO_APP_SECRET = "youdao_app_secret" @@ -479,6 +526,7 @@ class TweaksRepositoryImpl( private const val K_SHOW_ALL_PLATFORMS = "show_all_platforms" private const val K_BATTERY_OPT_PROMPT_DISMISSED = "battery_opt_prompt_dismissed" private const val K_LAST_SEEN_WHATS_NEW_VERSION_CODE = "last_seen_whats_new_version_code" + private const val K_ONBOARDING_COMPLETE = "onboarding_complete" private const val K_ANNOUNCEMENTS_DISMISSED_IDS = "announcements_dismissed_ids" private const val K_ANNOUNCEMENTS_ACKNOWLEDGED_IDS = "announcements_acknowledged_ids" private const val K_ANNOUNCEMENTS_MUTED_CATEGORIES = "announcements_muted_categories" @@ -488,5 +536,6 @@ class TweaksRepositoryImpl( private const val K_FAVOURITES_SORT_RULE = "favourites_sort_rule" private const val K_CONTENT_WIDTH = "content_width" private const val K_CUSTOM_FORGE_HOSTS = "custom_forge_hosts" + private const val K_RESTART_REASONS = "needs_restart_reasons" } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/UserSessionRepositoryImpl.kt similarity index 56% rename from core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt rename to core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/UserSessionRepositoryImpl.kt index d514633a6..24e776cbf 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/UserSessionRepositoryImpl.kt @@ -1,22 +1,41 @@ package zed.rainxch.core.data.repository import co.touchlab.kermit.Logger +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import zed.rainxch.core.data.cache.CacheManager +import zed.rainxch.core.data.cache.CacheManager.CacheTtl.USER_PROFILE import zed.rainxch.core.data.data_source.TokenStore -import zed.rainxch.core.domain.repository.AuthenticationState +import zed.rainxch.core.data.dto.UserProfileNetwork +import zed.rainxch.core.data.mappers.toUserProfile +import zed.rainxch.core.data.network.executeRequest +import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.model.UserProfile +import zed.rainxch.core.domain.repository.UserSessionRepository import kotlin.time.Clock import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) -class AuthenticationStateImpl( +class UserSessionRepositoryImpl( private val tokenStore: TokenStore, -) : AuthenticationState { + private val cacheManager: CacheManager, + private val httpClientProvider: () -> HttpClient, + private val logger: GitHubStoreLogger +) : UserSessionRepository { + private val httpClient: HttpClient get() = httpClientProvider() + private val _sessionExpiredEvent = MutableSharedFlow(extraBufferCapacity = 1) override val sessionExpiredEvent: SharedFlow = _sessionExpiredEvent.asSharedFlow() @@ -33,6 +52,47 @@ class AuthenticationStateImpl( override suspend fun isCurrentlyUserLoggedIn(): Boolean = tokenStore.currentToken() != null + override fun getUser(): Flow = flow { + val token = tokenStore.currentToken() + if (token == null) { + cacheManager.invalidate(CACHE_KEY) + emit(null) + return@flow + } + + val cached = cacheManager.get(CACHE_KEY) + if (cached != null) { + logger.debug("Profile cache hit") + emit(cached) + return@flow + } + + try { + val networkProfile = + httpClient + .executeRequest { + get("/user") { + header(HttpHeaders.Accept, "application/vnd.github+json") + } + }.getOrThrow() + + val userProfile = networkProfile.toUserProfile() + cacheManager.put(CACHE_KEY, userProfile, USER_PROFILE) + logger.debug("Fetched and cached user profile: ${userProfile.username}") + emit(userProfile) + } catch (e: Exception) { + logger.error("Failed to fetch user profile: ${e.message}") + + val stale = cacheManager.getStale(CACHE_KEY) + if (stale != null) { + logger.debug("Using stale cached profile as fallback") + emit(stale) + } else { + emit(null) + } + } + }.flowOn(Dispatchers.IO) + override suspend fun notifySessionExpired(tokenKey: String?) { if (tokenKey.isNullOrEmpty()) return sessionExpiredMutex.withLock { @@ -90,9 +150,15 @@ class AuthenticationStateImpl( _consecutiveFailures = 0 } + override suspend fun logout() { + tokenStore.clear() + cacheManager.clearAll() + } + private companion object { const val TAG = "AuthState" const val REQUIRED_CONSECUTIVE_FAILURES = 2 const val FAILURE_WINDOW_MS = 60_000L + private const val CACHE_KEY = "profile:me" } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/DataStoreToKSafeMigrator.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/DataStoreToKSafeMigrator.kt index 6fe271987..6fa0abcf0 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/DataStoreToKSafeMigrator.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/secure/DataStoreToKSafeMigrator.kt @@ -6,34 +6,19 @@ import androidx.datastore.preferences.core.edit import eu.anifantakis.lib.ksafe.KSafe import kotlinx.coroutines.flow.first -/** - * One-shot copier: lift values from a legacy [DataStore] into a [KSafe] - * vault. Idempotent — sets a marker in the KSafe vault so subsequent - * launches skip the work. - * - * Each entry is a `(legacyKey, transform)` pair. The transform reads from - * the legacy `Preferences` snapshot and writes into KSafe (decoupling - * primitive vs. derived shapes such as enums or JSON objects). - * - * The legacy keys are removed after a successful copy so the next read - * never sees the duplicate. - */ suspend fun migrateDataStoreToKSafe( legacy: DataStore, ksafe: KSafe, markerKey: String, entries: List, ) { - val alreadyMigrated = runCatching { ksafe.get(markerKey, false) }.getOrDefault(false) + val alreadyMigrated = runCatching { + ksafe.get(markerKey, false) + }.getOrDefault(false) if (alreadyMigrated) return val snapshotResult = runCatching { legacy.data.first() } - val snapshot = snapshotResult.getOrNull() - if (snapshot == null) { - // Legacy file unreadable. Don't set the marker — retry next launch - // so a transient I/O failure doesn't permanently skip migration. - return - } + val snapshot = snapshotResult.getOrNull() ?: return val movedKeys = mutableListOf>() var anyFailure = false @@ -42,12 +27,9 @@ suspend fun migrateDataStoreToKSafe( when { copyResult.isFailure -> anyFailure = true copyResult.getOrNull() == true -> movedKeys += entry.legacyKeys - // null/false → key absent or unsupported type; not a failure } } - // Only delete legacy keys we know we copied successfully. Even on - // partial failure, what we *did* copy is safe to remove. if (movedKeys.isNotEmpty()) { val deleteResult = runCatching { legacy.edit { prefs -> movedKeys.forEach { prefs.remove(it) } } @@ -55,8 +37,6 @@ suspend fun migrateDataStoreToKSafe( if (deleteResult.isFailure) anyFailure = true } - // Only mark complete if every entry succeeded AND legacy cleanup - // succeeded. Otherwise next launch retries the missing entries. if (!anyFailure) { runCatching { ksafe.put(markerKey, true) } } @@ -75,8 +55,7 @@ class MigrationEntry( ): MigrationEntry = MigrationEntry( legacyKeys = listOf(legacyKey), copyBlock = { snapshot, ksafe -> - val value: Any? = snapshot[legacyKey] - if (value == null) return@MigrationEntry false + val value: Any = snapshot[legacyKey] ?: return@MigrationEntry false when (value) { is Boolean -> ksafe.put(ksafeKey, value) is Int -> ksafe.put(ksafeKey, value) @@ -89,6 +68,7 @@ class MigrationEntry( val asStrings = (value as Set).toList() ksafe.put>(ksafeKey, asStrings) } + else -> return@MigrationEntry false } true diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt index a6ced167b..53f9644e9 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt @@ -31,48 +31,6 @@ import zed.rainxch.core.domain.system.SystemInstallSerializer import zed.rainxch.core.domain.util.AssetFileName import kotlin.random.Random -/** - * Default implementation of [DownloadOrchestrator]. - * - * # Lifetime - * - * Owned by Koin as a singleton with the application-scoped - * [CoroutineScope] (see `coreModule`'s `CoroutineScope` factory). All - * downloads run in [appScope] so they outlive any ViewModel that - * triggered them. The orchestrator itself never cancels its scope — - * the process going away does that for free. - * - * # Concurrency - * - * Uses a per-package [Mutex] surfaced via [stateMutex] to serialize - * map mutations. Actual download/install work runs in [appScope] so - * the mutex is only held for short metadata writes — never across - * the long-running download. - * - * Up to [DEFAULT_MAX_CONCURRENT] downloads run at once. Further - * enqueue calls don't block — they just spawn a coroutine that - * `delay`s while the active count is at capacity. The simpler "always - * spawn, let the platform Downloader queue internally" approach is - * tempting, but OkHttp doesn't queue — it'd open every connection at - * once and likely hit GitHub's rate limit. - * - * # Filename namespacing - * - * Every download is written to disk with the - * `__.ext` form via [AssetFileName.scoped]. - * This is the only place that name transformation lives — callers - * pass the original asset name and the orchestrator handles the rest. - * The scoped name is also what gets passed to [Downloader.download], - * so the Downloader's per-name dedup map is automatically scoped. - * - * # Install policies - * - * See [InstallPolicy] for the rules. Implementation detail: the - * "screen still attached" check at install time is done by re-reading - * `installPolicy` from the live state map *after* the download - * completes — never from a captured local — so a [downgradeToDeferred] - * call lands atomically against the in-flight download. - */ class DefaultDownloadOrchestrator( private val downloader: Downloader, private val multiSourceDownloader: MultiSourceDownloader, @@ -92,18 +50,8 @@ class DefaultDownloadOrchestrator( private val _downloads = MutableStateFlow>(emptyMap()) override val downloads: StateFlow> = _downloads.asStateFlow() - /** - * Mutex guarding map mutations *and* the active-count check used - * by the queue. Held only for short critical sections — never - * across `Downloader.download(...).collect`. - */ private val stateMutex = Mutex() - /** - * Job handles per package, used by [cancel] to interrupt the - * download. Separate from `_downloads` because Job is mutable - * state that doesn't belong in an immutable StateFlow value. - */ private val activeJobs = mutableMapOf() override fun observe(packageName: String): Flow = @@ -112,19 +60,11 @@ class DefaultDownloadOrchestrator( .distinctUntilChanged() override suspend fun enqueue(spec: DownloadSpec): String { - // Idempotent: if a download for this package is already - // running (or queued, or awaiting install), return its id and - // upgrade the install policy if the new caller wants a - // stronger guarantee. Common case: user taps Update twice in - // a row and we don't want to spawn two downloads. stateMutex.withLock { val existing = _downloads.value[spec.packageName] if (existing != null && existing.stage != DownloadStage.Failed && existing.stage != DownloadStage.Cancelled ) { - // Allow caller to upgrade policy: e.g. apps list - // started a Deferred download, then user opened - // Details and we want to flip to InstallWhileForeground. if (existing.installPolicy != spec.installPolicy && existing.installPolicy.priority < spec.installPolicy.priority ) { @@ -162,8 +102,6 @@ class DefaultDownloadOrchestrator( try { runDownload(spec) } catch (e: CancellationException) { - // Honour structured concurrency. The cancel path - // is responsible for cleaning state. Logger.d { "Orchestrator: download cancelled for ${spec.packageName}" } throw e } catch (t: Throwable) { @@ -185,23 +123,7 @@ class DefaultDownloadOrchestrator( return id } - /** - * The actual download/install pipeline. Runs entirely inside - * [appScope]. - * - * 1. Wait for a slot if [DEFAULT_MAX_CONCURRENT] is at capacity - * 2. Stream bytes via [Downloader] under the scoped filename, - * emitting progress updates - * 3. Read the latest [InstallPolicy] from state — this is the - * race-safe read that lets [downgradeToDeferred] land - * 4. Branch on policy: install in-process, defer to user, or - * hand off to the dialog-driven Details flow - */ private suspend fun runDownload(spec: DownloadSpec) { - // Wait for a worker slot. Polling is acceptable here because - // the wait is not on the user's critical path (a queued - // download is by definition something the user is OK waiting - // on). while (true) { val activeNow = stateMutex.withLock { @@ -218,10 +140,6 @@ class DefaultDownloadOrchestrator( originalName = spec.asset.name, ) - // Move to Downloading. Initialize totalBytes from the asset's - // declared size — the server's Content-Length might disagree - // (or be missing), in which case the download flow will - // override it with the live value below. updateEntry(spec.packageName) { it.copy( stage = DownloadStage.Downloading, @@ -236,8 +154,6 @@ class DefaultDownloadOrchestrator( it.copy( progressPercent = progress.percent, bytesDownloaded = progress.bytesDownloaded, - // Prefer the live Content-Length when present; - // fall back to whatever we already had (asset.size). totalBytes = progress.totalBytes ?: it.totalBytes, ) } @@ -251,10 +167,6 @@ class DefaultDownloadOrchestrator( it.copy(filePath = filePath, progressPercent = 100) } - // SHA-256 gate sits between download-complete and the install / - // park transition: a tampered mirror must not be able to feed - // an APK into the installer. Null digest = GitHub didn't expose - // one (older assets, non-asset endpoints); log and proceed. val expectedDigest = spec.asset.digest if (expectedDigest != null) { val mismatch = digestVerifier.verify(filePath, expectedDigest) @@ -271,9 +183,6 @@ class DefaultDownloadOrchestrator( Logger.i { "No digest for ${spec.asset.name}, skipping SHA-256 verification" } } - // Race-safe read of the *current* install policy. The - // ViewModel may have called downgradeToDeferred while the - // download was in flight; that mutation lands here. val effectivePolicy = stateMutex.withLock { _downloads.value[spec.packageName]?.installPolicy ?: spec.installPolicy @@ -282,40 +191,15 @@ class DefaultDownloadOrchestrator( when (effectivePolicy) { InstallPolicy.AlwaysInstall -> runInstall(spec, filePath) - // InstallWhileForeground: park *without* notifying. The - // foreground VM is expected to be observing and will run - // its own dialog-driven install on the file. The notifier - // only fires when the policy gets downgraded to Deferred - // (either explicitly via downgradeToDeferred, or - // originally), so the user only sees a notification when - // there's nobody watching the download interactively. InstallPolicy.InstallWhileForeground -> parkForUser(spec, filePath, notify = false) InstallPolicy.DeferUntilUserAction -> parkForUser(spec, filePath, notify = true) } } - /** - * Invokes the platform installer for [filePath]. On success the - * orchestrator entry is moved to [DownloadStage.Completed] (the - * consuming ViewModel will dismiss it from the map). - * - * Validation (signing fingerprint, package mismatch, etc.) is - * deliberately NOT done here. The dialog-driven path in - * `DetailsViewModel` handles those because they need user - * interaction. The orchestrator's "always install" path is used - * for Shizuku silent installs where the user has explicitly opted - * out of confirmations. - */ private suspend fun runInstall(spec: DownloadSpec, filePath: String) { updateEntry(spec.packageName) { it.copy(stage = DownloadStage.Installing) } val ext = spec.asset.name.substringAfterLast('.', "").lowercase() - // Tracks whether `installer.install` already returned - // DELEGATED_TO_SYSTEM. The system installer dialog is still up - // in that state — releasing the gate from a downstream catch - // would let the next queued install fire ACTION_VIEW into the - // open dialog and reintroduce the stacking bug. Broadcast or - // timeout owns the release in the delegated case. var delegated = false try { installer.ensurePermissionsOrThrow(ext) @@ -324,9 +208,6 @@ class DefaultDownloadOrchestrator( delegated = outcome == InstallOutcome.DELEGATED_TO_SYSTEM when (outcome) { InstallOutcome.COMPLETED -> { - // Synchronous install (Shizuku / Dhizuku) — broadcast - // arrives quickly, but release the gate now so the next - // queued install isn't blocked by a possibly-late receiver. systemInstallSerializer.markCompleted(spec.packageName) try { installedAppsRepository.setPendingInstallFilePath(spec.packageName, null) @@ -361,17 +242,6 @@ class DefaultDownloadOrchestrator( } } - /** - * "Park" path: download finished but the install policy says - * "don't auto-install". Persists the file path on the InstalledApp - * row so the apps list can show a "Ready to install" row, optionally - * fires the notification, and moves the orchestrator entry to - * [DownloadStage.AwaitingInstall]. - * - * @param notify Whether to poke the notifier. False for the - * InstallWhileForeground case (the foreground VM is watching); - * true for the DeferUntilUserAction case (no foreground watcher). - */ private suspend fun parkForUser( spec: DownloadSpec, filePath: String, @@ -388,10 +258,6 @@ class DefaultDownloadOrchestrator( throw e } catch (e: Exception) { Logger.e(e) { "Orchestrator: failed to persist pending install path" } - // Persistence is best-effort — the orchestrator state - // still has the file path, so the row can render via - // observe() during the same process lifetime. Survives - // process death is what we lose. } if (notify) { pendingInstallNotifier.notifyPending( @@ -406,17 +272,12 @@ class DefaultDownloadOrchestrator( } override suspend fun downgradeToDeferred(packageName: String) { - // Snapshot what we need under the lock and decide whether to - // notify outside it (notifier is third-party code, don't hold - // the mutex across it). val shouldNotify: Boolean val notifySpec: NotifyData? stateMutex.withLock { val existing = _downloads.value[packageName] ?: return when (existing.stage) { DownloadStage.Queued, DownloadStage.Downloading -> { - // In-flight: just flip the policy. parkForUser - // will see it later and notify. _downloads.update { state -> state + ( packageName to existing.copy( @@ -429,12 +290,6 @@ class DefaultDownloadOrchestrator( } DownloadStage.AwaitingInstall -> { - // Race: the orchestrator already parked the file - // (silently, because the policy was - // InstallWhileForeground) but the foreground VM - // is now going away. We need to retroactively - // notify the user so they don't lose track of - // the ready-to-install file. val needsNotify = existing.installPolicy == InstallPolicy.InstallWhileForeground _downloads.update { state -> @@ -463,7 +318,6 @@ class DefaultDownloadOrchestrator( DownloadStage.Cancelled, DownloadStage.Failed, -> { - // Too late or terminal — nothing meaningful to do. shouldNotify = false notifySpec = null } @@ -492,10 +346,6 @@ class DefaultDownloadOrchestrator( val job = stateMutex.withLock { activeJobs.remove(packageName) } job?.cancel() - // Best-effort: delete the partial file via the downloader's - // cancellation hook. The downloader's cancel path is keyed - // on the scoped filename — we can recompute it from the - // entry's owner/repo/asset name. val entry = _downloads.value[packageName] if (entry != null) { val scopedName = @@ -508,9 +358,6 @@ class DefaultDownloadOrchestrator( Logger.w(e) { "Orchestrator: cancelDownload failed for $scopedName" } } - // If the entry was parked (AwaitingInstall), clean up the - // persistent pending-install metadata and notification so - // the apps row doesn't keep showing "ready to install". if (entry.stage == DownloadStage.AwaitingInstall) { try { installedAppsRepository.setPendingInstallFilePath(packageName, null) @@ -535,9 +382,6 @@ class DefaultDownloadOrchestrator( override suspend fun installPending(packageName: String): InstallOutcome? { val entry = _downloads.value[packageName] ?: run { - // The orchestrator's in-memory entry may be gone (e.g. - // process restart). Fall back to the persisted - // pendingInstallFilePath on the InstalledApp row. val app = try { installedAppsRepository.getAppByPackage(packageName) } catch (e: CancellationException) { @@ -562,11 +406,6 @@ class DefaultDownloadOrchestrator( ext: String, ): InstallOutcome? { updateEntry(packageName) { it.copy(stage = DownloadStage.Installing) } - // See [runInstall] — once the install hands off to the system - // installer dialog, the gate must stay locked until broadcast - // or timeout releases it; otherwise a downstream cancellation - // would unlock the gate while the dialog is still up and let - // the next queued install stack ACTION_VIEW intents. var delegated = false return try { installer.ensurePermissionsOrThrow(ext) @@ -585,9 +424,6 @@ class DefaultDownloadOrchestrator( pendingInstallNotifier.clearPending(packageName) updateEntry(packageName) { it.copy(stage = DownloadStage.Completed) } } - // DELEGATED_TO_SYSTEM: the system installer dialog is - // showing. Don't clear pending metadata or mark Completed — - // PackageEventReceiver handles the final state transition. outcome } catch (e: CancellationException) { if (!delegated) systemInstallSerializer.markCompleted(packageName) @@ -601,9 +437,6 @@ class DefaultDownloadOrchestrator( } override fun dismiss(packageName: String) { - // Synchronous dismiss — no Mutex needed for the read-modify-write - // because StateFlow.update is itself atomic. We just don't - // touch activeJobs (cancel does that). _downloads.update { it - packageName } } @@ -629,19 +462,9 @@ class DefaultDownloadOrchestrator( } private fun generateId(): String = - // Cheap unique id without pulling in java.util.UUID — works - // on all KMP targets. Collisions are statistically negligible - // for the lifetime of the orchestrator. Random.nextLong().toULong().toString(36) + Random.nextLong().toULong().toString(36) } -/** - * Orderable strength of an install policy. Higher = stronger - * guarantee. Used by [DefaultDownloadOrchestrator.enqueue] when an - * existing download is re-enqueued with a different policy: we only - * upgrade (never downgrade) so the original caller's intent is - * preserved unless an even more committed caller arrives. - */ private val InstallPolicy.priority: Int get() = when (this) { InstallPolicy.DeferUntilUserAction -> 0 diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultSystemInstallSerializer.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultSystemInstallSerializer.kt index 15cb1b255..9956c7af6 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultSystemInstallSerializer.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultSystemInstallSerializer.kt @@ -25,7 +25,6 @@ class DefaultSystemInstallSerializer : SystemInstallSerializer { while (!pending.compareAndSet(null, packageName)) { pending.first { it == null } } - Unit } if (acquired == null) { Logger.w { @@ -40,14 +39,6 @@ class DefaultSystemInstallSerializer : SystemInstallSerializer { } override fun markCompleted(packageName: String) { - // Clear unconditionally rather than compareAndSet against the - // marked package: any package install/uninstall completion means - // the system installer activity is no longer holding our prior - // install hostage, so the next gated install can proceed. Using - // strict compareAndSet would leave the slot locked when a broadcast - // fires for a different package than the one we marked (e.g. - // `markPending` was called with an empty string because the APK - // info extractor failed to surface a packageName). pending.value = null } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt index 2ff6606eb..3db9ae0fd 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/LocalizationManager.kt @@ -1,27 +1,9 @@ package zed.rainxch.core.data.services interface LocalizationManager { - /** - * Returns the current device language code in ISO 639-1 format (e.g., "en", "zh", "ja") - * Can include region code if available (e.g., "zh-CN", "pt-BR") - */ fun getCurrentLanguageCode(): String - /** - * Returns the primary language code without region (e.g., "zh" from "zh-CN") - */ fun getPrimaryLanguageCode(): String - /** - * Overrides the process-wide JVM `Locale.getDefault()` used by - * Compose Resources' `LocalComposeEnvironment` for string - * resolution. Passing `null` (or blank) restores the original - * system locale captured at instance construction. - * - * Must be called from the composition side (see `App()`) *before* - * the `key(appLanguage)`-wrapped content remounts, so the new - * locale is picked up when `stringResource` re-reads - * `Locale.current` on recomposition. - */ fun setActiveLanguageTag(tag: String?) } diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt index 04f04759d..c0aa960f2 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/di/PlatformModule.jvm.kt @@ -42,7 +42,7 @@ import zed.rainxch.core.domain.utils.ClipboardHelper import zed.rainxch.core.domain.utils.ShareManager actual val corePlatformModule = module { - // Core + single { DesktopDownloader( files = get(), @@ -82,8 +82,6 @@ actual val corePlatformModule = module { DesktopLocalizationManager() } - // Locals - single { initDatabase() } @@ -108,9 +106,6 @@ actual val corePlatformModule = module { eu.anifantakis.lib.ksafe.KSafe(fileName = "ghs_announcements") } - - // Utils - single { DesktopBrowserHelper() } diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt index f2c770225..797f3cce0 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt @@ -7,10 +7,7 @@ import zed.rainxch.core.data.local.DesktopAppDataPaths import java.io.File fun initDatabase(): AppDatabase { - // SQLite WAL mode keeps two side files alongside the .db. Migrate sidecars - // FIRST so the .db never lands at the new location without its WAL — if - // the WAL/SHM copies fail, we abort before touching the .db and let the - // user retry next launch (the legacy files are still in tmp). + DesktopAppDataPaths.migrateFromTmpIfNeeded("github_store.db-wal") DesktopAppDataPaths.migrateFromTmpIfNeeded("github_store.db-shm") DesktopAppDataPaths.migrateFromTmpIfNeeded("github_store.db") diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/model/LinuxPackageType.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/model/LinuxPackageType.kt index b34953c9a..e4d451938 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/model/LinuxPackageType.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/model/LinuxPackageType.kt @@ -1,8 +1,8 @@ package zed.rainxch.core.data.model enum class LinuxPackageType { - DEB, // Debian/Ubuntu/Mint/Pop/Elementary - RPM, // Fedora/RHEL/CentOS/openSUSE/Rocky/Alma - ARCH, // Arch/Manjaro/EndeavourOS/Artix/CachyOS/Garuda - UNIVERSAL, // Unknown - show AppImage only + DEB, + RPM, + ARCH, + UNIVERSAL, } diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt index 942f10aa8..9360d18e3 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt @@ -14,20 +14,11 @@ actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient = HttpClient(OkHttp) { engine { config { - // Trust OS-installed root certificates in addition to the - // JVM cacerts bundle. Lets users behind TLS-intercepting - // tools (Watt Toolkit, Fiddler, corporate MITM) keep using - // the app without manually injecting certs into the JDK. - // Silently skipped on platforms where no OS keystore is - // available — default trust still applies. + buildOsTrustChainOrNull()?.let { chain -> sslSocketFactory(chain.socketFactory, chain.trustManager) } - // Reset any inherited global SOCKS authenticator before - // deciding what this client needs — prevents a stale - // Authenticator from a previous [ProxyConfig.Socks] client - // leaking into a subsequently-built plain client. Authenticator.setDefault(null) when (proxyConfig) { @@ -62,14 +53,7 @@ actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient = val proxyHost = proxyConfig.host val proxyPort = proxyConfig.port if (!username.isNullOrEmpty() && !password.isNullOrEmpty()) { - // SOCKS5 username/password auth goes through - // java.net.Authenticator (OkHttp has no - // dedicated SOCKS auth hook). Scope the - // credentials to the configured proxy host - // and port — `Authenticator.setDefault` is - // process-wide, so an unconditional responder - // would leak these creds to any other auth - // challenge the JVM sees. + Authenticator.setDefault( object : Authenticator() { override fun getPasswordAuthentication(): PasswordAuthentication? { diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopApkInspector.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopApkInspector.kt index c44824ec5..973cc518b 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopApkInspector.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopApkInspector.kt @@ -3,11 +3,6 @@ package zed.rainxch.core.data.services import zed.rainxch.core.domain.model.ApkInspection import zed.rainxch.core.domain.system.ApkInspector -/** - * Desktop has no concept of an APK manifest. The inspector is wired in - * for symmetry with Android — every call returns `null` so the UI can - * gate on `inspect(...) != null` without platform branching. - */ class DesktopApkInspector : ApkInspector { override suspend fun inspectFile(filePath: String): ApkInspection? = null diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloadProgressNotifier.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloadProgressNotifier.kt index af304542b..57205cfd0 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloadProgressNotifier.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloadProgressNotifier.kt @@ -2,11 +2,6 @@ package zed.rainxch.core.data.services import zed.rainxch.core.domain.system.DownloadProgressNotifier -/** - * Desktop has no system-shade equivalent that matches the Android - * download-notification UX, so this stays a no-op. The orchestrator - * still calls in unconditionally, avoiding a platform branch. - */ class DesktopDownloadProgressNotifier : DownloadProgressNotifier { override fun notifyProgress( packageName: String, diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt index 84a585f7c..a0a4b702e 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt @@ -82,9 +82,7 @@ class DesktopDownloader( suggestedFileName: String?, bypassMirror: Boolean, ): Flow = - // bypassMirror is a no-op here: this downloader uses OkHttp directly, - // not Ktor, so it never traverses MirrorRewriteInterceptor. The caller - // (MultiSourceDownloader) already passes the resolved direct/mirror URL. + flow { val client = buildClient() @@ -106,8 +104,7 @@ class DesktopDownloader( val downloadId = UUID.randomUUID().toString() val destination = File(dir, safeName) - // Each attempt writes to its own temp file so MultiSourceDownloader's - // direct/mirror race cannot have two jobs trampling the same path. + val tempFile = File(dir, "$safeName.part-$downloadId") if (tempFile.exists()) tempFile.delete() @@ -220,9 +217,7 @@ class DesktopDownloader( override suspend fun cancelDownload(fileName: String): Boolean = withContext(Dispatchers.IO) { - // Cancel every in-flight download for this fileName — MultiSource - // races run direct + mirror in parallel under the same logical - // name, so a single-id lookup would leave one of them running. + val ids = idsByName.remove(fileName)?.toList().orEmpty() if (ids.isEmpty()) return@withContext false diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt index 66b4e7a02..fa7e0c453 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt @@ -32,10 +32,6 @@ class DesktopInstaller( determineSystemArchitecture() } - /** - * Detects whether the app is running inside a Flatpak sandbox. - * Checks for the `/.flatpak-info` file which is always present inside Flatpak containers. - */ private val isRunningInFlatpak: Boolean by lazy { try { File("/.flatpak-info").exists() || @@ -122,10 +118,7 @@ class DesktopInstaller( } Platform.LINUX -> { - // Flatpak sandbox prefers native packages over AppImage - // because AppImages inside Flatpak require extra permission - // dances. Outside Flatpak we prefer AppImage — portable, - // no sudo needed. + if (isRunningInFlatpak) { when (linuxPackageType) { LinuxPackageType.DEB -> listOf(".deb", ".appimage", ".rpm", ".pkg.tar.zst") @@ -292,11 +285,6 @@ class DesktopInstaller( } } - /** - * When running inside a Flatpak sandbox, /etc/os-release belongs to the Flatpak runtime - * (e.g. org.freedesktop.Platform), not the host OS. To detect the host distro we read - * /run/host/os-release, which Flatpak bind-mounts from the host. - */ private fun detectHostLinuxPackageType(): LinuxPackageType { val hostOsRelease = File("/run/host/os-release") if (!hostOsRelease.exists()) { @@ -421,11 +409,7 @@ class DesktopInstaller( return when (platform) { Platform.WINDOWS -> ext in listOf("msi", "exe") Platform.MACOS -> ext in listOf("dmg", "pkg") - // "pkg.tar.zst" keeps the literal double-dotted form the Arch - // convention uses. Dispatch below checks the full filename - // suffix anyway, but callers that only have the ext token - // (e.g. file-extension-only classification paths) would see - // "zst" on its own. Accept "pkg.tar.zst" and bare "zst" both. + Platform.LINUX -> ext in listOf("appimage", "deb", "rpm", "pkg.tar.zst", "zst") else -> false } @@ -500,13 +484,6 @@ class DesktopInstaller( } - /** - * Flatpak-sandboxed installation flow. - * - * Since we can't execute system installers, we use xdg-open (which goes through - * the Flatpak portal to the host) to open the file with the host's default handler. - * This lets the host's software center / file manager handle the actual installation. - */ private fun installFromFlatpak( file: File, ext: String, @@ -514,10 +491,6 @@ class DesktopInstaller( Logger.i { "Running in Flatpak sandbox — delegating installation to host system" } Logger.i { "File: ${file.absolutePath} (.$ext)" } - // Arch packages use the double extension `.pkg.tar.zst`. Callers - // may pass either "pkg.tar.zst" or just "zst" via `ext` depending - // on which classification path computed it, so gate on the - // filename directly — same authoritative check installLinux uses. val nameLower = file.name.lowercase() if (nameLower.endsWith(".pkg.tar.zst")) { Logger.d { "Opening .pkg.tar.zst package via xdg-open portal for host installation" } @@ -609,11 +582,6 @@ class DesktopInstaller( } } - /** - * Show a notification from within the Flatpak sandbox. - * Uses notify-send which goes through the desktop notifications portal. - * Falls back to logging if notifications aren't available. - */ private fun showFlatpakNotification( title: String, message: String, @@ -635,13 +603,6 @@ class DesktopInstaller( } } - /** - * Opens the system file manager with the given file highlighted/selected. - * - * Tries D-Bus FileManager1.ShowItems first (works on GNOME, KDE, etc. and - * goes through the Flatpak portal), then falls back to xdg-open on the - * parent directory. - */ private fun openInFileManager(file: File) { try { val fileUri = "file://${file.absolutePath}" @@ -684,16 +645,7 @@ class DesktopInstaller( } "exe" -> { - // Hand off to ShellExecute via `cmd /c start` rather than - // java.awt.Desktop.open(file). Desktop.open converts the - // file to a URI internally and rejects paths that don't - // round-trip cleanly — non-ASCII characters in the user's - // Windows username (Chinese, Cyrillic, etc.) and unusual - // filename glyphs both surface as "Unsupported URI content" - // (#371). `start` takes the path verbatim through the - // system codepage and works regardless. The empty `""` is - // the window title, required because `start` treats the - // first quoted argument as the title. + try { ProcessBuilder("cmd", "/c", "start", "", file.absolutePath).start() } catch (e: IOException) { @@ -790,11 +742,7 @@ class DesktopInstaller( file: File, ext: String, ) { - // The `ext` parameter is just the final extension token, but - // Arch packages use the double-dotted form `.pkg.tar.zst`. So - // look at the full filename when routing to pacman — a bare - // `.zst` on a non-pacman-shaped filename is genuinely ambiguous - // and we shouldn't hand it to pacman. + val nameLower = file.name.lowercase() if (nameLower.endsWith(".pkg.tar.zst")) { installPacmanPackage(file) @@ -999,41 +947,18 @@ class DesktopInstaller( throw IOException("Could not install RPM package. Please install it manually.") } - /** - * Wraps a string for safe embedding inside a POSIX shell - * single-quoted context. Handles embedded single quotes via the - * `'\''` closing-escaping-reopening trick. - * - * `foo` → `'foo'` - * `foo'bar` → `'foo'\''bar'` - * `don't panic` → `'don'\''t panic'` - * - * Use this whenever a filename (or any externally-sourced string) - * needs to be interpolated into a shell command that ends up being - * executed — terminal command builders, `sh -c` invocations, etc. - */ private fun shellQuoteSingleQuotes(s: String): String = "'" + s.replace("'", "'\\''") + "'" private fun installPacmanPackage(file: File) { Logger.d { "Installing pacman package: ${file.absolutePath}" } - // Wrong-distro case: a `.pkg.tar.zst` on a non-Arch system is - // effectively impossible to install cleanly (no package manager - // knows the format, and conversion tools like `debtap` are - // Arch→Debian not the other way, and require user setup to - // seed their DB first). Show the user a clear terminal message - // instead of silently attempting a path that will fail. if (linuxPackageType != LinuxPackageType.ARCH) { Logger.w { "Pacman package (.pkg.tar.zst) on non-Arch system (type=$linuxPackageType)." } openTerminalForPacmanIncompatible(file.absolutePath) return } - // argv-list invocation — no shell involved, so filenames with - // special chars are passed safely. No `sh -c` fallback needed - // here because pacman -U doesn't need any shell-level chaining - // (unlike DEB's `dpkg || apt-get install -f`). val installMethods = listOf( listOf("pkexec", "pacman", "-U", "--noconfirm", file.absolutePath), @@ -1073,8 +998,7 @@ class DesktopInstaller( val quoted = shellQuoteSingleQuotes(filePath) if (availableTerminals.isEmpty()) { - // Notification body is user-visible text, not a shell command, - // so the raw path is fine to display here. + tryShowNotification( "Install via Terminal", "Run: sudo pacman -U $quoted", @@ -1132,9 +1056,7 @@ class DesktopInstaller( append("echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; ") append("echo ''; ") append("echo 'This file is an Arch Linux package (.pkg.tar.zst):'; ") - // printf treats %s as a plain arg, so the shell-escaped - // $quoted resolves back to the real path when printed — - // no literal escape characters bleed into the display. + append("printf ' %s\\n' $quoted; ") append("echo ''; ") append("echo 'Your system uses a different package format.'; ") @@ -1397,10 +1319,6 @@ class DesktopInstaller( } } - /** - * Move AppImage to ~/Applications directory - * Creates the directory if it doesn't exist - */ private fun moveToApplicationsDirectory(file: File): File { val homeDir = System.getProperty("user.home") val applicationsDir = File(homeDir, "Applications") diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt index 83eacefc2..26fb4cb26 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopLocalizationManager.kt @@ -3,11 +3,7 @@ package zed.rainxch.core.data.services import java.util.Locale class DesktopLocalizationManager : LocalizationManager { - /** - * Snapshot of the original JVM locale at construction time, so - * [setActiveLanguageTag] with a null argument can restore it even - * after prior overrides have modified `Locale.getDefault()`. - */ + private val systemDefault: Locale = Locale.getDefault() override fun getCurrentLanguageCode(): String { diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPendingInstallNotifier.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPendingInstallNotifier.kt index ec000cb79..58b4cda66 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPendingInstallNotifier.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPendingInstallNotifier.kt @@ -2,16 +2,6 @@ package zed.rainxch.core.data.services import zed.rainxch.core.domain.system.PendingInstallNotifier -/** - * Desktop has no pending-install flow: - * - The apps tab is hidden on Desktop (set via `Platform.ANDROID` gate - * in the bottom nav), so the user has no place to "tap to install" - * - Desktop installs always run in-process and complete synchronously - * via the OS package manager dialog - * - * Notifier is a no-op so the orchestrator can still call into it - * unconditionally without platform branching. - */ class DesktopPendingInstallNotifier : PendingInstallNotifier { override fun notifyPending( packageName: String, diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopUpdateScheduleManager.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopUpdateScheduleManager.kt index e50a57023..65b575055 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopUpdateScheduleManager.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopUpdateScheduleManager.kt @@ -2,15 +2,12 @@ package zed.rainxch.core.data.services import zed.rainxch.core.domain.system.UpdateScheduleManager -/** - * No-op implementation for Desktop — WorkManager is Android-only. - */ class DesktopUpdateScheduleManager : UpdateScheduleManager { override fun reschedule(intervalHours: Long) { - // No background scheduler on Desktop + } override fun cancel() { - // No background scheduler on Desktop + } } diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index ae2bc844e..fa88f6f78 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -10,15 +10,5 @@ kotlin { implementation(libs.kotlinx.coroutines.core) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AnnouncementsFeedSnapshot.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AnnouncementsFeedSnapshot.kt new file mode 100644 index 000000000..a176ad1c2 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AnnouncementsFeedSnapshot.kt @@ -0,0 +1,27 @@ +package zed.rainxch.core.domain.model + +data class AnnouncementsFeedSnapshot( + val items: List, + val dismissedIds: Set, + val acknowledgedIds: Set, + val mutedCategories: Set, + val lastFetchedAtMillis: Long, + val lastRefreshFailed: Boolean, +) { + val visibleItems: List + get() = items + .asSequence() + .filter { it.id !in dismissedIds } + .filter { it.category !in mutedCategories || !it.category.isMutable } + .toList() + + val unreadCount: Int + get() = visibleItems.count { it.id !in acknowledgedIds } + + val pendingCriticalAcknowledgment: Announcement? + get() = visibleItems.firstOrNull { + it.severity == AnnouncementSeverity.CRITICAL && + it.requiresAcknowledgment && + it.id !in acknowledgedIds + } +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ApkInspection.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ApkInspection.kt index 15e963902..8192b0508 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ApkInspection.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ApkInspection.kt @@ -1,89 +1,65 @@ package zed.rainxch.core.domain.model -/** - * Snapshot of an APK's declared metadata. Produced by - * [zed.rainxch.core.domain.system.ApkInspector] from either a file on - * disk (parked install) or an installed package's manifest. - * - * Everything here is *as the APK declares it* — the inspector does not - * resolve it against runtime grant state for permissions, that lives on - * [ApkPermission.granted] which is `null` for file-based inspections - * because there's no system-level grant before install. - */ data class ApkInspection( - /** App label as the APK declares it (e.g. "Signal"). */ + val appLabel: String, val packageName: String, val versionName: String?, val versionCode: Long?, - /** SHA-256 of the APK signer cert, hex-encoded with colons. */ + val signingFingerprint: String?, - /** Lowest API the APK runs on. */ + val minSdk: Int?, - /** API the developer compiled against. */ + val targetSdk: Int?, - /** Permissions declared in the manifest, mapped with protection level. */ + val permissions: List, - /** Fully-qualified main launcher activity, if any. */ + val mainActivity: String?, - /** Total declared activity count (manifest entries). */ + val activityCount: Int, - /** Total declared service count. */ + val serviceCount: Int, - /** Total declared receiver count. */ + val receiverCount: Int, - /** Bytes on disk (file-based) or APK file size on the device (installed). */ + val fileSizeBytes: Long?, - /** Absolute path to the inspected APK file, if any. */ + val filePath: String?, - /** Manifest's `android:debuggable` flag — sketchy if true on a release build. */ + val debuggable: Boolean, - /** Source of the inspection. */ + val source: Source, ) { enum class Source { - /** Read from a file on disk that's parked for install. */ + FILE, - /** Read from an installed package's manifest. */ INSTALLED, } } data class ApkPermission( - /** Fully-qualified permission name (e.g. `android.permission.INTERNET`). */ + val name: String, - /** Short, user-friendly label loaded from the system or derived from [name]. */ + val displayName: String, - /** Description loaded from the system, when available. */ + val description: String?, val protectionLevel: ProtectionLevel, - /** - * Runtime grant state on the device. `true`/`false` for installed - * dangerous permissions; `null` for file-based inspections (no - * grant exists pre-install) and for normal-protection permissions - * that are auto-granted at install. - */ + val granted: Boolean?, ) -/** - * Coarse bucketing of Android's permission protection levels — enough - * to drive a "danger color" in the UI without exposing every flag bit. - */ enum class ProtectionLevel { - /** Auto-granted at install. Low risk. */ + NORMAL, - /** Sensitive — requires runtime user grant. Show in red. */ DANGEROUS, - /** Granted only to apps signed with the same cert. Show in amber. */ SIGNATURE, - /** Privileged platform permissions. Show in deep amber. */ PRIVILEGED, - /** Couldn't classify. */ UNKNOWN, } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt index 3acfed511..2628a7a64 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppLanguage.kt @@ -1,29 +1,12 @@ package zed.rainxch.core.domain.model -/** - * A user-selectable UI language for the app. Each entry corresponds to a - * `values-` directory that ships with the Compose resources - * bundle, so the [tag] must match what the Android-style locale qualifier - * resolves to (e.g. `zh-rCN` → language tag `zh-CN`). - * - * [displayName] is intentionally hard-coded in the native script so the - * picker is readable regardless of the currently active UI language — a - * user stuck in the wrong language needs to recognise their own language - * to escape. - */ data class AppLanguage( - /** IETF BCP 47 language tag (e.g. `en`, `zh-CN`, `pt-BR`). */ + val tag: String, - /** Native-script label, e.g. `简体中文`, `Español`. */ + val displayName: String, ) -/** - * Registry of languages the app currently ships translations for. Keep - * in sync with `core/presentation/src/commonMain/composeResources/values-*` - * directories. Order is the order shown in the Tweaks picker (English - * first as the source-of-truth language, rest alphabetised by tag). - */ object AppLanguages { val ALL: List = listOf( diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppTheme.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppTheme.kt index 0024a084c..444fd73ab 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppTheme.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AppTheme.kt @@ -2,14 +2,26 @@ package zed.rainxch.core.domain.model enum class AppTheme { DYNAMIC, - OCEAN, - PURPLE, + NORD, + CREAM, FOREST, - SLATE, - AMBER, + PLUM, ; companion object { - fun fromName(name: String?): AppTheme = entries.find { it.name == name } ?: OCEAN + + private val LEGACY_MIGRATION = mapOf( + "OCEAN" to NORD, + "SLATE" to NORD, + "PURPLE" to PLUM, + "AMBER" to CREAM, + ) + + fun fromName(name: String?): AppTheme { + if (name.isNullOrEmpty()) return NORD + entries.firstOrNull { it.name == name }?.let { return it } + LEGACY_MIGRATION[name]?.let { return it } + return NORD + } } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt index 224c92035..5cd7d44e4 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt @@ -8,16 +8,12 @@ data class ExportedApp( val repoOwner: String, val repoName: String, val repoUrl: String, - // Monorepo tracking (added in export schema v2). Defaults keep - // old v1 JSON files decoding without changes. + val assetFilterRegex: String? = null, val fallbackToOlderReleases: Boolean = false, - // Preferred-variant tracking (added in export schema v3). Defaults - // keep older exports decoding without changes. + val preferredAssetVariant: String? = null, - // Multi-layer variant fingerprint (added in export schema v4): - // serialized token set, glob pattern, and same-position fallback - // metadata. All optional so older v1/v2/v3 exports still decode. + val preferredAssetTokens: String? = null, val assetGlobPattern: String? = null, val pickedAssetIndex: Int? = null, @@ -26,16 +22,7 @@ data class ExportedApp( @Serializable data class ExportedAppList( - /** - * Export schema version. - * - v2: added [ExportedApp.assetFilterRegex] / [ExportedApp.fallbackToOlderReleases] - * - v3: added [ExportedApp.preferredAssetVariant] - * - v4: added [ExportedApp.preferredAssetTokens] / [ExportedApp.assetGlobPattern] - * / [ExportedApp.pickedAssetIndex] / [ExportedApp.pickedAssetSiblingCount] - * - * All older versions still decode correctly because the new fields - * have safe defaults. - */ + val version: Int = 4, val exportedAt: Long = 0L, val apps: List = emptyList(), diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubReleaseExt.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubReleaseExt.kt index 315ccb043..5a5ee4697 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubReleaseExt.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubReleaseExt.kt @@ -2,43 +2,11 @@ package zed.rainxch.core.domain.model import zed.rainxch.core.domain.util.VersionMath -/** - * Single source of truth for "should this release be treated as a - * pre-release across the app". - * - * Combines: - * - [GithubRelease.isPrerelease] — the authoritative GitHub API flag. - * - [VersionMath.isPreReleaseTag] on [GithubRelease.tagName] — catches - * the common case where a maintainer publishes `v2.0.0-rc.1` but - * forgets to tick the "This is a pre-release" box. Without the - * tag heuristic, an opted-out user would be silently offered that - * build as if it were stable. - * - [VersionMath.isPreReleaseTag] on [GithubRelease.name] — some - * maintainers only put the `beta` marker in the human-readable - * release title (e.g. tag=`2.0.0`, name=`2.0.0 (beta)`). - * - * Every UI that shows a "Pre-release" badge and every filter that - * decides whether to surface a release to a given user MUST use this - * helper, otherwise the flag-vs-tag mismatch surfaces as a silent - * bug. - */ fun GithubRelease.isEffectivelyPreRelease(): Boolean = isPrerelease || VersionMath.isPreReleaseTag(tagName) || VersionMath.isPreReleaseTag(name) -/** - * Specific label for this release's pre-release marker — `"Beta"`, - * `"Alpha"`, `"RC"`, etc. — or `null` if no marker was detected. - * - * Tries the tag first (where the marker most often lives), falls - * back to the release name (some maintainers put the marker only - * in the title). Returns `null` when neither contains a recognised - * marker, in which case callers that still want to show a badge - * should check [isEffectivelyPreRelease] and fall back to a generic - * "Pre-release" pill — an opted-in API flag with no visible marker - * is still a pre-release, just one without a specific channel name. - */ fun GithubRelease.preReleaseLabel(): String? = VersionMath.preReleaseMarkerLabel(tagName) ?: VersionMath.preReleaseMarkerLabel(name) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/HostToken.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/HostToken.kt index 22d886393..30a72bb09 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/HostToken.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/HostToken.kt @@ -49,18 +49,6 @@ object HostNames { return noAuth.removeSuffix(".").removePrefix("www.").removePrefix("api.") } - /** - * Maps a request URL host to the canonical token storage host. - * - * GitHub stores the PAT under [GITHUB] (`github.com`), but real API - * requests go to `api.github.com` — without this collapse step the - * interceptor's `repo.get(request.url.host)` returns null on every - * GitHub REST call. - * - * Forgejo/Codeberg/Gitea instances expose their REST API at - * `https:///api/v1/...` (same host as the storage key), so - * they need no mapping. - */ fun sanitizePastedToken(raw: String): String { return raw.trim() .removePrefix("Bearer ") diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt index 3765b68d8..4d518e09d 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt @@ -34,109 +34,34 @@ data class InstalledApp( val latestVersionCode: Long? = null, val latestReleasePublishedAt: String? = null, val includePreReleases: Boolean = false, - /** - * Optional regex applied to asset names. When set, only assets whose - * names match the pattern are considered installable for this app — - * the building block for tracking one app inside a monorepo that ships - * multiple apps (e.g. `ente-auth.*` against `ente-io/ente`). - */ + val assetFilterRegex: String? = null, - /** - * When true, the update check walks back through past releases looking - * for one whose assets match [assetFilterRegex]. Required for monorepos - * where the latest release belongs to a sibling app. - */ + val fallbackToOlderReleases: Boolean = false, - /** - * Stable identifier for the asset variant (e.g. `arm64-v8a`, - * `universal`) that the user has chosen to track. Derived from the - * picked asset filename's tail (everything after the version) so it - * survives version bumps. `null` means "auto-pick by architecture". - */ + val preferredAssetVariant: String? = null, - /** - * Set when the update checker can't find an asset matching - * [preferredAssetVariant] in a fresh release — typically because the - * maintainer renamed or restructured the artefacts. The UI shows a - * "variant changed" prompt; the flag is cleared once the user picks - * a new variant. - */ + val preferredVariantStale: Boolean = false, - /** - * Token-set fingerprint of the picked asset, serialized via - * `AssetVariant.serializeTokens` (sorted, joined by `|`). Primary - * identity layer for the resolver — handles arch-before-version, - * OS-version interlopers, and counters between version and arch. - * - * `null` for older rows pinned before this column existed and for - * filenames where the token vocabulary recognises nothing. - */ + val preferredAssetTokens: String? = null, - /** - * Glob-pattern fingerprint of the picked asset (e.g. - * `app-*-arm64-v8a.apk`). Secondary identity layer used when the - * token vocabulary doesn't recognise anything in the filename — - * the most common case being custom flavor names. - */ + val assetGlobPattern: String? = null, - /** - * Zero-based index of the picked asset in the original release's - * installable-asset list. Last-resort same-position fallback when - * none of the fingerprint layers match in a fresh release. - */ + val pickedAssetIndex: Int? = null, - /** - * Total installable assets in the release the user picked from. - * Pairs with [pickedAssetIndex] for the same-position fallback. - */ + val pickedAssetSiblingCount: Int? = null, - /** - * Absolute path to a downloaded asset that's waiting for the user - * to confirm install. Non-null means: the orchestrator finished - * the download in `InstallWhileForeground` mode but the foreground - * screen had gone away by then, so the bytes are parked and the - * apps list shows a "ready to install" row. - * - * Cleared by the orchestrator after a successful install or when - * the user dismisses the row. - */ + val pendingInstallFilePath: String? = null, - /** - * Release tag of the version represented by [pendingInstallFilePath]. - * Used by Details to detect "the parked file matches the - * currently-selected release" and skip re-downloading on install. - */ + val pendingInstallVersion: String? = null, - /** - * Original (unscoped) asset filename of the parked file. Pairs - * with [pendingInstallVersion] for the Details-screen match. - */ + val pendingInstallAssetName: String? = null, - /** - * Release tag the user explicitly skipped via the apps row "Skip - * this version" action. While non-null, the periodic update check - * suppresses the badge for [packageName] when the matched release - * tag equals this value. Auto-clears the moment a strictly newer - * release lands so the user gets re-notified next cycle. - */ + val skippedReleaseTag: String? = null, val sourceHost: String? = null, ) -/** - * True when the app actually exists on device. A row with - * [InstalledApp.isPendingInstall] set means the bytes are parked on disk - * but the system install has not completed (or failed) — callers that - * surface an "Installed" badge must treat that case as *not* installed, - * otherwise the Details screen (which checks `isPendingInstall`) and the - * Home/Search cards drift out of sync after a failed install. - */ fun InstalledApp?.isReallyInstalled(): Boolean = this != null && !this.isPendingInstall -/** - * True when a genuine update is pending install — mirrors the check - * [zed.rainxch.details.presentation.components.SmartInstallButton] does - * so non-Details surfaces render the same state machine. - */ fun InstalledApp?.hasActualUpdate(): Boolean = this != null && this.isUpdateAvailable && !this.isPendingInstall diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerCategory.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerCategory.kt index 739368a34..e9d2a1d9c 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerCategory.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerCategory.kt @@ -34,12 +34,6 @@ enum class InstallerCategory(val sortPriority: Int) { "com.amazon.venezia", ) - // Heuristic fallback when the installer is null/unknown — many - // OEM-preloaded apps live in /data (so they aren't flagged - // FLAG_UPDATED_SYSTEM_APP) and report an installer string we - // don't recognize or none at all due to package-visibility - // quirks. Falling back to the package-name namespace keeps them - // out of the SIDELOADED bucket where they'd drown user content. private val VENDOR_PACKAGE_PREFIXES = listOf( "com.samsung.", diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MatchingPreview.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MatchingPreview.kt new file mode 100644 index 000000000..294820fc1 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MatchingPreview.kt @@ -0,0 +1,7 @@ +package zed.rainxch.core.domain.model + +data class MatchingPreview( + val release: GithubRelease?, + val matchedAssets: List, + val regexError: String? = null, +) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MirrorRemoved.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MirrorRemoved.kt new file mode 100644 index 000000000..611ef7203 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/MirrorRemoved.kt @@ -0,0 +1,5 @@ +package zed.rainxch.core.domain.model + +data class MirrorRemoved( + val displayName: String, +) \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyScope.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyScope.kt index 1da13af3a..b7ed6f5ce 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyScope.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyScope.kt @@ -1,24 +1,5 @@ package zed.rainxch.core.domain.model -/** - * Independent proxy "channels" — each scope has its own configurable - * [ProxyConfig] so users can, for example, route GitHub API traffic - * through a corporate proxy while keeping APK downloads direct. - * - * Every outbound request in the app belongs to exactly one scope: - * - * - [DISCOVERY] — GitHub REST API calls: search, home, details, - * repo metadata, user profiles, starred, installed - * apps update checks. Basically everything that - * hits `api.github.com`. - * - [DOWNLOAD] — APK file downloads: manual installs from Details, - * one-tap updates from the Installed Apps list, and - * the Android auto-update worker. - * - [TRANSLATION] — README translation requests (currently Google - * Translate). Kept separate because translation - * services are often blocked/unblocked independently - * of GitHub in restricted networks. - */ enum class ProxyScope { DISCOVERY, DOWNLOAD, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/RestartReason.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/RestartReason.kt new file mode 100644 index 000000000..5a57645bb --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/RestartReason.kt @@ -0,0 +1,6 @@ +package zed.rainxch.core.domain.model + +enum class RestartReason { + LANGUAGE, + THEME_MIGRATION, +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemArchitecture.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemArchitecture.kt index accefdb56..8d7121ac7 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemArchitecture.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemArchitecture.kt @@ -1,10 +1,10 @@ package zed.rainxch.core.domain.model enum class SystemArchitecture { - X86_64, // Intel/AMD 64-bit - AARCH64, // ARM 64-bit - X86, // Intel/AMD 32-bit - ARM, // ARM 32-bit + X86_64, + AARCH64, + X86, + ARM, UNKNOWN, ; diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ThemeMode.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ThemeMode.kt new file mode 100644 index 000000000..ac5d9a46e --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ThemeMode.kt @@ -0,0 +1,16 @@ +package zed.rainxch.core.domain.model + +enum class ThemeMode { + LIGHT, + DARK, + AMOLED, + SYSTEM, + ; + + companion object { + fun fromName(name: String?): ThemeMode { + if (name.isNullOrEmpty()) return SYSTEM + return entries.firstOrNull { it.name == name } ?: SYSTEM + } + } +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TokenValidation.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TokenValidation.kt new file mode 100644 index 000000000..c5a66e4ae --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TokenValidation.kt @@ -0,0 +1,7 @@ +package zed.rainxch.core.domain.model + +data class TokenValidation( + val login: String?, + val scopes: List, + val rateLimitRemaining: Int?, +) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TranslationProvider.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TranslationProvider.kt index 0ddd30003..414289899 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TranslationProvider.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/TranslationProvider.kt @@ -1,17 +1,5 @@ package zed.rainxch.core.domain.model -/** - * Backend used to translate README content. - * - * - [GOOGLE] hits Google's public `translate.googleapis.com/translate_a/single` - * endpoint. No credentials required and works everywhere Google does, but - * it's an undocumented endpoint that can change or rate-limit at any time, - * and it's unreachable from mainland China without a proxy. - * - [YOUDAO] hits Youdao's official `openapi.youdao.com/api`. Requires the - * user to provide their own `appKey` / `appSecret` from Youdao's developer - * portal (there's no anonymous free tier). Directly accessible from - * mainland China, which is why it exists — see issue #429. - */ enum class TranslationProvider { GOOGLE, YOUDAO, diff --git a/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/UserProfile.kt similarity index 86% rename from feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt rename to core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/UserProfile.kt index 6dbf0dc64..602085fe7 100644 --- a/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/model/UserProfile.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/UserProfile.kt @@ -1,4 +1,4 @@ -package zed.rainxch.profile.domain.model +package zed.rainxch.core.domain.model import kotlinx.serialization.Serializable @@ -12,4 +12,4 @@ data class UserProfile( val repositoryCount: Int, val followers: Int, val following: Int, -) +) \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/DigestVerifier.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/DigestVerifier.kt index f81c6093b..5bba5212c 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/DigestVerifier.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/DigestVerifier.kt @@ -1,13 +1,7 @@ package zed.rainxch.core.domain.network interface DigestVerifier { - /** - * Streams the file at [filePath] through SHA-256 and compares against - * [expectedDigest] (which may carry a `sha256:` prefix or be raw hex). - * - * @return null on match, a non-null human-readable reason on - * mismatch / IO error. - */ + suspend fun verify( filePath: String, expectedDigest: String, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/ProxyTester.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/ProxyTester.kt index 076789b01..45fb69064 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/ProxyTester.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/network/ProxyTester.kt @@ -2,41 +2,32 @@ package zed.rainxch.core.domain.network import zed.rainxch.core.domain.model.ProxyConfig -/** - * Verifies that a [ProxyConfig] can actually reach the GitHub API. Implementations - * should issue a single lightweight request through a throwaway HTTP client built - * with the supplied config so the test exercises the same engine code path the - * real client uses. - */ interface ProxyTester { suspend fun test(config: ProxyConfig): ProxyTestOutcome + + suspend fun test(config: ProxyConfig, url: String): ProxyTestOutcome = test(config) } sealed interface ProxyTestOutcome { - /** Connection succeeded. [latencyMs] is the round-trip time of the test request. */ + data class Success( val latencyMs: Long, ) : ProxyTestOutcome sealed interface Failure : ProxyTestOutcome { - /** Could not resolve a hostname (DNS failure or unresolved proxy host). */ + data object DnsFailure : Failure - /** Reached the network but could not connect to the proxy itself. */ data object ProxyUnreachable : Failure - /** Connection or socket timed out. */ data object Timeout : Failure - /** Proxy returned 407 / requested authentication. */ data object ProxyAuthRequired : Failure - /** Proxy or upstream returned a non-2xx HTTP status. */ data class UnexpectedResponse( val statusCode: Int, ) : Failure - /** Anything else (TLS errors, malformed config, etc.). */ data class Unknown( val message: String?, ) : Failure diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AnnouncementsRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AnnouncementsRepository.kt index 740358b56..f1c0a0306 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AnnouncementsRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AnnouncementsRepository.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.Announcement import zed.rainxch.core.domain.model.AnnouncementCategory import zed.rainxch.core.domain.model.AnnouncementSeverity +import zed.rainxch.core.domain.model.AnnouncementsFeedSnapshot interface AnnouncementsRepository { fun observeFeed(): Flow @@ -15,30 +16,4 @@ interface AnnouncementsRepository { suspend fun acknowledge(id: String) suspend fun setMuted(category: AnnouncementCategory, muted: Boolean) -} - -data class AnnouncementsFeedSnapshot( - val items: List, - val dismissedIds: Set, - val acknowledgedIds: Set, - val mutedCategories: Set, - val lastFetchedAtMillis: Long, - val lastRefreshFailed: Boolean, -) { - val visibleItems: List - get() = items - .asSequence() - .filter { it.id !in dismissedIds } - .filter { it.category !in mutedCategories || !it.category.isMutable } - .toList() - - val unreadCount: Int - get() = visibleItems.count { it.id !in acknowledgedIds } - - val pendingCriticalAcknowledgment: Announcement? - get() = visibleItems.firstOrNull { - it.severity == AnnouncementSeverity.CRITICAL && - it.requiresAcknowledgment && - it.id !in acknowledgedIds - } -} +} \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/CacheRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/CacheRepository.kt new file mode 100644 index 000000000..2cc58172f --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/CacheRepository.kt @@ -0,0 +1,9 @@ +package zed.rainxch.core.domain.repository + +import kotlinx.coroutines.flow.Flow + +interface CacheRepository { + fun observeCacheSize(): Flow + + suspend fun clearCache() +} \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/DeviceIdentityRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/DeviceIdentityRepository.kt deleted file mode 100644 index 0c2fa8e2c..000000000 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/DeviceIdentityRepository.kt +++ /dev/null @@ -1,7 +0,0 @@ -package zed.rainxch.core.domain.repository - -interface DeviceIdentityRepository { - suspend fun getDeviceId(): String - - suspend fun resetDeviceId(): String -} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/HostTokenRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/HostTokenRepository.kt index 384c8d859..40c7ff64d 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/HostTokenRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/HostTokenRepository.kt @@ -2,6 +2,7 @@ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.HostToken +import zed.rainxch.core.domain.model.TokenValidation interface HostTokenRepository { fun observeAll(): Flow> @@ -13,10 +14,4 @@ interface HostTokenRepository { suspend fun delete(host: String) suspend fun validate(host: String, token: String): Result -} - -data class TokenValidation( - val login: String?, - val scopes: List, - val rateLimitRemaining: Int?, -) +} \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt index 2dcbdfe77..47c8111df 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstalledApp +import zed.rainxch.core.domain.model.MatchingPreview interface InstalledAppsRepository { fun getAllInstalledApps(): Flow> @@ -45,16 +46,6 @@ interface InstalledAppsRepository { suspend fun updateApp(app: InstalledApp) - /** - * Atomically writes only the installed-version columns + the - * `isUpdateAvailable` flag for [packageName]. Prefer this over - * [updateApp] on hot paths where the caller holds a possibly-stale - * snapshot and only wants to persist a version change — full-row - * updates from stale snapshots can clobber concurrent writes to - * sibling columns (download orchestrator, variant pin, favourite - * toggle, periodic update check). Introduced for the external - * install path (`PackageEventReceiver`). - */ suspend fun updateInstalledVersion( packageName: String, installedVersion: String, @@ -78,42 +69,12 @@ interface InstalledAppsRepository { enabled: Boolean, ) - /** - * Persists per-app monorepo settings: an optional regex applied to asset - * names and whether the update checker should fall back to older - * releases when the latest one has no matching asset. - * - * Implementations should re-check the app for updates immediately so - * the UI reflects the new state without a manual refresh. - */ suspend fun setAssetFilter( packageName: String, regex: String?, fallbackToOlderReleases: Boolean, ) - /** - * Persists the user's preferred asset variant for [packageName] - * along with the full multi-layer fingerprint: - * - * - [variant]: legacy substring-tail label, used as the display - * name and as a third-tier match in the resolver - * - [tokens]: serialized token-set fingerprint (primary identity) - * - [glob]: glob-pattern fingerprint (secondary identity) - * - [pickedIndex]: zero-based index of the picked asset in the - * release's installable-asset list (same-position fallback) - * - [siblingCount]: total installable assets in the picked release - * - * Always clears the `preferredVariantStale` flag in the same write - * because the user has just made an explicit choice. - * - * Pass `null` for all fields except [packageName] to unpin and fall - * back to the platform auto-picker — convenient via [clearPreferredVariant]. - * - * Implementations should re-check the app for updates immediately - * so the cached `latestAsset*` fields point at the variant the user - * just selected, without waiting for the next periodic worker. - */ suspend fun setPreferredVariant( packageName: String, variant: String?, @@ -123,53 +84,15 @@ interface InstalledAppsRepository { siblingCount: Int? = null, ) - /** - * Convenience for [setPreferredVariant] that clears every - * fingerprint layer for [packageName] in a single call. The - * resolver will fall back to the platform auto-picker on the next - * update check. - */ suspend fun clearPreferredVariant(packageName: String) - /** - * Marks [tag] as skipped for [packageName] so the periodic update - * check stops surfacing it as an available update. Pass `null` for - * [tag] to clear an existing skip. - * - * Implementations also clear the row's `isUpdateAvailable` flag in - * the same write when [tag] is non-null so the apps list drops the - * badge immediately. The skip auto-clears the moment a strictly - * newer release lands; users do not have to unskip manually for a - * future bump. - */ suspend fun setSkippedReleaseTag( packageName: String, tag: String?, ) - /** - * Live stream of every installed app whose - * [InstalledApp.skippedReleaseTag] is non-null. Backs the Tweaks - * "Skipped updates" sub-screen; emits `[]` when no app is currently - * skipping a release. - */ fun getAppsWithSkippedReleaseTag(): Flow> - /** - * Sets (or clears) the path + version + asset name of a - * downloaded-but-not-yet-installed asset for [packageName]. - * - * Used by `DefaultDownloadOrchestrator` when an - * `InstallPolicy.InstallWhileForeground` download completes - * after the foreground screen has been destroyed — the file is - * parked, these three columns are set, and the apps list shows - * a "Ready to install" row. The Details screen also uses - * [version] + [assetName] to detect "the parked file matches - * the currently-selected release" and skip re-downloading. - * - * Pass `null` for all three to clear (after a successful install - * or after the user dismissed the row). - */ suspend fun setPendingInstallFilePath( packageName: String, path: String?, @@ -177,17 +100,6 @@ interface InstalledAppsRepository { assetName: String? = null, ) - /** - * Dry-run helper for the per-app advanced settings sheet. Fetches a - * window of releases for [owner]/[repo] (honouring [includePreReleases]) - * and returns the assets in the most-recent release that match - * [regex] — or, if [fallbackToOlderReleases] is true and the latest - * release matches nothing, the assets from the next release that does. - * - * Returns an empty list when no matching release is found in the - * window. Never throws — failures resolve to an empty list and are - * logged at debug level. - */ suspend fun previewMatchingAssets( owner: String, repo: String, @@ -197,14 +109,4 @@ interface InstalledAppsRepository { ): MatchingPreview suspend fun executeInTransaction(block: suspend () -> R): R -} - -/** - * Snapshot returned by [InstalledAppsRepository.previewMatchingAssets] for - * the per-app advanced settings sheet's live preview. - */ -data class MatchingPreview( - val release: GithubRelease?, - val matchedAssets: List, - val regexError: String? = null, -) +} \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/MirrorRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/MirrorRepository.kt index f048fea65..cbf16b1ce 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/MirrorRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/MirrorRepository.kt @@ -3,34 +3,21 @@ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import zed.rainxch.core.domain.model.MirrorConfig import zed.rainxch.core.domain.model.MirrorPreference +import zed.rainxch.core.domain.model.MirrorRemoved interface MirrorRepository { - /** - * Emits the cached catalog immediately, then fresh entries on each - * successful refresh. Falls back to the bundled list when the cache - * is empty and the backend is unreachable. - */ + fun observeCatalog(): Flow> - /** Forces a backend fetch ignoring the 24h cache. */ suspend fun refreshCatalog(): Result fun observePreference(): Flow suspend fun setPreference(pref: MirrorPreference) - /** - * Emits a one-shot notice when the user's previously-selected mirror - * disappears from a freshly-fetched catalog and the repository - * auto-falls-back to Direct. UI surfaces a toast. - */ fun observeRemovedNotices(): Flow suspend fun snoozeAutoSuggest(forMs: Long) suspend fun dismissAutoSuggestPermanently() } - -data class MirrorRemoved( - val displayName: String, -) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt index a1c6aa0d9..f37e6c440 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ProxyRepository.kt @@ -11,4 +11,12 @@ interface ProxyRepository { scope: ProxyScope, config: ProxyConfig, ) + + fun getMasterProxyConfig(): Flow + + suspend fun setMasterProxyConfig(config: ProxyConfig) + + fun getUseMaster(scope: ProxyScope): Flow + + suspend fun setUseMaster(scope: ProxyScope, useMaster: Boolean) } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/SeenReposRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/SeenReposRepository.kt index d8f791c6e..f1c81c584 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/SeenReposRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/SeenReposRepository.kt @@ -11,11 +11,6 @@ interface SeenReposRepository { suspend fun markAsSeen(repo: GithubRepoSummary) - /** - * Primitive-arg overload for surfaces that only hold the UI model - * (Home / Search cards) and would otherwise have to reconstitute a - * full [GithubRepoSummary] just to mark a repo seen. - */ suspend fun markAsSeen( repoId: Long, repoName: String, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt deleted file mode 100644 index a1d7a305b..000000000 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt +++ /dev/null @@ -1,64 +0,0 @@ -package zed.rainxch.core.domain.repository - -interface TelemetryRepository { - fun recordSearchPerformed(resultCount: Int) - - fun recordSearchResultClicked(repoId: Long) - - fun recordRepoViewed(repoId: Long) - - fun recordReleaseDownloaded(repoId: Long) - - fun recordInstallStarted(repoId: Long) - - fun recordInstallSucceeded(repoId: Long) - - fun recordInstallFailed(repoId: Long, errorCode: String?) - - fun recordAppOpenedAfterInstall(repoId: Long) - - fun recordUninstalled(repoId: Long) - - fun recordFavorited(repoId: Long) - - fun recordUnfavorited(repoId: Long) - - // ── E1 external-import telemetry ──────────────────────────────── - // All payloads are bucketed/enum strings — never package names, - // repo names, app labels, or fingerprints. See E1 plan §8. - - suspend fun importScanStarted(trigger: String) - - suspend fun importScanCompleted(candidateCountBucket: String, durationMsBucket: String) - - suspend fun importMatchAttempted(strategy: String, confidenceBucket: String) - - suspend fun importAutoLinked(countBucket: String) - - suspend fun importManuallyLinked(countBucket: String, source: String) - - suspend fun importSkipped(countBucket: String, persisted: String) - - suspend fun importUnlinkedFromDetails() - - suspend fun importPermissionRequested() - - suspend fun importPermissionOutcome(granted: Boolean, sdkIntBucket: String) - - suspend fun importSearchOverrideUsed() - - suspend fun importSearchOverrideNoResults() - - suspend fun signingSeedSyncCompleted(rowsAddedBucket: String, durationMsBucket: String) - - suspend fun externalMatchApiFailure(statusCodeBucket: String, retried: Boolean) - - suspend fun flushPending() - - /** - * Drops any buffered events that have not yet been transmitted. - * Called when the user resets their analytics ID so events that - * were recorded under the old ID don't leak out attached to it. - */ - suspend fun clearPending() -} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt index 65005bfdf..d984b24ae 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt @@ -7,6 +7,8 @@ import zed.rainxch.core.domain.model.ContentWidth import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerType +import zed.rainxch.core.domain.model.RestartReason +import zed.rainxch.core.domain.model.ThemeMode import zed.rainxch.core.domain.model.TranslationProvider interface TweaksRepository { @@ -22,6 +24,14 @@ interface TweaksRepository { suspend fun setAmoledTheme(enabled: Boolean) + fun getThemeMode(): Flow + + suspend fun setThemeMode(mode: ThemeMode) + + fun getOnboardingComplete(): Flow + + suspend fun setOnboardingComplete(complete: Boolean) + fun getFontTheme(): Flow suspend fun setFontTheme(fontTheme: FontTheme) @@ -70,10 +80,6 @@ interface TweaksRepository { suspend fun setContentWidth(width: ContentWidth) - fun getTelemetryEnabled(): Flow - - suspend fun setTelemetryEnabled(enabled: Boolean) - fun getTranslationProvider(): Flow suspend fun setTranslationProvider(provider: TranslationProvider) @@ -106,31 +112,14 @@ interface TweaksRepository { suspend fun setMicrosoftTranslatorRegion(region: String) - /** - * Selected UI language as a BCP 47 tag (e.g. `zh-CN`). Emits - * `null` when the user hasn't picked one — which means "follow - * whatever the JVM/Android locale is" at app start. `null` is - * distinct from `""`: the former is the unset state, the latter - * would be a malformed user choice we don't support. - */ fun getAppLanguage(): Flow suspend fun setAppLanguage(tag: String?) - /** - * When `true`, Details kicks off a translation of the README and - * release notes immediately on load, using - * [getAutoTranslateTargetLang] as the target. Default `false`. - */ fun getAutoTranslateEnabled(): Flow suspend fun setAutoTranslateEnabled(enabled: Boolean) - /** - * BCP 47 tag of the language Details auto-translates into when - * [getAutoTranslateEnabled] is `true`. `null` falls back to the - * UI language ([getAppLanguage]) at translate time. - */ fun getAutoTranslateTargetLang(): Flow suspend fun setAutoTranslateTargetLang(tag: String?) @@ -147,50 +136,22 @@ interface TweaksRepository { suspend fun setExternalImportBannerDismissedAtCount(count: Int) - /** - * Permanent dismissal flag for the Keep Android Open campaign banner on - * Apps. False until user taps the close button; flips to true forever - * once dismissed. - */ fun getKaoBannerDismissed(): Flow suspend fun setKaoBannerDismissed(dismissed: Boolean) - /** - * One-shot flag for the APK Inspect coachmark next to the install - * button on the details screen. `false` until the user has seen the - * coachmark at least once; flips permanently to `true` thereafter. - */ fun getApkInspectCoachmarkShown(): Flow suspend fun setApkInspectCoachmarkShown(shown: Boolean) - /** - * One-shot flag for the release-channel coachmark on the Details - * screen. Survey signal — users don't realise the per-app channel - * chip toggles betas. `false` until shown at least once; permanent - * `true` after dismissal. - */ fun getChannelChipCoachmarkShown(): Flow suspend fun setChannelChipCoachmarkShown(shown: Boolean) - /** - * When true, the release-assets picker on Details shows installers - * for every OS (grouped by platform section). When false (default), - * only assets installable on the current platform are listed. - */ fun getShowAllPlatforms(): Flow suspend fun setShowAllPlatforms(enabled: Boolean) - /** - * One-shot watermark for the battery-optimization prompt on - * aggressive-OEM ROMs (Oppo / OnePlus / Realme / Xiaomi / vivo / - * Honor). `false` until the user has either granted the exemption - * or explicitly dismissed the prompt; flips to `true` afterwards - * and is never re-shown. - */ fun getBatteryOptimizationPromptDismissed(): Flow suspend fun setBatteryOptimizationPromptDismissed(dismissed: Boolean) @@ -232,4 +193,10 @@ interface TweaksRepository { suspend fun addCustomForgeHost(host: String) suspend fun removeCustomForgeHost(host: String) + + fun getNeedsRestartReasons(): Flow> + + suspend fun addRestartReason(reason: RestartReason) + + suspend fun clearRestartReasons() } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/UserSessionRepository.kt similarity index 72% rename from core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt rename to core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/UserSessionRepository.kt index 8e2803b84..4636e89a0 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/UserSessionRepository.kt @@ -2,9 +2,11 @@ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow +import zed.rainxch.core.domain.model.UserProfile -interface AuthenticationState { +interface UserSessionRepository { fun isUserLoggedIn(): Flow + fun getUser(): Flow suspend fun isCurrentlyUserLoggedIn(): Boolean @@ -13,4 +15,5 @@ interface AuthenticationState { suspend fun notifySessionExpired(tokenKey: String?) suspend fun notifyRequestSucceeded(tokenKey: String?) + suspend fun logout() } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/WhatsNewLoader.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/WhatsNewLoader.kt index 1afbcffc8..16ed4119b 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/WhatsNewLoader.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/WhatsNewLoader.kt @@ -3,17 +3,6 @@ package zed.rainxch.core.domain.repository import zed.rainxch.core.domain.model.WhatsNewEntry interface WhatsNewLoader { - /** - * Optional explicit BCP-47 [languageTag] (e.g. `"zh-CN"`, `"fr"`, - * `null`). When non-null, this takes precedence over the global - * `LocalizationManager` lookup — used to defeat the race where the - * `getAppLanguage()` flow fans out to multiple subscribers and the - * loader runs before the global `Locale.getDefault()` has caught up - * with the user's just-picked language. Null falls back to - * whatever `LocalizationManager.getCurrentLanguageCode()` returns - * at call time (suitable for paths where the tag isn't known - * upfront, e.g. a deep-linked one-shot load). - */ suspend fun loadAll(languageTag: String? = null): List suspend fun forVersionCode(versionCode: Int, languageTag: String? = null): WhatsNewEntry? diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ApkInspector.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ApkInspector.kt index f49e52b9e..695fa8099 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ApkInspector.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ApkInspector.kt @@ -2,27 +2,9 @@ package zed.rainxch.core.domain.system import zed.rainxch.core.domain.model.ApkInspection -/** - * Reads metadata out of an APK file or an installed package and returns - * the result as an [ApkInspection]. Used by the "Inspect APK" sheet to - * show users what a package declares before they install it (and to - * audit installed packages after the fact). - * - * Implementations live per-platform; on Android the Inspector reads - * from `PackageManager`, on JVM/desktop it returns `null` (no APK - * concept on those targets). - */ interface ApkInspector { - /** - * Inspects an APK file at [filePath]. Returns `null` if the file - * doesn't exist, isn't a valid APK, or the platform can't extract - * metadata. - */ + suspend fun inspectFile(filePath: String): ApkInspection? - /** - * Inspects an installed package by [packageName]. Returns `null` - * if the package isn't on the system or metadata is unavailable. - */ suspend fun inspectInstalled(packageName: String): ApkInspection? } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadOrchestrator.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadOrchestrator.kt index 8f468f211..66eff0217 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadOrchestrator.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadOrchestrator.kt @@ -4,183 +4,40 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import zed.rainxch.core.domain.model.GithubAsset -/** - * Application-scoped manager for in-flight downloads and installs. - * - * # Why this exists - * - * Before this interface, every download/install ran inside the calling - * ViewModel's `viewModelScope`. Two problems followed: - * - * 1. **Screen-leave kills installs** — navigating away from the - * details screen mid-download cancels the OkHttp call and deletes - * the partial file. Users had to camp on the screen to update - * anything. - * - * 2. **No cross-screen coordination** — the apps list and the details - * screen each had their own download bookkeeping. Hopping between - * screens lost progress, status, and cancel handles. - * - * The orchestrator solves both by owning a long-lived [CoroutineScope] - * tied to the application's lifetime. ViewModels become **observers** - * of [downloads] rather than **owners** of any work — when a screen is - * destroyed, the work keeps running and the next screen instance picks - * the state right back up. - * - * # Install policies - * - * Each enqueued download declares an [InstallPolicy] that controls what - * happens when the bytes are on disk: - * - * - [InstallPolicy.AlwaysInstall] — fire the installer no matter what. - * Used for **Shizuku** silent installs, where there's no UI dialog - * and the user has explicitly opted into the unattended path. - * - * - [InstallPolicy.InstallWhileForeground] — fire the installer **only - * if the screen that requested the download is still alive**. The - * ViewModel must call [downgradeToDeferred] in `onCleared()`. If the - * downgrade lands before the download completes, the installer is - * not invoked; instead the file is parked, the row is marked - * "pending install", and the notifier is poked. - * - * - [InstallPolicy.DeferUntilUserAction] — never auto-install. Used by - * the apps list when the user has tapped "Update" but the screen - * might leave at any moment, and by recovery flows. The file lands - * on disk and the user installs it explicitly. - * - * # Concurrency model - * - * Up to [maxConcurrent] downloads run in parallel; further `enqueue` - * calls join an internal queue. Cancellation is per-package: cancelling - * one download doesn't touch the others. Implementations should - * surface a `SupervisorJob` so a single failed download can't poison - * the scope. - * - * # Filename namespacing - * - * The orchestrator scopes filenames as `owner_repo_originalName.ext` - * before passing them to the [zed.rainxch.core.domain.network.Downloader] - * — this prevents collisions between two repos that ship installers - * with the same name (e.g. `app.apk`). - */ interface DownloadOrchestrator { - /** - * Live snapshot of every download the orchestrator currently knows - * about, keyed by package name. Includes downloads in every stage - * (queued, downloading, awaiting install, failed, cancelled). The - * map is updated atomically so consumers can use plain `collect` - * without worrying about torn reads. - * - * Entries are removed when: - * - The install completes (foreground or Shizuku) and - * [dismiss] is called by the consuming ViewModel - * - The user explicitly dismisses a failed/cancelled download - * - The orchestrator is cleared (process death) - * - * Pending-install entries (`stage = AwaitingInstall`) survive - * across screen instances precisely so the apps list can show - * "ready to install" rows. - */ + val downloads: StateFlow> - /** - * Convenience flow that emits the entry for [packageName] whenever - * it changes — equivalent to `downloads.map { it[packageName] }` - * with `distinctUntilChanged`. Use this from a ViewModel observing - * a single app. - */ fun observe(packageName: String): Flow - /** - * Enqueues a download for [spec]. If a download for the same - * package is already in progress, this returns the existing - * download's id without starting a new one — duplicates are a - * silent no-op so consumers can be naïve about idempotency. - * - * @return the orchestrator's internal id for the download. Use it - * with [cancel] / [dismiss] for safe targeting (the package name - * also works but is less precise if a row gets re-enqueued). - */ suspend fun enqueue(spec: DownloadSpec): String - /** - * Atomically swaps the install policy for [packageName] to - * [InstallPolicy.DeferUntilUserAction]. Called by foreground - * ViewModels in `onCleared()` so a download that was started with - * [InstallPolicy.InstallWhileForeground] doesn't auto-install - * after the screen goes away. - * - * Race-safe: if the download has already finished and the install - * is in progress, this is a no-op (the install completes). If the - * download is still running, the policy flips immediately and the - * orchestrator parks the file when bytes are done. - * - * No-op if no download exists for [packageName]. - */ suspend fun downgradeToDeferred(packageName: String) - /** - * Cancels the download for [packageName] (if any), deletes the - * partial file, and removes the entry from [downloads]. Safe to - * call from any thread; idempotent. - */ suspend fun cancel(packageName: String) - /** - * Triggers the install of a previously-deferred download. Looks up - * the file via [OrchestratedDownload.filePath] for [packageName], - * runs the platform installer, and removes the entry on success. - * - * Used by: - * - The apps list "Install" button when a row is in - * [DownloadStage.AwaitingInstall] - * - The notification action that opens the apps list and resumes - * - * Validation (signing fingerprint, package mismatch) is the - * caller's responsibility — the orchestrator runs the bare - * `installer.install` call. The dialog-driven path in - * `DetailsViewModel` continues to handle the screen-attached - * case where the validation surface lives. - */ suspend fun installPending(packageName: String): InstallOutcome? - /** - * Removes the entry for [packageName] from [downloads]. Used when - * the consuming ViewModel has handled a terminal state (success, - * error, cancellation) and doesn't want it sticking around in the - * map. - */ fun dismiss(packageName: String) } -/** - * What the caller wants the orchestrator to do with a single asset. - */ data class DownloadSpec( - /** Package name being installed/updated. Used as the map key. */ + val packageName: String, - /** Repository owner — half of the filename namespace prefix. */ + val repoOwner: String, - /** Repository name — the other half. */ + val repoName: String, - /** Asset to download. The orchestrator pulls URL/name/size from this. */ + val asset: GithubAsset, - /** Display name shown in notifications and progress UI. */ + val displayAppName: String, - /** What to do with the file once it's on disk. See [InstallPolicy]. */ + val installPolicy: InstallPolicy, - /** - * Release tag of the version being downloaded. Stored on the - * orchestrator entry for log / display purposes; the orchestrator - * itself doesn't interpret it. - */ + val releaseTag: String, ) -/** - * Snapshot of one download's state in [DownloadOrchestrator.downloads]. - * Immutable — every state transition produces a new instance. - */ data class OrchestratedDownload( val id: String, val packageName: String, @@ -191,85 +48,44 @@ data class OrchestratedDownload( val assetSize: Long, val downloadUrl: String, val releaseTag: String, - /** Path on disk once the download has completed. Null until then. */ + val filePath: String?, - /** Current install policy — can change mid-flight via downgrade. */ + val installPolicy: InstallPolicy, val stage: DownloadStage, - /** 0..100, null when content-length is unknown. */ + val progressPercent: Int?, - /** - * Bytes received so far. Updated on every emission of the - * underlying download flow — gives the UI a smooth byte counter - * even when the percent integer hasn't ticked over. - */ + val bytesDownloaded: Long = 0L, - /** - * Total bytes expected. Falls back to [assetSize] when the - * server doesn't send `Content-Length`. Null only in rare cases - * where neither is known up front. - */ + val totalBytes: Long? = null, - /** Error message if [stage] is [DownloadStage.Failed]. */ + val errorMessage: String? = null, val installOutcome: InstallOutcome? = null, ) -/** - * Lifecycle stages a download moves through. Forward-only except via - * [DownloadOrchestrator.cancel] which can interrupt anything before - * `Completed`. - */ enum class DownloadStage { - /** Sitting in the queue, waiting for a worker slot. */ + Queued, - /** OkHttp is actively pulling bytes. */ Downloading, - /** Bytes are on disk; the orchestrator is about to invoke the installer. */ Installing, - /** - * Bytes are on disk and the install policy said "wait for the user". - * The apps list shows a one-tap install button for entries in this - * stage. The notifier was poked when the entry first landed here. - */ AwaitingInstall, - /** Installer reported success. The entry is eligible for [DownloadOrchestrator.dismiss]. */ Completed, - /** Cancelled by [DownloadOrchestrator.cancel]. */ Cancelled, - /** Download or install failed. See [OrchestratedDownload.errorMessage]. */ Failed, } -/** - * What the orchestrator should do with the bytes once they're on disk. - * See the interface kdoc for the rationale behind each policy. - */ enum class InstallPolicy { - /** - * Always invoke the installer when the download finishes. Used - * for Shizuku silent installs and other unattended paths. - */ + AlwaysInstall, - /** - * Invoke the installer only if the screen that requested the - * download is still attached. Foreground ViewModels declare this - * and call [DownloadOrchestrator.downgradeToDeferred] in - * `onCleared()`. - */ InstallWhileForeground, - /** - * Never auto-install. The file is parked in - * [DownloadStage.AwaitingInstall] and the user installs from the - * apps list explicitly. - */ DeferUntilUserAction, } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadProgressNotifier.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadProgressNotifier.kt index 1709e08ad..eb0176f33 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadProgressNotifier.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/DownloadProgressNotifier.kt @@ -1,40 +1,7 @@ package zed.rainxch.core.domain.system -/** - * Surfaces live download progress in the system notification shade so - * users can track downloads while the app is in the background (see - * GitHub-Store#373). - * - * Lifecycle: [DownloadOrchestrator] is the single source of truth for - * download state. A platform observer subscribes to - * [DownloadOrchestrator.downloads] and calls [notifyProgress] on every - * `Queued` / `Downloading` emission, and [clearProgress] on any terminal - * or install-stage transition. The orchestrator itself does not know - * about this notifier — wiring is one-way. - * - * Platform contracts: - * - **Android**: persistent ongoing notification with progress bar and - * a "Cancel" action that broadcasts back to - * `DownloadCancelReceiver`. Channel id `app_downloads`. - * - **JVM/Desktop**: no-op. Desktop downloads happen with the window - * visible and installers complete synchronously via the OS dialog; - * there is no equivalent of the Android notification shade to target. - */ interface DownloadProgressNotifier { - /** - * Posts or updates the progress notification for [packageName]. - * Safe to call on every progress tick — the Android impl uses - * `setOnlyAlertOnce(true)` so the notification updates silently. - * - * @param packageName Stable key; also drives the notification id. - * @param appName User-visible title. - * @param versionTag Release tag shown alongside byte counts. - * @param percent 0..100, or `null` when the server did not send - * a `Content-Length` header (indeterminate spinner). - * @param bytesDownloaded Bytes received so far — shown in the - * notification's content text. - * @param totalBytes Expected total, or `null` if unknown. - */ + fun notifyProgress( packageName: String, appName: String, @@ -44,10 +11,5 @@ interface DownloadProgressNotifier { totalBytes: Long?, ) - /** - * Dismisses the progress notification for [packageName]. Called on - * any transition out of `Queued` / `Downloading` — including - * `AwaitingInstall`, which is owned by [PendingInstallNotifier]. - */ fun clearProgress(packageName: String) } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt index 9ab0a99e0..44e6ccddb 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt @@ -3,22 +3,10 @@ package zed.rainxch.core.domain.system import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.SystemArchitecture -/** - * Result of an [Installer.install] call. - */ enum class InstallOutcome { - /** - * Installation completed synchronously (e.g. Shizuku silent install). - * The package is already installed on the system — no need to wait - * for a broadcast to confirm. - */ + COMPLETED, - /** - * Installation was handed off to the system UI or an external process. - * The caller should treat the install as pending until a - * PACKAGE_ADDED / PACKAGE_REPLACED broadcast confirms it. - */ DELEGATED_TO_SYSTEM, } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PendingInstallNotifier.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PendingInstallNotifier.kt index 0dbb37f0f..d487b7621 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PendingInstallNotifier.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PendingInstallNotifier.kt @@ -1,38 +1,7 @@ package zed.rainxch.core.domain.system -/** - * Surfaces "your download is ready, tap to install" notifications. - * - * Used by [DownloadOrchestrator] when an [InstallPolicy.InstallWhileForeground] - * download finishes after the foreground screen has been destroyed - * (the user navigated away mid-download). The notification gives them - * a one-tap path back into the app to complete the install. - * - * Platform contracts: - * - **Android**: real notification with a deep link into the Details - * page for the specific app (`githubstore://repo/owner/name`). - * Falls back to the apps tab (`githubstore://apps`) when owner/repo - * are unavailable. Channel id `app_updates`. - * - **JVM/Desktop**: no-op (Desktop doesn't background-install APKs; - * the apps tab is hidden on Desktop anyway). - */ interface PendingInstallNotifier { - /** - * Posts (or updates) the notification for [packageName]. - * - * @param packageName Used as a stable notification id so multiple - * pending installs each get their own row instead of replacing - * each other. - * @param repoOwner GitHub owner — used to build the - * `githubstore://repo/owner/name` deep link so tapping the - * notification opens the Details page for *this specific app*. - * @param repoName GitHub repo — paired with [repoOwner] for the - * deep link. - * @param appName User-visible app name shown as the notification - * title. - * @param versionTag Release tag shown as the notification body - * (e.g. "v1.2.3"). - */ + fun notifyPending( packageName: String, repoOwner: String, @@ -41,10 +10,5 @@ interface PendingInstallNotifier { versionTag: String, ) - /** - * Dismisses the pending-install notification for [packageName]. - * Called by the orchestrator when the user installs the file - * (either from the apps row or from the notification action). - */ fun clearPending(packageName: String) } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchResult.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchResult.kt index 2cb3f2768..c229be1cd 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchResult.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/RepoMatchResult.kt @@ -7,10 +7,7 @@ data class RepoMatchSuggestion( val source: RepoMatchSource, val stars: Int? = null, val description: String? = null, - // Non-null when the suggestion lives on a non-GitHub forge - // (Codeberg / Forgejo / custom). Drives the URL builder in the - // "select suggestion" path so we don't navigate to github.com for - // a Forgejo repo. + val sourceHost: String? = null, ) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/SystemInstallSerializer.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/SystemInstallSerializer.kt index dcb330b6a..233634cd2 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/SystemInstallSerializer.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/SystemInstallSerializer.kt @@ -9,14 +9,7 @@ interface SystemInstallSerializer { fun markCompleted(packageName: String) companion object { - // Tuned down from 60s after field reports of "stuck at 100%" - // when a prior install returned DELEGATED_TO_SYSTEM and the - // broadcast that releases the gate never arrived (user - // dismissed the system dialog, OEM throttling, Shizuku - // fallback to default installer with no follow-through). - // 15s is long enough to cover a normal Shizuku/Dhizuku silent - // install round-trip and short enough that the queue recovers - // before the user gives up. + const val DEFAULT_TIMEOUT_MS: Long = 15_000L } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/UpdateScheduleManager.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/UpdateScheduleManager.kt index 061806a78..d36f87342 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/UpdateScheduleManager.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/UpdateScheduleManager.kt @@ -1,19 +1,8 @@ package zed.rainxch.core.domain.system -/** - * Abstraction for rescheduling background update checks. - * Android implementation delegates to WorkManager; Desktop is a no-op. - */ interface UpdateScheduleManager { - /** - * Reschedules the periodic update check with a new interval. - * Takes effect immediately (replaces existing schedule). - */ + fun reschedule(intervalHours: Long) - /** - * Cancels every pending update-check / auto-update worker. Used - * when the user disables background update checking entirely. - */ fun cancel() } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt index 04499653b..3dd70ee85 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt @@ -9,18 +9,6 @@ import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.PackageMonitor -/** - * Use case for synchronizing installed apps state with the system package manager. - * - * Responsibilities: - * 1. Remove apps from DB that are no longer installed on the system - * 2. Migrate legacy apps missing versionName/versionCode fields - * 3. Resolve pending installs once they appear in the system package manager - * 4. Clean up stale pending installs (older than 24 hours) - * 5. Detect external version changes (downgrades on rooted devices, sideloads, etc.) - * - * This should be called before loading or refreshing app data to ensure consistency. - */ class SyncInstalledAppsUseCase( private val packageMonitor: PackageMonitor, private val installedAppsRepository: InstalledAppsRepository, @@ -28,7 +16,7 @@ class SyncInstalledAppsUseCase( private val logger: GitHubStoreLogger, ) { companion object { - private const val PENDING_TIMEOUT_MS = 24 * 60 * 60 * 1000L // 24 hours + private const val PENDING_TIMEOUT_MS = 24 * 60 * 60 * 1000L } suspend operator fun invoke(): Result = @@ -43,13 +31,7 @@ class SyncInstalledAppsUseCase( val toResolvePending = mutableListOf() val toDeleteStalePending = mutableListOf() val toSyncVersions = mutableListOf() - // Rows that are confirmed-installed but still carry a - // stale `pendingInstallFilePath`. Happens when the user - // installed a parked file but the broadcast handler - // missed the cleanup (process killed mid-install, - // legacy rows from before the cleanup was wired in, - // etc.). Without this sweep the apps screen keeps - // rendering an "Install" CTA forever. + val toClearStaleParkedFile = mutableListOf() appsInDb.forEach { app -> @@ -72,7 +54,6 @@ class SyncInstalledAppsUseCase( toMigrate.add(app.packageName to migrationResult) } - // Detect external version changes (downgrades on rooted devices, sideloads, etc.) isOnSystem && platform == Platform.ANDROID -> { toSyncVersions.add(app) } @@ -106,12 +87,7 @@ class SyncInstalledAppsUseCase( val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName) if (systemInfo != null) { val latestVersionCode = app.latestVersionCode ?: 0L - // Also pin `installedVersion` (tag) to the - // intended new release. Skipping it leaves - // checkForUpdates re-flagging the row as - // updatable on every sweep because the - // tag-string compare keeps seeing the old - // version (#515). + val resolvedTag = app.latestVersion ?: systemInfo.versionName installedAppsRepository.updateApp( app.copy( @@ -129,11 +105,7 @@ class SyncInstalledAppsUseCase( installedAppsRepository.updatePendingStatus(app.packageName, false) logger.info("Resolved pending install (no system info): ${app.packageName}") } - // Resolution implies the system holds the - // package — drop the parked-file metadata - // so the apps row stops advertising an - // Install CTA on a file the user already - // installed. + installedAppsRepository.setPendingInstallFilePath( packageName = app.packageName, path = null, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFileName.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFileName.kt index 9a39ef40f..059661cb0 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFileName.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFileName.kt @@ -1,64 +1,11 @@ package zed.rainxch.core.domain.util -/** - * Builds a filename safe for the local downloads directory that's also - * unique across repositories. - * - * # Why this exists - * - * Two different repos can ship installers with the same filename - * (`app.apk`, `release.apk`, `installer.exe` are all common). Without - * a per-repo namespace prefix, downloading both clobbers the first - * file, breaks the orchestrator's "active downloads by filename" - * tracking, and can install the wrong APK on top of the right row. - * - * # Output format - * - * __. - * - * Examples: - * - * ente-io / ente / ente-auth-3.2.5-arm64-v8a.apk - * → ente-io_ente_ente-auth-3.2.5-arm64-v8a.apk - * - * APKMirror / SomeApp / app.apk - * → apkmirror_someapp_app.apk - * - * d4rk7355608 / Apps_Manager / Apps Manager-v1.0.apk - * → d4rk7355608_apps_manager_apps_manager-v1.0.apk - * - * # Sanitization - * - * Every component is independently sanitized to: - * - Lowercase (filesystem case-folding portability — APFS folds, ext4 - * doesn't, NTFS varies — folding eagerly avoids cross-platform - * surprises) - * - Strip path traversal: `..`, `/`, `\` - * - Replace whitespace and shell-unfriendly characters with `_` - * - Collapse runs of `_` - * - * The original extension is preserved separately so the system - * installer's MIME-type detection still works (`.apk`, `.exe`, `.dmg`, - * etc.). - */ object AssetFileName { - /** - * Characters that need replacement. Path separators must go; - * everything else is for sanity (whitespace, shell metacharacters, - * Windows-illegal chars `< > : " | ? *`). The output keeps `-` and - * letters/digits intact because those are the most common tokens - * in real filenames. - */ + private val FORBIDDEN = Regex("""[^a-z0-9.\-]""") private val MULTI_UNDERSCORE = Regex("_+") - /** - * The maximum length of any single component (owner, repo, name). - * 64 chars × 3 components + 2 separators + extension stays - * comfortably under the 255-byte filename limit on every - * filesystem we target. - */ private const val MAX_COMPONENT_LEN = 64 fun scoped( @@ -69,10 +16,6 @@ object AssetFileName { val safeOwner = sanitizeComponent(owner).take(MAX_COMPONENT_LEN) val safeRepo = sanitizeComponent(repo).take(MAX_COMPONENT_LEN) - // Split extension off the original name first so we don't - // mangle the dot. Extension carries semantic meaning (the - // installer dispatches on it), so we keep it intact and just - // sanitize the body. val originalLower = originalName.lowercase() val dotIndex = originalLower.lastIndexOf('.') val (body, ext) = @@ -84,10 +27,6 @@ object AssetFileName { val safeBody = sanitizeComponent(body).take(MAX_COMPONENT_LEN) val safeExt = sanitizeExtension(ext) - // Compose. Empty components are replaced with `unknown` so we - // never produce a name like `__app.apk` (the multi-underscore - // collapse would still produce `_app.apk`, which is uglier - // than carrying a placeholder). val ownerPart = safeOwner.ifBlank { "unknown" } val repoPart = safeRepo.ifBlank { "unknown" } val bodyPart = safeBody.ifBlank { "asset" } @@ -96,11 +35,6 @@ object AssetFileName { return MULTI_UNDERSCORE.replace(joined, "_") } - /** - * True when [fileName] is in the scoped format produced by [scoped]. - * Used by the orchestrator to detect "is this already namespaced" - * so callers can pass either form during the migration window. - */ fun isScoped( fileName: String, owner: String, @@ -115,26 +49,19 @@ object AssetFileName { private fun sanitizeComponent(input: String): String { if (input.isBlank()) return "" val lowered = input.lowercase() - // Replace path separators *first* so they always become a - // single `_` rather than getting eaten by FORBIDDEN's - // greedy collapse. + val withoutSeparators = lowered.replace('/', '_').replace('\\', '_') - // Strip path-traversal sequences before letter sanitization; - // a leading `..` would otherwise survive as `..` and the - // OS would interpret it. + val noDots = withoutSeparators.replace("..", "_") return FORBIDDEN.replace(noDots, "_") } private fun sanitizeExtension(ext: String): String { if (ext.isEmpty()) return "" - // Extensions are short and character-restricted by convention. - // We allow `.apk`, `.exe`, `.dmg`, `.deb`, `.rpm`, `.msi`, - // `.pkg`, `.zip` and similar — anything weirder gets the - // generic sanitizer applied. + val cleaned = FORBIDDEN.replace(ext, "") - // Re-prepend the dot if the regex stripped it. + return if (cleaned.startsWith('.')) cleaned else ".$cleaned" } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt index 966b3efd1..3366725e5 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetFilter.kt @@ -1,20 +1,5 @@ package zed.rainxch.core.domain.util -/** - * Compiled, validated wrapper around a per-app asset name regex. - * - * Use [AssetFilter.parse] when reading a (possibly user-supplied) pattern out - * of storage or a form field — it returns `null` for blank input and a - * [Result.failure] for an invalid regex, so the caller can decide whether to - * surface a validation error. - * - * Once compiled, [matches] is allocation-free for the hot path used by - * `checkForUpdates` (compile once per app, evaluate against many asset names). - * - * Matching uses [Regex.containsMatchIn], not [Regex.matches]. That makes - * casual patterns like `ente-auth` or `arm64` "just work" without forcing the - * user to wrap the value in `.*` — it matches Obtainium's behaviour. - */ class AssetFilter private constructor( val pattern: String, private val regex: Regex, @@ -28,13 +13,7 @@ class AssetFilter private constructor( override fun toString(): String = "AssetFilter($pattern)" companion object { - /** - * Parses a raw user-supplied pattern. - * - * @return `null` if [raw] is null/blank, otherwise a [Result] wrapping - * either the compiled filter or the [PatternSyntaxException]-equivalent - * exception thrown by Kotlin's regex compiler. - */ + fun parse(raw: String?): Result? { val trimmed = raw?.trim().orEmpty() if (trimmed.isEmpty()) return null @@ -43,30 +22,12 @@ class AssetFilter private constructor( } } - /** - * Suggests a sensible filter regex from a sample asset name. - * Strips the version suffix (anything from the first `-` - * onward) and returns the leading prefix as a **literal-prefix - * regex** — escaped and anchored to the start of the filename so - * metacharacters in the prefix don't get interpreted as regex - * operators. - * - * Examples: - * ente-auth-3.2.5-arm64-v8a.apk → ^\Qente-auth-\E - * Photos-1.7.0-universal.apk → ^\QPhotos-\E - * app+1.2.3.apk → ^\Qapp+\E (the `+` is escaped) - * no-version.apk → null (no clear version anchor) - * - * Returns `null` when the asset name has no clear version anchor — - * blindly returning the full filename would create a filter that - * matches only that exact build. - */ fun suggestFromAssetName(assetName: String): String? { - // Try the common "name-1.2.3" / "name_1.2.3" / "name 1.2.3" patterns. + val versionAnchor = Regex("[-_ .]\\d") val match = versionAnchor.find(assetName) ?: return null val prefix = assetName.substring(0, match.range.first + 1) - // Need at least 2 meaningful chars; otherwise the suggestion is noise. + if (prefix.length < 2) return null return "^" + Regex.escape(prefix) } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetPlatform.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetPlatform.kt index 7c766d1a0..f466035fa 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetPlatform.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetPlatform.kt @@ -2,13 +2,6 @@ package zed.rainxch.core.domain.util import zed.rainxch.core.domain.model.DiscoveryPlatform -/** - * Maps a release asset filename to the OS platform it targets, by - * extension. Returns `null` for files we can't classify (zip bundles, - * sources, sig/sha files, etc.) — callers should drop those from the - * cross-platform picker so the list isn't polluted by non-installable - * sidecars. - */ fun assetPlatformOf(assetName: String): DiscoveryPlatform? { val lower = assetName.lowercase() return when { diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt index 5e452959f..bb58963bf 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt @@ -2,109 +2,38 @@ package zed.rainxch.core.domain.util import zed.rainxch.core.domain.model.GithubAsset -/** - * Identifies the "variant" of a GitHub release asset — the part of the - * filename that stays stable across releases (architecture, packaging - * flavour, etc.) so the user's choice can survive a version bump. - * - * # Why this is non-trivial - * - * Release filenames are wildly inconsistent. The naïve approach - * (substring after the version segment) breaks on a handful of common - * shapes: - * - * - **Arch-before-version**: `app-arm64-v8a-1.2.3.apk` — version is at - * the end, the variant token is in front of it - * - **Counter between version and arch**: `app-1.2.3-beta.1-arm64-v8a.apk` - * — substring tail would include `beta.1`, drifting release-over-release - * - **Version-code-only naming**: `app_1234_arm64-v8a.apk` — no dotted - * version anchor - * - **OS-version interlopers**: `app-android-12.0-1.2.3-arm64.apk` — the - * OS version is the first dotted-digit substring - * - * # The strategy - * - * Three layers of identity, each cheaper to compute than the next, all - * consulted when matching against a fresh release: - * - * 1. **Token-set fingerprint** ([extractTokens]): pulls a *set* of - * well-known arch / flavor / qualifier tokens out of the filename - * regardless of position. Two filenames are "the same variant" if - * their token sets are equal. This is the primary identity and - * handles arch-before-version, OS-version interlopers, and counters - * in one go. - * - * 2. **Glob-pattern fingerprint** ([deriveGlob]): replaces version-shaped - * substrings with `*` (and digit-only run substrings with `*`), - * yielding e.g. `app-*-arm64-v8a.apk` from `app-1.2.3-arm64-v8a.apk`. - * Used as a secondary key when the token vocabulary doesn't recognise - * anything in the filename — covers custom flavor names like - * `myapp-foss-1.2.3.apk` → `myapp-foss-*.apk`. - * - * 3. **Substring-tail extract** ([extract]): the legacy fallback. Kept - * for surface compatibility with the existing UI (badge labels) and - * because it's a cheap way to display *something* readable to the - * user when the token vocabulary returns nothing. - * - * # Vocabulary policy - * - * The token vocabulary is intentionally a closed set. Open-ended token - * extraction (e.g. "anything that's not a digit run") leaks app names, - * release qualifiers, and date components into the variant identity, - * which is exactly what the substring-tail approach gets wrong. The - * vocabulary covers the tokens that actually distinguish APK variants - * in the wild — architectures, install flavors, signing qualifiers. - * - * Casing is normalised to lowercase before comparison; some maintainers - * flip casing release-over-release. - */ object AssetVariant { - /** - * Closed vocabulary of tokens that are meaningful for variant - * identity. Anything *not* in this set is considered noise (app name, - * release qualifier, date component, etc.) and is ignored. - * - * Architecture tokens dominate the list because that's the most - * common variant axis on Android. Flavor tokens (`fdroid`, `play`, - * `foss`, `gms`, `nogms`, `huawei`) cover the second-most-common - * axis: same arch, different distribution channel. - * - * `release` and `signed` are explicitly excluded — they appear in - * a lot of filenames but rarely *distinguish* assets within a - * single release. Including them would create false negatives - * when one release uses `release-arm64-v8a` and the next uses - * `arm64-v8a`. Same reasoning applies to `aligned`, `unsigned`. - */ + private val ARCH_TOKENS = setOf( - // 64-bit ARM (the modern default on Android) + "arm64-v8a", "arm64", "aarch64", - // 32-bit ARM + "armeabi-v7a", "armeabi", "armv7", "armv7a", "armv8", - // x86 family + "x86_64", "x86-64", "x64", "x86", "i386", "i686", - // MIPS (rare but real on legacy hardware) + "mips", "mips64", - // Universal / fat APKs + "universal", "all", ) private val FLAVOR_TOKENS = setOf( - // Distribution channels + "fdroid", "f-droid", "play", @@ -115,7 +44,7 @@ object AssetVariant { "huawei", "amazon", "samsung", - // Build flavors + "foss", "libre", "free", @@ -129,31 +58,11 @@ object AssetVariant { "nightly", ) - /** - * The full vocabulary used for token-set comparison. Lazily merged - * because the JVM `Set` union allocates and there's no reason to do - * it on every call. - */ private val VOCABULARY: Set by lazy { ARCH_TOKENS + FLAVOR_TOKENS } - /** - * Strips hyphens/underscores so alias spellings of the same flavor - * (`f-droid` ⇄ `fdroid`, `play-store` ⇄ `playstore`) collapse to a - * single comparable form. Used by [filterByPackageFlavor] to align - * asset-token output (which may carry hyphenated bi-grams) with - * package-segment input (which can't legally contain hyphens). - */ private fun canonicalFlavorToken(token: String): String = token.replace('-', ' ').replace('_', ' ').replace(" ", "").lowercase() - /** - * Splits a filename into candidate tokens. Splits on the usual - * separators (`-`, `_`, ` `, `.`) so that compound names like - * `arm64-v8a` survive intact only after the recombine step below. - * - * Note: tokens are *normalised to lowercase* and the file extension - * is stripped first. - */ private fun tokenize(assetName: String): List { val withoutExt = assetName.substringBeforeLast('.') return withoutExt @@ -162,34 +71,14 @@ object AssetVariant { .filter { it.isNotEmpty() } } - /** - * Extracts the **token-set fingerprint** of [assetName]. Returns the - * subset of [VOCABULARY] that appears in the filename, accounting for - * compound tokens that span the splitter (e.g. `arm64-v8a` is - * tokenised as `["arm64", "v8a"]` but recognised as the compound - * `arm64-v8a` via a sliding-window check). - * - * Two assets share the same variant identity iff [extractTokens] - * returns equal sets. - * - * Returns an empty set when the filename contains no recognisable - * tokens — that's a deliberate "no token-based identity available, - * fall through to the next layer" signal. - */ fun extractTokens(assetName: String): Set { val tokens = tokenize(assetName) if (tokens.isEmpty()) return emptySet() val found = mutableSetOf() - // First pass: 3-grams, 2-grams, then 1-grams. Order matters - // because longer matches should take precedence: matching - // `armeabi-v7a` should not also match the bare `armeabi` - // afterwards (they're the same conceptual variant in different - // tokenisations of the maintainer's filename). val consumed = BooleanArray(tokens.size) - // 3-grams (rare but `armeabi-v7a-release` style exists) for (i in 0 until tokens.size - 2) { if (consumed[i] || consumed[i + 1] || consumed[i + 2]) continue val candidate = "${tokens[i]}-${tokens[i + 1]}-${tokens[i + 2]}" @@ -201,11 +90,6 @@ object AssetVariant { } } - // 2-grams: `arm64-v8a`, `armeabi-v7a`, `x86-64`, `x86_64` - // The tokenizer split on both `-` and `_`, so `x86_64` becomes - // `["x86", "64"]`; we recombine with `-` for vocabulary lookup - // *and* try the underscore form to cover `x86_64`. Both forms - // appear in the wild from different maintainers. for (i in 0 until tokens.size - 1) { if (consumed[i] || consumed[i + 1]) continue val dashed = "${tokens[i]}-${tokens[i + 1]}" @@ -223,7 +107,6 @@ object AssetVariant { } } - // 1-grams: bare tokens for (i in tokens.indices) { if (consumed[i]) continue if (tokens[i] in VOCABULARY) { @@ -235,57 +118,10 @@ object AssetVariant { return found } - /** - * Derives a **glob-pattern fingerprint** from [assetName]: replaces - * any dotted-digit version segment and any standalone digit run with - * `*`, leaving everything else intact. Used as a secondary identity - * when [extractTokens] returns an empty set. - * - * Examples: - * - * `app-1.2.3-arm64-v8a.apk` → `app-*-arm64-v8a.apk` - * `myapp-foss-1.2.3.apk` → `myapp-foss-*.apk` - * `Project_v2.0.1_universal.apk` → `project_v*_universal.apk` - * `release-2024.04.10-debug.apk` → `release-*-debug.apk` - * `app_1234_arm64.apk` → `app_*_arm64.apk` - * - * The result is also lowercased so two maintainers with different - * casing conventions still produce the same fingerprint. - * - * Returns `null` when the filename has no version-shaped or - * digit-run substring at all — there'd be nothing to wildcard, so - * the glob would just equal the filename and provides no rescue - * value beyond exact-match. - */ - /** - * Extracts the **base-name stem** of an asset — the lowercased, - * separator-stripped concatenation of every token that isn't a - * version-like number, an arch token, or a flavor token. Used to - * detect "sibling app in the same repo" cases where two releases - * ship `AppA-1.10.apk` and `AppB-2.20.apk` and the auto-picker - * would otherwise swap one for the other based on numeric version - * alone (issue #591). - * - * `AppA-1.10.apk` → `"appa"` - * `AppB-2.20.apk` → `"appb"` - * `app-arm64-v8a-1.10.apk` and `app-x86_64-1.10.apk` → both `"app"` - * `app-1.0.apk` and `app-fdroid-1.0.apk` → both `"app"` - * - * Returns an empty string when stripping leaves nothing behind - * (release ships only a versioned filename like `2.0.apk`). Callers - * treat empty as "no stem signal — don't filter". - */ fun extractBaseStem(assetName: String): String { val tokens = tokenize(assetName) if (tokens.isEmpty()) return "" - // Mirror `extractTokens`' n-gram consumption pass so the - // fragments of compound vocab entries (e.g. `arm64-v8a` → - // tokens `["arm64","v8a"]`) are both stripped, not just the - // canonical-form half. Without this, `v8a` / `v7a` would - // survive the filter and `app-arm64-v8a-1.10.apk` would yield - // a different stem than `app-x86_64-1.10.apk`, defeating the - // sibling-app detection for arch-variant releases. val consumed = BooleanArray(tokens.size) for (i in 0 until tokens.size - 2) { @@ -315,13 +151,6 @@ object AssetVariant { return out.toString() } - /** - * `1`, `10`, `1.0.0`, `v2.0.1`, `2024.04.10`, `1.0-rc1`, `beta3` — - * common patterns used in release filenames to encode the version. - * Conservative on purpose: false positives here just lose a stem - * character; false negatives would let a numeric variant leak into - * the stem and break the sibling-app detection. - */ private fun isVersionLikeToken(token: String): Boolean { if (token.isEmpty()) return false if (token.all { it.isDigit() }) return true @@ -331,33 +160,13 @@ object AssetVariant { fun deriveGlob(assetName: String): String? { val lower = assetName.lowercase() - // Match either: - // - a versioned segment with at least one dot (e.g. `1.2.3`, - // `v2.0.1`, `2024.04.10`) - // - OR a standalone digit run of length >= 2 (the `1234` - // version-code-only case). Length >= 2 avoids replacing - // legitimate single-digit tokens like `v8` in `arm64-v8a`. + val versionPattern = Regex("""v?\d+(?:\.\d+)+|(?, pinnedVariant: String?, pinnedTokens: Set? = null, pinnedGlob: String? = null, ): GithubAsset? { - // Layer 1: token-set match. The strongest signal — survives - // arch-before-version, OS-version interlopers, and counters. + if (!pinnedTokens.isNullOrEmpty()) { val match = assets.firstOrNull { asset -> extractTokens(asset.name) == pinnedTokens @@ -430,8 +210,6 @@ object AssetVariant { if (match != null) return match } - // Layer 2: glob match. Catches custom flavor names that the - // closed token vocabulary doesn't know about. if (!pinnedGlob.isNullOrBlank()) { val match = assets.firstOrNull { asset -> deriveGlob(asset.name) == pinnedGlob @@ -439,26 +217,12 @@ object AssetVariant { if (match != null) return match } - // Layer 3: legacy tail-string match. Keeps rows pinned before - // the multi-layer rewrite working without forcing a re-pick. val target = pinnedVariant?.trim()?.takeIf { it.isNotBlank() } ?: return null return assets.firstOrNull { asset -> extract(asset.name)?.equals(target, ignoreCase = true) == true } } - /** - * Same-position fallback used by the resolver as a last resort - * before falling back to the platform auto-picker. When the new - * release contains exactly the same number of installable assets - * as the original picked-from release, returning the asset at the - * same index preserves the user's intent in cases where every - * asset has been renamed in lockstep (e.g. an entire flavor - * dimension was added). - * - * Returns `null` when [siblingCountAtPickTime] is null, zero, - * or doesn't match `assets.size`. - */ fun resolveBySamePosition( assets: List, originalIndex: Int?, @@ -470,20 +234,6 @@ object AssetVariant { return assets.getOrNull(originalIndex) } - /** - * Pulls the variant tag out of a sample asset filename and returns - * it normalised, or `null` when the name doesn't carry a meaningful - * variant. Skips the work entirely when [siblingAssetCount] is 1 or 0 - * because single-asset releases have nothing to remember. - * - * The returned tail is the **display label** that gets stored in - * `InstalledApp.preferredAssetVariant` and shown to the user. The - * matching algorithm itself uses [extractTokens] / [deriveGlob] — - * those are also derived at pin time and stored separately. - * - * Single-asset releases and "no variant suffix" filenames both return - * `null` rather than the empty string — there's nothing to pin. - */ fun deriveFromPickedAsset( pickedAssetName: String, siblingAssetCount: Int, @@ -493,22 +243,6 @@ object AssetVariant { return variant.takeIf { it.isNotEmpty() } } - /** - * Bundle of all three identity layers derived from a single picked - * asset filename. Used by the persistence path so the caller can - * write all fingerprints atomically and the resolver can match on - * any of them later. - * - * - [variant]: legacy substring tail (display label) - * - [tokens]: token-set fingerprint, empty when the filename has - * no vocabulary tokens - * - [glob]: glob-pattern fingerprint, null when there's no - * version-shaped substring to wildcard - * - * Returns `null` when [siblingAssetCount] <= 1 (nothing to pin) or - * when *all three* identity layers came up empty — at that point - * the asset has no stable fingerprint and pinning would be a lie. - */ data class VariantFingerprint( val variant: String?, val tokens: Set, @@ -523,23 +257,11 @@ object AssetVariant { val variant = extract(pickedAssetName)?.takeIf { it.isNotEmpty() } val tokens = extractTokens(pickedAssetName) val glob = deriveGlob(pickedAssetName) - // If everything is empty there's nothing to remember — return - // null so callers persist `null` and fall back to the platform - // auto-picker on update. + if (variant == null && tokens.isEmpty() && glob == null) return null return VariantFingerprint(variant = variant, tokens = tokens, glob = glob) } - /** - * Serializes a token set to a stable string for storage. Sorted so - * that identical sets always serialize to identical strings, which - * is what makes string equality a valid set-equality check at the - * SQL layer (avoiding a JSON column or a join table). - * - * Format: tokens joined by `|`. Returns `null` for empty sets so - * the column can stay nullable and the resolver knows when there's - * no token fingerprint to compare against. - */ fun serializeTokens(tokens: Set): String? { if (tokens.isEmpty()) return null return tokens.sorted().joinToString("|") @@ -550,31 +272,6 @@ object AssetVariant { return serialized.split('|').filter { it.isNotBlank() }.toSet() } - /** - * Narrows [assets] to the subset whose filename flavor matches the - * flavor implied by [trackedPackageName]. Used by the update-check - * auto-picker to avoid swapping the user's installed APK for a - * sibling release artifact that ships a different package id - * (typically a `.fdroid` variant alongside the stock package). - * - * The picker only knows asset names — the actual APK package is - * inside the binary, which would require downloading every - * candidate. Filenames are usually honest about the flavor though: - * an `.apk` named `app-fdroid-release.apk` almost always installs - * with package id `.fdroid`. We exploit that to keep the - * auto-pick safe by default. - * - * Rules: - * - If the tracked package contains any [FLAVOR_TOKENS] segment - * (case-insensitive, dot-separated), keep only assets whose - * filename tokens contain at least one of those flavor tokens. - * - Otherwise (stock package, no flavor marker), keep only assets - * whose filename tokens contain NO flavor markers. - * - If either filter would eliminate every candidate the input is - * returned unchanged — losing the auto-update prompt is worse - * than picking a marginally wrong-flavor asset, and the user - * can still pin the right variant from the picker UI. - */ fun filterByPackageFlavor( assets: List, trackedPackageName: String, @@ -582,12 +279,7 @@ object AssetVariant { if (assets.isEmpty()) return assets val packageSegments = trackedPackageName.lowercase().split('.').filter { it.isNotBlank() } - // Canonicalise both sides via hyphen/underscore strip so package - // segments (`fdroid`, can't legally hold `-`) match against asset - // tokens that may come back hyphenated (`f-droid`) when the - // filename uses an aliased spelling. Without this, the set - // intersection misses the alias forms `FLAVOR_TOKENS` deliberately - // covers. + val packageFlavorTokens = packageSegments.filter { it in FLAVOR_TOKENS } .map(::canonicalFlavorToken) .toSet() diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/EmojiShortcodes.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/EmojiShortcodes.kt index 531472c50..cd7f8e37d 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/EmojiShortcodes.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/EmojiShortcodes.kt @@ -1,14 +1,10 @@ package zed.rainxch.core.domain.util object EmojiShortcodes { - // Subset of github/gemoji that covers the long tail of READMEs. - // ~250 entries — picked from gemoji download stats + common usage in - // README/CHANGELOG headers. Not exhaustive on purpose; missing - // shortcodes survive as literal text rather than ballooning the - // bundle with a 1500-entry table we mostly don't need. + private val TABLE: Map = mapOf( - // Frequently used in README hero / status sections + "rocket" to "🚀", "sparkles" to "✨", "tada" to "🎉", @@ -36,7 +32,7 @@ object EmojiShortcodes { "point_left" to "👈", "point_up" to "☝️", "point_down" to "👇", - // Status / signals + "warning" to "⚠️", "white_check_mark" to "✅", "heavy_check_mark" to "✔️", @@ -54,7 +50,7 @@ object EmojiShortcodes { "grey_question" to "❔", "grey_exclamation" to "❕", "information_source" to "ℹ️", - // Hearts + "heart" to "❤️", "yellow_heart" to "💛", "green_heart" to "💚", @@ -67,7 +63,7 @@ object EmojiShortcodes { "broken_heart" to "💔", "heart_eyes" to "😍", "hearts" to "♥️", - // Faces + "smile" to "😄", "smiley" to "😃", "grin" to "😁", @@ -106,7 +102,7 @@ object EmojiShortcodes { "poop" to "💩", "hankey" to "💩", "shit" to "💩", - // Dev / engineer + "computer" to "💻", "iphone" to "📱", "watch" to "⌚", @@ -140,7 +136,7 @@ object EmojiShortcodes { "mute" to "🔇", "mega" to "📣", "loudspeaker" to "📢", - // Tools / build + "hammer" to "🔨", "wrench" to "🔧", "screwdriver" to "🪛", @@ -159,7 +155,7 @@ object EmojiShortcodes { "magnet" to "🧲", "balance_scale" to "⚖️", "scales" to "⚖️", - // Symbols + "bug" to "🐛", "ant" to "🐜", "bulb" to "💡", @@ -208,7 +204,7 @@ object EmojiShortcodes { "earth_africa" to "🌍", "earth_asia" to "🌏", "globe_with_meridians" to "🌐", - // Animals frequently in READMEs + "octocat" to "🐙", "cat" to "🐱", "dog" to "🐶", @@ -234,7 +230,7 @@ object EmojiShortcodes { "see_no_evil" to "🙈", "hear_no_evil" to "🙉", "speak_no_evil" to "🙊", - // Food / coffee + "coffee" to "☕", "tea" to "🍵", "beer" to "🍺", @@ -299,7 +295,7 @@ object EmojiShortcodes { "wilted_flower" to "🥀", "bouquet" to "💐", "cherry_blossom" to "🌸", - // Travel / weather + "airplane" to "✈️", "rocket_ship" to "🚀", "car" to "🚗", @@ -330,10 +326,6 @@ object EmojiShortcodes { "tornado" to "🌪️", ) - // [a-z0-9_+\-] is a superset of github shortcode chars. The - // bookend `:` markers must be tight against the token (no spaces), - // and the shortcode itself must not embed `:` (so URL like - // `https://example.com:8080` doesn't grab `8080` as a code). private val PATTERN = Regex(""":([a-z0-9_+\-]{1,40}):""") fun render(input: String): String { @@ -350,11 +342,6 @@ object EmojiShortcodes { TABLE[key] ?: match.value } - // Split the markdown body into alternating (text, code) regions - // anchored on triple-backtick fences. Single-backtick inline code - // is left in the text regions on purpose — losing rare emoji - // collisions in inline code is fine; properly tokenising inline - // spans isn't worth the parser complexity. private data class Chunk(val content: String, val isCode: Boolean) private fun splitOutCodeRegions(input: String): List { @@ -369,10 +356,7 @@ object EmojiShortcodes { out += Chunk(input.substring(i), isCode = inCode) break } - // Take everything up to and including the fence into the - // current region — the fence itself is part of the code - // block when we're transitioning out, so always include - // 3 chars in the "code" side. + if (inCode) { out += Chunk(input.substring(i, next + fence.length), isCode = true) } else { diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/ExternalInstallVerdict.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/ExternalInstallVerdict.kt index fcaa45f87..4a983431a 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/ExternalInstallVerdict.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/ExternalInstallVerdict.kt @@ -2,65 +2,21 @@ package zed.rainxch.core.domain.util import zed.rainxch.core.domain.model.InstalledApp -/** - * Decision produced by [resolveExternalInstallVerdict] after an - * externally-performed install/update surfaces via PackageManager. - */ enum class VersionVerdict { - /** System version meets or exceeds every signal we have. */ + UP_TO_DATE, - /** System version is strictly older than at least one reliable signal. */ UPDATE_AVAILABLE, - /** - * No signal was reliable enough to decide. The caller should - * leave the current `isUpdateAvailable` flag alone and trigger - * an authoritative re-check via - * [zed.rainxch.core.domain.repository.InstalledAppsRepository.checkForUpdates]. - */ UNKNOWN, } -/** - * Best-effort local verdict after an external install/update. - * - * An Android APK install gives us two pieces of truth — [newVersionName] - * and [newVersionCode] — both of which are stricter than what we can - * usually know about the tracked "latest" release. We try a ladder of - * signals, stopping at the first one that can produce a reliable answer: - * - * 1. **versionCode comparison.** Monotonic integer by Android contract. - * Only usable when we've captured a non-zero `latestVersionCode` for - * this app (i.e. a previous install round stamped it). If both sides - * have a real `versionCode`, one comparison nails the answer. - * - * 2. **versionName vs latestVersionName.** These are the post-install - * values from PackageManager, which means same axis. Run through - * [VersionMath.normalizeVersion] so `1.2.3` and `v1.2.3-stable` line - * up, then semver-compare. - * - * 3. **versionName vs latestVersion (release tag).** Works when the - * maintainer's tag contains the real version (e.g. `v1.2.3`, - * `release-1.2.3`, `build-2025.04.10`) — [VersionMath.normalizeVersion] - * extracts the dotted-digit core. - * - * 4. **Give up.** Return [VersionVerdict.UNKNOWN]. The caller is - * expected to defer to the network-backed - * [zed.rainxch.core.domain.repository.InstalledAppsRepository.checkForUpdates], - * which does the same comparison with fresh GitHub release data. - * - * This function is pure — it does not read or write any state, and it - * makes no network or disk calls. Callers combine it with an async - * authoritative re-check so that an incorrect optimistic answer is - * corrected within seconds. - */ fun resolveExternalInstallVerdict( app: InstalledApp, newVersionName: String, newVersionCode: Long, ): VersionVerdict { - // Priority 1: integer versionCode (most reliable when available). + val latestVersionCode = app.latestVersionCode ?: 0L if (latestVersionCode > 0L && newVersionCode > 0L) { return if (newVersionCode >= latestVersionCode) { @@ -70,17 +26,12 @@ fun resolveExternalInstallVerdict( } } - // Priority 2: versionName ↔ latestVersionName (same axis — both - // come from PackageManager on their respective installs). val latestName = app.latestVersionName if (!latestName.isNullOrBlank() && newVersionName.isNotBlank()) { val verdict = compareAndDecide(newVersionName, latestName) if (verdict != VersionVerdict.UNKNOWN) return verdict } - // Priority 3: versionName ↔ latestVersion (release tag). Different - // axis but VersionMath.normalizeVersion handles the common cases - // (`v1.2.3`, `release-1.2.0`, `App-v1.2.0-stable`, …). val latestTag = app.latestVersion if (!latestTag.isNullOrBlank() && newVersionName.isNotBlank()) { val verdict = compareAndDecide(newVersionName, latestTag) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/RepoIdCodec.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/RepoIdCodec.kt index ab05cf578..69766db11 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/RepoIdCodec.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/RepoIdCodec.kt @@ -1,36 +1,8 @@ package zed.rainxch.core.domain.util -/** - * Packs a foreign-host repo identity into the `Long` `repoId` slot - * used throughout the schema (which was originally GitHub-only, where - * IDs are globally unique). - * - * Layout (64-bit `Long`): - * - bit 63 — sign flag. Negative means "foreign source" (see - * [isForeignSource]); positive means native GitHub. - * - bits 40-62 — 23-bit host fingerprint, derived from - * `host.lowercase().hashCode()`. 23 bits gives 8 388 608 - * buckets — birthday-collision probability for a user - * tracking N self-hosted forges is roughly - * `N² / (2 × 8 388 608)`; e.g. 200 forges → ~0.0025 - * chance of any two sharing a bucket. The previous 15-bit - * layout (32 768 buckets) hit ~0.6 probability at that - * scale, which CR flagged as a real risk. - * - bits 0-39 — 40-bit raw repository ID. Forgejo / Gitea instances - * auto-increment from 1; 2⁴⁰ ≈ 1 trillion comfortably - * covers any realistic instance. - * - * Note: this layout is NOT backwards-compatible with the original - * 15-bit fingerprint encoding. Database rows persisted under the old - * scheme will decode to different `repoId`s after upgrade. Because the - * Codeberg / Forgejo source feature is shipping in a preview release - * (versionCode 18 — see whatsnew/18.json), the migration cost is - * accepted; existing rows for non-GitHub sources will simply be - * re-fetched on next scan. - */ object RepoIdCodec { - private const val HOST_FINGERPRINT_MASK = 0x7FFFFFL // 23 bits - private const val RAW_ID_MASK = 0xFFFFFFFFFFL // 40 bits + private const val HOST_FINGERPRINT_MASK = 0x7FFFFFL + private const val RAW_ID_MASK = 0xFFFFFFFFFFL private const val HOST_FINGERPRINT_SHIFT = 40 fun encode(host: String?, rawId: Long): Long { diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/RepositoryUrlParser.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/RepositoryUrlParser.kt index 80ff1e473..852b01af8 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/RepositoryUrlParser.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/RepositoryUrlParser.kt @@ -44,14 +44,7 @@ object RepositoryUrlParser { private fun looksLikeForgejoHost(host: String): Boolean { val lower = host.lowercase() - // Conservative heuristic: only match hosts whose name literally - // contains a forge brand or starts with `git.` (the de-facto - // self-hosted convention). Previously `code.*` and `source.*` - // were also matched and produced false positives for - // `code.visualstudio.com`, `source.android.com`, - // `source.unsplash.com`, and similar non-forge services. Users - // with idiosyncratic hostnames should add them via - // Tweaks → Custom forges instead. + return lower.contains("forgejo") || lower.contains("gitea") || lower.startsWith("git.") diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/SeparateAdjacentImageLinks.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/SeparateAdjacentImageLinks.kt index b36ffba9d..8a619fe68 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/SeparateAdjacentImageLinks.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/SeparateAdjacentImageLinks.kt @@ -1,33 +1,5 @@ package zed.rainxch.core.domain.util -/** - * Splits adjacent badge / image-link lines into separate paragraphs so - * each renders as a block-level element instead of inline content in a - * single paragraph. - * - * Why this matters: markdown like - * - * ``` - * [![Apache-2.0](badge1.svg)](url1) - * [![Downloads](badge2.svg)](url2) - * [![Stars](badge3.svg)](url3) - * ``` - * - * is one paragraph with soft line breaks. The renderer (mikepenz lib) - * uses ONE shared `Placeholder` for all inline images in a paragraph - * — when image height > the placeholder slot, adjacent images paint - * into each other's vertical space and visually overlap. Splitting - * the lines with blank rows turns each link into its own paragraph, - * which the lib renders via the block-level image component - * (`LinkAwareMarkdownImage` in our case) where size is controlled - * cleanly. - * - * Strategy: walk line by line. If a line contains ONLY a single - * markdown link / image-link / HTML anchor-wrapping-image (i.e. it - * is "structurally a badge row"), and the PREVIOUS non-blank line - * also contained ONLY a link / image, ensure there's a blank row - * between them. No-op for everything else. - */ fun separateAdjacentImageLinks(content: String): String { if (content.isEmpty()) return content val lines = content.split('\n') @@ -51,38 +23,27 @@ fun separateAdjacentImageLinks(content: String): String { return out.toString() } -/** - * `true` when [line] (already trimmed) is composed purely of image-link - * structures with optional whitespace between — i.e. nothing else but - * markdown link / image-link / HTML `......` constructs. - * - * Heuristic-quality only. Aim is to recognise common badge rows in the - * wild without misclassifying paragraphs that happen to start with an - * image link inline next to prose. - */ private fun isPureImageLinkLine(line: String): Boolean { - // Cheap rejection: must contain `](` (markdown link) or ` ... ` blocks that contain an `` + stripped = stripped.replace( Regex( """]*>\s*(?:]*/?>|\s)*""", @@ -90,7 +51,7 @@ private fun isPureImageLinkLine(line: String): Boolean { ), "", ) - // Standalone `` + stripped = stripped.replace( Regex("""]*/?>""", RegexOption.IGNORE_CASE), "", diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/VersionMath.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/VersionMath.kt index 69d1b7f26..70e7fdf4a 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/VersionMath.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/VersionMath.kt @@ -1,74 +1,16 @@ package zed.rainxch.core.domain.util -/** - * Single source of truth for version-string normalization and ordering - * across the app. Both the periodic update check - * (`InstalledAppsRepositoryImpl.checkForUpdates`) and the external-install - * detection path (`ExternalInstallVerdict`) now call through here so a - * single comparator change propagates everywhere instead of drifting - * between private copies. - * - * Design invariants: - * - Every public function is pure; no I/O, no time, no randomness. - * - Inputs are `String?` where realistic so callers don't have to - * guard against nulls from the DB or the release feed. - * - Semver-compatible strings get semver semantics (including - * `-preRelease` ordering per spec: `1.0.0-beta < 1.0.0`). - * - Non-semver strings degrade gracefully: we try to extract a - * dotted-digit core (so `release-1.2.3` still compares like - * `1.2.3`), and only fall back to lexicographic comparison when - * the string has no recognisable version core at all. - */ object VersionMath { - /** - * Reduces a tag or installed-version string to a form that - * [parseSemanticVersion] can digest. - * - * Strategy, in order: - * 1. Trim and strip common tag prefixes (`refs/tags/`, `v`, `V`). - * 2. Drop `+build` metadata (per semver spec, ignored for - * ordering). - * 3. If the result parses as semver, return it. - * 4. Otherwise extract the first dotted-digit substring - * (optionally followed by a `-pre` identifier) and return - * that — handles maintainer prefixes like `release-1.2.0`, - * `App-v1.2.0-stable`, `build-2025.04.10`. - * 5. If nothing numeric is found at all, return the cleaned - * string so the caller can fall back to equality / lex. - * - * Examples: - * `v1.2.3` → `1.2.3` - * `1.2.3+sha.abcd` → `1.2.3` - * `1.2.3-rc1` → `1.2.3-rc1` - * `release-1.2.0` → `1.2.0` - * `App-v1.2.0-stable` → `1.2.0-stable` - * `build-2025.04.10` → `2025.04.10` - * `refs/tags/v1.2.3` → `1.2.3` - * `not-a-version` → `not-a-version` - * `null` → `""` - */ + fun normalizeVersion(version: String?): String { if (version.isNullOrBlank()) return "" val cleaned = stripFullPrefix(version) val withoutBuildMetadata = cleaned.substringBefore('+') - // Hyphenated calver (`2024-10-15`) — semver would otherwise read - // the trailing `-10-15` as a pre-release identifier and rank - // `2024-10-15 < 2024`, which is the opposite of what users mean - // by a date-stamped release. Re-shape into dotted calver so the - // semver path numeric-compares each segment. + val calverNormalized = normalizeCalverHyphen(withoutBuildMetadata) - // Adjacent-letter pre-release (`1.2.0beta01`) — insert the - // missing hyphen so the numeric core and the marker can be - // parsed independently. Only inserts before known markers to - // avoid mauling architecture suffixes like `1arm64`. + val separated = insertHyphenBeforeKnownMarker(calverNormalized) - // Build-variant suffix (`-f`, `-m`, `-arm64`, `-stable`, …) — - // some maintainers append a flavour marker to the installed - // versionName but tag GitHub with the bare semver core, so the - // exact same artefact reads as `1.8.6-f` on-device but `1.8.6` - // in the release feed. Strip these flavour markers so the - // comparator doesn't perpetually report a phantom update. - // Pre-release markers (`-beta`, `-rc1`, …) are preserved. + val deflavoured = stripBuildVariantSuffix(separated) if (parseSemanticVersion(deflavoured) != null) { return deflavoured @@ -83,10 +25,7 @@ object VersionMath { .trim() .removePrefix("refs/tags/") .trim() - // Word-style prefixes like `version-`, `release/`, `app_`, - // `build-`, `ver.` — common in maintainer-customised tag - // schemes. Strips a single leading word and any single - // separator before the version core. Case-insensitive. + val wordMatch = VERSION_WORD_PREFIX.find(trimmed) val withoutWord = if (wordMatch != null) trimmed.substring(wordMatch.range.last + 1) else trimmed return withoutWord.removePrefix("v").removePrefix("V").trim() @@ -102,40 +41,6 @@ object VersionMath { return if (tail.isNotEmpty()) "$core-$tail" else core } - /** - * Strip a curated set of build-variant / flavour markers that - * routinely appear in installed `versionName`s but never in the - * GitHub release tag (the maintainer ships a single `1.8.6` tag - * but emits `1.8.6-f`, `1.8.6-m` APKs). Without this step the - * comparator reads the suffix as a semver pre-release identifier, - * ranks the bare tag higher, and surfaces a phantom update. - * - * Recognised markers (case-insensitive, before any `.` separator - * inside the pre-release segment): - * - **Build flavour**: `f` / `full`, `m` / `mini` / `minified`, - * `l` / `lite`, `r` / `release`, `d` / `debug`, `x` / `extended`. - * - **Channel**: `stable`, `final`, `prod`, `production`, `gms`, - * `fdroid`, `github`, `store`. - * - **Architecture**: `armv7`, `armv8`, `arm64`, `armeabi`, `x86`, - * `x64`, `x86_64`, `universal`, `android`, `ios`. - * - * Intentionally NOT stripped (these are real pre-release markers): - * - `alpha`, `beta`, `rc`, `preview`, `prerelease`, `snapshot`, - * `canary`, `nightly`, `milestone`, `ea`, `dev`, `pre`, `m\d+`. - * - * The function only acts when [version] parses as semver; if the - * input lacks a recognisable numeric core there's nothing to anchor - * a "core + flavour" split to and we leave the string alone. - * - * Examples: - * `1.8.6-f` → `1.8.6` (full APK) - * `1.8.6-m` → `1.8.6` (minified APK) - * `1.8.6-arm64` → `1.8.6` (architecture) - * `1.8.6-stable` → `1.8.6` (channel) - * `1.8.6-b` → `1.8.6-b` (untouched: `b` could be beta) - * `1.8.6-beta` → `1.8.6-beta` (real pre-release) - * `1.8.6-rc.1` → `1.8.6-rc.1` (real pre-release) - */ private fun stripBuildVariantSuffix(version: String): String { val parsed = parseSemanticVersion(version) ?: return version val pre = parsed.preRelease ?: return version @@ -145,15 +50,10 @@ object VersionMath { private fun isBuildVariantMarker(preRelease: String): Boolean { if (preRelease.isEmpty()) return false - // Compound pre-release identifiers (`armv7-beta`, `rc.1`) are - // ambiguous: even if the first token looks like a build flavour, - // a downstream segment may signal a real pre-release. Bail and - // keep the suffix intact rather than risk silently swallowing - // pre-release intent. + if (preRelease.contains('.') || preRelease.contains('-')) return false val token = preRelease.lowercase() - // Real pre-release markers always win — don't strip something - // that semver/users treat as a pre-release identifier. + if (KNOWN_PRE_RELEASE_PREFIXES.any { token.startsWith(it) }) { return false } @@ -163,15 +63,15 @@ object VersionMath { private val BUILD_VARIANT_LITERALS = setOf( - // Build flavours (single-letter) + "f", "m", "l", "r", "d", "x", - // Build flavours (words) + "full", "mini", "minified", "lite", "release", "debug", "extended", - // Distribution channel + "stable", "final", "prod", "production", "gms", "fdroid", "github", "store", - // Architecture + "armv7", "armv8", "arm64", "armeabi", "x86", "x64", "x86_64", "universal", "android", "ios", @@ -181,11 +81,7 @@ object VersionMath { val match = ADJACENT_ALPHA_PATTERN.find(s) ?: return s val letterStart = match.range.first + 1 val tail = s.substring(letterStart).lowercase() - // `m\d+` covers the JetBrains-style milestone shorthand (`m5`, - // `m12`) which `PRE_RELEASE_MARKER_PATTERN` already matches but - // a string `startsWith` over [KNOWN_PRE_RELEASE_PREFIXES] does - // not — without the explicit regex check, `1.2.0m5` would slip - // past detection and silently rank as stable `1.2.0`. + val isKnownMarker = KNOWN_PRE_RELEASE_PREFIXES.any { tail.startsWith(it) } || M_DIGIT_TAIL_PATTERN.containsMatchIn(tail) @@ -193,15 +89,6 @@ object VersionMath { return s.substring(0, letterStart) + "-" + s.substring(letterStart) } - /** - * Returns `true` if [candidate] is strictly newer than [current] - * after normalization. Handles semver (including pre-release - * ordering per spec) and falls back to lexicographic comparison - * for strings with no parseable version core. - * - * Both arguments are normalized via [normalizeVersion] before - * comparison, so callers can pass raw tag strings. - */ fun isVersionNewer(candidate: String?, current: String?): Boolean { val normCandidate = normalizeVersion(candidate) val normCurrent = normalizeVersion(current) @@ -210,55 +97,14 @@ object VersionMath { return compareNormalized(normCandidate, normCurrent) > 0 } - /** - * Three-way comparison of two raw version strings after - * normalization. Returns a positive int if [a] > [b], negative if - * [a] < [b], `0` if equal or both empty. - * - * Use this when you need the full ordering (e.g. detecting - * downgrades). Prefer [isVersionNewer] when you just need a - * boolean. - */ fun compareVersions(a: String?, b: String?): Int { val normA = normalizeVersion(a) val normB = normalizeVersion(b) return compareNormalized(normA, normB) } - /** - * Returns `true` when both inputs normalize to the same version. - * Tolerates prefix/format drift the GitHub feed routinely produces - * (e.g. release tag `v3.1.3` vs system-reported `3.1.3`, - * `release-1.2.0` vs `1.2.0`, `1.2.3+sha.abcd` vs `1.2.3`). - * - * Two empty/null/blank inputs are treated as equal — this is fine - * for the UI-text guards that use this (don't render a redundant - * "installed: …" subtext when there's nothing meaningful to show). - * Callers that need a stricter "both present and equal" check - * should compare [normalizeVersion] against `""` first. - */ fun isSameVersion(a: String?, b: String?): Boolean = compareVersions(a, b) == 0 - /** - * Strict literal equality after the conservative cleanup pass that - * [stripCommonPrefixes] (delegating to [stripFullPrefix]) applies: - * trim, strip `refs/tags/`, strip a single case-insensitive - * word-style prefix with separator (`version-`, `release/`, `app_`, - * `build-`, `ver.`), strip a leading `v` / `V`, trim again. - * - * Differs from [isSameVersion] in that it does NOT strip `+build` - * metadata, does NOT extract a dotted-digit core from arbitrarily - * prefixed tags, and is case-sensitive on the suffix. Use this in - * UI branches that gate "Open" vs "Install" CTAs — semver treats - * `1.0.0+build.1` and `1.0.0+build.2` as equivalent for ordering, - * but users (and maintainers who abuse build metadata to ship - * distinct artifacts under the same numeric core) consider them - * different versions. - * - * Two null/blank inputs return `false`. The check requires both - * sides to be present; otherwise the caller would gate UI on - * "two unknowns are the same", which is never the intent. - */ fun isExactSameVersion(a: String?, b: String?): Boolean { val cleanedA = stripCommonPrefixes(a) ?: return false val cleanedB = stripCommonPrefixes(b) ?: return false @@ -278,8 +124,7 @@ object VersionMath { if (parsedA != null && parsedB != null) { return compareSemver(parsedA, parsedB) } - // Neither is parseable as semver — last-resort lexicographic - // comparison. Callers should treat this as low-confidence. + return a.compareTo(b) } @@ -290,26 +135,15 @@ object VersionMath { val bi = b.numbers.getOrElse(i) { 0L } if (ai != bi) return ai.compareTo(bi) } - // Numeric parts equal — spec: stable > pre-release when - // pre-release only present on one side. + return when { a.preRelease == null && b.preRelease == null -> 0 - a.preRelease == null -> 1 // a has no pre, so a > b + a.preRelease == null -> 1 b.preRelease == null -> -1 else -> comparePreRelease(a.preRelease, b.preRelease) } } - /** - * Compare pre-release identifiers per semver spec: - * - Identifiers consisting of only digits are compared - * numerically. - * - Identifiers with letters are compared lexically. - * - Numeric identifiers always have lower precedence than - * alphanumeric. - * - A larger set of pre-release fields has higher precedence if - * all preceding are equal. - */ private fun comparePreRelease(a: String, b: String): Int { val aParts = a.split(".") val bParts = b.split(".") @@ -321,7 +155,7 @@ object VersionMath { val cmp = when { aNum != null && bNum != null -> aNum.compareTo(bNum) - aNum != null -> -1 // numeric < alphanumeric + aNum != null -> -1 bNum != null -> 1 else -> ap.compareTo(bp) } @@ -353,80 +187,26 @@ object VersionMath { private val DOTTED_DIGIT_PATTERN = Regex("""\d+(?:\.\d+)*(?:-[\w.]+)?""") - /** - * Word-style tag prefix that some maintainers use instead of the - * usual leading `v`. Recognises a single leading word followed by - * an optional separator and the version core. Case-insensitive so - * `Release_1.2.0`, `release/1.2.0`, `App-1.2.0` all collapse. - * - * The trailing class allows `-`, `_`, `/`, `.` or whitespace as - * separators; the regex explicitly does NOT match the bare prefix - * with no separator (`version1.2.3`) — that's an unusual format - * and safer left to the dotted-digit fallback. - */ private val VERSION_WORD_PREFIX = Regex( """^(version|release|app|build|ver)\s*[-_/.]\s*""", RegexOption.IGNORE_CASE, ) - /** - * Hyphenated calver: `2024-10-15`, `2024-3-1`, optionally followed - * by a trailing identifier (`2024-10-15-rc1`). Year is constrained - * to 1900–2199 to avoid swallowing semver pre-release identifiers - * that start with a small integer (`1.0-10-rc1` should NOT be - * treated as the year 10). - */ private val CALVER_HYPHEN_PATTERN = Regex("""^((?:19|20|21)\d{2})-(\d{1,2})-(\d{1,2})(?:[-.](.+))?$""") - /** - * Catches `1.2.0beta01` / `2.0RC1` / `0.9preview2` — a digit - * directly followed by a letter, no separator. Used to insert the - * missing hyphen ONLY when the tail starts with a known - * pre-release marker (architecture suffixes like `1.2.0arm64` are - * left intact). - */ private val ADJACENT_ALPHA_PATTERN = Regex("""\d[A-Za-z]""") - /** - * JetBrains-style milestone shorthand match used in - * [insertHyphenBeforeKnownMarker]. Matches `m1`, `m12`, `M5`, - * etc. at the start of a tail like `m5-arm64`. Kept separate - * from [KNOWN_PRE_RELEASE_PREFIXES] because that list is - * `startsWith`-friendly literal prefixes; this one needs a - * regex. - */ private val M_DIGIT_TAIL_PATTERN = Regex("""^m\d+""", RegexOption.IGNORE_CASE) - /** - * 8-digit date integer like `20260502`. Year constrained to - * 1900-2199 to keep this from swallowing arbitrary 8-digit - * integers that maintainers might use as monotonic build numbers - * unrelated to the calendar. - */ private val DATE_INTEGER_PATTERN = Regex("""(?:19|20|21)\d{2}\d{2}\d{2}""") - /** - * Dotted calver — `2024.10.15`, optionally with a trailing build - * identifier (`2024.10.15.4567`). Year guard same as [DATE_INTEGER_PATTERN]. - */ private val DOTTED_CALVER_PATTERN = Regex("""(?:19|20|21)\d{2}\.\d{1,2}\.\d{1,2}(?:\.\d+)?""") - /** - * Bare commit-hash style tag (7-40 lowercase hex chars). Some - * repositories use commit SHAs as release tags — these never - * compare meaningfully but should be classified as such so UIs - * can render them as "build" rather than as a version number. - */ private val COMMIT_HASH_PATTERN = Regex("""[0-9a-f]{7,40}""") - /** - * Marker prefixes recognised when separating an adjacent-letter - * pre-release. Mirrors [PRE_RELEASE_MARKER_PATTERN] but as plain - * strings for the `startsWith` check. - */ private val KNOWN_PRE_RELEASE_PREFIXES = listOf( "alpha", @@ -443,81 +223,13 @@ object VersionMath { "pre", ) - /** - * Heuristic: returns `true` when [tag] contains a well-known - * pre-release marker. - * - * Why this exists: the GitHub API exposes a `prerelease: bool` - * flag on every release, but **maintainers regularly forget to - * set it**. A release tagged `v2.0.0-rc.1` with `prerelease: - * false` is still semantically a pre-release, and surfacing it - * as a stable update to opted-out users is a silent foot-gun. - * `GithubRelease.isEffectivelyPreRelease()` combines the API flag - * with this tag heuristic so one is enough. - * - * Recognised markers (case-insensitive), preceded by `-`, `.`, - * or `_`, and followed by a separator, digit, or end-of-string: - * - `alpha`, `beta`, `rc` — classic semver pre-release labels - * - `preview`, `snapshot`, `canary`, `nightly` — CI / early builds - * - `milestone` / `m\d+` — JetBrains-style milestone builds - * - `ea` — early access (Oracle / JetBrains / vendor convention) - * - `dev` — dev build shorthand - * - `pre` — generic pre-release prefix when followed by digit or dot - * - * Intentionally **not** recognised (too ambiguous / too many - * false positives): - * - `test` (`-test-build` is often a real release artefact) - * - `a\d+` / `b\d+` alone (collides with `-arm64`, `-amd64`, etc.) - * - `stable` / `release` (explicit non-markers) - * - * Examples that match: - * `v1.2.3-beta`, `1.2.3-alpha.1`, `v2-rc.2`, `1.0.0-preview2`, - * `2025.04-nightly`, `v1.0.0-canary.3`, `1.0-m5`, - * `0.9.0-snapshot`, `7.0-ea` - * - * Examples that DO NOT match: - * `v1.2.3`, `1.2.3-stable`, `v1.2.3-android`, `release-1.2.3`, - * `v2.0-final`, `v1.0.0-test-3` - */ fun isPreReleaseTag(tag: String?): Boolean { if (tag.isNullOrBlank()) return false - // Pre-process so the regex's `\b` boundary catches markers that - // sit flush against the numeric core (e.g. `1.2.0beta01`) — the - // regex itself stays anchored on word boundaries to keep the - // false-positive rate low for embedded substrings. + val separated = insertHyphenBeforeKnownMarker(tag) return PRE_RELEASE_MARKER_PATTERN.containsMatchIn(separated) } - /** - * Returns the canonical label for the first pre-release marker - * found in [tag], or `null` if none. Intended for UI badges that - * want to show "Beta" / "Alpha" / "RC" instead of a generic - * "Pre-release" pill — a much better signal for users deciding - * whether to install. - * - * Labels are returned in title-case regardless of how they were - * spelled in the tag (so `V1.0-BETA` and `v1.0-beta` both - * resolve to `"Beta"`). - * - * Mapping rules: - * - `alpha` → `Alpha` - * - `beta` → `Beta` - * - `rc` / `rc\d+` → `RC` - * - `preview` → `Preview` - * - `prerelease` → `Pre-release` - * - `snapshot` → `Snapshot` - * - `canary` → `Canary` - * - `nightly` → `Nightly` - * - `milestone` / `m\d+` → `Milestone` - * - `ea` → `Early Access` - * - `dev` → `Dev` - * - `pre` → `Pre` - * - * Callers that also want to treat the API `prerelease` flag as - * authoritative should use this alongside - * [zed.rainxch.core.domain.model.isEffectivelyPreRelease]. - */ fun preReleaseMarkerLabel(tag: String?): String? { if (tag.isNullOrBlank()) return null val separated = insertHyphenBeforeKnownMarker(tag) @@ -541,50 +253,26 @@ object VersionMath { } private val PRE_RELEASE_MARKER_PATTERN = - // `\b` word boundaries cleanly separate markers from the - // surrounding tag (so `alpha` matches `v1.0-alpha` but not - // `alphabet`). The trailing `\d*` allows shorthand suffixes - // like `rc1`, `beta2`, `preview3` without requiring a - // separator between the word and the number. Longer - // alternatives (`prerelease`) come before shorter prefixes - // (`pre`) so the regex engine finds the longest match. + Regex( "\\b(alpha|beta|rc|preview|prerelease|snapshot|canary|nightly|milestone|ea|dev|pre|m\\d+)\\d*\\b", RegexOption.IGNORE_CASE, ) - /** - * Coarse classification of the versioning scheme a tag string - * appears to follow. Useful for UI surfaces that want to render - * a date-stamped release differently from a semver one ("Released - * 2024-10-15" vs "Version 1.2.3"), or warn when a maintainer - * appears to have switched schemes mid-history (which silently - * breaks ordering — `1.2.0` would always read as older than - * `20260502` under numeric semver compare even if it was tagged - * later). - * - * The classification is intentionally rough; the underlying - * comparator does NOT branch on it. Callers can combine - * [detectScheme] outputs from two tags to detect cross-scheme - * comparisons that warrant a UI hint. - */ fun detectScheme(version: String?): Scheme { if (version.isNullOrBlank()) return Scheme.Unknown val cleaned = stripFullPrefix(version).substringBefore('+') if (cleaned.isEmpty()) return Scheme.Unknown - // Hyphenated calver — yyyy-mm-dd, optionally with a trailing - // identifier we don't care about for the classification. + if (CALVER_HYPHEN_PATTERN.matchEntire(cleaned) != null) return Scheme.CalVer - // Single 8-digit run looks like yyyymmdd, e.g. `20260502`. + DATE_INTEGER_PATTERN.matchEntire(cleaned)?.let { return Scheme.CalVer } - // Dotted calver — yyyy.mm.dd inside a semver-shaped string. + DOTTED_CALVER_PATTERN.matchEntire(cleaned)?.let { return Scheme.CalVer } - // Anything that parses as semver after our normalisation pass - // is semver, including adjacent-letter pre-release variants. + val separated = insertHyphenBeforeKnownMarker(cleaned) if (parseSemanticVersion(separated) != null) return Scheme.SemVer - // Hex-ish commit pointers (`v1.2.0+abc1234` strips the build - // metadata, but a bare commit hash falls here). + if (COMMIT_HASH_PATTERN.matchEntire(cleaned) != null) return Scheme.CommitHash return Scheme.Unknown } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/applyThemeAwareImages.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/applyThemeAwareImages.kt index 9105f380c..b45acb46c 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/applyThemeAwareImages.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/applyThemeAwareImages.kt @@ -7,18 +7,14 @@ fun applyThemeAwareImages( if (content.isEmpty()) return content var processed = content - // Strip markdown-image entries whose URL has `#gh-dark-mode-only` - // or `#gh-light-mode-only` and we're on the OTHER theme. GitHub's - // own renderer hides these on the mismatched theme; emulating that - // keeps READMEs from showing both light + dark variants stacked. processed = processed.replace( Regex("""!\[([^\]]*)\]\(([^)]*?)#gh-(dark|light)-mode-only([^)]*)\)"""), ) { match -> val mode = match.groupValues[3] if ((isDark && mode == "light") || (!isDark && mode == "dark")) { - "" // drop entirely; alt text would just be noise here + "" } else { - // Strip the fragment so the URL is clean for Coil's cache key. + val alt = match.groupValues[1] val urlBase = match.groupValues[2] val trailing = match.groupValues[4] @@ -26,7 +22,6 @@ fun applyThemeAwareImages( } } - // Same for raw HTML tags. processed = processed.replace( Regex( """]*?)src\s*=\s*(["'])([^"']*?)#gh-(dark|light)-mode-only([^"']*?)\2([^>]*?)>""", diff --git a/core/presentation/build.gradle.kts b/core/presentation/build.gradle.kts index 1b3803c25..e54b45038 100644 --- a/core/presentation/build.gradle.kts +++ b/core/presentation/build.gradle.kts @@ -7,17 +7,16 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.collections.immutable) implementation(projects.core.domain) implementation(libs.bundles.landscapist) implementation(libs.jetbrains.lifecycle.compose) - implementation(libs.kotlinx.datetime) - implementation(libs.kotlinx.collections.immutable) implementation(libs.jetbrains.compose.components.resources) - implementation(libs.androidx.compose.ui.tooling.preview) } } diff --git a/core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/color/DominantColorFromImage.android.kt b/core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/color/DominantColorFromImage.android.kt new file mode 100644 index 000000000..7af6f1634 --- /dev/null +++ b/core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/color/DominantColorFromImage.android.kt @@ -0,0 +1,33 @@ +package zed.rainxch.core.presentation.color + +import android.graphics.Bitmap +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.scale +import coil3.BitmapImage +import coil3.Image + +private const val SampleSize = 48 + +actual fun computeDominantFromImage(image: Image): Color? { + val src = (image as? BitmapImage)?.bitmap ?: return null + val software = if (src.config == Bitmap.Config.HARDWARE) { + src.copy(Bitmap.Config.ARGB_8888, false) ?: return null + } else if (src.config != Bitmap.Config.ARGB_8888) { + src.copy(Bitmap.Config.ARGB_8888, false) ?: src + } else { + src + } + val sampled: Bitmap = if (software.width > SampleSize || software.height > SampleSize) { + software.scale(SampleSize, SampleSize, filter = true) + } else { + software + } + val w = sampled.width + val h = sampled.height + val pixels = IntArray(w * h) + val pxResult = runCatching { + sampled.getPixels(pixels, 0, w, 0, 0, w, h) + } + if (pxResult.isFailure) return null + return dominantFromArgb(pixels) +} diff --git a/core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/theme/Theme.android.kt b/core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/theme/DynamicColorScheme.android.kt similarity index 52% rename from core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/theme/Theme.android.kt rename to core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/theme/DynamicColorScheme.android.kt index f3068611d..7e2b0ff81 100644 --- a/core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/theme/Theme.android.kt +++ b/core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/theme/DynamicColorScheme.android.kt @@ -1,24 +1,18 @@ package zed.rainxch.core.presentation.theme import android.os.Build -import androidx.annotation.ChecksSdkIntAtLeast import androidx.compose.material3.ColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext -@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) -actual fun isDynamicColorAvailable(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S +actual fun isDynamicColorAvailable(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S @Composable -actual fun getDynamicColorScheme(darkTheme: Boolean): ColorScheme? { +actual fun dynamicColorScheme(isDark: Boolean): ColorScheme? { if (!isDynamicColorAvailable()) return null - val context = LocalContext.current - return if (darkTheme) { - dynamicDarkColorScheme(context) - } else { - dynamicLightColorScheme(context) - } + return if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } diff --git a/core/presentation/src/commonMain/composeResources/files/licenses.json b/core/presentation/src/commonMain/composeResources/files/licenses.json new file mode 100644 index 000000000..1dc3e378c --- /dev/null +++ b/core/presentation/src/commonMain/composeResources/files/licenses.json @@ -0,0 +1,28 @@ +{ + "generatedAt": "2026-05-25", + "note": "Maintainer-curated. To regenerate, run ./gradlew :composeApp:printRuntimeDependencies and reconcile against this list.", + "libraries": [ + {"name": "Kotlin", "license": "Apache-2.0", "url": "https://github.com/JetBrains/kotlin"}, + {"name": "Compose Multiplatform", "license": "Apache-2.0", "url": "https://github.com/JetBrains/compose-multiplatform"}, + {"name": "Jetpack Compose", "license": "Apache-2.0", "url": "https://developer.android.com/jetpack/compose"}, + {"name": "Ktor", "license": "Apache-2.0", "url": "https://github.com/ktorio/ktor"}, + {"name": "Room", "license": "Apache-2.0", "url": "https://developer.android.com/jetpack/androidx/releases/room"}, + {"name": "Koin", "license": "Apache-2.0", "url": "https://github.com/InsertKoinIO/koin"}, + {"name": "kotlinx.serialization", "license": "Apache-2.0", "url": "https://github.com/Kotlin/kotlinx.serialization"}, + {"name": "kotlinx.coroutines", "license": "Apache-2.0", "url": "https://github.com/Kotlin/kotlinx.coroutines"}, + {"name": "kotlinx.datetime", "license": "Apache-2.0", "url": "https://github.com/Kotlin/kotlinx-datetime"}, + {"name": "kotlinx.collections.immutable", "license": "Apache-2.0", "url": "https://github.com/Kotlin/kotlinx.collections.immutable"}, + {"name": "DataStore", "license": "Apache-2.0", "url": "https://developer.android.com/jetpack/androidx/releases/datastore"}, + {"name": "Landscapist", "license": "Apache-2.0", "url": "https://github.com/skydoves/landscapist"}, + {"name": "Coil", "license": "Apache-2.0", "url": "https://github.com/coil-kt/coil"}, + {"name": "Kermit", "license": "Apache-2.0", "url": "https://github.com/touchlab/Kermit"}, + {"name": "MOKO Permissions", "license": "Apache-2.0", "url": "https://github.com/icerockdev/moko-permissions"}, + {"name": "Navigation Compose", "license": "Apache-2.0", "url": "https://developer.android.com/jetpack/androidx/releases/navigation"}, + {"name": "multiplatform-markdown-renderer", "license": "Apache-2.0", "url": "https://github.com/mikepenz/multiplatform-markdown-renderer"}, + {"name": "Shizuku", "license": "Apache-2.0", "url": "https://github.com/RikkaApps/Shizuku"}, + {"name": "WorkManager", "license": "Apache-2.0", "url": "https://developer.android.com/jetpack/androidx/releases/work"}, + {"name": "KSafe", "license": "Apache-2.0", "url": "https://github.com/Anifantakis/KSafe"}, + {"name": "Material Icons Extended", "license": "Apache-2.0", "url": "https://fonts.google.com/icons"}, + {"name": "Geist (Vercel)", "license": "OFL-1.1", "url": "https://github.com/vercel/geist-font"} + ] +} diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/19.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/19.json new file mode 100644 index 000000000..ef62c5798 --- /dev/null +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/19.json @@ -0,0 +1,24 @@ +{ + "versionCode": 19, + "versionName": "1.9.0", + "releaseDate": "2026-05-23", + "showAsSheet": true, + "sections": [ + { + "type": "NEW", + "bullets": [ + "Design overhaul — new Geist typography, hero app header on Details, redesigned Home cards with platform glyphs, Apple-style menus, and a refreshed Library with an updates banner and a 'Ready to install' section.", + "Tablet two-pane layout — Home, Search, and Library keep the list on the left and open the repo on the right; drag the divider to resize.", + "Inner Details pages — About and What's New now slide in as dedicated screens with their own back action, and stay inside the right pane on tablet.", + "Real Apple and Tux icons for the macOS and Linux platform indicators across cards and lists." + ] + }, + { + "type": "FIXED", + "bullets": [ + "Discovery cards now show every platform a repo ships installers for, not just the one whose endpoint listed it.", + "What's New tag row no longer wraps long release tags into a one-character-wide vertical date column." + ] + } + ] +} diff --git a/core/presentation/src/commonMain/composeResources/font/fraunces.ttf b/core/presentation/src/commonMain/composeResources/font/fraunces.ttf new file mode 100644 index 000000000..8210f9488 Binary files /dev/null and b/core/presentation/src/commonMain/composeResources/font/fraunces.ttf differ diff --git a/core/presentation/src/commonMain/composeResources/font/fraunces_italic.ttf b/core/presentation/src/commonMain/composeResources/font/fraunces_italic.ttf new file mode 100644 index 000000000..2ddf59a11 Binary files /dev/null and b/core/presentation/src/commonMain/composeResources/font/fraunces_italic.ttf differ diff --git a/core/presentation/src/commonMain/composeResources/font/geist.ttf b/core/presentation/src/commonMain/composeResources/font/geist.ttf new file mode 100644 index 000000000..f63f0afc6 Binary files /dev/null and b/core/presentation/src/commonMain/composeResources/font/geist.ttf differ diff --git a/core/presentation/src/commonMain/composeResources/font/geist_mono.ttf b/core/presentation/src/commonMain/composeResources/font/geist_mono.ttf new file mode 100644 index 000000000..f1f640b6c Binary files /dev/null and b/core/presentation/src/commonMain/composeResources/font/geist_mono.ttf differ diff --git a/core/presentation/src/commonMain/composeResources/font/inter_black.ttf b/core/presentation/src/commonMain/composeResources/font/inter_black.ttf deleted file mode 100644 index dbb1b3bc7..000000000 Binary files a/core/presentation/src/commonMain/composeResources/font/inter_black.ttf and /dev/null differ diff --git a/core/presentation/src/commonMain/composeResources/font/inter_bold.ttf b/core/presentation/src/commonMain/composeResources/font/inter_bold.ttf deleted file mode 100644 index e974d96fc..000000000 Binary files a/core/presentation/src/commonMain/composeResources/font/inter_bold.ttf and /dev/null differ diff --git a/core/presentation/src/commonMain/composeResources/font/inter_light.ttf b/core/presentation/src/commonMain/composeResources/font/inter_light.ttf deleted file mode 100644 index 1a2a6f252..000000000 Binary files a/core/presentation/src/commonMain/composeResources/font/inter_light.ttf and /dev/null differ diff --git a/core/presentation/src/commonMain/composeResources/font/inter_medium.ttf b/core/presentation/src/commonMain/composeResources/font/inter_medium.ttf deleted file mode 100644 index 5c88739bd..000000000 Binary files a/core/presentation/src/commonMain/composeResources/font/inter_medium.ttf and /dev/null differ diff --git a/core/presentation/src/commonMain/composeResources/font/inter_regular.ttf b/core/presentation/src/commonMain/composeResources/font/inter_regular.ttf deleted file mode 100644 index 6b088a711..000000000 Binary files a/core/presentation/src/commonMain/composeResources/font/inter_regular.ttf and /dev/null differ diff --git a/core/presentation/src/commonMain/composeResources/font/inter_semi_bold.ttf b/core/presentation/src/commonMain/composeResources/font/inter_semi_bold.ttf deleted file mode 100644 index ceb8576ab..000000000 Binary files a/core/presentation/src/commonMain/composeResources/font/inter_semi_bold.ttf and /dev/null differ diff --git a/core/presentation/src/commonMain/composeResources/font/inter_tight.ttf b/core/presentation/src/commonMain/composeResources/font/inter_tight.ttf new file mode 100644 index 000000000..7d6421216 Binary files /dev/null and b/core/presentation/src/commonMain/composeResources/font/inter_tight.ttf differ diff --git a/core/presentation/src/commonMain/composeResources/font/jetbrains_mono.ttf b/core/presentation/src/commonMain/composeResources/font/jetbrains_mono.ttf new file mode 100644 index 000000000..aa310be8b Binary files /dev/null and b/core/presentation/src/commonMain/composeResources/font/jetbrains_mono.ttf differ diff --git a/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_bold.ttf b/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_bold.ttf deleted file mode 100644 index 8c93043de..000000000 Binary files a/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_bold.ttf and /dev/null differ diff --git a/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_light.ttf b/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_light.ttf deleted file mode 100644 index 15f15a2a1..000000000 Binary files a/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_light.ttf and /dev/null differ diff --git a/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_medium.ttf b/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_medium.ttf deleted file mode 100644 index 97671156d..000000000 Binary files a/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_medium.ttf and /dev/null differ diff --git a/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_regular.ttf b/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_regular.ttf deleted file mode 100644 index dff66cc50..000000000 Binary files a/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_regular.ttf and /dev/null differ diff --git a/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_semi_bold.ttf b/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_semi_bold.ttf deleted file mode 100644 index a70e69bd2..000000000 Binary files a/core/presentation/src/commonMain/composeResources/font/jetbrains_mono_semi_bold.ttf and /dev/null differ diff --git a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml index de76ec3c0..de1e7dd3d 100644 --- a/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml +++ b/core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml @@ -3,8 +3,6 @@ GitHub Store - - التطبيقات المثبتة العودة التحقق من التحديثات @@ -12,7 +10,6 @@ تعذر تشغيل %1$s فشل فتح %1$s فشل تحديث %1$s: %2$s - فشل التحديث فشل تحديث الكل: %1$s تم تحديث جميع التطبيقات بنجاح لا توجد تحديثات متاحة @@ -61,12 +58,9 @@ تسجيل الدخول بـ GitHub - تم الإلغاء خطأ غير معروف - اللغة: - اكتشف المستودعات ابحث عن مستودع، وصف… @@ -115,54 +109,29 @@ الملف الشخصي - - المظهر - اللغة - تجاوز لغة واجهة التطبيق. - لغة التطبيق - يغير القوائم والأزرار والرسائل في جميع أنحاء التطبيق. لا يغير المحتوى القادم من GitHub. اتباع النظام أعد التشغيل لتطبيق اللغة الجديدة. إعادة التشغيل - الشبكة - حول لون السمة سمة AMOLED السوداء خلفية سوداء نقية للوضع الداكن - اللون المحدد: %1$s - - الإصدار - المساعدة والدعم تسجيل الخروج - - نوع الوكيل - بدون - النظام - HTTP - SOCKS المضيف المنفذ اسم المستخدم (اختياري) كلمة المرور (اختياري) حفظ الوكيل تم حفظ إعدادات الوكيل - يستخدم إعدادات الوكيل الخاصة بجهازك - يجب أن يكون المنفذ بين 1–65535 - اتصال مباشر، بدون وكيل فشل حفظ إعدادات الوكيل المضيف مطلوب أدخل اسم مضيف أو عنوان IP صالحًا منفذ الوكيل غير صالح - إظهار كلمة المرور - إخفاء كلمة المرور - اختبار - جارٍ الاختبار… الاتصال ناجح (%1$d مللي ثانية) تعذر تحليل المضيف. تحقق من عنوان الوكيل. تعذر الوصول إلى خادم الوكيل. @@ -170,29 +139,19 @@ يلزم التحقق من الوكيل. استجابة غير متوقعة: HTTP %1$d فشل اختبار الاتصال - يمكن لكل فئة استخدام البروكسي الخاص بها. قم بتكوينها بشكل مستقل. - الاكتشاف (GitHub API) - الصفحة الرئيسية والبحث وتفاصيل المستودع وفحوصات التحديث - التنزيلات - تنزيلات APK والتحديثات التلقائية - الترجمة - خدمة ترجمة README تم تسجيل الخروج بنجاح، جارٍ إعادة التوجيه... - تم مسح ذاكرة التخزين المؤقت بنجاح تحذير! هل أنت متأكد أنك تريد تسجيل الخروج؟ - ديناميكي - محيط - بنفسجي غابة - رمادي - كهرماني + Nord + Cream + Plum فتح المستودع @@ -200,8 +159,6 @@ إلغاء التنزيل عرض خيارات التثبيت - - خطأ في تحميل التفاصيل إعادة المحاولة لا يوجد وصف. لا توجد ملاحظات إصدار. @@ -210,7 +167,6 @@ حول هذا التطبيق سجلات التثبيت - المؤلف ما الجديد @@ -218,8 +174,6 @@ تحديث متاح غير متاح تثبيت الأحدث - إعادة التثبيت - تحديث التطبيق جارٍ التنزيل @@ -259,14 +213,9 @@ لا توجد نتائج أخرى على GitHub فشل الجلب من GitHub. حاول مرة أخرى. - - بواسطة %1$s - • المثبت: %1$s متوافق مع المعمارية التحديث إلى %1$s - - فشل تحميل التفاصيل تم حفظ المثبت في مجلد التنزيلات @@ -294,30 +243,13 @@ خطأ: %1$s - نوع الملف .%1$s غير مدعوم - لم يتم العثور على الملف المنزّل - الرائج - إصدار ساخن - الأكثر شعبية - الخصوصية - الوسائط - الإنتاجية - الشبكة - أدوات المطور جارٍ البحث عن مستودعات... - جارٍ تحميل المزيد... - لا مزيد من المستودعات إعادة المحاولة فشل تحميل المستودعات - عرض التفاصيل - تم التحديث للتو - تم التحديث منذ %1$d ساعة - تم التحديث أمس - تم التحديث منذ %1$d يوم تم التحديث في %1$s تم تجاوز حد الطلبات @@ -366,8 +298,6 @@ تجاهل فشلت مزامنة المستودعات المميزة بنجمة - الملف الشخصي للمطور - فتح الملف الشخصي للمطور فشل تحميل المستودعات فشل تحميل الملف الشخصي @@ -380,7 +310,6 @@ البحث في المستودعات… مسح البحث - الكل مع إصدارات المثبتة المفضلة @@ -392,7 +321,6 @@ مستودع - مستودعات عرض %1$d من %2$d مستودع @@ -400,8 +328,6 @@ لا توجد مستودعات مثبتة لا توجد مستودعات مفضلة - - تم التحديث %1$s يحتوي على إصدار منذ %1$d سنة @@ -422,7 +348,7 @@ الرئيسية البحث - التطبيقات + المكتبة الملف الشخصي نسخة متفرعة @@ -439,17 +365,11 @@ آخر فحص: %1$s - لم يتم الفحص مطلقاً الآن منذ %1$d دقيقة منذ %1$d ساعة جارٍ التحقق من التحديثات… - - تتبع هذا التطبيق - تمت إضافة التطبيق إلى قائمة التتبع - فشل تتبع التطبيق: %1$s - التطبيق قيد التتبع بالفعل تسجيل الدخول إلى GitHub @@ -469,8 +389,6 @@ سيؤدي هذا إلى مسح جلستك المحلية والبيانات المخزنة مؤقتاً. لإلغاء الوصول بالكامل، قم بزيارة إعدادات GitHub > التطبيقات. - - ينتهي الرمز خلال %1$s انتهت صلاحية رمز الجهاز. يرجى محاولة تسجيل الدخول مرة أخرى للحصول على رمز جديد. يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى. @@ -487,71 +405,33 @@ فشل مشاركة الرابط تم نسخ الرابط إلى الحافظة - - ترجمة - جارٍ الترجمة… - عرض الأصلي - تُرجم إلى %1$s ترجمة إلى… البحث عن لغة - تغيير اللغة فشلت الترجمة. يرجى المحاولة مرة أخرى. فتح رابط GitHub تم اكتشاف رابط GitHub في الحافظة - الكشف التلقائي عن روابط الحافظة - الكشف التلقائي عن روابط GitHub من الحافظة عند فتح البحث الروابط المكتشفة فتح في التطبيق لم يتم العثور على رابط GitHub في الحافظة - التخزين - مسح ذاكرة التخزين المؤقت - الحجم الحالي: مسح - ادعم GitHub Store - ادعم المشروع - مجاني للأبد.\nبدعمكم يستمر. - دعمكم يبقي GitHub Store مجانيًا ومستقلًا ومُحدّثًا باستمرار — بدون إعلانات، بدون تتبع، فقط طريقة أفضل لتثبيت التطبيقات مفتوحة المصدر. - أقوم ببناء وصيانة هذا المشروع بمفردي أثناء إنهاء دراستي. كل مساهمة — كبيرة أو صغيرة — تذهب مباشرة لتغطية تكاليف الخوادم والتطوير وإبقاء التطبيق مجانيًا بدون إعلانات للجميع. - صوّت لـ GitHub Store! - تم ترشيح GitHub Store لجوائز Golden Kodee في KotlinConf 2026. - 1. التسجيل - 2. التصويت - ينتهي التصويت في 22 مارس - 1. سجل في منصة الجوائز (المتابعة عبر Google) - 2. اضغط على زر التصويت أدناه - 3. ابحث عن Usmon Narzullayev واضغط تصويت - GitHub Sponsors - شهري أو لمرة واحدة — يموّل التطوير مباشرة - Buy Me a Coffee - مساهمة سريعة لمرة واحدة - طرق أخرى للمساعدة - ضع نجمة للمستودع - يزيد الظهور ويساعد الآخرين على اكتشافه - الإبلاغ عن الأخطاء - كل بلاغ يجعل التطبيق أفضل للجميع - شارك مع الأصدقاء - الكلمة المنقولة هي أفضل تسويق - كل مساهمة — مالية أو غير ذلك — تُحدث فرقًا حقيقيًا. شكرًا لكونكم جزءًا من هذا. - - التثبيت الافتراضي نافذة التثبيت القياسية للنظام Shizuku @@ -568,16 +448,10 @@ تحديث التطبيقات تلقائيًا تنزيل التحديثات وتثبيتها تلقائيًا في الخلفية عبر Shizuku - التحديثات فترة التحقق من التحديثات عدد مرات التحقق من تحديثات التطبيق في الخلفية التحقق التلقائي من التحديثات يبحث عن التحديثات في الخلفية بشكل دوري. أوقفه لتوفير البطارية — يمكنك دائماً التحقق يدوياً من شاشة تفاصيل أي تطبيق. - ٣ ساعات - ٦ ساعات - ١٢ ساعة - ٢٤ ساعة - السماح بالتحديثات في الخلفية جهازك يوقف المهام الخلفية بقوة. أضف GitHub Store إلى قائمة الاستثناء من تحسين البطارية حتى تعمل عمليات فحص التحديثات والتثبيت الصامت بشكل موثوق. فتح الإعدادات @@ -592,18 +466,13 @@ جارٍ التحقق… ربط وتتبع التحقق من آخر إصدار… - تنزيل APK للتحقق… التحقق من مفتاح التوقيع… عدم تطابق اسم الحزمة: ملف APK هو %1$s، لكن التطبيق المحدد هو %2$s عدم تطابق مفتاح التوقيع: ملف APK في هذا المستودع موقّع من مطور مختلف اختر المثبّت اختر ملف APK للتحقق من مطابقته للتطبيق المثبت - فشل التنزيل تصدير استيراد - استيراد التطبيقات - الصق ملف JSON المُصدَّر لاستعادة التطبيقات المتتبعة - الصق JSON المُصدَّر هنا… قناة البيتا الافتراضية تشمل التطبيقات التي تتعقبها حديثاً إصدارات البيتا افتراضياً. التطبيقات المتعقَّبة مسبقاً تحتفظ بإعدادها الخاص (بدّله من شاشة التفاصيل). إلغاء تثبيت التطبيق؟ @@ -616,9 +485,6 @@ تم ربط %1$s بـ %2$s/%3$s فشل التصدير: %1$s فشل الاستيراد: %1$s - تم استيراد %1$d تطبيقات - ، %1$d تم تخطيها - ، %1$d فشلت تغيّر مفتاح التوقيع تغيّرت شهادة توقيع هذا التطبيق منذ تثبيته لأول مرة.\n\nقد يعني هذا أن المطور غيّر مفتاح التوقيع، أو أن الملف قد تم التلاعب به.\n\nالمتوقع: %1$s\nالمستلم: %2$s التثبيت على أي حال @@ -631,8 +497,6 @@ ملفات متعددة متاحة تتوفر عدة ملفات قابلة للتثبيت لهذا الإصدار. يرجى مراجعة القائمة واختيار الملف المناسب لجهازك. معلومات - إعادة المحاولة - اكتشاف تلقائي: %1$s اختر اللغة عدم تطابق الحزمة: ملف APK هو %1$s، لكن التطبيق المثبت هو %2$s. تم حظر التحديث. عدم تطابق مفتاح التوقيع: تم توقيع التحديث بواسطة مطور مختلف. تم حظر التحديث. @@ -645,27 +509,13 @@ واسع واسع جدًا - إخفاء المستودعات المشاهَدة - إخفاء المستودعات التي شاهدتها بالفعل من خلاصات الاكتشاف - مسح سجل المشاهدة - إعادة تعيين جميع المستودعات المشاهَدة لتظهر مجدداً في الخلاصات تم مسح سجل المشاهدة تمت المشاهدة هذا المستودع لك إخفاء المستودع - تم إخفاء المستودع - تراجع - الخصوصية - ساعد في تحسين البحث - مشاركة بيانات الاستخدام (عمليات البحث والتثبيتات والتفاعلات) المرتبطة بمعرّف تحليلي قابل لإعادة التعيين. لا تتم مشاركة تفاصيل الحساب. - إعادة تعيين معرف التحليلات - إنشاء معرف مجهول جديد، مما يقطع الصلة بالبيانات السابقة. - تم إعادة تعيين معرف التحليلات تمت مشاهدته مؤخرًا المستودعات التي قمت بزيارتها - الحزم التي تم تنزيلها - ملفات APK والمثبتات من إصدارات GitHub حذف الكل حذف جميع التنزيلات؟ سيؤدي هذا إلى حذف جميع ملفات APK والمثبتات نهائيًا (%1$s). يمكنك إعادة تنزيلها في أي وقت. @@ -676,9 +526,7 @@ مسح الكل إزالة - تعديلات تعديلات - إصدارات تجريبية عامل تصفية الأصول @@ -738,8 +586,6 @@ جاهز للتثبيت تثبيت (جاهز) - - الترجمة اختر الخدمة المستخدمة لترجمة README. خدمة الترجمة يعمل Google عالميًا دون تهيئة. يعمل Youdao من الصين القارية ولكنه يتطلب بيانات اعتماد API من بوابة مطوري Youdao. @@ -782,8 +628,6 @@ ghp_… أو github_pat_… تسجيل الدخول إلغاء - عرض الرمز - إخفاء الرمز يرجى لصق رمز. هذا لا يبدو كرمز GitHub. تبدأ الرموز بـ ghp_ أو github_pat_. هذا الرمز غير صالح أو تم إلغاؤه. @@ -800,13 +644,11 @@ اختر قناة الإصدار اضغط للتبديل بين الإصدارات المستقرة وإصدارات البيتا لهذا التطبيق. حسناً - تبديل الإصدارات التجريبية لهذا التطبيق التبديل إلى الإصدار المستقر %1$s لا يوجد إصدار مستقر منذ %1$d أشهر لا يوجد إصدار مستقر منذ %1$d أيام إصدارات تجريبية نشطة لكن المشروع لم يُصدر بناءً مستقراً منذ فترة. قد لا تتقارب الإصدارات التجريبية إلى إصدار مستقر. ما الذي تغيّر منذ %1$s - — %1$s — استيراد التطبيقات المثبّتة @@ -829,7 +671,6 @@ المزيد من التطابقات إخفاء التطابقات مثبّت عبر %1$s - نعتقد أن هذا هو %1$s · %2$d اضغط للبحث عن مستودع توسيع لرؤية تطابقات أخرى طي البطاقة @@ -917,7 +758,6 @@ إلغاء الربط بهذا المستودع - المزيد من الخيارات إلغاء ربط هذا التطبيق؟ سنتوقف عن تتبع %1$s كمثبّت من هذا المستودع. يبقى التطبيق على جهازك — يُزال الرابط فقط. إلغاء الربط @@ -937,8 +777,6 @@ تعذر التحديث. حاول مرة أخرى لاحقاً. فشل التحديث. حاول مرة أخرى. - - إرسال ملاحظات إرسال ملاحظات إغلاق الفئة @@ -973,16 +811,12 @@ منصّات مخصّصة - أضف مضيفات Forgejo / Gitea ليتعرّف عليها GHS - %1$d مُضافة منصّات مخصّصة أدخل اسم مضيف Forgejo أو Gitea (مثل git.example.com). سيقبل GHS روابط هذه المضيفات في نموذج الربط اليدوي. إضافة لا توجد منصّات مخصّصة بعد. تم - - مرآة التنزيل مرآة التنزيل تُستخدم لتنزيل أصول الإصدار. تتجه مكالمات GitHub API دائماً مباشرة. يجب على معظم المستخدمين إبقاء هذا على GitHub المباشر. رسمي @@ -998,7 +832,6 @@ يجب أن يحتوي القالب على {url} مرة واحدة بالضبط حفظ اختبار المحدد - جارٍ الاختبار… تم الوصول في %1$dms أرجعت المرآة %1$d انتهت المهلة بعد 5 ثوانٍ @@ -1006,12 +839,6 @@ فشل: %1$s كل المرايا معطّلة؟ يمكنك استضافة مرآتك الخاصة في 5 دقائق — راجع الوثائق. %1$s لم يعد متاحاً، تم التبديل إلى GitHub المباشر. - عدم تطابق المجموع الاختباري — قد يكون الملف قد تم التلاعب به - هل تجرب مرآة أسرع؟ - يُفضّل بعض المستخدمين على الشبكات البطيئة استخدام وكيل مجتمعي. - اختر واحدة - ربما لاحقاً - لا تسأل مجدداً إضافة من المميزة بنجمة @@ -1022,13 +849,7 @@ الأمان الحالة استطلاعات - أغلق بعد الاطلاع - %1$d ي لا شيء لمشاركته الآن. - %1$d س - الآن - آخر تحديث منذ %1$s - %1$d د لا يمكن تعطيله إظهار العناصر من فتح إعدادات الكتم @@ -1085,7 +906,6 @@ مطوي موسّع محدَّث - تحديثات متاحة إسقاط المثبِّت المحفوظ لـ %1$s وإزالة الصف من قائمة تطبيقاتك؟ سيتم حذف ملف APK الذي تم تنزيله. إسقاط التثبيت المعلّق؟ منح الإذن @@ -1171,7 +991,6 @@ جديد ما الجديد في %1$s ما الجديد - سجل التغييرات بالإنجليزية. الترجمات مرحَّب بها عبر Issues. الإصدار %1$s · %2$s @@ -1193,7 +1012,6 @@ تعذّر تحديث التفضيل المستودعات المخفية إدارة المستودعات التي أخفيتها من الرئيسية والبحث. - %1$d مخفي لا توجد مستودعات مخفية اضغط مطولاً على بطاقة مستودع في الرئيسية أو البحث لإخفائه من الاستكشاف. إظهار @@ -1209,11 +1027,293 @@ Windows macOS Linux - منصة أخرى — يفتح في المتصفح للحفظ والنقل جهازك للنقل أبقِ أندرويد مفتوحًا سياسة التحقق من المطورين من Google تهدد التوزيع المفتوح لأندرويد. GitHub Store يدعم التحالف. اعرف المزيد إغلاق البانر + + + انقر على أي بطاقة على اليسار لعرض تفاصيلها هنا. + اختر مستودعاً + جاهز للتثبيت + إعدادات متقدمة + إجراءات سريعة + الإعدادات + الحالة + اختر أي تطبيق على اليسار لرؤية تفاصيله وإجراءاته. + اختر تطبيقاً + المثبَّت + الأحدث + فتح المستودع + اختيار النوع + إخفاء القائمة + عرض القائمة + راجع أو حدّث كل شيء دفعة واحدة. + تحديث واحد متاح + %1$d تحديثات متاحة + رجوع + رجوع + صورة + عرض الكل › + + لا توجد مضيفات + مضيف واحد + مضيفان + %1$d مضيفات + %1$d مضيفاً + %1$d مضيف + + يتم البحث في Codeberg و gitea.com و git.disroot.org افتراضياً — لا حاجة لإضافتها هنا. استخدم هذه القائمة لمضيف Forgejo أو Gitea الذي تستضيفه بنفسك فقط. + حول + المطوّر + الإحصائيات + عرض الملف الشخصي + ما الجديد + تخطّي والمراجعة لاحقاً + نقرأ كل تقرير. + الحصول + #%1$d + من مستودعاتك المميّزة + إصدارات رائجة + الأكثر شعبية + الأعلى تداولاً الآن + استكشف + عرض الكل + عرض المزيد + إضافة رمز + رجوع + إلغاء + حذف + حفظ + إضافة رمز + تم الاكتشاف: %1$s + عنوان المنصّة + اتركه فارغاً للاحتفاظ بالرمز الحالي. اكتب قيمة جديدة لاستبداله. + استبدال الرمز لـ %1$s + سيتم الاتصال بـ %1$s + + لا توجد رموز + رمز واحد + رمزان + %1$d رموز + %1$d رمزاً + %1$d رمز + + انقر على + لإضافة رمز وصول شخصي + لا توجد رموز محفوظة + رموز الوصول الشخصية لكل مضيف منصّة (GitHub، Codeberg، Forgejo) + اسم العرض (اختياري) + رمز الوصول الشخصي + أنت مسجَّل الدخول إلى GitHub عبر التطبيق — وهذا هو المسار الموصى به. أضف رمزاً هنا فقط إذا تعذّر تسجيل الدخول عبر المتصفح (شبكة مقيّدة، لا متصفح) أو لمنصّات غير GitHub. + فتح صفحة الرمز + منصّة أخرى + Forgejo، Gitea، استضافة ذاتية + لصق الرمز + إضافة رمز لـ… + تعديل التسمية + غير صالح · %1$s + المزيد + الإدارة على %1$s + استبدال الرمز + %1$s متبقٍ + صالح · %1$s + تحقّق + تم حفظ الرمز لـ %1$s + رموز المصادقة + تراجع + تعذّر استعادة الرمز لـ %1$s + تمت إزالة الرمز لـ %1$s + فشل التحقق: %1$s + خطأ غير معروف + المضيف غير صالح + المضيف مطلوب + الرمز مطلوب + الرمز قصير جداً + حول GitHub Store + إرسال ملاحظات… + تراخيص المصادر المفتوحة + مساعدة + سياسة الخصوصية + إعدادات التطبيق، السمة، الشبكة، الترجمة، الخيارات المتقدمة. + تتطلب بعض التغييرات إعادة تشغيل التطبيق لتفعيلها. + لاحقاً + اللغة السمة + المتأثر: %1$s + إعادة التشغيل الآن + إزالة الفلتر + تم + فلاتر + إعادة تعيين الكل + اللغة + المنصّة + ترتيب حسب + المصدر + تصفية النتائج + التطبيق + الاتصال + التثبيت والتحديثات + المظهر + الخصوصية والبيانات + + لا توجد تطبيقات + تطبيق واحد + تطبيقان + %1$d تطبيقات + %1$d تطبيقاً + %1$d تطبيق + + ترجمة README وملاحظات الإصدار تلقائياً عند فتح مستودع. تستخدم لغة التطبيق كهدف. + اتباع لغة التطبيق (%1$s) + الترجمة إلى + ترجمة المستودعات تلقائياً + إلغاء + تغيير اللغة + المصدر المكتشف: %1$s + إعادة المحاولة + عرض النص الأصلي + عرض الترجمة + اعرض هذه الصفحة بلغة أخرى. + عرض النص الأصلي + تمت الترجمة إلى %1$s + جارٍ الترجمة… + اللغة الهدف + ترجمة + الترجمة إلى %1$s + مفتاح المصادقة + احصل على مفتاح DeepL مجاني ← + سجّل في deepl.com/pro-api. مفاتيح الطبقة المجانية تنتهي بـ :fx وتستخدم نقطة النهاية المجانية تلقائياً. الطبقة المدفوعة تحافظ على خصوصية نصك؛ الطبقة المجانية قد تستخدم النص المرسَل لتحسين النماذج. + حفظ المفتاح + تم حفظ مفتاح DeepL + مفتاح API (اختياري) + عنوان المثيل + يعمل مباشرةً عبر مرآة Disroot العامة — اترك الحقول فارغة لاستخدامها. لخصوصية أكبر، الصق رابط استضافتك الذاتية. لا يلزم مفتاح API إلا إذا اشترطه المثيل. + حفظ الإعدادات + تم حفظ إعدادات LibreTranslate + احصل على مفتاح Azure مجاني ← + Azure Translator يحترم الخصوصية (وضع No-Trace افتراضياً — لا يُخزَّن نصك ولا يُستخدم للتدريب). الطبقة المجانية تغطي مليوني حرف شهرياً. المنطقة لازمة فقط للموارد غير العالمية. + مفتاح الاشتراك + المنطقة (مثل westeurope، اتركه فارغاً للعالمي) + حفظ بيانات الاعتماد + تم حفظ بيانات اعتماد Microsoft Translator + DeepL + LibreTranslate + Microsoft + GitHub Store + المكتبات المستخدمة في التطبيق. + تراخيص المصادر المفتوحة + العرض على github-store.org. + سياسة الخصوصية + عرض كود هذا التطبيق. + الكود المصدري على GitHub + متجر تطبيقات عابر للمنصّات لإصدارات GitHub و Codeberg و Forgejo. + ملاحظات الإصدارات السابقة. + ما الجديد + يُطبَّق على كل الحركة ما لم يُعَد تجاوزه أدناه. + مخصّص + اختر وضع الاتصال أدناه. معظم المستخدمين يتركونه على 'بدون وكيل'. + كيف يصل التطبيق إلى الإنترنت + الاتصال الرئيسي + HTTP/HTTPS + معظم الوكلاء المؤسّسية. + بدون وكيل + SOCKS5 + Tor، أنفاق SSH. + النظام + يستخدم كل نطاق الاتصال الرئيسي افتراضياً. اختر 'مخصّص' للاحتفاظ بإعداداته الخاصة. + تجاوزات لكل نطاق + لصق رابط كامل + الصق رابط وكيل كاملاً وسنملأ النموذج نيابةً عنك. + استخدام هذا الرابط + تعذّر قراءة هذا الرابط. + scheme://user:pass@host:port + لصق رابط الوكيل + واجهة GitHub البرمجية، البحث، تفاصيل المستودع. + البحث والبيانات الوصفية + تنزيلات APK والأصول. + التنزيلات + مكالمات DeepL و Microsoft و LibreTranslate. + الترجمة + اختبار + استخدام الرئيسي + رموز الوصول + معلومات التطبيق + المظهر + الاتصال + إرسال ملاحظات + طريقة التثبيت + اللغة + الخصوصية + المصادر + التخزين + انقر للإدارة + الترجمة + سلوك التحديث + المثبّتات الصامتة وإسناد المثبِّت ميزات خاصة بـ Android. عمليات التثبيت على سطح المكتب تمر عبر مدير حزم النظام. + طريقة التثبيت لـ Android فقط + يُعاد تشغيل التطبيق عند تبديل اللغة. + انقر على أي إدخال لفتح صفحة مشروعه. + يقوم GitHub Store على هذه المكتبات. + تراخيص المصادر المفتوحة + سجل التصفّح + انسَ المستودعات التي فتحتها سابقاً. + لن يُلغي هذا تمييز أي مستودع بنجمة أو إضافته للمفضلة. + مسح + مسح سجل المشاهدة؟ + مسح سجل المشاهدة + عند نسخك لرابط من github.com أو codeberg.org، سنطلب منك فتحه. + اكتشاف روابط المستودعات في الحافظة + المستودعات التي كتمتها من الموجَزات والبحث. + المستودعات المخفية + تخطّي المستودعات المُشاهَدة في الموجَزات والبحث. + إخفاء المستودعات التي شاهدتها بالفعل لا معرّفات. نظام التشغيل والمنصّة. لا توجد إعدادات تطابق '%1$s'. + البحث في الإعدادات + إضافة مضيف Forgejo أو Gitea + المضيفات المضافة + مرآة GitHub + يبحث التطبيق في GitHub افتراضياً. وجِّه عبر مرآة إقليمية أو أضف مضيفات Forgejo / Gitea مخصّصة. + حيث يبحث التطبيق عن المستودعات + الافتراضي (github.com) + نحتفظ بالمثبّتات لتُستأنف التحديثات بسرعة. + مسح + ملفات APK المنزَّلة + 0 ب + يستخدم: %1$s + هذه الشاشة جزء من التصميم الجديد قيد التطوير. الإعدادات لا تزال تعمل — يجري نقلها إلى هنا من التخطيط القديم. + قادم في تحديث لاحق + + لا مستودعات مخفية + مستودع مخفي واحد + مستودعان مخفيان + %1$d مستودعات مخفية + %1$d مستودعًا مخفيًا + %1$d مستودع مخفي + + تواصل + انضم إلى المجتمع + 6 أماكن + استفسارات الأعمال + الشراكات، الصحافة، التكاملات. + تواصل + المكتبة + التحديثات + الحساب + الوضع + العرض + ديناميكي + خيارات تسجيل دخول إضافية + إخفاء الخيارات + منصات الاكتشاف + تصفية الصفحة الرئيسية حسب المنصة. اترك الكل بدون تحديد لعرض كل شيء. + حول + الإصدار، المجتمع، القانوني + قابل للتثبيت هنا + لا توجد مستودعات بأصول قابلة للتثبيت لهذه المنصة + كل %1$d ساعة + يوميًا + كل %1$d أيام + أسبوعيًا + كل أسبوعين + كل 30 يومًا diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index c50a05434..23a51086e 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -3,8 +3,6 @@ GitHub Store - - ইনস্টল করা অ্যাপসমূহ পেছনে যান আপডেট পরীক্ষা করুন @@ -12,7 +10,6 @@ %1$s চালু করা যায়নি %1$s খুলতে ব্যর্থ %1$s আপডেট করতে ব্যর্থ: %2$s - আপডেট ব্যর্থ সব আপডেট ব্যর্থ হয়েছে: %1$s সব অ্যাপ সফলভাবে আপডেট হয়েছে কোনো আপডেট পাওয়া যায়নি @@ -61,12 +58,9 @@ GitHub দিয়ে সাইন ইন করুন - বাতিল করা হয়েছে অজানা ত্রুটি - ভাষা: - রিপোজিটরি আবিষ্কার করুন রিপো, বিবরণ খুঁজুন… @@ -115,46 +109,31 @@ প্রোফাইল - - চেহারা - ভাষা - অ্যাপের UI ভাষা ওভাররাইড করুন। - অ্যাপের ভাষা - সম্পূর্ণ অ্যাপের মেনু, বোতাম এবং বার্তা পরিবর্তন করে। GitHub থেকে আসা বিষয়বস্তু পরিবর্তন করে না। সিস্টেম অনুসরণ করুন নতুন ভাষা প্রয়োগ করতে পুনরায় চালু করুন। পুনরায় চালু করুন - সম্পর্কে - নেটওয়ার্ক থিমের রঙ AMOLED কালো থিম অন্ধকার মোডের জন্য সম্পূর্ণ কালো ব্যাকগ্রাউন্ড - নির্বাচিত রঙ: %1$s - - সংস্করণ - সহায়তা ও সাপোর্ট লগআউট সফলভাবে লগআউট হয়েছে, রিডাইরেক্ট করা হচ্ছে... - ক্যাশ সফলভাবে পরিষ্কার করা হয়েছে সতর্কতা! আপনি কি নিশ্চিতভাবে লগআউট করতে চান? - ডাইনামিক - সমুদ্র - বেগুনি বন - স্লেট - অ্যাম্বার + Nord + Cream + Plum রিপোজিটরি খুলুন @@ -162,8 +141,6 @@ ডাউনলোড বাতিল করুন ইনস্টল অপশন দেখান - - বিস্তারিত লোড করতে ত্রুটি দেখা গেছে পুনরায় চেষ্টা করুন কোনো বিবরণ দেওয়া হয়নি। কোনো রিলিজ নোট নেই। @@ -172,7 +149,6 @@ এই অ্যাপ বৃত্তান্ত ইনস্টল লগ - লেখক নতুন কী আছে @@ -180,8 +156,6 @@ আপডেট উপলব্ধ উপলব্ধ নয় সর্বশেষটি ইনস্টল করুন - পুনরায় ইনস্টল করুন - অ্যাপ আপডেট করুন ডাউনলোড হচ্ছে @@ -210,14 +184,9 @@ GitHub-এ আর ফলাফল নেই GitHub থেকে আনতে ব্যর্থ। আবার চেষ্টা করুন। - - %1$s দ্বারা - • ইনস্টল করা: %1$s আর্কিটেকচার উপযোগী %1$s -এ আপডেট করুন - - বিস্তারিত লোড করতে ব্যর্থ ইনস্টলারটি Downloads ফোল্ডারে সংরক্ষিত হয়েছে @@ -243,30 +212,13 @@ ত্রুটি: %1$s - .%1$s অ্যাসেট টাইপ সমর্থিত নয় - ডাউনলোড করা ফাইল পাওয়া যায়নি - ট্রেন্ডিং - হট রিলিজ - সবচেয়ে জনপ্রিয় - গোপনীয়তা - মিডিয়া - উৎপাদনশীলতা - নেটওয়ার্ক - ডেভ টুলস রিপোজিটরি খোঁজা হচ্ছে... - আরও লোড হচ্ছে... - আর কোনো রিপোজিটরি নেই পুনরায় চেষ্টা রিপোজিটরি লোড করতে ব্যর্থ - বিস্তারিত দেখুন - এইমাত্র আপডেট হয়েছে - %1$d ঘণ্টা আগে আপডেট হয়েছে - গতকাল আপডেট হয়েছে - %1$d দিন আগে আপডেট হয়েছে %1$s তারিখে আপডেট হয়েছে রেট লিমিট অতিক্রম করেছে @@ -315,8 +267,6 @@ বন্ধ করুন স্টার করা রিপোজিটরি সিঙ্ক করতে ব্যর্থ হয়েছে - ডেভেলপার প্রোফাইল - ডেভেলপার প্রোফাইল খুলুন রিপোজিটরি লোড করতে ব্যর্থ @@ -330,7 +280,6 @@ রিপোজিটরি খুঁজুন… অনুসন্ধান মুছুন - সব রিলিজ সহ ইনস্টল করা পছন্দ @@ -342,7 +291,6 @@ রিপোজিটরি - রিপোজিটরি %2$d টির মধ্যে %1$d টি রিপোজিটরি দেখানো হচ্ছে @@ -350,8 +298,6 @@ কোনো রিপোজিটরি ইনস্টল করা হয়নি কোনো পছন্দের রিপোজিটরি নেই - - %1$s আপডেট করা হয়েছে রিলিজ আছে @@ -373,7 +319,7 @@ হোম অনুসন্ধান - অ্যাপস + লাইব্রেরি প্রোফাইল ফর্ক @@ -404,35 +350,21 @@ সর্বশেষ পরীক্ষা: %1$s - কখনো পরীক্ষা করা হয়নি এইমাত্র %1$d মিনিট আগে %1$d ঘণ্টা আগে আপডেট পরীক্ষা করা হচ্ছে… - - প্রক্সি ধরন - নেই - সিস্টেম - HTTP - SOCKS হোস্ট পোর্ট ব্যবহারকারীর নাম (ঐচ্ছিক) পাসওয়ার্ড (ঐচ্ছিক) প্রক্সি সংরক্ষণ প্রক্সি সেটিংস সংরক্ষিত হয়েছে - আপনার ডিভাইসের প্রক্সি সেটিংস ব্যবহার করে - পোর্ট ১–৬৫৫৩৫ এর মধ্যে হতে হবে - সরাসরি সংযোগ, কোনো প্রক্সি নেই প্রক্সি সেটিংস সংরক্ষণ করতে ব্যর্থ হয়েছে প্রক্সি হোস্ট প্রয়োজন একটি বৈধ হোস্টনাম বা IP ঠিকানা লিখুন অবৈধ প্রক্সি পোর্ট - পাসওয়ার্ড দেখান - পাসওয়ার্ড লুকান - পরীক্ষা - পরীক্ষা চলছে… সংযোগ ঠিক আছে (%1$d ms) হোস্ট সমাধান করা যায়নি। প্রক্সি ঠিকানা যাচাই করুন। প্রক্সি সার্ভারে পৌঁছানো যায়নি। @@ -440,20 +372,8 @@ প্রক্সি প্রমাণীকরণ প্রয়োজন। অপ্রত্যাশিত প্রতিক্রিয়া: HTTP %1$d সংযোগ পরীক্ষা ব্যর্থ - প্রতিটি বিভাগ তার নিজস্ব প্রক্সি ব্যবহার করতে পারে। সেগুলি স্বাধীনভাবে কনফিগার করুন। - আবিষ্কার (GitHub API) - হোম, অনুসন্ধান, রেপো বিবরণ এবং আপডেট চেক - ডাউনলোড - APK ডাউনলোড এবং স্বয়ংক্রিয় আপডেট - অনুবাদ - README অনুবাদ পরিষেবা - - এই অ্যাপ ট্র্যাক করুন - অ্যাপ ট্র্যাকিং তালিকায় যোগ করা হয়েছে - অ্যাপ ট্র্যাক করতে ব্যর্থ: %1$s - অ্যাপটি ইতিমধ্যে ট্র্যাক করা হচ্ছে GitHub-এ সাইন ইন করুন @@ -470,7 +390,6 @@ আবার সাইন ইন করুন অতিথি হিসেবে চালিয়ে যান এটি আপনার স্থানীয় সেশন এবং ক্যাশ ডেটা মুছে ফেলবে। সম্পূর্ণরূপে অ্যাক্সেস প্রত্যাহার করতে, GitHub Settings > Applications এ যান। - কোডের মেয়াদ শেষ হবে %1$s এ ডিভাইস কোডের মেয়াদ শেষ হয়ে গেছে। একটি নতুন কোড পেতে অনুগ্রহ করে আবার সাইন ইন করার চেষ্টা করুন। অনুগ্রহ করে আপনার ইন্টারনেট সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন। @@ -487,70 +406,32 @@ লিংক শেয়ার করতে ব্যর্থ হয়েছে লিংক ক্লিপবোর্ডে কপি করা হয়েছে - - অনুবাদ করুন - অনুবাদ হচ্ছে… - মূল দেখান - %1$s এ অনুবাদিত অনুবাদ করুন… ভাষা খুঁজুন - ভাষা পরিবর্তন করুন অনুবাদ ব্যর্থ হয়েছে। আবার চেষ্টা করুন। GitHub লিংক খুলুন ক্লিপবোর্ডে GitHub লিংক পাওয়া গেছে - ক্লিপবোর্ড লিংক স্বয়ংক্রিয় সনাক্তকরণ - অনুসন্ধান খোলার সময় স্বয়ংক্রিয়ভাবে ক্লিপবোর্ড থেকে GitHub লিংক সনাক্ত করুন সনাক্তকৃত লিংক অ্যাপে খুলুন ক্লিপবোর্ডে কোনো GitHub লিংক পাওয়া যায়নি - স্টোরেজ - ক্যাশে পরিষ্কার করুন - বর্তমান আকার: পরিষ্কার করুন - GitHub Store সমর্থন করুন - প্রকল্পকে সমর্থন করুন - চিরকাল বিনামূল্যে।\nআপনাদের সহায়তায় চলে। - আপনার সমর্থন GitHub Store কে বিনামূল্যে, স্বাধীন এবং সক্রিয়ভাবে রক্ষণাবেক্ষণ করে রাখে — কোনো বিজ্ঞাপন নেই, কোনো ট্র্যাকিং নেই, শুধু ওপেন-সোর্স অ্যাপ ইনস্টল করার একটি ভালো উপায়। - আমি পড়াশোনা শেষ করতে করতে একাই এই প্রকল্পটি তৈরি ও রক্ষণাবেক্ষণ করছি। প্রতিটি অবদান — বড় হোক বা ছোট — সরাসরি সার্ভার খরচ, উন্নয়ন এবং সবার জন্য অ্যাপটিকে বিজ্ঞাপনমুক্ত রাখতে ব্যবহৃত হয়। - GitHub Store এর জন্য ভোট দিন! - GitHub Store KotlinConf 2026 এর Golden Kodee Awards এর জন্য মনোনীত হয়েছে। - 1. নিবন্ধন করুন - 2. ভোট দিন - ভোট ২২ মার্চ পর্যন্ত - 1. পুরস্কার প্ল্যাটফর্মে নিবন্ধন করুন (Google দিয়ে চালিয়ে যান) - 2. নিচে Vote চাপুন - 3. Usmon Narzullayev খুঁজে Vote চাপুন - GitHub Sponsors - মাসিক বা একবারের — সরাসরি উন্নয়নে অবদান রাখে - Buy Me a Coffee - দ্রুত একবারের অবদান - সহায়তার অন্যান্য উপায় - রিপোজিটরিতে স্টার দিন - দৃশ্যমানতা বাড়ায় এবং অন্যদের খুঁজে পেতে সাহায্য করে - বাগ রিপোর্ট করুন - প্রতিটি রিপোর্ট সবার জন্য অ্যাপটিকে আরও ভালো করে - বন্ধুদের সাথে শেয়ার করুন - মুখের কথাই সেরা মার্কেটিং - প্রতিটি অবদান — আর্থিক হোক বা না হোক — সত্যিই একটি পার্থক্য তৈরি করে। এর অংশ হওয়ার জন্য আপনাকে ধন্যবাদ। - - ইনস্টলেশন ডিফল্ট স্ট্যান্ডার্ড সিস্টেম ইনস্টল ডায়ালগ Shizuku @@ -567,16 +448,10 @@ স্বয়ংক্রিয়ভাবে অ্যাপ আপডেট করুন Shizuku এর মাধ্যমে ব্যাকগ্রাউন্ডে স্বয়ংক্রিয়ভাবে আপডেট ডাউনলোড এবং ইনস্টল করুন - আপডেট আপডেট চেক করার ব্যবধান ব্যাকগ্রাউন্ডে কতক্ষণ পর পর অ্যাপ আপডেট খোঁজা হবে ব্যাকগ্রাউন্ড আপডেট চেক নিয়মিত ব্যাকগ্রাউন্ডে আপডেট খোঁজে। ব্যাটারি বাঁচাতে বন্ধ করুন — আপনি যেকোনো অ্যাপের ডিটেইলস স্ক্রিন থেকে ম্যানুয়ালি চেক করতে পারবেন। - ৩ঘ - ৬ঘ - ১২ঘ - ২৪ঘ - ব্যাকগ্রাউন্ড আপডেট অনুমতি দিন আপনার ডিভাইস ব্যাকগ্রাউন্ড টাস্কগুলো আক্রমণাত্মকভাবে বন্ধ করে। GitHub Store-কে ব্যাটারি অপটিমাইজেশন থেকে হোয়াইটলিস্ট করুন যাতে নির্ধারিত আপডেট চেক এবং সাইলেন্ট ইনস্টল নির্ভরযোগ্যভাবে চলে। সেটিংস খুলুন @@ -591,18 +466,13 @@ যাচাই হচ্ছে… লিঙ্ক এবং ট্র্যাক করুন সর্বশেষ রিলিজ পরীক্ষা হচ্ছে… - যাচাইয়ের জন্য APK ডাউনলোড হচ্ছে… সাইনিং কী যাচাই হচ্ছে… প্যাকেজ নাম মেলেনি: APK হলো %1$s, কিন্তু নির্বাচিত অ্যাপ হলো %2$s সাইনিং কী মেলেনি: এই রিপোজিটরির APK একজন ভিন্ন ডেভেলপার দ্বারা স্বাক্ষরিত ইনস্টলার নির্বাচন করুন আপনার ইনস্টল করা অ্যাপের সাথে যাচাই করতে APK নির্বাচন করুন - ডাউনলোড ব্যর্থ রপ্তানি আমদানি - অ্যাপ আমদানি করুন - ট্র্যাক করা অ্যাপ পুনরুদ্ধার করতে রপ্তানি করা JSON পেস্ট করুন - রপ্তানি করা JSON এখানে পেস্ট করুন… ডিফল্ট বেটা চ্যানেল নতুন ট্র্যাক করা অ্যাপ ডিফল্টভাবে বেটা বিল্ড অন্তর্ভুক্ত করবে। ইতিমধ্যে ট্র্যাক করা অ্যাপ তাদের নিজস্ব সেটিং রাখবে (অ্যাপের বিস্তারিত স্ক্রিনে পরিবর্তন করুন)। অ্যাপ আনইনস্টল করবেন? @@ -615,9 +485,6 @@ %1$s %2$s/%3$s এর সাথে লিঙ্ক করা হয়েছে রপ্তানি ব্যর্থ: %1$s আমদানি ব্যর্থ: %1$s - %1$d অ্যাপ আমদানি করা হয়েছে - , %1$d বাদ দেওয়া হয়েছে - , %1$d ব্যর্থ সাইনিং কী পরিবর্তিত হয়েছে এই অ্যাপের সাইনিং সার্টিফিকেট প্রথম ইনস্টলের পর থেকে পরিবর্তিত হয়েছে।\n\nএর অর্থ হতে পারে ডেভেলপার তাদের সাইনিং কী পরিবর্তন করেছে, অথবা বাইনারি পরিবর্তন করা হয়েছে।\n\nপ্রত্যাশিত: %1$s\nপ্রাপ্ত: %2$s যাই হোক ইনস্টল করুন @@ -630,8 +497,6 @@ একাধিক সম্পদ উপলব্ধ এই রিলিজের জন্য একাধিক ইনস্টলযোগ্য ফাইল উপলব্ধ। তালিকা পর্যালোচনা করুন এবং আপনার ডিভাইসের জন্য উপযুক্তটি নির্বাচন করুন। তথ্য - পুনরায় চেষ্টা - স্বয়ংক্রিয়ভাবে শনাক্ত: %1$s ভাষা নির্বাচন করুন প্যাকেজ অমিল: APK হলো %1$s, কিন্তু ইনস্টল করা অ্যাপ হলো %2$s। আপডেট ব্লক করা হয়েছে। সাইনিং কী অমিল: আপডেটটি একজন ভিন্ন ডেভেলপার দ্বারা সাইন করা হয়েছে। আপডেট ব্লক করা হয়েছে। @@ -644,27 +509,13 @@ প্রশস্ত অতিরিক্ত প্রশস্ত - দেখা রিপোজিটরি লুকান - আপনি ইতিমধ্যে দেখেছেন এমন রিপোজিটরি আবিষ্কার ফিড থেকে লুকান - দেখার ইতিহাস মুছুন - সমস্ত দেখা রিপোজিটরি রিসেট করুন যাতে সেগুলো ফিডে আবার দেখা যায় দেখার ইতিহাস মুছে ফেলা হয়েছে দেখা হয়েছে এই রিপো আপনার রিপোজিটরি লুকান - রিপোজিটরি লুকানো হয়েছে - পূর্বাবস্থা - গোপনীয়তা - অনুসন্ধান উন্নত করতে সহায়তা করুন - পুনরায় সেট করা যায় এমন একটি বিশ্লেষণ আইডির সাথে যুক্ত ব্যবহার ডেটা (অনুসন্ধান, ইনস্টল, ইন্টারঅ্যাকশন) শেয়ার করুন। অ্যাকাউন্ট বিবরণ শেয়ার করা হয় না। - বিশ্লেষণ আইডি রিসেট করুন - একটি নতুন বেনামী আইডি তৈরি করুন, অতীতের টেলিমেট্রির সাথে সংযোগ বিচ্ছিন্ন করে। - বিশ্লেষণ আইডি রিসেট করা হয়েছে সাম্প্রতিক দেখা আপনি যেসব রিপোজিটরি দেখেছেন - ডাউনলোড করা প্যাকেজসমূহ - GitHub রিলিজ থেকে APK এবং ইনস্টলার সব মুছুন সব ডাউনলোড মুছবেন? এটি সব APK এবং ইনস্টলার স্থায়ীভাবে মুছে ফেলবে (%1$s)। আপনি যেকোনো সময় আবার ডাউনলোড করতে পারবেন। @@ -675,9 +526,7 @@ সব মুছুন সরান - টুইকস টুইকস - প্রি-রিলিজ অ্যাসেট ফিল্টার @@ -737,8 +586,6 @@ ইনস্টলের জন্য প্রস্তুত ইনস্টল (প্রস্তুত) - - অনুবাদ README অনুবাদের জন্য ব্যবহৃত পরিষেবা নির্বাচন করুন। অনুবাদ পরিষেবা Google বিশ্বব্যাপী কনফিগারেশন ছাড়াই কাজ করে। Youdao মূল ভূখণ্ডের চীন থেকে কাজ করে কিন্তু Youdao ডেভেলপার পোর্টাল থেকে API শংসাপত্র প্রয়োজন। @@ -781,8 +628,6 @@ ghp_… বা github_pat_… সাইন ইন বাতিল - টোকেন দেখান - টোকেন লুকান অনুগ্রহ করে একটি টোকেন পেস্ট করুন। এটি GitHub টোকেন বলে মনে হচ্ছে না। টোকেন ghp_ বা github_pat_ দিয়ে শুরু হয়। এই টোকেনটি অবৈধ অথবা বাতিল করা হয়েছে। @@ -800,13 +645,11 @@ আপনার রিলিজ চ্যানেল বাছুন এই অ্যাপের স্থিতিশীল রিলিজ এবং বেটা বিল্ডের মধ্যে স্যুইচ করতে আলতো চাপুন। বুঝেছি - এই অ্যাপের জন্য বেটা রিলিজ টগল করুন স্থিতিশীল %1$s-এ যান %1$d মাসে কোনো স্থিতিশীল রিলিজ নেই %1$d দিনে কোনো স্থিতিশীল রিলিজ নেই সক্রিয় প্রি-রিলিজ রয়েছে কিন্তু প্রকল্পটি কিছুদিন ধরে স্থিতিশীল বিল্ড প্রকাশ করেনি। বেটা স্থিতিশীল রিলিজে পরিণত নাও হতে পারে। %1$s থেকে কী পরিবর্তন হয়েছে - — %1$s — ইনস্টল করা অ্যাপ আমদানি করুন @@ -825,7 +668,6 @@ আরও মিল মিল লুকান %1$s দিয়ে ইনস্টল করা - আমরা মনে করি এটি %1$s · %2$d রিপো খুঁজতে ট্যাপ করুন অন্য মিল দেখতে প্রসারিত করুন কার্ড সংকুচিত করুন @@ -893,7 +735,6 @@ এই রিপো থেকে আনলিঙ্ক করুন - আরও বিকল্প এই অ্যাপ আনলিঙ্ক করবেন? এই রিপো থেকে ইনস্টল হিসেবে %1$s ট্র্যাক করা বন্ধ করব। অ্যাপটি আপনার ডিভাইসে থাকবে — শুধু লিঙ্ক সরানো হবে। আনলিঙ্ক করুন @@ -913,8 +754,6 @@ রিফ্রেশ করা যায়নি। শীঘ্রই আবার চেষ্টা করুন। রিফ্রেশ ব্যর্থ হয়েছে। আবার চেষ্টা করুন। - - ফিডব্যাক পাঠান ফিডব্যাক পাঠান বন্ধ করুন বিভাগ @@ -950,15 +789,12 @@ কাস্টম ফোর্জ - GHS যেসব Forgejo / Gitea হোস্ট চিনবে যোগ করুন - %1$d যোগ করা হয়েছে কাস্টম ফোর্জ Forgejo বা Gitea ইনস্ট্যান্সের হোস্টনেম দিন (যেমন git.example.com)। ম্যানুয়াল লিঙ্ক শিটে GHS এই হোস্টের URL গ্রহণ করবে। যোগ এখনো কোনো কাস্টম ফোর্জ নেই। সম্পন্ন - ডাউনলোড মিরর ডাউনলোড মিরর রিলিজ অ্যাসেট ডাউনলোড করতে ব্যবহৃত। GitHub API কল সবসময় সরাসরি যায়। বেশিরভাগ ব্যবহারকারীর Direct GitHub-এ রাখা উচিত। অফিসিয়াল @@ -974,7 +810,6 @@ টেমপ্লেটে {url} ঠিক একবার থাকতে হবে সংরক্ষণ করুন নির্বাচিত পরীক্ষা করুন - পরীক্ষা চলছে… %1$dms-এ পৌঁছানো হয়েছে মিরর %1$d ফেরত দিয়েছে 5 সেকেন্ড পরে টাইমআউট @@ -982,12 +817,6 @@ ব্যর্থ: %1$s সব মিরর নষ্ট? ৫ মিনিটে নিজের মিরর হোস্ট করুন — ডক্স দেখুন। %1$s আর পাওয়া যাচ্ছে না, Direct GitHub-এ স্যুইচ করা হয়েছে। - চেকসাম মিলছে না — ফাইলটি হয়তো পরিবর্তন করা হয়েছে - আরও দ্রুত মিরর চেষ্টা করবেন? - ধীর নেটওয়ার্কে কিছু ব্যবহারকারী কমিউনিটি প্রক্সিতে ভালো ফলাফল পান। - একটি বেছে নিন - পরে হয়তো - আর জিজ্ঞেস করবেন না স্টার করা থেকে যোগ করুন @@ -998,13 +827,7 @@ সুরক্ষা অবস্থা জরিপ - দেখার পর বন্ধ করুন - %1$d দিন এই মুহূর্তে শেয়ার করার মতো কিছু নেই। - %1$d ঘ - এইমাত্র - %1$s আগে সর্বশেষ রিফ্রেশ হয়েছে - %1$d মি নিষ্ক্রিয় করা যাবে না আইটেম দেখান মিউট সেটিংস খুলুন @@ -1061,7 +884,6 @@ সংকুচিত প্রসারিত আপ টু ডেট - আপডেট পাওয়া যাচ্ছে %1$s এর পার্ক করা ইনস্টলার বাদ দিয়ে আপনার অ্যাপের তালিকা থেকে সারিটি সরাবেন? ডাউনলোড করা APK মুছে ফেলা হবে। মুলতুবি ইনস্টল বাদ দেবেন? অনুমতি দিন @@ -1147,7 +969,6 @@ নতুন %1$s এ নতুন কী আছে নতুন কী আছে - চেঞ্জলগ ইংরেজিতে। Issues-এর মাধ্যমে অনুবাদ স্বাগত। সংস্করণ %1$s · %2$s @@ -1169,7 +990,6 @@ পছন্দ আপডেট করা যায়নি লুকানো রিপোজিটরি হোম ও সার্চ থেকে লুকানো রিপোজিটরি পরিচালনা করুন। - %1$d লুকানো কোনো লুকানো রিপোজিটরি নেই হোম বা সার্চে রিপোজিটরি কার্ডে দীর্ঘক্ষণ চাপ দিয়ে আবিষ্কার থেকে লুকান। দেখান @@ -1185,11 +1005,277 @@ Windows macOS Linux - অন্য প্ল্যাটফর্ম — ট্রান্সফারের জন্য সংরক্ষণে ব্রাউজারে খুলবে আপনার ডিভাইস ট্রান্সফারের জন্য Android-কে উন্মুক্ত রাখুন Google-এর ডেভেলপার-যাচাইকরণ নীতি উন্মুক্ত Android বিতরণকে হুমকির মুখে ফেলেছে। GitHub Store কোয়ালিশনের পাশে। আরও জানুন ব্যানার বন্ধ করুন + + + + %1$dটি হোস্ট + %1$dটি হোস্ট + + + %1$dটি রিপো + %1$dটি রিপো + + + %1$dটি টোকেন + %1$dটি টোকেন + + + %1$dটি অ্যাপ + %1$dটি অ্যাপ + + এখানে এর বিস্তারিত দেখতে বাঁদিকের যেকোনো কার্ডে আলতো চাপুন। + একটি রিপোজিটরি বেছে নিন + ইনস্টলের জন্য প্রস্তুত + উন্নত সেটিংস + দ্রুত অ্যাকশন + সেটিংস + অবস্থা + বাঁদিক থেকে যেকোনো অ্যাপ নির্বাচন করুন এর বিবরণ ও অ্যাকশন দেখতে। + একটি অ্যাপ বেছে নিন + ইনস্টল করা + সর্বশেষ + রিপোজিটরি খুলুন + ভ্যারিয়েন্ট বেছে নিন + তালিকা লুকান + তালিকা দেখুন + পর্যালোচনা করুন বা একসাথে সব আপডেট করুন। + ১টি আপডেট উপলব্ধ + %1$dটি আপডেট উপলব্ধ + পেছনে + পেছনে + ছবি + সব দেখুন › + Codeberg, gitea.com এবং git.disroot.org ইতিমধ্যেই ডিফল্টভাবে অনুসন্ধান করা হয় — সেগুলো এখানে যোগ করার দরকার নেই। শুধু আপনার নিজস্ব সেলফ-হোস্টেড Forgejo বা Gitea-এর জন্য এই তালিকা ব্যবহার করুন। + বৃত্তান্ত + ডেভেলপার + পরিসংখ্যান + প্রোফাইল দেখুন + নতুন কী আছে + বাদ দিন এবং পরে পর্যালোচনা করুন + আমরা প্রতিটি রিপোর্ট পড়ি। + নিন + #%1$d + আপনার স্টার থেকে + আলোচিত রিলিজ + সবচেয়ে জনপ্রিয় + এখন ট্রেন্ডিং + আবিষ্কার + সব দেখুন + আরও দেখুন + টোকেন যোগ করুন + পেছনে + বাতিল + মুছুন + সংরক্ষণ + টোকেন যোগ করুন + সনাক্ত করা হয়েছে: %1$s + ফোর্জের ঠিকানা + বিদ্যমান টোকেন রাখতে খালি রাখুন। প্রতিস্থাপন করতে নতুন মান টাইপ করুন। + %1$s এর জন্য টোকেন প্রতিস্থাপন করুন + %1$s এর সাথে সংযুক্ত হবে + একটি Personal Access Token যোগ করতে + এ চাপুন + কোনো টোকেন সংরক্ষিত নেই + প্রতিটি ফোর্জ হোস্টের জন্য Personal Access Token (GitHub, Codeberg, Forgejo) + প্রদর্শনের নাম (ঐচ্ছিক) + Personal Access Token + আপনি অ্যাপের মাধ্যমে GitHub-এ সাইন ইন করেছেন — এটাই প্রস্তাবিত পথ। ব্রাউজার সাইন ইন না চললে (সীমাবদ্ধ নেটওয়ার্ক, ব্রাউজার নেই) অথবা GitHub ছাড়া অন্য ফোর্জের জন্যই কেবল এখানে টোকেন যোগ করুন। + টোকেন পেজ খুলুন + অন্য ফোর্জ + Forgejo, Gitea, সেলফ-হোস্টেড + টোকেন পেস্ট করুন + এর জন্য টোকেন যোগ করুন… + লেবেল সম্পাদনা + অবৈধ · %1$s + আরও + %1$s এ পরিচালনা করুন + টোকেন প্রতিস্থাপন + %1$s অবশিষ্ট + বৈধ · %1$s + পরীক্ষা + %1$s এর জন্য টোকেন সংরক্ষিত হয়েছে + প্রমাণীকরণ টোকেন + পূর্বাবস্থায় ফেরান + %1$s এর জন্য টোকেন পুনরুদ্ধার করা যায়নি + %1$s এর জন্য টোকেন সরানো হয়েছে + যাচাই ব্যর্থ হয়েছে: %1$s + অজানা ত্রুটি + অবৈধ হোস্ট + হোস্ট প্রয়োজন + টোকেন প্রয়োজন + টোকেন খুব ছোট + GitHub Store সম্পর্কে + ফিডব্যাক পাঠান… + ওপেন সোর্স লাইসেন্স + সহায়তা + গোপনীয়তা নীতি + অ্যাপ সেটিংস, থিম, নেটওয়ার্ক, অনুবাদ, উন্নত বিকল্প। + কিছু পরিবর্তন প্রয়োগ করতে পুনরায় চালু করা প্রয়োজন। + পরে + ভাষা থিম + প্রভাবিত: %1$s + এখন পুনরায় চালু করুন + ফিল্টার সরান + সম্পন্ন + ফিল্টার + সব রিসেট করুন + ভাষা + প্ল্যাটফর্ম + সাজান + উৎস + ফলাফল ফিল্টার করুন + অ্যাপ + সংযোগ + ইনস্টল ও আপডেট + চেহারা ও অনুভূতি + গোপনীয়তা ও ডেটা + রিপোজিটরি খোলার সময় README এবং রিলিজ নোট স্বয়ংক্রিয়ভাবে অনুবাদ করুন। আপনার অ্যাপের ভাষা লক্ষ্য হিসেবে ব্যবহৃত হবে। + অ্যাপের ভাষা অনুসরণ করুন (%1$s) + এতে অনুবাদ করুন + রিপোজিটরি স্বয়ংক্রিয় অনুবাদ + বাতিল + ভাষা পরিবর্তন + সনাক্তকৃত উৎস: %1$s + পুনরায় চেষ্টা + মূলটি দেখান + অনুবাদ দেখান + এই পৃষ্ঠাটি অন্য ভাষায় রেন্ডার করুন। + মূলটি দেখানো হচ্ছে + %1$s ভাষায় অনুবাদিত + অনুবাদ করা হচ্ছে… + লক্ষ্য ভাষা + অনুবাদ + %1$s ভাষায় অনুবাদ করুন + Auth key + একটি ফ্রি DeepL key নিন → + deepl.com/pro-api তে সাইন আপ করুন। ফ্রি tier key :fx দিয়ে শেষ হয় এবং স্বয়ংক্রিয়ভাবে ফ্রি এন্ডপয়েন্ট ব্যবহার করে। Pro tier আপনার টেক্সট গোপন রাখে; Free tier মডেল উন্নয়নে জমা দেওয়া টেক্সট ব্যবহার করতে পারে। + Key সংরক্ষণ + DeepL key সংরক্ষিত + API key (ঐচ্ছিক) + ইনস্ট্যান্স URL + পাবলিক Disroot মিরর দিয়ে সরাসরি কাজ করে — এটি ব্যবহার করতে ক্ষেত্রগুলি খালি রাখুন। আরও গোপনীয়তার জন্য, আপনার নিজস্ব সেলফ-হোস্টেড URL পেস্ট করুন। ইনস্ট্যান্সের প্রয়োজন হলেই API key লাগবে। + সেটিংস সংরক্ষণ + LibreTranslate সেটিংস সংরক্ষিত + একটি ফ্রি Azure key নিন → + Azure Translator গোপনীয়তা-সম্মানজনক (ডিফল্টভাবে No-Trace — আপনার টেক্সট কখনো সংরক্ষণ বা প্রশিক্ষণে ব্যবহৃত হয় না)। ফ্রি tier মাসে ২০ লক্ষ অক্ষর কভার করে। নন-গ্লোবাল রিসোর্সের জন্যই কেবল Region প্রয়োজন। + সাবস্ক্রিপশন key + Region (যেমন westeurope, Global-এর জন্য খালি রাখুন) + শংসাপত্র সংরক্ষণ + Microsoft Translator শংসাপত্র সংরক্ষিত + DeepL + LibreTranslate + Microsoft + GitHub Store + অ্যাপে ব্যবহৃত লাইব্রেরি। + ওপেন সোর্স লাইসেন্স + github-store.org এ দেখুন। + গোপনীয়তা নীতি + এই অ্যাপের সোর্স দেখুন। + GitHub-এ সোর্স কোড + GitHub, Codeberg এবং Forgejo রিলিজের জন্য ক্রস-প্ল্যাটফর্ম অ্যাপ স্টোর। + পূর্ববর্তী রিলিজ নোট। + নতুন কী আছে + নিচে ওভাররাইড না করা পর্যন্ত সব ট্রাফিকে প্রযোজ্য। + কাস্টম + নিচে একটি সংযোগ মোড বেছে নিন। বেশিরভাগ মানুষ এটিকে No proxy-তে রাখেন। + অ্যাপ কীভাবে ইন্টারনেটে পৌঁছায় + প্রধান সংযোগ + HTTP/HTTPS + বেশিরভাগ কর্পোরেট প্রক্সি। + কোনো প্রক্সি নয় + SOCKS5 + Tor, SSH টানেল। + সিস্টেম + প্রতিটি স্কোপ ডিফল্টভাবে প্রধান সংযোগ ব্যবহার করে। নিজস্ব সেটিংস রাখতে 'কাস্টম' বেছে নিন। + প্রতি-স্কোপ ওভাররাইড + সম্পূর্ণ URL পেস্ট করুন + একটি সম্পূর্ণ প্রক্সি URL পেস্ট করুন, আমরা আপনার জন্য ফর্ম পূরণ করব। + এই URL ব্যবহার করুন + সেই URL পড়া যায়নি। + scheme://user:pass@host:port + প্রক্সি URL পেস্ট করুন + GitHub API, অনুসন্ধান, রিপো বিবরণ। + অনুসন্ধান ও মেটাডেটা + APK ও অ্যাসেট ডাউনলোড। + ডাউনলোড + DeepL, Microsoft, LibreTranslate কল। + অনুবাদ + পরীক্ষা + প্রধানটি ব্যবহার করুন + অ্যাক্সেস টোকেন + অ্যাপের তথ্য + চেহারা + সংযোগ + ফিডব্যাক পাঠান + ইনস্টলের পদ্ধতি + ভাষা + গোপনীয়তা + উৎস + স্টোরেজ + পরিচালনা করতে চাপুন + অনুবাদ + আপডেটের আচরণ + নীরব ইনস্টলার ও ইনস্টলার অ্যাট্রিবিউশন Android-এর ফিচার। ডেস্কটপ ইনস্টল OS-এর প্যাকেজ ম্যানেজার দিয়ে হয়। + ইনস্টলের পদ্ধতি শুধু Android-এর জন্য + ভাষা পরিবর্তন করলে অ্যাপ পুনরায় চালু হবে। + প্রকল্প পৃষ্ঠা খুলতে যেকোনো এন্ট্রিতে চাপুন। + GitHub Store এই লাইব্রেরিগুলির উপর দাঁড়িয়ে। + ওপেন সোর্স লাইসেন্স + ব্রাউজিং ইতিহাস + আপনি কোন রিপো খুলেছেন তা ভুলে যান। + এটি কিছুকে আনস্টার বা আনফেভারিট করবে না। + পরিষ্কার করুন + দেখার ইতিহাস পরিষ্কার করবেন? + দেখার ইতিহাস পরিষ্কার করুন + যখন আপনি একটি github.com বা codeberg.org লিংক কপি করবেন, আমরা সেটি খুলতে প্রম্পট করব। + ক্লিপবোর্ডে রিপো লিংক সনাক্ত করুন + যেসব রিপো আপনি ফিড ও সার্চ থেকে মিউট করেছেন। + লুকানো রিপোজিটরি + ফিড ও সার্চে দেখা রিপোগুলি বাদ দিন। + যে রিপো ইতিমধ্যে দেখেছি সেগুলি লুকান কোনো শনাক্তকারী নেই। OS এবং প্ল্যাটফর্ম। '%1$s' এর সাথে কোনো সেটিংস মেলে না। + সেটিংস অনুসন্ধান করুন + একটি Forgejo বা Gitea হোস্ট যোগ করুন + যোগ করা হোস্ট + GitHub মিরর + অ্যাপ ডিফল্টভাবে GitHub-এ অনুসন্ধান করে। আঞ্চলিক মিরর দিয়ে রুট করুন বা কাস্টম Forgejo / Gitea হোস্ট যোগ করুন। + অ্যাপ কোথায় রিপোজিটরি খোঁজে + ডিফল্ট (github.com) + আপডেট দ্রুত আবার শুরু করতে আমরা ইনস্টলারগুলো রেখে দিই। + পরিষ্কার করুন + ডাউনলোড করা APK + ০ B + ব্যবহার: %1$s + এই স্ক্রিনটি চলমান নতুন ডিজাইনের অংশ। সেটিংসগুলো এখনো কাজ করে — শুধু পুরোনো লেআউট থেকে এখানে সরানো হচ্ছে। + পরবর্তী আপডেটে আসছে + যোগাযোগ + কমিউনিটিতে যোগ দিন + ৬টি স্থান + ব্যবসায়িক অনুসন্ধান + অংশীদারি, প্রেস, ইন্টিগ্রেশন। + যোগাযোগ + লাইব্রেরি + আপডেট + অ্যাকাউন্ট + মোড + ডিসপ্লে + ডাইনামিক + আরও সাইন-ইন বিকল্প + বিকল্প লুকান + ডিসকভারি প্ল্যাটফর্ম + হোম ফিডকে নির্দিষ্ট প্ল্যাটফর্মে ফিল্টার করুন। সব বন্ধ রাখলে সব দেখাবে। + সম্পর্কে + সংস্করণ, কমিউনিটি, আইনি + এখানে ইনস্টলযোগ্য + এই প্ল্যাটফর্মের জন্য ইনস্টলযোগ্য অ্যাসেট সহ কোনো রিপো নেই + প্রতি %1$d ঘণ্টায় + প্রতিদিন + প্রতি %1$d দিনে + সাপ্তাহিক + প্রতি 2 সপ্তাহে + প্রতি 30 দিনে diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index b0c330a70..57df04d24 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -1,14 +1,12 @@ GitHub Store - Aplicaciones instaladas Volver Buscar actualizaciones No se puede iniciar %1$s No se pudo abrir %1$s Error al actualizar %1$s: %2$s - Error en la actualización Error al actualizar todo: %1$s Todas las aplicaciones se actualizaron correctamente No hay actualizaciones disponibles @@ -48,12 +46,9 @@ Iniciar sesión con GitHub - Cancelado Error desconocido - Idioma: - Descubrir repositorios Buscar repositorio, descripción… Filtrar por lenguaje @@ -98,50 +93,34 @@ Perfil - APARIENCIA - IDIOMA - Reemplaza el idioma de la interfaz. - Idioma de la aplicación - Cambia los menús, botones y mensajes en toda la aplicación. No cambia el contenido que viene de GitHub. Seguir el sistema Reinicia para aplicar el nuevo idioma. Reiniciar - ACERCA DE - RED Color del tema Tema negro AMOLED Fondo negro puro para modo oscuro - Color seleccionado: %1$s - Versión - Ayuda y soporte Cerrar sesión Sesión cerrada correctamente, redirigiendo… - Caché borrada con éxito ¡Advertencia! ¿Estás seguro de que deseas cerrar sesión? - Dinámico - Océano - Púrpura Bosque - Pizarra - Ámbar + Nord + Cream + Plum - Error al cargar detalles Acerca de esta app Registros de instalación - Autor Novedades Instalado Actualización disponible Instalar última versión - Reinstalar Descargando Actualizando @@ -160,12 +139,9 @@ No hay más resultados en GitHub Error al buscar en GitHub. Inténtalo de nuevo. - por %1$s - • Instalado: %1$s Arquitectura compatible Actualizar a %1$s - No se pudieron cargar los detalles El instalador se guardó en Descargas Descarga iniciada @@ -189,30 +165,13 @@ Usar una aplicación de terceros para instalar el APK Error: %1$s - Tipo de archivo .%1$s no compatible - Archivo descargado no encontrado - Tendencias - Lanzamiento en caliente - Los más populares - Privacidad - Multimedia - Productividad - Red - Herramientas de desarrollo Buscando repositorios... - Cargando... - No hay más repositorios Reintentar No se pudieron cargar los repositorios - Ver detalles - actualizado ahora mismo - actualizado hace %1$d h - actualizado ayer - actualizado hace %1$d días actualizado el %1$s Límite de solicitudes excedido @@ -261,8 +220,6 @@ Cerrar No se pudieron sincronizar los repositorios destacados - Perfil del desarrollador - Abrir perfil de desarrollador Error al cargar repositorios @@ -276,7 +233,6 @@ Buscar repositorios… Borrar búsqueda - Todos Con lanzamientos Instalados Favoritos @@ -288,7 +244,6 @@ repositorio - repositorios Mostrando %1$d de %2$d repositorios @@ -296,8 +251,6 @@ No hay repositorios instalados No hay repositorios favoritos - - Actualizado hace %1$s Tiene lanzamiento @@ -319,7 +272,7 @@ Inicio Buscar - Aplicaciones + Biblioteca Perfil Bifurcar @@ -345,7 +298,6 @@ No disponible - Actualizar app Instalación pendiente @@ -369,35 +321,21 @@ Última comprobación: %1$s - Nunca comprobado justo ahora hace %1$d min hace %1$d h Comprobando actualizaciones… - - Tipo de proxy - Ninguno - Sistema - HTTP - SOCKS Host Puerto Nombre de usuario (opcional) Contraseña (opcional) Guardar proxy Configuración de proxy guardada - Usa la configuración de proxy del dispositivo - El puerto debe ser 1–65535 - Conexión directa, sin proxy No se pudieron guardar los ajustes del proxy Se requiere el host del proxy Introduce un nombre de host o dirección IP válida Puerto de proxy no válido - Mostrar contraseña - Ocultar contraseña - Probar - Probando… Conexión correcta (%1$d ms) No se pudo resolver el host. Verifica la dirección del proxy. No se pudo conectar con el servidor proxy. @@ -405,20 +343,8 @@ Se requiere autenticación del proxy. Respuesta inesperada: HTTP %1$d La prueba de conexión falló - Cada categoría puede usar su propio proxy. Configúralos de forma independiente. - Descubrimiento (API de GitHub) - Inicio, búsqueda, detalles del repo y comprobación de actualizaciones - Descargas - Descargas de APK y actualizaciones automáticas - Traducción - Servicio de traducción de README - - Rastrear esta app - App añadida a la lista de seguimiento - Error al rastrear la app: %1$s - La app ya está siendo rastreada Iniciar sesión en GitHub @@ -435,7 +361,6 @@ Iniciar sesión de nuevo Continuar como invitado Esto borrará tu sesión local y los datos en caché. Para revocar el acceso completamente, visita GitHub Settings > Applications. - El código expira en %1$s El código del dispositivo ha expirado. Intenta iniciar sesión de nuevo para obtener un nuevo código. Revisa tu conexión a internet e intenta de nuevo. @@ -452,66 +377,28 @@ No se pudo compartir el enlace Enlace copiado al portapapeles - - Traducir - Traduciendo… - Mostrar original - Traducido a %1$s Traducir a… Buscar idioma - Cambiar idioma Error de traducción. Inténtalo de nuevo. Abrir enlace de GitHub Enlace de GitHub detectado en el portapapeles - Detectar enlaces del portapapeles - Detectar automáticamente enlaces de GitHub del portapapeles al abrir la búsqueda Enlaces detectados Abrir en la app No se encontró enlace de GitHub en el portapapeles - Almacenamiento - Borrar caché - Tamaño actual: Borrar - Apoya GitHub Store - Apoyar el proyecto - Gratis para siempre.\nFinanciado por ti. - Tu apoyo mantiene GitHub Store gratuito, independiente y activamente mantenido — sin anuncios, sin rastreo, solo una mejor forma de instalar apps de código abierto. - Desarrollo y mantengo este proyecto solo mientras termino mis estudios. Cada contribución — grande o pequeña — va directamente a costos de servidor, desarrollo y mantener la app sin anuncios para todos. - ¡Vota por GitHub Store! - GitHub Store está nominado a los Golden Kodee Awards en KotlinConf 2026. Tu voto toma solo 2 minutos y significa mucho. - 1. Registrarse - 2. Votar - La votación cierra el 22 de marzo - 1. Regístrate en la plataforma de premios (Continuar con Google) - 2. Toca Votar abajo para abrir la página - 3. Busca a Usmon Narzullayev y pulsa Votar - GitHub Sponsors - Mensual o puntual — financia directamente el desarrollo - Buy Me a Coffee - Contribución rápida de una sola vez - OTRAS FORMAS DE AYUDAR - Dar estrella al repositorio - Aumenta la visibilidad y ayuda a otros a encontrarlo - Reportar errores - Cada reporte mejora la app para todos - Compartir con amigos - El boca a boca es el mejor marketing - Cada contribución — financiera o no — marca una diferencia real. Gracias por ser parte de esto. - - Instalación Predeterminado Diálogo de instalación estándar del sistema Shizuku @@ -528,16 +415,10 @@ Actualizar apps automáticamente Descargar e instalar actualizaciones en segundo plano a través de Shizuku - Actualizaciones Intervalo de verificación Con qué frecuencia buscar actualizaciones en segundo plano Comprobación de actualizaciones en segundo plano Busca actualizaciones periódicamente en segundo plano. Desactívalo para ahorrar batería — puedes comprobar manualmente desde la pantalla de detalles de cualquier app. - 3h - 6h - 12h - 24h - Permitir actualizaciones en segundo plano Tu dispositivo cierra agresivamente las tareas en segundo plano. Excluye GitHub Store de la optimización de batería para que las comprobaciones programadas y las instalaciones silenciosas funcionen de forma fiable. Abrir ajustes @@ -552,18 +433,13 @@ Validando… Vincular y seguir Comprobando último lanzamiento… - Descargando APK para verificación… Verificando clave de firma… Nombre de paquete no coincide: el APK es %1$s, pero la app seleccionada es %2$s Clave de firma no coincide: el APK de este repositorio fue firmado por un desarrollador diferente Seleccionar instalador Elige el APK para verificar contra tu app instalada - Error en la descarga Exportar Importar - Importar apps - Pega el JSON exportado para restaurar tus apps rastreadas - Pega el JSON exportado aquí… Canal beta predeterminado Las apps recién seguidas incluyen compilaciones beta por defecto. Las ya seguidas conservan su propio canal (cámbialo en la pantalla de Detalles de la app). ¿Desinstalar app? @@ -576,9 +452,6 @@ %1$s vinculada a %2$s/%3$s Error en la exportación: %1$s Error en la importación: %1$s - %1$d apps importadas - , %1$d omitidas - , %1$d fallidas Clave de firma cambiada El certificado de firma de esta app ha cambiado desde su primera instalación.\n\nEsto podría significar que el desarrollador rotó su clave de firma, o el binario pudo haber sido manipulado.\n\nEsperado: %1$s\nRecibido: %2$s Instalar de todos modos @@ -591,8 +464,6 @@ Múltiples recursos disponibles Hay varios archivos instalables disponibles para este lanzamiento. Revisa la lista y selecciona el que se ajuste a tu dispositivo. Información - Reintentar - Detectado automáticamente: %1$s Seleccionar idioma Paquete no coincide: el APK es %1$s, pero la aplicación instalada es %2$s. Actualización bloqueada. Clave de firma no coincide: la actualización fue firmada por un desarrollador diferente. Actualización bloqueada. @@ -605,27 +476,13 @@ Ancho Extra ancho - Ocultar repositorios vistos - Ocultar repositorios que ya has visto de las fuentes de descubrimiento - Borrar historial de vistos - Restablecer todos los repositorios vistos para que aparezcan de nuevo en las fuentes Historial de vistos borrado Visto Eres dueño de este repo Ocultar repositorio - Repositorio ocultado - Deshacer - Privacidad - Ayudar a mejorar la búsqueda - Compartir datos de uso (búsquedas, instalaciones, interacciones) asociados a un ID de análisis restablecible. No se comparten detalles de la cuenta. - Restablecer ID de análisis - Generar un nuevo ID anónimo, cortando el vínculo con la telemetría anterior. - ID de análisis restablecido Vistos recientemente Repositorios que has visitado - Paquetes descargados - APKs e instaladores desde lanzamientos de GitHub Eliminar todo ¿Eliminar todas las descargas? Esto eliminará permanentemente todos los APKs e instaladores (%1$s). Puedes volver a descargarlos en cualquier momento. @@ -636,9 +493,7 @@ Borrar todo Eliminar - Ajustes Ajustes - Pre-lanzamientos Filtro de assets @@ -698,8 +553,6 @@ Listo para instalar Instalar (listo) - - Traducción Elige el servicio usado para traducir los README. Servicio de traducción Google funciona globalmente sin configuración. Youdao funciona desde China continental, pero requiere credenciales API del portal de desarrolladores de Youdao. @@ -742,8 +595,6 @@ ghp_… o github_pat_… Iniciar sesión Cancelar - Mostrar token - Ocultar token Pega un token. Esto no parece un token de GitHub. Los tokens empiezan con ghp_ o github_pat_. Ese token no es válido o ha sido revocado. @@ -764,13 +615,11 @@ Elige tu canal de lanzamiento Toca para alternar entre lanzamientos estables y compilaciones beta de esta app. Entendido - Alternar versiones beta de esta aplicación Cambiar a %1$s estable Sin versión estable en %1$d meses Sin versión estable en %1$d días Hay versiones previas activas, pero el proyecto no ha lanzado una versión estable en un tiempo. Las betas podrían no derivar en una versión estable. Novedades desde %1$s - — %1$s — Desvincular de este repositorio - Más opciones ¿Desvincular esta aplicación? Dejaremos de rastrear %1$s como instalada desde este repositorio. La aplicación permanece en tu dispositivo — solo se elimina el vínculo. Desvincular @@ -884,7 +731,6 @@ - Enviar comentarios Enviar comentarios Cerrar Categoría @@ -922,15 +768,12 @@ ═══════════════════════════════════════════════════════════════ --> Forjas personalizadas - Añade hosts Forgejo / Gitea que quieras que GHS reconozca - %1$d añadidas Forjas personalizadas Introduce el hostname de una instancia Forgejo o Gitea (por ejemplo git.example.com). GHS aceptará URLs de estos hosts en el formulario de vinculación manual. Añadir Aún no hay forjas personalizadas. Hecho - Espejo de descarga Espejo de descarga Se usa para descargar archivos de versiones. Las llamadas a la API de GitHub siempre van directas. La mayoría de los usuarios debería dejarlo en GitHub directo. Oficial @@ -946,7 +789,6 @@ La plantilla debe contener {url} exactamente una vez Guardar Probar seleccionado - Probando… Alcanzado en %1$dms El espejo devolvió %1$d Tiempo de espera agotado tras 5s @@ -954,12 +796,6 @@ Error: %1$s ¿Todos los espejos caídos? Puedes alojar el tuyo en 5 minutos — consulta la documentación. %1$s ya no está disponible; cambiado a GitHub directo. - Error de suma de verificación — el archivo puede haber sido manipulado - ¿Probar un espejo más rápido? - Algunos usuarios en redes lentas tienen mejor suerte con un proxy de la comunidad. - Elegir uno - Quizás más tarde - No preguntar de nuevo Añadir desde destacados @@ -970,13 +806,7 @@ Seguridad Estado Encuestas - Cerrar tras confirmar - %1$d d Nada que compartir ahora mismo. - %1$d h - ahora mismo - Actualizado hace %1$s - %1$d min No se puede desactivar Mostrar elementos de Abrir ajustes de silencio @@ -1033,7 +863,6 @@ Contraído Expandido Al día - Actualizaciones disponibles ¿Descartar el instalador aparcado de %1$s y quitar la fila de tu lista de apps? El APK descargado se eliminará. ¿Descartar instalación pendiente? Conceder permiso @@ -1119,7 +948,6 @@ Nuevo Novedades en %1$s Novedades - El changelog está en inglés. Las traducciones son bienvenidas vía Issues. Versión %1$s · %2$s @@ -1141,7 +969,6 @@ No se pudo actualizar la preferencia Repositorios ocultos Gestiona los repositorios que ocultaste de Inicio y Búsqueda. - %1$d oculto(s) Sin repositorios ocultos Mantén pulsada una tarjeta de repositorio en Inicio o Búsqueda para ocultarla. Mostrar @@ -1157,11 +984,277 @@ Windows macOS Linux - Otra plataforma — se abre en el navegador para guardar y transferir Tu dispositivo Para transferir Mantén Android abierto La política de verificación de desarrolladores de Google amenaza la distribución abierta de Android. GitHub Store apoya a la coalición. Saber más Cerrar banner + + + Toca cualquier tarjeta de la izquierda para ver sus detalles aquí. + Elige un repositorio + Listas para instalar + Ajustes avanzados + Acciones rápidas + Ajustes + Estado + Selecciona una app a la izquierda para ver sus detalles y acciones. + Elige una app + Instalada + Última + Abrir repositorio + Elegir variante + Ocultar lista + Ver lista + Revisa o actualiza todo a la vez. + 1 actualización disponible + %1$d actualizaciones disponibles + Atrás + Atrás + Imagen + Ver todo › + + %1$d host + %1$d hosts + + Codeberg, gitea.com y git.disroot.org ya se buscan por defecto — no hace falta añadirlos aquí. Usa esta lista solo para tu propio Forgejo o Gitea autohospedado. + Acerca de + Desarrollador + Estadísticas + Ver perfil + Novedades + Omitir y revisar después + Leemos cada reporte. + Obtener + #%1$d + De tus favoritos + Versiones del momento + Más populares + Tendencias + Descubrir + Ver todo + Ver más + Añadir token + Atrás + Cancelar + Eliminar + Guardar + Añadir token + Detectado: %1$s + Dirección del forge + Déjalo en blanco para conservar el token actual. Escribe un valor nuevo para reemplazarlo. + Reemplazar token de %1$s + Se conectará a %1$s + + %1$d token + %1$d tokens + + Toca + para añadir un token de acceso personal + No hay tokens guardados + Tokens de acceso personal por host de forge (GitHub, Codeberg, Forgejo) + Nombre visible (opcional) + Token de acceso personal + Ya iniciaste sesión en GitHub desde la app — esa es la ruta recomendada. Añade un token aquí solo si el inicio de sesión por navegador no funciona (red restrictiva, sin navegador) o para forges distintos de GitHub. + Abrir página del token + Otro forge + Forgejo, Gitea, autohospedado + Pegar token + Añadir token para… + Editar etiqueta + Inválido · %1$s + Más + Gestionar en %1$s + Reemplazar token + %1$s restantes + Válido · %1$s + Verificar + Token guardado para %1$s + Tokens de autenticación + Deshacer + No se pudo restaurar el token de %1$s + Token de %1$s eliminado + Validación fallida: %1$s + error desconocido + Host inválido + Host requerido + Token requerido + Token demasiado corto + Acerca de GitHub Store + Enviar comentarios… + Licencias de código abierto + Ayuda + Política de privacidad + Ajustes de la app, tema, red, traducción, opciones avanzadas. + Algunos cambios necesitan reiniciar para aplicarse. + Más tarde + idioma tema + Afectado: %1$s + Reiniciar ahora + Quitar filtro + Listo + Filtros + Restablecer todo + Idioma + Plataforma + Ordenar por + Origen + Filtrar resultados + Aplicación + Conectividad + Instalaciones y actualizaciones + Apariencia + Privacidad y datos + + %1$d app + %1$d apps + + Traduce el README y las notas de versión automáticamente al abrir un repositorio. Usa el idioma de la app como destino. + Sigue el idioma de la app (%1$s) + Traducir a + Traducir repositorios automáticamente + Cancelar + Cambiar idioma + Origen detectado: %1$s + Reintentar + Ver original + Ver traducción + Muestra esta página en otro idioma. + Mostrando el original + Traducido al %1$s + Traduciendo… + Idioma de destino + Traducir + Traducir al %1$s + Clave de autenticación + Obtener una clave gratuita de DeepL → + Regístrate en deepl.com/pro-api. Las claves del plan gratuito terminan en :fx y usan el endpoint gratuito automáticamente. El plan Pro mantiene tu texto privado; el plan gratuito puede usar el texto enviado para mejorar los modelos. + Guardar clave + Clave de DeepL guardada + Clave de API (opcional) + URL de la instancia + Funciona sin configuración mediante el mirror público de Disroot — deja los campos vacíos para usarlo. Para mayor privacidad, pega la URL de tu propia instancia autohospedada. La clave de API solo es necesaria si la instancia la requiere. + Guardar ajustes + Ajustes de LibreTranslate guardados + Obtener una clave gratuita de Azure → + Azure Translator respeta tu privacidad (No-Trace por defecto — tu texto nunca se almacena ni se usa para entrenamiento). El plan gratuito cubre 2 M de caracteres al mes. La región solo es necesaria para recursos no globales. + Clave de suscripción + Región (p. ej. westeurope, déjalo vacío para Global) + Guardar credenciales + Credenciales de Microsoft Translator guardadas + DeepL + LibreTranslate + Microsoft + GitHub Store + Bibliotecas usadas en la app. + Licencias de código abierto + Ver en github-store.org. + Política de privacidad + Ver el código fuente de esta app. + Código fuente en GitHub + Tienda de apps multiplataforma para versiones de GitHub, Codeberg y Forgejo. + Notas de versiones anteriores. + Novedades + Se aplica a todo el tráfico salvo que se sustituya abajo. + Personalizado + Elige un modo de conexión abajo. La mayoría lo deja en Sin proxy. + Cómo accede la app a internet + Conexión principal + HTTP/HTTPS + La mayoría de proxies corporativos. + Sin proxy + SOCKS5 + Tor, túneles SSH. + Sistema + Cada ámbito usa la conexión principal por defecto. Elige 'Personalizado' para mantener sus propios ajustes. + Sustituciones por ámbito + Pegar URL completa + Pega una URL de proxy completa y rellenaremos el formulario por ti. + Usar esta URL + No se pudo leer esa URL. + esquema://usuario:contraseña@host:puerto + Pegar URL del proxy + API de GitHub, búsqueda, detalles de repo. + Búsqueda y metadatos + Descargas de APK y otros assets. + Descargas + Llamadas a DeepL, Microsoft, LibreTranslate. + Traducción + Probar + Usar principal + Tokens de acceso + Información de la app + Apariencia + Conexión + Enviar comentarios + Método de instalación + Idioma + Privacidad + Fuentes + Almacenamiento + Toca para gestionar + Traducción + Comportamiento de actualizaciones + Los instaladores silenciosos y la atribución de instalador son funciones de Android. Las instalaciones en escritorio pasan por el gestor de paquetes del sistema operativo. + El método de instalación es solo para Android + La app se reinicia al cambiar de idioma. + Toca cualquier entrada para abrir su página del proyecto. + GitHub Store se apoya en estas bibliotecas. + Licencias de código abierto + Historial de navegación + Olvidar qué repos ya has abierto. + Esto no quitará favoritos ni estrellas a nada. + Borrar + ¿Borrar el historial de vistos? + Borrar historial de vistos + Cuando copies un enlace de github.com o codeberg.org, te preguntaremos si quieres abrirlo. + Detectar enlaces de repo en el portapapeles + Repos que has silenciado en los feeds y la búsqueda. + Repositorios ocultos + Omite los repos vistos en feeds y búsqueda. + Ocultar repos que ya he visto Sin identificadores. Sistema operativo y plataforma. Ningún ajuste coincide con '%1$s'. + Buscar ajustes + Añadir un host de Forgejo o Gitea + Hosts añadidos + Mirror de GitHub + La app busca en GitHub por defecto. Enruta por un mirror regional o añade hosts personalizados de Forgejo / Gitea. + Dónde busca la app los repositorios + Predeterminado (github.com) + Conservamos los instaladores para que las actualizaciones se reanuden rápido. + Borrar + APK descargados + 0 B + En uso: %1$s + Esta pantalla forma parte del rediseño en curso. Los ajustes siguen funcionando — solo los estamos moviendo aquí desde el diseño anterior. + Llegará en una próxima versión + + %1$d repo oculto + %1$d repos ocultos + + Conectar + Únete a la comunidad + 6 lugares + Consultas comerciales + Alianzas, prensa, integraciones. + Contactar + Biblioteca + Actualizaciones + Cuenta + Modo + Pantalla + Dinámico + Más opciones de inicio de sesión + Ocultar opciones + Plataformas de descubrimiento + Filtra el feed principal por plataforma. Déjalas todas desactivadas para verlo todo. + Acerca de + Versión, comunidad, legal + Instalable aquí + Sin repositorios con assets instalables para esta plataforma + Cada %1$d horas + Diariamente + Cada %1$d días + Semanalmente + Cada 2 semanas + Cada 30 días diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index d3f8a6402..58b19cd00 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -1,14 +1,12 @@ GitHub Store - Applications installées Retour Vérifier les mises à jour Impossible de lancer %1$s Impossible d’ouvrir %1$s Échec de la mise à jour de %1$s : %2$s - Échec de la mise à jour Échec de la mise à jour globale : %1$s Toutes les applications ont été mises à jour Aucune mise à jour disponible @@ -48,12 +46,9 @@ Se connecter avec GitHub - Annulé Erreur inconnue - Langue : - Découvrir des dépôts Rechercher dépôt, description… Filtrer par langage @@ -98,50 +93,34 @@ Profil - APPARENCE - LANGUE - Remplace la langue de l'interface. - Langue de l'application - Modifie les menus, boutons et messages dans toute l'application. Ne modifie pas le contenu provenant de GitHub. Suivre le système Redémarrez pour appliquer la nouvelle langue. Redémarrer - À PROPOS - RÉSEAU Couleur du thème Thème noir AMOLED Fond noir pur pour le mode sombre - Couleur sélectionnée : %1$s - Version - Aide et support Se déconnecter Déconnexion réussie, redirection… - Cache vidé avec succès Attention ! Voulez-vous vraiment vous déconnecter ? - Dynamique - Océan - Violet Forêt - Ardoise - Ambre + Nord + Cream + Plum - Erreur de chargement À propos de cette application Journaux d’installation - Auteur Nouveautés Installé Mise à jour disponible Installer la dernière version - Réinstaller Téléchargement Mise à jour @@ -160,12 +139,9 @@ Plus de résultats sur GitHub Échec de la recherche GitHub. Réessayez. - par %1$s - • Installé : %1$s Architecture compatible Mettre à jour vers %1$s - Impossible de charger les détails L’installateur a été enregistré dans Téléchargements Téléchargement démarré @@ -189,30 +165,13 @@ Utiliser une application tierce pour installer l'APK Erreur : %1$s - Type de fichier .%1$s non pris en charge - Fichier téléchargé introuvable - Tendances - Sortie en salles - Les plus populaires - Confidentialité - Médias - Productivité - Réseau - Outils de développement Recherche de dépôts... - Chargement... - Plus aucun dépôt Réessayer Impossible de charger les dépôts - Voir les détails - mis à jour à l’instant - mis à jour il y a %1$d h - mis à jour hier - mis à jour il y a %1$d j mis à jour le %1$s Limite de requêtes dépassée @@ -261,8 +220,6 @@ Fermer Échec de la synchronisation des dépôts favoris - Profil du développeur - Ouvrir le profil du développeur Échec du chargement des dépôts @@ -276,7 +233,6 @@ Rechercher des dépôts… Effacer la recherche - Tous Avec versions Installés Favoris @@ -288,7 +244,6 @@ dépôt - dépôts Affichage de %1$d sur %2$d dépôts @@ -297,8 +252,6 @@ Aucun dépôt favori Signaler un problème - - Mis à jour %1$s A une version @@ -320,7 +273,7 @@ Accueil Rechercher - Applications + Bibliothèque Profil Fork @@ -345,7 +298,6 @@ Non disponible - Mettre à jour Installation en attente @@ -369,35 +321,21 @@ Dernière vérification : %1$s - Jamais vérifié à l'instant il y a %1$d min il y a %1$d h Vérification des mises à jour… - - Type de proxy - Aucun - Système - HTTP - SOCKS Hôte Port Nom d'utilisateur (facultatif) Mot de passe (facultatif) Sauvegarder le Proxy Paramètres du proxy enregistrés - Utilise les paramètres proxy de l'appareil - Le port doit être entre 1 et 65535 - Connexion directe, pas de proxy Échec de l'enregistrement des paramètres du proxy L’hôte du proxy est requis Saisissez un nom d’hôte ou une adresse IP valide Port proxy invalide - Afficher le mot de passe - Masquer le mot de passe - Tester - Test en cours… Connexion OK (%1$d ms) Impossible de résoudre l'hôte. Vérifiez l'adresse du proxy. Impossible de joindre le serveur proxy. @@ -405,20 +343,8 @@ Authentification proxy requise. Réponse inattendue : HTTP %1$d Échec du test de connexion - Chaque catégorie peut utiliser son propre proxy. Configurez-les indépendamment. - Découverte (API GitHub) - Accueil, recherche, détails du dépôt et vérifications de mise à jour - Téléchargements - Téléchargements APK et mises à jour automatiques - Traduction - Service de traduction du README - - Suivre cette app - App ajoutée à la liste de suivi - Échec du suivi de l'app : %1$s - L'app est déjà suivie Se connecter à GitHub @@ -435,7 +361,6 @@ Se reconnecter Continuer en tant qu'invité Cela effacera votre session locale et les données en cache. Pour révoquer complètement l'accès, visitez GitHub Settings > Applications. - Le code expire dans %1$s Le code de l'appareil a expiré. Veuillez réessayer de vous connecter pour obtenir un nouveau code. Vérifiez votre connexion internet et réessayez. @@ -452,67 +377,29 @@ Échec du partage du lien Lien copié dans le presse-papiers - - Traduire - Traduction… - Afficher l'original - Traduit en %1$s Traduire en… Rechercher une langue - Changer de langue Échec de la traduction. Veuillez réessayer. Ouvrir le lien GitHub Lien GitHub détecté dans le presse-papiers - Détecter les liens du presse-papiers - Détecter automatiquement les liens GitHub du presse-papiers lors de l'ouverture de la recherche Liens détectés Ouvrir dans l'app Aucun lien GitHub trouvé dans le presse-papiers - Stockage - Vider le cache - Taille actuelle : Vider - Soutenir GitHub Store - Soutenir le projet - Gratuit pour toujours.\nFinancé par vous. - Votre soutien permet de garder GitHub Store gratuit, indépendant et activement maintenu — sans publicité, sans suivi, juste une meilleure façon d’installer des apps open-source. - Je développe et maintiens ce projet seul tout en finissant mes études. Chaque contribution — grande ou petite — va directement aux coûts de serveur, au développement et au maintien de l’app sans publicité pour tous. - Votez pour GitHub Store ! - GitHub Store est nommé aux Golden Kodee Awards de KotlinConf 2026. Votre vote prend seulement 2 minutes. - 1. S’inscrire - 2. Voter - Vote jusqu’au 22 mars - 1. Inscrivez-vous sur la plateforme (Continuer avec Google) - 2. Appuyez sur Voter ci-dessous - 3. Trouvez Usmon Narzullayev et cliquez sur Voter - GitHub Sponsors - Mensuel ou ponctuel — finance directement le développement - Buy Me a Coffee - Contribution rapide en une fois - AUTRES FAÇONS D’AIDER - Mettre une étoile au dépôt - Augmente la visibilité et aide les autres à le trouver - Signaler des bugs - Chaque signalement améliore l’app pour tous - Partager avec des amis - Le bouche-à-oreille est le meilleur marketing - Chaque contribution — financière ou non — fait une vraie différence. Merci d'en faire partie. - - Installation Par défaut Boîte de dialogue d'installation système standard Shizuku @@ -529,16 +416,10 @@ Mise à jour automatique Télécharger et installer automatiquement les mises à jour en arrière-plan via Shizuku - Mises à jour Intervalle de vérification Fréquence de vérification des mises à jour en arrière-plan Vérification des mises à jour en arrière-plan Vérifie périodiquement les mises à jour en arrière-plan. Désactivez pour économiser la batterie — vous pouvez vérifier manuellement depuis la fiche détails de n'importe quelle app. - 3h - 6h - 12h - 24h - Autoriser les mises à jour en arrière-plan Votre appareil interrompt agressivement les tâches d'arrière-plan. Excluez GitHub Store de l'optimisation de la batterie pour que les vérifications planifiées et les installations silencieuses fonctionnent de manière fiable. Ouvrir les paramètres @@ -553,18 +434,13 @@ Validation… Lier et suivre Vérification de la dernière version… - Téléchargement de l'APK pour vérification… Vérification de la clé de signature… Nom de paquet différent : l'APK est %1$s, mais l'app sélectionnée est %2$s Clé de signature différente : l'APK de ce dépôt a été signé par un autre développeur Sélectionner l'installateur Choisissez l'APK à vérifier avec votre app installée - Échec du téléchargement Exporter Importer - Importer des apps - Collez le JSON exporté pour restaurer vos apps suivies - Collez le JSON exporté ici… Canal bêta par défaut Les apps nouvellement suivies incluent les versions bêta par défaut. Les apps déjà suivies conservent leur propre paramètre (modifiable dans Détails de l'app). Désinstaller l'app ? @@ -577,9 +453,6 @@ %1$s liée à %2$s/%3$s Échec de l'exportation : %1$s Échec de l'importation : %1$s - %1$d apps importées - , %1$d ignorées - , %1$d échouées Clé de signature modifiée Le certificat de signature de cette app a changé depuis sa première installation.\n\nCela peut signifier que le développeur a changé sa clé de signature, ou que le binaire a été altéré.\n\nAttendu : %1$s\nReçu : %2$s Installer quand même @@ -592,8 +465,6 @@ Plusieurs ressources disponibles Plusieurs fichiers installables sont disponibles pour cette version. Veuillez examiner la liste et sélectionner celui qui correspond à votre appareil. Informations - Réessayer - Détection automatique : %1$s Sélectionner la langue Incompatibilité de paquet : l'APK est %1$s, mais l'application installée est %2$s. Mise à jour bloquée. Incompatibilité de clé de signature : la mise à jour a été signée par un développeur différent. Mise à jour bloquée. @@ -601,32 +472,18 @@ Barre de défilement Afficher la barre de défilement dans les listes défilantes (bureau) Largeur du contenu - Largeur maximale de la colonne sur l\'écran de détails (bureau) + Largeur maximale de la colonne sur l'écran de détails (bureau) Compact Large Très large - Masquer les dépôts consultés - Masquer les dépôts que vous avez déjà consultés des flux de découverte - Effacer l'historique des consultations - Réinitialiser tous les dépôts consultés pour qu'ils réapparaissent dans les flux Historique des consultations effacé Consulté Vous possédez ce dépôt Masquer le dépôt - Dépôt masqué - Annuler - Confidentialité - Aider à améliorer la recherche - Partager des données d'utilisation (recherches, installations, interactions) associées à un identifiant d'analyse réinitialisable. Aucun détail de compte n'est partagé. - Réinitialiser l'ID d'analytique - Générer un nouvel ID anonyme, coupant le lien avec la télémétrie passée. - ID d'analytique réinitialisé Récemment consultés Dépôts que vous avez visités - Paquets téléchargés - APK et installateurs depuis les versions GitHub Tout supprimer Supprimer tous les téléchargements ? Cela supprimera définitivement tous les APK et installateurs (%1$s). Vous pouvez les télécharger à nouveau à tout moment. @@ -637,9 +494,7 @@ Tout effacer Supprimer - Réglages Réglages - Pré-versions Filtre d'assets @@ -699,8 +554,6 @@ Prêt à installer Installer (prêt) - - Traduction Choisissez le service utilisé pour traduire les README. Service de traduction Google fonctionne partout sans configuration. Youdao fonctionne depuis la Chine continentale mais nécessite des identifiants API du portail développeur de Youdao. @@ -743,8 +596,6 @@ ghp_… ou github_pat_… Se connecter Annuler - Afficher le token - Masquer le token Collez un token. Cela ne ressemble pas à un token GitHub. Les tokens commencent par ghp_ ou github_pat_. Ce token est invalide ou a été révoqué. @@ -765,13 +616,11 @@ Choisissez votre canal de version Touchez pour basculer entre les versions stables et les bêtas pour cette app. Compris - Activer/désactiver les versions bêta de cette application Passer à %1$s stable Aucune version stable depuis %1$d mois Aucune version stable depuis %1$d jour(s) Des préversions actives existent, mais le projet n'a pas publié de version stable depuis un moment. Les bêtas pourraient ne pas aboutir à une version stable. Nouveautés depuis %1$s - — %1$s — Délier de ce dépôt - Plus d'options Délier cette application ? Nous cesserons de suivre %1$s comme installée depuis ce dépôt. L'application reste sur votre appareil — seul le lien est supprimé. Délier @@ -885,7 +732,6 @@ - Envoyer un commentaire Envoyer un commentaire Fermer Catégorie @@ -923,15 +769,12 @@ ═══════════════════════════════════════════════════════════════ --> Forges personnalisées - Ajoute des hôtes Forgejo / Gitea que tu veux que GHS reconnaisse - %1$d ajoutées Forges personnalisées Saisis le nom d'hôte d'une instance Forgejo ou Gitea (ex. git.example.com). GHS acceptera les URLs de ces hôtes dans le formulaire de liaison manuelle. Ajouter Aucune forge personnalisée pour l'instant. Terminé - Miroir de téléchargement Miroir de téléchargement Utilisé pour télécharger les fichiers de versions. Les appels à l'API GitHub passent toujours en direct. La plupart des utilisateurs devraient laisser ce paramètre sur GitHub direct. Officiel @@ -947,7 +790,6 @@ Le modèle doit contenir {url} exactement une fois Enregistrer Tester la sélection - Test en cours… Atteint en %1$dms Le miroir a renvoyé %1$d Délai dépassé après 5s @@ -955,12 +797,6 @@ Échec : %1$s Tous les miroirs hors ligne ? Vous pouvez héberger le vôtre en 5 minutes — consultez la documentation. %1$s n'est plus disponible, basculé vers GitHub direct. - Somme de contrôle incorrecte — le fichier a peut-être été altéré - Essayer un miroir plus rapide ? - Certains utilisateurs sur des réseaux lents ont plus de succès avec un proxy communautaire. - En choisir un - Peut-être plus tard - Ne plus demander Ajouter depuis les favoris @@ -971,13 +807,7 @@ Sécurité État Sondages - Fermer après prise en compte - %1$d j Rien à partager pour le moment. - %1$d h - à l'instant - Actualisé il y a %1$s - %1$d min Impossible à désactiver Afficher les éléments de Ouvrir les paramètres de silence @@ -1034,7 +864,6 @@ Réduite Développée À jour - Mises à jour disponibles Abandonner l'installeur en attente pour %1$s et retirer la ligne de votre liste d'apps ? L'APK téléchargé sera supprimé. Abandonner l'installation en attente ? Accorder l'autorisation @@ -1120,7 +949,6 @@ Nouveau Nouveautés de %1$s Nouveautés - Le changelog est en anglais. Traductions bienvenues via les Issues. Version %1$s · %2$s @@ -1142,7 +970,6 @@ Impossible de mettre à jour la préférence Dépôts masqués Gérez les dépôts masqués depuis Accueil et Recherche. - %1$d masqué(s) Aucun dépôt masqué Appuyez longuement sur une carte de dépôt dans Accueil ou Recherche pour la masquer. Afficher @@ -1158,11 +985,276 @@ Windows macOS Linux - Autre plateforme — s'ouvre dans le navigateur pour enregistrement et transfert Votre appareil Pour transfert Gardez Android ouvert La politique de vérification des développeurs de Google menace la distribution ouverte d'Android. GitHub Store soutient la coalition. En savoir plus Fermer la bannière + + Touchez une carte à gauche pour voir ses détails ici. + Choisissez un dépôt + Prêt à installer + Paramètres avancés + Actions rapides + Paramètres + État + Sélectionnez une app à gauche pour voir ses détails et actions. + Choisissez une app + Installée + Dernière + Ouvrir le dépôt + Choisir la variante + Masquer la liste + Voir la liste + Examinez ou mettez tout à jour d'un coup. + 1 mise à jour disponible + %1$d mises à jour disponibles + Retour + Retour + Image + Tout voir › + + %1$d hôte + %1$d hôtes + + Codeberg, gitea.com et git.disroot.org sont déjà interrogés par défaut — inutile de les ajouter ici. Cette liste sert uniquement à vos propres instances Forgejo ou Gitea auto-hébergées. + À propos + Développeur + Statistiques + Voir le profil + Nouveautés + Ignorer et examiner plus tard + Nous lisons chaque retour. + Obtenir + n°%1$d + Depuis vos favoris + Versions chaudes + Les plus populaires + Tendances du moment + Découvrir + Tout voir + Voir plus + Ajouter un jeton + Retour + Annuler + Supprimer + Enregistrer + Ajouter un jeton + Détecté : %1$s + Adresse de la forge + Laissez vide pour conserver le jeton existant. Saisissez une nouvelle valeur pour le remplacer. + Remplacer le jeton pour %1$s + Se connectera à %1$s + + %1$d jeton + %1$d jetons + + Touchez + pour ajouter un Personal Access Token + Aucun jeton enregistré + Personal access tokens par hôte de forge (GitHub, Codeberg, Forgejo) + Nom d'affichage (facultatif) + Personal access token + Vous êtes connecté à GitHub via l'application — c'est la méthode recommandée. N'ajoutez un jeton ici que si la connexion par navigateur ne fonctionne pas (réseau restrictif, pas de navigateur) ou pour des forges autres que GitHub. + Ouvrir la page des jetons + Autre forge + Forgejo, Gitea, auto-hébergé + Coller le jeton + Ajouter un jeton pour… + Modifier l'étiquette + Invalide · %1$s + Plus + Gérer sur %1$s + Remplacer le jeton + %1$s restants + Valide · %1$s + Vérifier + Jeton enregistré pour %1$s + Jetons d'authentification + Annuler + Impossible de restaurer le jeton pour %1$s + Jeton pour %1$s supprimé + Échec de la validation : %1$s + erreur inconnue + Hôte invalide + Hôte requis + Jeton requis + Jeton trop court + À propos de GitHub Store + Envoyer un commentaire… + Licences open source + Aide + Politique de confidentialité + Paramètres, thème, réseau, traduction, options avancées. + Certains changements nécessitent un redémarrage pour s'appliquer. + Plus tard + langue thème + Concerné : %1$s + Redémarrer + Retirer le filtre + Terminé + Filtres + Tout réinitialiser + Langage + Plateforme + Trier par + Source + Filtrer les résultats + Application + Connectivité + Installations & mises à jour + Apparence + Confidentialité & données + + %1$d app + %1$d apps + + Traduit automatiquement le README et les notes de version à l'ouverture d'un dépôt. Utilise la langue de l'app comme cible. + Selon la langue de l'app (%1$s) + Traduire en + Traduire les dépôts automatiquement + Annuler + Changer de langue + Source détectée : %1$s + Réessayer + Afficher l'original + Afficher la traduction + Affichez cette page dans une autre langue. + Affichage de l'original + Traduit en %1$s + Traduction… + Langue cible + Traduire + Traduire en %1$s + Clé d'authentification + Obtenir une clé DeepL gratuite → + Inscrivez-vous sur deepl.com/pro-api. Les clés du niveau gratuit se terminent par :fx et utilisent automatiquement l'endpoint gratuit. Le niveau Pro préserve la confidentialité de votre texte ; le niveau gratuit peut utiliser les textes soumis pour améliorer les modèles. + Enregistrer la clé + Clé DeepL enregistrée + Clé API (facultative) + URL de l'instance + Fonctionne d'emblée via le miroir public Disroot — laissez les champs vides pour l'utiliser. Pour plus de confidentialité, collez l'URL de votre instance auto-hébergée. Une clé API n'est nécessaire que si l'instance l'exige. + Enregistrer + Paramètres LibreTranslate enregistrés + Obtenir une clé Azure gratuite → + Azure Translator respecte la vie privée (No-Trace par défaut — votre texte n'est jamais stocké ni utilisé pour l'entraînement). Le niveau gratuit couvre 2 millions de caractères par mois. La région n'est nécessaire que pour les ressources non globales. + Clé d'abonnement + Région (ex. westeurope, laissez vide pour Global) + Enregistrer les identifiants + Identifiants Microsoft Translator enregistrés + DeepL + LibreTranslate + Microsoft + GitHub Store + Bibliothèques utilisées par l'application. + Licences open source + Consulter sur github-store.org. + Politique de confidentialité + Consulter le code source de l'application. + Code source sur GitHub + Boutique d'applications multi-plateforme pour les versions GitHub, Codeberg et Forgejo. + Notes des versions précédentes. + Nouveautés + S'applique à tout le trafic sauf si remplacé ci-dessous. + Personnalisé + Choisissez un mode de connexion ci-dessous. La plupart des utilisateurs gardent « Aucun proxy ». + Comment l'application accède à internet + Connexion principale + HTTP/HTTPS + La plupart des proxys d'entreprise. + Aucun proxy + SOCKS5 + Tor, tunnels SSH. + Système + Chaque portée utilise la connexion principale par défaut. Choisissez « Personnalisé » pour conserver des paramètres dédiés. + Remplacements par portée + Coller l'URL complète + Collez une URL de proxy complète et nous remplirons le formulaire pour vous. + Utiliser cette URL + Impossible de lire cette URL. + scheme://user:pass@host:port + Coller l'URL du proxy + API GitHub, recherche, détails du dépôt. + Recherche & métadonnées + Téléchargements d'APK et d'assets. + Téléchargements + Appels DeepL, Microsoft, LibreTranslate. + Traduction + Tester + Utiliser la principale + Jetons d'accès + Infos de l'app + Apparence + Connexion + Envoyer un commentaire + Méthode d'installation + Langue + Confidentialité + Sources + Stockage + Toucher pour gérer + Traduction + Comportement des mises à jour + Les installateurs silencieux et l'attribution d'installeur sont des fonctionnalités Android. Sur ordinateur, les installations passent par le gestionnaire de paquets du système. + La méthode d'installation est réservée à Android + L'application redémarre lors du changement de langue. + Touchez une entrée pour ouvrir la page du projet. + GitHub Store s'appuie sur ces bibliothèques. + Licences open source + Historique de navigation + Oublier quels dépôts vous avez déjà ouverts. + Cela ne retire pas vos étoiles ni vos favoris. + Effacer + Effacer l'historique des consultations ? + Effacer l'historique des consultations + Lorsque vous copiez un lien github.com ou codeberg.org, nous vous proposons de l'ouvrir. + Détecter les liens de dépôts dans le presse-papiers + Dépôts que vous avez masqués des flux et de la recherche. + Dépôts masqués + Ignorer les dépôts déjà vus dans les flux et la recherche. + Masquer les dépôts déjà consultés Aucun identifiant. Système d'exploitation et plateforme. Aucun paramètre ne correspond à « %1$s ». + Rechercher dans les paramètres + Ajouter un hôte Forgejo ou Gitea + Hôtes ajoutés + Miroir GitHub + L'application interroge GitHub par défaut. Passez par un miroir régional ou ajoutez des hôtes Forgejo / Gitea personnalisés. + Où l'application cherche les dépôts + Par défaut (github.com) + Nous conservons les installateurs pour que les mises à jour reprennent rapidement. + Vider + APK téléchargés + 0 o + Utilisation : %1$s + Cet écran fait partie de la refonte en cours. Les paramètres fonctionnent toujours — ils sont simplement en cours de migration depuis l'ancienne disposition. + Bientôt disponible + + %1$d dépôt masqué + %1$d dépôts masqués + + Se connecter + Rejoignez la communauté + 6 endroits + Demandes professionnelles + Partenariats, presse, intégrations. + Contacter + Bibliothèque + Mises à jour + Compte + Mode + Affichage + Dynamique + Plus d'options de connexion + Masquer les options + Plateformes de découverte + Filtre le fil d'accueil par plateforme. Laisse tout désactivé pour tout voir. + À propos + Version, communauté, mentions légales + Installable ici + Aucun dépôt avec des assets installables pour cette plateforme + Toutes les %1$d heures + Quotidien + Tous les %1$d jours + Hebdomadaire + Toutes les 2 semaines + Tous les 30 jours diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index 9f31f1e7d..c93332df1 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -3,8 +3,6 @@ GitHub Store - - इंस्टॉल किए गए ऐप्स वापस जाएँ अपडेट के लिए जाँच करें @@ -12,7 +10,6 @@ %1$s को लॉन्च नहीं किया जा सकता %1$s को खोलने में विफल रहा %1$s अपडेट करने में विफल रहा: %2$s - अपडेट विफल सभी अपडेट विफल रहे: %1$s सभी ऐप्स सफलतापूर्वक अपडेट हो गए कोई अपडेट उपलब्ध नहीं @@ -61,12 +58,9 @@ GitHub से साइन इन करें - रद्द किया गया अज्ञात त्रुटि - भाषा: - रिपॉजिटरीज़ खोजें रेपो, विवरण खोजें… @@ -115,46 +109,31 @@ प्रोफ़ाइल - - उपस्थिति - भाषा - ऐप की UI भाषा को बदलें। - ऐप भाषा - पूरे ऐप में मेनू, बटन और संदेश बदलता है। GitHub से आने वाली सामग्री नहीं बदलती। सिस्टम का पालन करें नई भाषा लागू करने के लिए पुनरारंभ करें। पुनरारंभ करें - के बारे में - नेटवर्क थीम रंग AMOLED ब्लैक थीम डार्क मोड के लिए गहरा काला बैकग्राउंड - चुना हुआ रंग: %1$s - - संस्करण - सहायता & समर्थन लॉग आउट सफलतापूर्वक लॉग आउट हो गए, रीडायरेक्ट किया जा रहा है... - कैश सफलतापूर्वक साफ़ किया गया चेतावनी! क्या आप लॉग आउट करना चाहते हैं? - डायनामिक - ओशन - पर्पल फॉरेस्ट - स्लेट - एम्बर + Nord + Cream + Plum रिपॉजिटरी खोलें @@ -162,8 +141,6 @@ डाउनलोड रद्द करें इंस्टॉल विकल्प दिखाएँ - - विवरण लोड करने में त्रुटि पुन: प्रयास करें कोई विवरण नहीं दिया गया है। कोई रिलीज़ नोट्स नहीं। @@ -172,7 +149,6 @@ इस ऐप के बारे में इंस्टॉल लॉग्स - लेखक नया क्या है @@ -180,8 +156,6 @@ उपलब्ध अपडेट उपलब्ध नहीं है नवीनतम स्थापित करें - पुनः इंस्टॉल करें - ऐप अपडेट करें डाउनलोड हो रहा है @@ -210,14 +184,9 @@ GitHub पर और परिणाम नहीं हैं GitHub से लाने में विफल। पुनः प्रयास करें। - - द्वारा %1$s - • स्थापित: %1$s आर्किटेक्चर संगत %1$s पर अपडेट करें - - विवरण लोड करने में विफल इंस्टॉलर को डाउनलोड फ़ोल्डर में सेव कर दिया गया है। @@ -243,20 +212,11 @@ गलती: %1$s - संपदा प्रकार .%1$s समर्थित नहीं - डाउनलोड की गई फ़ाइल नहीं मिली रिपॉजिटरी ढूंढी जा रही हैं... - और लोड हो रहा है... - अब और कोई रिपॉजिटरी नहीं पुन: प्रयास करें रिपॉजिटरी लोड करने में विफल रहा - विवरण देखें - अभी-अभी अपडेट किया गया - %1$d घंटे पहले अपडेट किया गया - कल अपडेट किया गया - %1$d दिन पहले अपडेट किया गया %1$s को अपडेट किया गया दर सीमा पार हो गई @@ -305,8 +265,6 @@ हटाएं तारांकित रिपॉजिटरी को सिंक करने में विफल रहा - डेवलपर प्रोफ़ाइल - डेवलपर प्रोफ़ाइल खोलें रिपॉजिटरी लोड करने में विफल रहा प्रोफ़ाइल लोड करने में विफल @@ -319,7 +277,6 @@ रिपॉजिटरी खोजें… खोज साफ़ करें - सभी रिलीज़ के साथ स्थापित पसंदीदा @@ -331,7 +288,6 @@ रिपॉजिटरी - रिपॉजिटरीज़ %2$d में से %1$d रिपॉजिटरी दिखाए जा रहे हैं @@ -339,8 +295,6 @@ कोई रिपॉजिटरी इंस्टॉल नहीं है कोई पसंदीदा रिपॉजिटरी नहीं - - %1$s पहले अपडेट किया गया रिलीज़ हो गया है %1$d साल पहले @@ -361,7 +315,7 @@ होम खोज - ऐप्स + लाइब्रेरी प्रोफ़ाइल फोर्क @@ -374,16 +328,7 @@ कोई संस्करण चयनित नहीं संस्करण - - रुझान - हॉट रिलीज़ - सबसे लोकप्रिय - गोपनीयता - मीडिया - उत्पादकता - नेटवर्क - डेव टूल्स इंस्टॉल लंबित @@ -403,35 +348,21 @@ अंतिम जाँच: %1$s - कभी जाँच नहीं की अभी %1$d मिनट पहले %1$d घंटे पहले अपडेट की जाँच हो रही है… - - प्रॉक्सी प्रकार - कोई नहीं - सिस्टम - HTTP - SOCKS होस्ट पोर्ट उपयोगकर्ता नाम (वैकल्पिक) पासवर्ड (वैकल्पिक) प्रॉक्सी सहेजें प्रॉक्सी सेटिंग्स सहेजी गईं - आपके डिवाइस की प्रॉक्सी सेटिंग का उपयोग करता है - पोर्ट 1–65535 के बीच होना चाहिए - सीधा कनेक्शन, कोई प्रॉक्सी नहीं प्रॉक्सी सेटिंग्स सहेजने में विफल प्रॉक्सी होस्ट आवश्यक है मान्य होस्टनाम या IP पता दर्ज करें अमान्य प्रॉक्सी पोर्ट - पासवर्ड दिखाएँ - पासवर्ड छुपाएँ - परीक्षण - परीक्षण हो रहा है… कनेक्शन ठीक है (%1$d ms) होस्ट हल नहीं हो सका। प्रॉक्सी पता जाँचें। प्रॉक्सी सर्वर तक नहीं पहुँचा जा सका। @@ -439,20 +370,8 @@ प्रॉक्सी प्रमाणीकरण आवश्यक है। अप्रत्याशित प्रतिक्रिया: HTTP %1$d कनेक्शन परीक्षण विफल - प्रत्येक श्रेणी अपना प्रॉक्सी उपयोग कर सकती है। उन्हें स्वतंत्र रूप से कॉन्फ़िगर करें। - खोज (GitHub API) - होम, खोज, रेपो विवरण और अपडेट जांच - डाउनलोड - APK डाउनलोड और स्वचालित अपडेट - अनुवाद - README अनुवाद सेवा - - इस ऐप को ट्रैक करें - ऐप ट्रैकिंग सूची में जोड़ा गया - ऐप ट्रैक करने में विफल: %1$s - ऐप पहले से ट्रैक हो रहा है GitHub में साइन इन करें @@ -469,7 +388,6 @@ फिर से साइन इन करें अतिथि के रूप में जारी रखें यह आपका स्थानीय सत्र और कैश डेटा साफ़ कर देगा। पूर्ण रूप से पहुँच रद्द करने के लिए, GitHub Settings > Applications पर जाएँ। - कोड %1$s में समाप्त होगा डिवाइस कोड की अवधि समाप्त हो गई है। नया कोड प्राप्त करने के लिए कृपया फिर से साइन इन करें। कृपया अपना इंटरनेट कनेक्शन जाँचें और पुनः प्रयास करें। @@ -486,70 +404,32 @@ लिंक साझा करने में विफल लिंक क्लिपबोर्ड में कॉपी किया गया - - अनुवाद करें - अनुवाद हो रहा है… - मूल दिखाएं - %1$s में अनुवादित अनुवाद करें… भाषा खोजें - भाषा बदलें अनुवाद विफल। कृपया पुनः प्रयास करें। GitHub लिंक खोलें क्लिपबोर्ड में GitHub लिंक मिला - क्लिपबोर्ड लिंक स्वतः पहचानें - खोज खोलते समय क्लिपबोर्ड से GitHub लिंक स्वचालित रूप से पहचानें पहचाने गए लिंक ऐप में खोलें क्लिपबोर्ड में कोई GitHub लिंक नहीं मिला - संग्रहण - कैश साफ़ करें - वर्तमान आकार: साफ़ करें - GitHub Store का समर्थन करें - प्रोजेक्ट को समर्थन दें - हमेशा मुफ्त।\nआपके सहयोग से चलता है। - आपका सहयोग GitHub Store को मुफ्त, स्वतंत्र और सक्रिय रूप से अपडेट रखता है — कोई विज्ञापन नहीं, कोई ट्रैकिंग नहीं, बस ओपन-सोर्स ऐप्स इंस्टॉल करने का एक बेहतर तरीका। - मैं पढ़ाई पूरी करते हुए अकेले इस प्रोजेक्ट को बनाता और संभालता हूँ। हर योगदान — बड़ा हो या छोटा — सीधे सर्वर लागत, विकास और सबके लिए ऐप को विज्ञापन-मुक्त रखने में जाता है। - GitHub Store के लिए वोट करें! - GitHub Store KotlinConf 2026 Golden Kodee Awards के लिए नामांकित है। - 1. रजिस्टर करें - 2. वोट करें - वोटिंग 22 मार्च को बंद होगी - 1. प्लेटफॉर्म पर रजिस्टर करें (Google से जारी रखें) - 2. नीचे वोट बटन दबाएँ - 3. Usmon Narzullayev खोजें और वोट करें - GitHub Sponsors - मासिक या एक बार — सीधे विकास को वित्तपोषित करता है - Buy Me a Coffee - त्वरित एक बार का योगदान - मदद करने के अन्य तरीके - रिपॉजिटरी को स्टार दें - दृश्यता बढ़ाता है और दूसरों को इसे खोजने में मदद करता है - बग रिपोर्ट करें - हर रिपोर्ट सबके लिए ऐप को बेहतर बनाती है - दोस्तों के साथ साझा करें - मुँह की बात सबसे अच्छा मार्केटिंग है - हर योगदान — आर्थिक हो या न हो — एक वास्तविक फर्क डालता है। इसका हिस्सा होने के लिए धन्यवाद। - - इंस्टॉलेशन डिफ़ॉल्ट मानक सिस्टम इंस्टॉल डायलॉग Shizuku @@ -566,16 +446,10 @@ ऐप्स ऑटो-अपडेट करें Shizuku के माध्यम से पृष्ठभूमि में स्वचालित रूप से अपडेट डाउनलोड और इंस्टॉल करें - अपडेट अपडेट जाँच अंतराल पृष्ठभूमि में ऐप अपडेट कितनी बार जाँचें पृष्ठभूमि अपडेट जाँच पृष्ठभूमि में समय-समय पर अपडेट जाँचता है। बैटरी बचाने के लिए बंद करें — किसी भी ऐप के विवरण स्क्रीन से मैन्युअली जाँच की जा सकती है। - 3घ - 6घ - 12घ - 24घ - बैकग्राउंड अपडेट की अनुमति दें आपका डिवाइस बैकग्राउंड कार्य आक्रामक रूप से बंद कर देता है। GitHub Store को बैटरी ऑप्टिमाइज़ेशन से व्हाइटलिस्ट करें ताकि निर्धारित अपडेट चेक और साइलेंट इंस्टॉल विश्वसनीय रूप से चलें। सेटिंग्स खोलें @@ -590,18 +464,13 @@ सत्यापन हो रहा है… लिंक करें और ट्रैक करें नवीनतम रिलीज़ की जाँच हो रही है… - सत्यापन के लिए APK डाउनलोड हो रहा है… साइनिंग कुंजी सत्यापित हो रही है… पैकेज नाम मेल नहीं खाता: APK %1$s है, लेकिन चयनित ऐप %2$s है साइनिंग कुंजी मेल नहीं खाती: इस रिपॉजिटरी का APK किसी अन्य डेवलपर द्वारा हस्ताक्षरित है इंस्टॉलर चुनें अपने इंस्टॉल किए गए ऐप से मिलान करने के लिए APK चुनें - डाउनलोड विफल निर्यात आयात - ऐप्स आयात करें - अपने ट्रैक किए गए ऐप्स को पुनर्स्थापित करने के लिए निर्यात किया गया JSON पेस्ट करें - निर्यात किया गया JSON यहाँ पेस्ट करें… डिफ़ॉल्ट बीटा चैनल नए ट्रैक किए गए ऐप डिफ़ॉल्ट रूप से बीटा बिल्ड शामिल करते हैं। पहले से ट्रैक किए गए ऐप अपनी सेटिंग रखते हैं (ऐप के विवरण स्क्रीन से बदलें)। ऐप अनइंस्टॉल करें? @@ -614,9 +483,6 @@ %1$s को %2$s/%3$s से लिंक किया गया निर्यात विफल: %1$s आयात विफल: %1$s - %1$d ऐप्स आयात किए गए - , %1$d छोड़े गए - , %1$d विफल साइनिंग कुंजी बदल गई इस ऐप का साइनिंग प्रमाणपत्र पहली बार इंस्टॉल होने के बाद बदल गया है।\n\nइसका मतलब हो सकता है कि डेवलपर ने अपनी साइनिंग कुंजी बदल दी, या बाइनरी के साथ छेड़छाड़ की गई हो।\n\nअपेक्षित: %1$s\nप्राप्त: %2$s फिर भी इंस्टॉल करें @@ -629,8 +495,6 @@ कई संसाधन उपलब्ध इस रिलीज़ के लिए कई इंस्टॉल करने योग्य फ़ाइलें उपलब्ध हैं। कृपया सूची की समीक्षा करें और अपने डिवाइस के लिए उपयुक्त फ़ाइल चुनें। जानकारी - पुनः प्रयास - स्वतः पहचाना गया: %1$s भाषा चुनें पैकेज मेल नहीं खाता: APK %1$s है, लेकिन इंस्टॉल किया गया ऐप %2$s है। अपडेट ब्लॉक किया गया। साइनिंग कुंजी मेल नहीं खाती: अपडेट किसी अन्य डेवलपर द्वारा साइन किया गया था। अपडेट ब्लॉक किया गया। @@ -643,27 +507,13 @@ चौड़ा अतिरिक्त चौड़ा - देखे गए रिपॉजिटरी छुपाएँ - पहले से देखे गए रिपॉजिटरी को डिस्कवरी फ़ीड से छुपाएँ - देखने का इतिहास साफ़ करें - सभी देखे गए रिपॉजिटरी रीसेट करें ताकि वे फ़ीड में फिर से दिखें देखने का इतिहास साफ़ किया गया देखा गया यह रेपो आपका है रिपॉजिटरी छिपाएं - रिपॉजिटरी छिपाई गई - पूर्ववत् - गोपनीयता - खोज को बेहतर बनाने में मदद करें - रीसेट करने योग्य एनालिटिक्स आईडी से जुड़े उपयोग डेटा (खोज, इंस्टॉल, इंटरैक्शन) साझा करें। खाता विवरण साझा नहीं किए जाते। - एनालिटिक्स आईडी रीसेट करें - एक नया अनाम आईडी बनाएं, पिछले टेलीमेट्री से लिंक को तोड़ें। - एनालिटिक्स आईडी रीसेट किया गया हाल ही में देखा गया वे रिपॉजिटरी जिन्हें आपने देखा है - डाउनलोड किए गए पैकेज - GitHub रिलीज़ से APK और इंस्टॉलर सभी हटाएँ सभी डाउनलोड हटाएँ? यह सभी APK और इंस्टॉलर को स्थायी रूप से हटा देगा (%1$s)। आप उन्हें कभी भी फिर से डाउनलोड कर सकते हैं। @@ -674,9 +524,7 @@ सभी साफ करें हटाएँ - ट्वीक्स ट्वीक्स - प्री-रिलीज़ एसेट फ़िल्टर @@ -736,8 +584,6 @@ इंस्टॉल के लिए तैयार इंस्टॉल (तैयार) - - अनुवाद README का अनुवाद करने के लिए उपयोग की जाने वाली सेवा चुनें। अनुवाद सेवा Google बिना कॉन्फ़िगरेशन के वैश्विक रूप से काम करता है। Youdao मुख्यभूमि चीन से काम करता है लेकिन Youdao डेवलपर पोर्टल से API क्रेडेंशियल्स की आवश्यकता है। @@ -780,8 +626,6 @@ ghp_… या github_pat_… साइन इन रद्द करें - टोकन दिखाएं - टोकन छुपाएं कृपया एक टोकन पेस्ट करें। यह GitHub टोकन नहीं लगता। टोकन ghp_ या github_pat_ से शुरू होते हैं। यह टोकन अमान्य है या निरस्त कर दिया गया है। @@ -803,13 +647,11 @@ अपना रिलीज़ चैनल चुनें इस ऐप के स्थिर रिलीज़ और बीटा बिल्ड के बीच स्विच करने के लिए टैप करें। समझ गया - इस ऐप के बीटा रिलीज़ टॉगल करें %1$s स्थिर पर स्विच करें %1$d महीनों में कोई स्थिर रिलीज़ नहीं %1$d दिनों में कोई स्थिर रिलीज़ नहीं सक्रिय प्री-रिलीज़ हैं, लेकिन प्रोजेक्ट ने काफी समय से कोई स्थिर बिल्ड नहीं दिया। बीटा स्थिर रिलीज़ में नहीं बदल सकते। %1$s के बाद क्या बदला - — %1$s — इस रिपो से अनलिंक करें - अधिक विकल्प इस ऐप को अनलिंक करें? हम %1$s को इस रिपो से इंस्टॉल के रूप में ट्रैक करना बंद कर देंगे। ऐप आपके डिवाइस पर रहेगा — केवल लिंक हटाया जाएगा। अनलिंक करें @@ -923,7 +763,6 @@ - फ़ीडबैक भेजें फ़ीडबैक भेजें बंद करें श्रेणी @@ -961,15 +800,12 @@ ═══════════════════════════════════════════════════════════════ --> कस्टम फोर्ज - जो Forgejo / Gitea होस्ट GHS पहचाने उन्हें जोड़ें - %1$d जोड़े गए कस्टम फोर्ज Forgejo या Gitea इंस्टेंस का होस्टनेम डालें (जैसे git.example.com)। GHS मैन्युअल लिंक शीट में इन होस्ट के URL स्वीकार करेगा। जोड़ें अभी कोई कस्टम फोर्ज नहीं। पूर्ण - डाउनलोड मिरर डाउनलोड मिरर रिलीज़ फ़ाइलें डाउनलोड करने के लिए उपयोग किया जाता है। GitHub API कॉल हमेशा सीधे जाते हैं। अधिकांश उपयोगकर्ताओं को इसे Direct GitHub पर छोड़ देना चाहिए। आधिकारिक @@ -985,7 +821,6 @@ टेम्पलेट में {url} ठीक एक बार होना चाहिए सहेजें चुने को टेस्ट करें - परीक्षण चल रहा है… %1$dms में पहुंचा मिरर ने %1$d लौटाया 5s बाद टाइमआउट @@ -993,12 +828,6 @@ विफल: %1$s सभी मिरर बंद हैं? आप 5 मिनट में अपना खुद का होस्ट कर सकते हैं — दस्तावेज़ देखें। %1$s अब उपलब्ध नहीं है, Direct GitHub पर स्विच किया गया। - चेकसम मेल नहीं — फ़ाइल से छेड़छाड़ हो सकती है - तेज़ मिरर आज़माएं? - धीमे नेटवर्क पर कुछ उपयोगकर्ताओं को कम्युनिटी प्रॉक्सी से बेहतर परिणाम मिलते हैं। - एक चुनें - शायद बाद में - दोबारा न पूछें तारांकित से जोड़ें @@ -1009,13 +838,7 @@ सुरक्षा स्थिति सर्वेक्षण - पुष्टि के बाद बंद करें - %1$d दिन अभी साझा करने के लिए कुछ नहीं है। - %1$d घं - अभी-अभी - %1$s पहले रिफ़्रेश हुआ - %1$d मि बंद नहीं किया जा सकता आइटम दिखाएं म्यूट सेटिंग खोलें @@ -1072,7 +895,6 @@ संक्षिप्त विस्तृत अद्यतन - अपडेट उपलब्ध %1$s के लिए पार्क किए गए इंस्टॉलर को छोड़ें और अपनी ऐप्स सूची से पंक्ति हटाएँ? डाउनलोड किया गया APK हटा दिया जाएगा। लंबित इंस्टॉल छोड़ें? अनुमति दें @@ -1158,7 +980,6 @@ नया %1$s में नया क्या है नया क्या है - चेंजलॉग अंग्रेज़ी में है। Issues के माध्यम से अनुवाद का स्वागत है। संस्करण %1$s · %2$s @@ -1180,7 +1001,6 @@ प्राथमिकता अपडेट नहीं हो सकी छिपे हुए रिपॉजिटरी होम और सर्च से छिपाए गए रिपॉजिटरी प्रबंधित करें। - %1$d छिपे हुए कोई छिपा रिपॉजिटरी नहीं होम या सर्च में रेपो कार्ड पर लंबा दबाकर इसे डिस्कवरी से छिपाएं। दिखाएं @@ -1196,11 +1016,281 @@ Windows macOS Linux - अन्य प्लेटफ़ॉर्म — ट्रांसफ़र के लिए सहेजने हेतु ब्राउज़र में खुलता है आपका डिवाइस ट्रांसफ़र के लिए Android को खुला रखें Google की डेवलपर-सत्यापन नीति खुले Android वितरण को खतरे में डालती है। GitHub Store गठबंधन के साथ है। और जानें बैनर बंद करें + + + + यहाँ इसके विवरण देखने के लिए बाईं ओर किसी कार्ड पर टैप करें। + एक रिपॉजिटरी चुनें + इंस्टॉल के लिए तैयार + उन्नत सेटिंग्स + त्वरित क्रियाएँ + सेटिंग्स + स्थिति + इसके विवरण और क्रियाएँ देखने के लिए बाईं ओर किसी ऐप का चयन करें। + एक ऐप चुनें + स्थापित + नवीनतम + रिपॉजिटरी खोलें + वेरिएंट चुनें + सूची छिपाएँ + सूची देखें + एक साथ सब कुछ देखें या अपडेट करें। + 1 अपडेट उपलब्ध + %1$d अपडेट उपलब्ध + वापस + वापस + छवि + सभी देखें › + Codeberg, gitea.com और git.disroot.org पहले से डिफ़ॉल्ट रूप से खोजे जाते हैं — आपको उन्हें यहाँ जोड़ने की ज़रूरत नहीं है। इस सूची का उपयोग केवल अपने सेल्फ-होस्टेड Forgejo या Gitea के लिए करें। + परिचय + डेवलपर + आँकड़े + प्रोफ़ाइल देखें + नया क्या है + छोड़ें और बाद में समीक्षा करें + हम हर रिपोर्ट पढ़ते हैं। + पाएँ + #%1$d + आपके स्टार से + गर्म रिलीज़ + सर्वाधिक लोकप्रिय + अभी ट्रेंडिंग + खोजें + सभी देखें + और देखें + टोकन जोड़ें + वापस + रद्द करें + हटाएँ + सहेजें + टोकन जोड़ें + पहचाना गया: %1$s + फोर्ज पता + मौजूदा टोकन रखने के लिए खाली छोड़ें। बदलने के लिए नया मान टाइप करें। + %1$s के लिए टोकन बदलें + %1$s से कनेक्ट करेगा + पर्सनल एक्सेस टोकन जोड़ने के लिए + पर टैप करें + कोई टोकन संग्रहीत नहीं + प्रति फोर्ज होस्ट के लिए पर्सनल एक्सेस टोकन (GitHub, Codeberg, Forgejo) + डिस्प्ले नाम (वैकल्पिक) + पर्सनल एक्सेस टोकन + आप ऐप के ज़रिए GitHub में साइन इन हैं — यही अनुशंसित रास्ता है। यहाँ टोकन केवल तभी जोड़ें जब ब्राउज़र साइन-इन काम न करे (प्रतिबंधित नेटवर्क, ब्राउज़र नहीं) या GitHub के अलावा किसी अन्य फोर्ज के लिए। + टोकन पेज खोलें + अन्य फोर्ज + Forgejo, Gitea, सेल्फ-होस्टेड + टोकन पेस्ट करें + इसके लिए टोकन जोड़ें… + लेबल संपादित करें + अमान्य · %1$s + अधिक + %1$s पर प्रबंधित करें + टोकन बदलें + %1$s शेष + मान्य · %1$s + जाँचें + %1$s के लिए टोकन सहेजा गया + प्रमाणीकरण टोकन + पूर्ववत करें + %1$s के लिए टोकन पुनर्स्थापित नहीं हो सका + %1$s के लिए टोकन हटाया गया + सत्यापन विफल: %1$s + अज्ञात त्रुटि + अमान्य होस्ट + होस्ट आवश्यक + टोकन आवश्यक + टोकन बहुत छोटा + GitHub Store के बारे में + फ़ीडबैक भेजें… + ओपन सोर्स लाइसेंस + सहायता + गोपनीयता नीति + ऐप सेटिंग्स, थीम, नेटवर्क, अनुवाद, उन्नत विकल्प। + कुछ बदलाव लागू करने के लिए पुनरारंभ की आवश्यकता है। + बाद में + भाषा थीम + प्रभावित: %1$s + अभी पुनरारंभ करें + फ़िल्टर हटाएँ + हो गया + फ़िल्टर + सभी रीसेट करें + भाषा + प्लेटफ़ॉर्म + क्रमबद्ध करें + स्रोत + परिणाम फ़िल्टर करें + ऐप + कनेक्टिविटी + इंस्टॉल और अपडेट + रूप और अनुभव + गोपनीयता और डेटा + रिपॉजिटरी खोलने पर README और रिलीज़ नोट्स स्वचालित रूप से अनुवाद करें। आपकी ऐप भाषा को लक्ष्य के रूप में उपयोग करता है। + ऐप भाषा का अनुसरण करें (%1$s) + अनुवाद करें + रिपॉजिटरी स्वतः-अनुवाद करें + रद्द करें + भाषा बदलें + पहचाना गया स्रोत: %1$s + पुनः प्रयास करें + मूल दिखाएँ + अनुवाद दिखाएँ + इस पृष्ठ को किसी अन्य भाषा में देखें। + मूल दिखाया जा रहा है + %1$s में अनुवादित + अनुवाद हो रहा है… + लक्ष्य भाषा + अनुवाद करें + %1$s में अनुवाद करें + Auth key + मुफ़्त DeepL key पाएँ → + deepl.com/pro-api पर साइन अप करें। मुफ़्त स्तर की keys :fx से समाप्त होती हैं और स्वचालित रूप से मुफ़्त एंडपॉइंट का उपयोग करती हैं। Pro स्तर आपके टेक्स्ट को निजी रखता है; मुफ़्त स्तर भेजे गए टेक्स्ट का उपयोग मॉडल सुधार के लिए कर सकता है। + key सहेजें + DeepL key सहेजी गई + API key (वैकल्पिक) + Instance URL + सार्वजनिक Disroot मिरर के माध्यम से तुरंत काम करता है — इसे उपयोग करने के लिए फ़ील्ड खाली छोड़ें। अधिक गोपनीयता के लिए, अपना सेल्फ-होस्टेड URL पेस्ट करें। API key केवल तभी आवश्यक है जब इंस्टेंस को इसकी ज़रूरत हो। + सेटिंग्स सहेजें + LibreTranslate सेटिंग्स सहेजी गईं + मुफ़्त Azure key पाएँ → + Azure Translator गोपनीयता का सम्मान करता है (डिफ़ॉल्ट रूप से No-Trace — आपका टेक्स्ट कभी संग्रहीत नहीं होता और न ही प्रशिक्षण के लिए उपयोग होता है)। मुफ़्त स्तर 2M वर्ण/माह कवर करता है। क्षेत्र केवल गैर-वैश्विक संसाधनों के लिए आवश्यक है। + सब्सक्रिप्शन key + क्षेत्र (जैसे westeurope, Global के लिए खाली छोड़ें) + क्रेडेंशियल्स सहेजें + Microsoft Translator क्रेडेंशियल्स सहेजे गए + DeepL + LibreTranslate + Microsoft + GitHub Store + ऐप में उपयोग की गई लाइब्रेरियाँ। + ओपन सोर्स लाइसेंस + github-store.org पर देखें। + गोपनीयता नीति + इस ऐप का सोर्स देखें। + GitHub पर सोर्स कोड + GitHub, Codeberg और Forgejo रिलीज़ के लिए क्रॉस-प्लेटफ़ॉर्म ऐप स्टोर। + पिछले रिलीज़ नोट्स। + नया क्या है + जब तक नीचे ओवरराइड न हो, सभी ट्रैफ़िक पर लागू होता है। + कस्टम + नीचे एक कनेक्शन मोड चुनें। अधिकांश लोग इसे No proxy पर छोड़ देते हैं। + ऐप इंटरनेट तक कैसे पहुँचता है + मुख्य कनेक्शन + HTTP/HTTPS + अधिकांश कॉर्पोरेट प्रॉक्सी। + कोई प्रॉक्सी नहीं + SOCKS5 + Tor, SSH टनल। + सिस्टम + प्रत्येक स्कोप डिफ़ॉल्ट रूप से मुख्य कनेक्शन का उपयोग करता है। अपनी सेटिंग्स रखने के लिए 'कस्टम' चुनें। + प्रति-स्कोप ओवरराइड + पूरा URL पेस्ट करें + एक पूरा प्रॉक्सी URL पेस्ट करें और हम आपके लिए फ़ॉर्म भर देंगे। + इस URL का उपयोग करें + वह URL पढ़ा नहीं जा सका। + scheme://user:pass@host:port + प्रॉक्सी URL पेस्ट करें + GitHub API, खोज, रिपो विवरण। + खोज और मेटाडेटा + APK और एसेट डाउनलोड। + डाउनलोड + DeepL, Microsoft, LibreTranslate कॉल। + अनुवाद + परीक्षण + मुख्य का उपयोग करें + एक्सेस टोकन + ऐप जानकारी + रूप + कनेक्शन + फ़ीडबैक भेजें + इंस्टॉल विधि + भाषा + गोपनीयता + स्रोत + संग्रहण + प्रबंधन के लिए टैप करें + अनुवाद + अपडेट व्यवहार + साइलेंट इंस्टॉलर और इंस्टॉलर एट्रिब्यूशन Android-केवल सुविधाएँ हैं। डेस्कटॉप इंस्टॉल OS पैकेज मैनेजर के माध्यम से होते हैं। + इंस्टॉल विधि Android-केवल है + भाषा बदलने पर ऐप पुनरारंभ होता है। + किसी भी प्रविष्टि को उसके प्रोजेक्ट पृष्ठ पर खोलने के लिए टैप करें। + GitHub Store इन लाइब्रेरियों पर खड़ा है। + ओपन सोर्स लाइसेंस + ब्राउज़िंग इतिहास + भूल जाएँ कि आपने कौन सी रिपो पहले से खोली हैं। + यह किसी भी चीज़ को अनस्टार या अनफ़ेवरेट नहीं करेगा। + साफ़ करें + देखा गया इतिहास साफ़ करें? + देखा गया इतिहास साफ़ करें + जब आप github.com या codeberg.org लिंक कॉपी करते हैं, तो हम इसे खोलने का संकेत देंगे। + क्लिपबोर्ड में रिपो लिंक पहचानें + वे रिपो जिन्हें आपने फ़ीड और खोज से म्यूट किया है। + छिपी हुई रिपॉजिटरी + फ़ीड और खोज में देखी हुई रिपो छोड़ें। + मेरे द्वारा पहले से देखी गई रिपो छिपाएँ कोई पहचानकर्ता नहीं। OS और प्लेटफ़ॉर्म। कोई सेटिंग '%1$s' से मेल नहीं खाती। + सेटिंग्स खोजें + Forgejo या Gitea होस्ट जोड़ें + जोड़े गए होस्ट + GitHub मिरर + ऐप डिफ़ॉल्ट रूप से GitHub खोजता है। क्षेत्रीय मिरर के माध्यम से रूट करें या कस्टम Forgejo / Gitea होस्ट जोड़ें। + ऐप रिपॉजिटरी कहाँ देखता है + डिफ़ॉल्ट (github.com) + हम इंस्टॉलर रखते हैं ताकि अपडेट तेज़ी से फिर शुरू हो सकें। + साफ़ करें + डाउनलोड किए गए APK + 0 B + उपयोग में: %1$s + यह स्क्रीन प्रगति में पुनर्डिज़ाइन का हिस्सा है। सेटिंग्स अभी भी काम करती हैं — उन्हें पुराने लेआउट से यहाँ स्थानांतरित किया जा रहा है। + अगले अपडेट में आ रहा है + + + %1$d होस्ट + %1$d होस्ट + + + %1$d रिपो + %1$d रिपो + + + %1$d टोकन + %1$d टोकन + + + %1$d ऐप + %1$d ऐप + + जुड़ें + समुदाय में शामिल हों + 6 जगह + व्यावसायिक पूछताछ + साझेदारी, प्रेस, इंटीग्रेशन। + संपर्क करें + लाइब्रेरी + अपडेट + अकाउंट + मोड + डिस्प्ले + डायनामिक + अधिक साइन-इन विकल्प + विकल्प छिपाएँ + डिस्कवरी प्लेटफ़ॉर्म + होम फ़ीड को विशिष्ट प्लेटफ़ॉर्म पर फ़िल्टर करें। सब बंद रहने पर सब कुछ दिखेगा। + के बारे में + संस्करण, समुदाय, कानूनी + यहाँ इंस्टॉल योग्य + इस प्लेटफ़ॉर्म के लिए इंस्टॉल योग्य ऐसेट वाले रिपॉज़िटरी नहीं + हर %1$d घंटे + रोज़ाना + हर %1$d दिन + साप्ताहिक + हर 2 सप्ताह + हर 30 दिन diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index 8b4c75a31..c3117adb5 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -3,8 +3,6 @@ GitHub Store - - App Installate Indietro Controlla Aggiornamenti @@ -12,7 +10,6 @@ Impossibile lanciare %1$s Impossibile aprire %1$s Impossibile aggiornare %1$s: %2$s - Aggiornamento fallito Aggiornamento generale fallito: %1$s Tutte le app sono state aggiornate con successo Nessun aggiornamento disponibile @@ -61,12 +58,9 @@ Accedi con GitHub - Annullato Errore sconosciuto - Lingua: - Scopri Repositories Cerca repo, descrizione… @@ -115,46 +109,31 @@ Profilo - - ASPETTO - LINGUA - Sovrascrivi la lingua dell'interfaccia. - Lingua dell'App - Cambia menu, pulsanti e messaggi in tutta l'app. Non modifica i contenuti provenienti da GitHub. Segui il sistema Riavvia per applicare la nuova lingua. Riavvia - INFORMAZIONI - RETE Colore del Tema Tema Amoled Nero Nero puro per il tema scuro - Colore selezionato: %1$s - - Versione - Aiuto & Supporto Esci Uscito con successo, reindirizzamento… - Cache cancellata con successo Attenzione! Sei sicuro di voler uscire? - Dinamico - Oceano - Viola Foresta - Ardesia - Ambra + Nord + Cream + Plum Apri repository @@ -162,8 +141,6 @@ Annulla download Mostra opzioni di installazione - - Errore nel caricamento dei dettagli Riprova Nessuna descrizione fornita. Nessuna nota di aggiornamento. @@ -172,7 +149,6 @@ Informazioni su quest'app Installa logs - Autore Cosa c'è di nuovo @@ -180,8 +156,6 @@ Aggiornamento disponibile Non disponibile Installa l'ultima versione - Reinstalla - Aggiorna app Scaricamento @@ -210,14 +184,9 @@ Nessun altro risultato su GitHub Ricerca GitHub fallita. Riprova. - - per %1$s - • Installate: %1$s Architettura compatibile Aggiorna a %1$s - - Impossibile caricare i dettagli L'installer è stato salvato nella cartella di download @@ -243,30 +212,13 @@ Errore: %1$s - Tipo di archivio .%1$s non supportato - File scaricato non trovato - In tendenza - Rilascio a caldo - I più popolari - Privacy - Media - Produttività - Rete - Strumenti di sviluppo Ricerca di repositories... - Caricamento in corso... - Non ci sono più repositories Riprova Impossibile caricare repositories - Vedi Dettagli - appena aggiornata - aggiornata %1$d ora/e fa - aggiornata ieri - aggiornata %1$d giorno/i fa aggiornata il %1$s Superato limite di richieste @@ -311,8 +263,6 @@ Chiudi Impossibile sincronizzare i repository preferiti - Profilo dello sviluppatore - Apri il profilo dello sviluppatore Impossibile caricare i repository @@ -326,7 +276,6 @@ Cerca repository… Cancella ricerca - Tutti Con release Installati Preferiti @@ -338,7 +287,6 @@ repository - repository Visualizzazione di %1$d su %2$d repository @@ -346,8 +294,6 @@ Nessun repository installato Nessun repository preferito - - Aggiornato %1$s fa Ha una release @@ -369,7 +315,7 @@ Home Cerca - App + Libreria Profilo Fork @@ -405,35 +351,21 @@ Ultimo controllo: %1$s - Mai controllato proprio ora %1$d min fa %1$d h fa Controllo aggiornamenti… - - Tipo di proxy - Nessuno - Sistema - HTTP - SOCKS Host Porta Nome utente (facoltativo) Password (facoltativo) Salva Proxy Impostazioni proxy salvate - Usa le impostazioni proxy del dispositivo - La porta deve essere 1–65535 - Connessione diretta, nessun proxy Impossibile salvare le impostazioni del proxy L'host del proxy è obbligatorio Inserisci un nome host o indirizzo IP valido Porta proxy non valida - Mostra password - Nascondi password - Verifica - Verifica in corso… Connessione OK (%1$d ms) Impossibile risolvere l'host. Controlla l'indirizzo del proxy. Impossibile raggiungere il server proxy. @@ -441,20 +373,8 @@ Autenticazione proxy richiesta. Risposta inattesa: HTTP %1$d Verifica connessione non riuscita - Ogni categoria può utilizzare il proprio proxy. Configurali in modo indipendente. - Scoperta (API GitHub) - Home, ricerca, dettagli repo e controlli aggiornamenti - Download - Download APK e aggiornamenti automatici - Traduzione - Servizio di traduzione README - - Traccia questa app - App aggiunta alla lista di monitoraggio - Impossibile tracciare l'app: %1$s - L'app è già monitorata Accedi a GitHub @@ -471,7 +391,6 @@ Accedi di nuovo Continua come ospite Questo cancellerà la sessione locale e i dati nella cache. Per revocare completamente l'accesso, visita GitHub Settings > Applications. - Il codice scade tra %1$s Il codice del dispositivo è scaduto. Riprova ad accedere per ottenere un nuovo codice. Controlla la tua connessione internet e riprova. @@ -488,69 +407,31 @@ Impossibile condividere il link Link copiato negli appunti - - Traduci - Traduzione… - Mostra originale - Tradotto in %1$s Traduci in… Cerca lingua - Cambia lingua Traduzione fallita. Riprova. Apri link GitHub Link GitHub rilevato negli appunti - Rileva link dagli appunti - Rileva automaticamente i link GitHub dagli appunti all'apertura della ricerca Link rilevati Apri nell'app Nessun link GitHub trovato negli appunti - Archiviazione - Pulisci cache - Dimensione attuale: Pulisci - Supporta GitHub Store - Supporta il progetto - Gratuito per sempre.\nFinanziato da te. - Il tuo supporto mantiene GitHub Store gratuito, indipendente e attivamente aggiornato — niente pubblicità, niente tracciamento, solo un modo migliore per installare app open-source. - Sviluppo e mantengo questo progetto da solo mentre finisco gli studi. Ogni contributo — grande o piccolo — va direttamente ai costi del server, allo sviluppo e a mantenere l'app senza pubblicità per tutti. - Vota GitHub Store! - GitHub Store è nominato ai Golden Kodee Awards al KotlinConf 2026. - 1. Registrati - 2. Vota - Le votazioni chiudono il 22 marzo - 1. Registrati sulla piattaforma (Continua con Google) - 2. Tocca Vota qui sotto - 3. Trova Usmon Narzullayev e premi Vota - GitHub Sponsors - Mensile o una tantum — finanzia direttamente lo sviluppo - Buy Me a Coffee - Contributo rapido una tantum - ALTRI MODI PER AIUTARE - Metti una stella al repository - Aumenta la visibilità e aiuta gli altri a trovarlo - Segnala bug - Ogni segnalazione migliora l'app per tutti - Condividi con amici - Il passaparola è il miglior marketing - Ogni contributo — finanziario o meno — fa davvero la differenza. Grazie per farne parte. - - Installazione Predefinito Finestra di installazione standard del sistema Shizuku @@ -567,16 +448,10 @@ Aggiornamento automatico Scarica e installa automaticamente gli aggiornamenti in background tramite Shizuku - Aggiornamenti Intervallo di controllo Ogni quanto verificare gli aggiornamenti in background Verifica aggiornamenti in background Verifica periodicamente gli aggiornamenti in background. Disattiva per risparmiare batteria — puoi sempre verificare manualmente dalla schermata dettagli di qualsiasi app. - 3h - 6h - 12h - 24h - Consenti aggiornamenti in background Il tuo dispositivo chiude in modo aggressivo le attività in background. Esenta GitHub Store dall'ottimizzazione della batteria affinché i controlli pianificati e le installazioni silenziose funzionino in modo affidabile. Apri impostazioni @@ -591,18 +466,13 @@ Validazione… Collega e monitora Controllo dell'ultima versione… - Download APK per la verifica… Verifica della chiave di firma… Nome pacchetto diverso: l'APK è %1$s, ma l'app selezionata è %2$s Chiave di firma diversa: l'APK di questo repository è stato firmato da uno sviluppatore diverso Seleziona installatore Scegli l'APK da verificare con la tua app installata - Download fallito Esporta Importa - Importa app - Incolla il JSON esportato per ripristinare le app monitorate - Incolla il JSON esportato qui… Canale beta predefinito Le app appena tracciate includono build beta per impostazione predefinita. Le app già tracciate mantengono il proprio canale (modificalo nella schermata Dettagli). Disinstallare l'app? @@ -615,9 +485,6 @@ %1$s collegata a %2$s/%3$s Esportazione fallita: %1$s Importazione fallita: %1$s - %1$d app importate - , %1$d saltate - , %1$d fallite Chiave di firma cambiata Il certificato di firma di questa app è cambiato dalla prima installazione.\n\nQuesto potrebbe significare che lo sviluppatore ha cambiato la chiave di firma, o il binario potrebbe essere stato alterato.\n\nPrevisto: %1$s\nRicevuto: %2$s Installa comunque @@ -630,8 +497,6 @@ Risorse multiple disponibili Ci sono più file installabili disponibili per questa versione. Controlla la lista e seleziona quello adatto al tuo dispositivo. Informazioni - Riprova - Rilevato automaticamente: %1$s Seleziona lingua Pacchetto non corrispondente: l'APK è %1$s, ma l'app installata è %2$s. Aggiornamento bloccato. Chiave di firma non corrispondente: l'aggiornamento è stato firmato da uno sviluppatore diverso. Aggiornamento bloccato. @@ -644,27 +509,13 @@ Ampio Extra ampio - Nascondi repository visualizzati - Nascondi i repository già visualizzati dai feed di scoperta - Cancella cronologia visualizzazioni - Reimposta tutti i repository visualizzati in modo che ricompaiano nei feed Cronologia visualizzazioni cancellata Visualizzato Sei il proprietario di questo repo Nascondi repository - Repository nascosto - Annulla - Privacy - Aiuta a migliorare la ricerca - Condividi dati di utilizzo (ricerche, installazioni, interazioni) associati a un ID analitico ripristinabile. Nessun dettaglio dell'account viene condiviso. - Reimposta ID analitico - Genera un nuovo ID anonimo, interrompendo il collegamento con la telemetria passata. - ID analitico reimpostato Visualizzati di recente Repository che hai visitato - Pacchetti scaricati - APK e installer dalle release GitHub Elimina tutto Eliminare tutti i download? Questo rimuoverà definitivamente tutti gli APK e installer (%1$s). Puoi riscaricarli in qualsiasi momento. @@ -675,9 +526,7 @@ Cancella tutto Rimuovi - Modifiche Modifiche - Pre-release Filtro asset @@ -737,8 +586,6 @@ Pronto per l'installazione Installa (pronto) - - Traduzione Scegli il servizio usato per tradurre i README. Servizio di traduzione Google funziona ovunque senza configurazione. Youdao funziona dalla Cina continentale ma richiede credenziali API dal portale sviluppatori di Youdao. @@ -781,8 +628,6 @@ ghp_… o github_pat_… Accedi Annulla - Mostra token - Nascondi token Incolla un token. Non sembra un token GitHub. I token iniziano con ghp_ o github_pat_. Questo token non è valido o è stato revocato. @@ -804,13 +649,11 @@ Scegli il canale di rilascio Tocca per passare tra rilasci stabili e build beta per questa app. Ho capito - Attiva/disattiva le versioni beta per questa app Passa a %1$s stabile Nessuna versione stabile da %1$d mesi Nessuna versione stabile da %1$d giorni Ci sono pre-release attive, ma il progetto non ha pubblicato una build stabile da un po'. Le versioni beta potrebbero non convergere a un rilascio stabile. Novità da %1$s - — %1$s — Scollega da questo repository - Altre opzioni Scollegare questa app? Smetteremo di tracciare %1$s come installata da questo repository. L'app rimane sul dispositivo — viene rimosso solo il collegamento. Scollega @@ -924,7 +765,6 @@ - Invia feedback Invia feedback Chiudi Categoria @@ -962,15 +802,12 @@ ═══════════════════════════════════════════════════════════════ --> Forge personalizzate - Aggiungi host Forgejo / Gitea che vuoi che GHS riconosca - %1$d aggiunte Forge personalizzate Inserisci l'hostname di un'istanza Forgejo o Gitea (es. git.example.com). GHS accetterà URL di questi host nel modulo di collegamento manuale. Aggiungi Nessuna forge personalizzata. Fatto - Mirror di download Mirror di download Utilizzato per scaricare i file delle release. Le chiamate API di GitHub passano sempre in modo diretto. La maggior parte degli utenti dovrebbe lasciare questa impostazione su GitHub diretto. Ufficiale @@ -986,7 +823,6 @@ Il modello deve contenere {url} esattamente una volta Salva Testa selezionato - Test in corso… Raggiunto in %1$dms Il mirror ha restituito %1$d Timeout dopo 5s @@ -994,12 +830,6 @@ Errore: %1$s Tutti i mirror non disponibili? Puoi ospitarne uno tuo in 5 minuti — consulta la documentazione. %1$s non è più disponibile, passato a GitHub diretto. - Checksum non corrispondente — il file potrebbe essere stato manomesso - Provare un mirror più veloce? - Alcuni utenti con connessioni lente hanno risultati migliori con un proxy della comunità. - Scegli uno - Forse più tardi - Non chiedere di nuovo Aggiungi dai preferiti @@ -1010,13 +840,7 @@ Sicurezza Stato Sondaggi - Chiudi dopo presa visione - %1$d g Nulla da condividere al momento. - %1$d h - proprio ora - Aggiornato %1$s fa - %1$d min Non può essere disattivato Mostra elementi da Apri impostazioni silenzia @@ -1073,7 +897,6 @@ Compresso Espanso Aggiornato - Aggiornamenti disponibili Scartare l'installer parcheggiato per %1$s e rimuovere la riga dalla tua lista app? L'APK scaricato verrà eliminato. Scartare l'installazione in attesa? Concedi permesso @@ -1159,7 +982,6 @@ Novità Novità in %1$s Cosa c'è di nuovo - Il changelog è in inglese. Le traduzioni sono benvenute tramite Issues. Versione %1$s · %2$s @@ -1181,7 +1003,6 @@ Impossibile aggiornare la preferenza Repository nascosti Gestisci i repository nascosti da Home e Ricerca. - %1$d nascosti Nessun repository nascosto Tieni premuta una scheda repository in Home o Ricerca per nasconderla. Mostra @@ -1197,11 +1018,277 @@ Windows macOS Linux - Altra piattaforma — si apre nel browser per salvare e trasferire Il tuo dispositivo Da trasferire Mantieni Android aperto La policy di verifica degli sviluppatori di Google minaccia la distribuzione aperta di Android. GitHub Store sostiene la coalizione. Scopri di più Chiudi banner + + + Tocca una scheda a sinistra per vederne i dettagli qui. + Scegli un repository + Pronte da installare + Impostazioni avanzate + Azioni rapide + Impostazioni + Stato + Seleziona un'app a sinistra per vedere dettagli e azioni. + Scegli un'app + Installata + Ultima + Apri repository + Scegli variante + Nascondi elenco + Vedi elenco + Esamina o aggiorna tutto in una volta. + 1 aggiornamento disponibile + %1$d aggiornamenti disponibili + Indietro + Indietro + Immagine + Vedi tutto › + + %1$d host + %1$d host + + Codeberg, gitea.com e git.disroot.org vengono già cercati per impostazione predefinita — non c'è bisogno di aggiungerli qui. Usa questo elenco solo per i tuoi Forgejo o Gitea self-hosted. + Informazioni + Sviluppatore + Statistiche + Vedi profilo + Cosa c'è di nuovo + Salta e rivedi più tardi + Leggiamo ogni segnalazione. + Ottieni + #%1$d + Dai tuoi preferiti + Release in evidenza + I più popolari + Di tendenza ora + Scopri + Vedi tutto + Vedi altri + Aggiungi token + Indietro + Annulla + Elimina + Salva + Aggiungi token + Rilevato: %1$s + Indirizzo forge + Lascia vuoto per mantenere il token esistente. Inserisci un nuovo valore per sostituirlo. + Sostituisci token per %1$s + Si connetterà a %1$s + + %1$d token + %1$d token + + Tocca + per aggiungere un personal access token + Nessun token salvato + Personal access token per ciascun host forge (GitHub, Codeberg, Forgejo) + Nome visualizzato (facoltativo) + Personal access token + Hai effettuato l'accesso a GitHub dall'app — è il metodo consigliato. Aggiungi un token qui solo se l'accesso dal browser non funziona (rete restrittiva, niente browser) o per forge diverse da GitHub. + Apri pagina token + Altra forge + Forgejo, Gitea, self-hosted + Incolla token + Aggiungi token per… + Modifica etichetta + Non valido · %1$s + Altro + Gestisci su %1$s + Sostituisci token + %1$s rimanenti + Valido · %1$s + Verifica + Token salvato per %1$s + Token di autenticazione + Annulla + Impossibile ripristinare il token per %1$s + Token per %1$s rimosso + Validazione fallita: %1$s + errore sconosciuto + Host non valido + Host obbligatorio + Token obbligatorio + Token troppo corto + Informazioni su GitHub Store + Invia feedback… + Licenze open source + Aiuto + Informativa sulla privacy + Impostazioni app, tema, rete, traduzione, opzioni avanzate. + Alcune modifiche richiedono un riavvio per essere applicate. + Più tardi + lingua tema + Interessati: %1$s + Riavvia ora + Rimuovi filtro + Fatto + Filtri + Reimposta tutto + Lingua + Piattaforma + Ordina per + Origine + Filtra risultati + App + Connettività + Installazioni e aggiornamenti + Aspetto + Privacy e dati + + %1$d app + %1$d app + + Traduci automaticamente README e note di rilascio quando apri un repository. Usa la lingua dell'app come destinazione. + Segui la lingua dell'app (%1$s) + Traduci in + Traduci repository automaticamente + Annulla + Cambia lingua + Origine rilevata: %1$s + Riprova + Mostra originale + Mostra traduzione + Visualizza questa pagina in un'altra lingua. + Versione originale + Tradotto in %1$s + Traduzione in corso… + Lingua di destinazione + Traduci + Traduci in %1$s + Auth key + Ottieni una key DeepL gratuita → + Registrati su deepl.com/pro-api. Le key del piano gratuito finiscono con :fx e usano automaticamente l'endpoint free. Il piano Pro mantiene privato il tuo testo; il piano Free potrebbe usare il testo inviato per migliorare i modelli. + Salva key + Key DeepL salvata + API key (facoltativa) + URL dell'istanza + Funziona subito tramite il mirror pubblico Disroot — lascia i campi vuoti per usarlo. Per maggiore privacy, incolla l'URL della tua istanza self-hosted. La API key serve solo se l'istanza lo richiede. + Salva impostazioni + Impostazioni LibreTranslate salvate + Ottieni una key Azure gratuita → + Azure Translator rispetta la privacy (No-Trace per impostazione predefinita — il testo non viene mai memorizzato né usato per l'addestramento). Il piano gratuito copre 2M di caratteri al mese. La regione serve solo per risorse non globali. + Subscription key + Regione (es. westeurope, lascia vuoto per Global) + Salva credenziali + Credenziali Microsoft Translator salvate + DeepL + LibreTranslate + Microsoft + GitHub Store + Librerie usate nell'app. + Licenze open source + Vedi su github-store.org. + Informativa sulla privacy + Vedi il codice sorgente dell'app. + Codice sorgente su GitHub + App store multipiattaforma per release di GitHub, Codeberg e Forgejo. + Note delle release precedenti. + Cosa c'è di nuovo + Si applica a tutto il traffico salvo override qui sotto. + Personalizzato + Scegli una modalità di connessione qui sotto. Nella maggior parte dei casi lascia su Nessun proxy. + Come l'app raggiunge internet + Connessione principale + HTTP/HTTPS + La maggior parte dei proxy aziendali. + Nessun proxy + SOCKS5 + Tor, tunnel SSH. + Sistema + Ogni ambito usa la connessione principale per impostazione predefinita. Scegli 'Personalizzato' per mantenerne uno proprio. + Override per ambito + Incolla URL completo + Incolla un URL proxy completo e compileremo noi il modulo. + Usa questo URL + Impossibile leggere quell'URL. + scheme://user:pass@host:port + Incolla URL del proxy + API di GitHub, ricerca, dettagli repo. + Ricerca e metadati + Download di APK e asset. + Download + Chiamate a DeepL, Microsoft, LibreTranslate. + Traduzione + Testa + Usa principale + Token di accesso + Informazioni app + Aspetto + Connessione + Invia feedback + Metodo di installazione + Lingua + Privacy + Sorgenti + Archiviazione + Tocca per gestire + Traduzione + Comportamento aggiornamenti + Gli installer silenziosi e l'attribuzione installer sono funzioni Android. Su desktop l'installazione passa per il package manager del sistema. + Il metodo di installazione è solo Android + L'app si riavvia quando cambi lingua. + Tocca una voce per aprirne la pagina del progetto. + GitHub Store si basa su queste librerie. + Licenze open source + Cronologia di navigazione + Dimentica quali repo hai già aperto. + Non rimuoverà stelle né preferiti. + Cancella + Cancellare la cronologia visualizzata? + Cancella cronologia visualizzata + Quando copi un link github.com o codeberg.org, ti chiederemo di aprirlo. + Rileva link a repo negli appunti + Repo che hai silenziato da feed e ricerca. + Repository nascosti + Salta i repo già visti in feed e ricerca. + Nascondi repo già visualizzati Nessun identificatore. Sistema operativo e piattaforma. Nessuna impostazione corrisponde a '%1$s'. + Cerca impostazioni + Aggiungi un host Forgejo o Gitea + Host aggiunti + Mirror GitHub + L'app cerca su GitHub per impostazione predefinita. Instrada tramite un mirror regionale o aggiungi host Forgejo / Gitea personalizzati. + Dove l'app cerca i repository + Predefinito (github.com) + Conserviamo gli installer per riprendere gli aggiornamenti rapidamente. + Cancella + APK scaricati + 0 B + In uso: %1$s + Questa schermata fa parte del redesign in corso. Le impostazioni funzionano ancora — vengono solo spostate qui dal vecchio layout. + In arrivo + + %1$d repo nascosto + %1$d repo nascosti + + Connettiti + Unisciti alla community + 6 luoghi + Richieste commerciali + Partnership, stampa, integrazioni. + Contattaci + Libreria + Aggiornamenti + Account + Modalità + Visualizzazione + Dinamico + Altre opzioni di accesso + Nascondi opzioni + Piattaforme di scoperta + Filtra il feed Home per piattaforma. Lasciale tutte disattivate per vedere tutto. + Informazioni + Versione, community, legale + Installabile qui + Nessun repository con asset installabili per questa piattaforma + Ogni %1$d ore + Quotidiano + Ogni %1$d giorni + Settimanale + Ogni 2 settimane + Ogni 30 giorni diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index aedd1b79e..dbee65ba4 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -1,14 +1,12 @@ GitHub Store - インストール済みアプリ 戻る 更新を確認 %1$s を起動できません %1$s を開けませんでした %1$s の更新に失敗しました:%2$s - 更新に失敗しました すべての更新に失敗しました:%1$s すべてのアプリが更新されました 更新はありません @@ -48,12 +46,9 @@ GitHubでサインイン - キャンセルされました 不明なエラー - 言語: - リポジトリを探索 リポジトリや説明を検索… 言語でフィルター @@ -98,51 +93,35 @@ プロフィール - 外観 - 言語 - アプリのUI言語を上書きします。 - アプリの言語 - アプリ全体のメニュー、ボタン、メッセージを変更します。GitHubからのコンテンツは変更されません。 システムに従う 新しい言語を適用するには再起動してください。 再起動 - 情報 - ネットワーク テーマカラー AMOLED ブラックテーマ ダークモード用の純黒背景 - 選択された色:%1$s - バージョン - ヘルプとサポート ログアウト ログアウトしました。リダイレクト中… - キャッシュを正常にクリアしました 警告! ログアウトしてもよろしいですか? - ダイナミック - オーシャン - パープル フォレスト - スレート - アンバー + Nord + Cream + Plum - 詳細の読み込みに失敗しました このアプリについて インストールログ - 作者 新機能 問題を報告 インストール済み 更新あり 最新版をインストール - 再インストール ダウンロード中 更新中 @@ -161,12 +140,9 @@ GitHubにこれ以上の結果はありません GitHubからの取得に失敗しました。再試行してください。 - %1$s 作 - • インストール済み: %1$s アーキテクチャ互換 %1$s に更新 - 詳細を読み込めませんでした インストーラーはダウンロードに保存されました ダウンロード開始 @@ -190,30 +166,13 @@ サードパーティアプリを使用してAPKをインストール エラー: %1$s - ファイル形式 .%1$s は未対応です - ダウンロードしたファイルが見つかりません - トレンド - ホットリリース - 最も人気のある - プライバシー - メディア - 生産性 - ネットワーク - 開発ツール リポジトリを検索中… - 読み込み中… - これ以上ありません 再試行 リポジトリの読み込みに失敗しました - 詳細を見る - たった今更新 - %1$d時間前に更新 - 昨日更新 - %1$d日前に更新 %1$s に更新 レート制限を超えました @@ -262,8 +221,6 @@ 閉じる スター付きリポジトリの同期に失敗しました - 開発者プロフィール - 開発者プロフィールを開く リポジトリの読み込みに失敗しました @@ -277,7 +234,6 @@ リポジトリを検索… 検索をクリア - すべて リリースあり インストール済み お気に入り @@ -289,7 +245,6 @@ リポジトリ - リポジトリ %2$d件中%1$d件のリポジトリを表示 @@ -297,8 +252,6 @@ インストール済みのリポジトリがありません お気に入りのリポジトリがありません - - %1$sに更新 リリースあり @@ -320,7 +273,7 @@ ホーム 検索 - アプリ + ライブラリ プロフィール フォーク @@ -345,7 +298,6 @@ 利用不可 - アプリを更新 インストール待ち @@ -369,35 +321,21 @@ 最終確認: %1$s - 未確認 たった今 %1$d分前 %1$d時間前 アップデートを確認中… - - プロキシの種類 - なし - システム - HTTP - SOCKS ホスト ポート ユーザー名(任意) パスワード(任意) プロキシを保存 プロキシ設定を保存しました - デバイスのプロキシ設定を使用します - ポートは1〜65535の範囲で指定してください - 直接接続、プロキシなし プロキシ設定の保存に失敗しました プロキシホストは必須です 有効なホスト名または IP アドレスを入力してください 無効なプロキシポート - パスワードを表示 - パスワードを非表示 - テスト - テスト中… 接続OK (%1$d ms) ホストを解決できません。プロキシアドレスを確認してください。 プロキシサーバーに接続できません。 @@ -405,20 +343,8 @@ プロキシ認証が必要です。 予期しない応答:HTTP %1$d 接続テストに失敗しました - 各カテゴリは独自のプロキシを使用できます。個別に設定してください。 - ディスカバリー (GitHub API) - ホーム、検索、リポジトリ詳細、更新確認 - ダウンロード - APK ダウンロードと自動更新 - 翻訳 - README 翻訳サービス - - このアプリを追跡 - アプリを追跡リストに追加しました - アプリの追跡に失敗しました: %1$s - このアプリは既に追跡中です GitHubにサインイン @@ -435,7 +361,6 @@ 再度サインイン ゲストとして続行 ローカルセッションとキャッシュデータが消去されます。アクセスを完全に取り消すには、GitHub Settings > Applicationsにアクセスしてください。 - コードの有効期限: %1$s デバイスコードの有効期限が切れました。 新しいコードを取得するために再度サインインしてください。 インターネット接続を確認して再試行してください。 @@ -452,68 +377,30 @@ リンクの共有に失敗しました リンクをクリップボードにコピーしました - - 翻訳 - 翻訳中… - 原文を表示 - %1$sに翻訳済み 翻訳先… 言語を検索 - 言語を変更 翻訳に失敗しました。もう一度お試しください。 GitHubリンクを開く クリップボードにGitHubリンクを検出 - クリップボードリンクの自動検出 - 検索画面を開く際にクリップボードからGitHubリンクを自動検出 検出されたリンク アプリで開く クリップボードにGitHubリンクが見つかりません - ストレージ - キャッシュをクリア - 現在のサイズ: クリア - GitHub Store を支援 - プロジェクトを支援 - ずっと無料。\nあなたの支援で成り立っています。 - あなたの支援が GitHub Store を無料で、独立して、積極的にメンテナンスされた状態に保ちます — 広告なし、追跡なし、オープンソースアプリをインストールするためのより良い方法です。 - 学業を終えながら、このプロジェクトを一人で開発・維持しています。すべての貢献 — 大きくても小さくても — サーバー費用、開発、そしてアプリを全員に広告なしで提供し続けることに直接使われます。 - GitHub Store に投票! - KotlinConf 2026 の Golden Kodee Awards にノミネートされています。投票は2分で完了します。 - 1. 登録 - 2. 投票 - 投票締切:3月22日 - 1. 賞のプラットフォームに登録(Googleで続行) - 2. 下の「投票」をタップ - 3. Usmon Narzullayev を見つけて投票 - GitHub Sponsors - 月額または一回 — 開発を直接支援 - Buy Me a Coffee - 手軽な一回限りの貢献 - 他の支援方法 - リポジトリにスター - 知名度を上げ、他の人が見つけやすくなります - バグを報告 - すべての報告がアプリをみんなのために改善します - 友達と共有 - 口コミが最高のマーケティングです - すべての貢献 — 金銭的であろうとなかろうと — 本当に大きな力になります。参加してくれてありがとうございます。 - - インストール デフォルト 標準のシステムインストールダイアログ Shizuku @@ -530,16 +417,10 @@ アプリを自動更新 Shizukuを使用してバックグラウンドで自動的にアップデートをダウンロードしてインストール - アップデート アップデート確認間隔 バックグラウンドでアプリのアップデートを確認する頻度 バックグラウンド更新確認 バックグラウンドで定期的に更新を確認します。バッテリー節約のためオフにできます — 各アプリの詳細画面から手動で確認できます。 - 3時間 - 6時間 - 12時間 - 24時間 - バックグラウンド更新を許可 このデバイスはバックグラウンドタスクを積極的に終了します。バッテリー最適化からGitHub Storeを除外して、定期的な更新チェックとサイレントインストールが確実に動作するようにしましょう。 設定を開く @@ -554,18 +435,13 @@ 検証中… リンクして追跡 最新リリースを確認中… - 検証用APKをダウンロード中… 署名キーを検証中… パッケージ名が一致しません:APKは%1$sですが、選択されたアプリは%2$sです 署名キーが一致しません:このリポジトリのAPKは別の開発者によって署名されています インストーラーを選択 インストール済みアプリと照合するAPKを選択 - ダウンロード失敗 エクスポート インポート - アプリをインポート - エクスポートしたJSONを貼り付けて追跡中のアプリを復元 - エクスポートしたJSONをここに貼り付け… デフォルトのベータチャンネル 新しく追跡したアプリはデフォルトでベータビルドを含みます。既に追跡中のアプリは独自の設定を保持します(アプリの詳細画面で変更可能)。 アプリをアンインストールしますか? @@ -578,9 +454,6 @@ %1$sを%2$s/%3$sにリンクしました エクスポート失敗:%1$s インポート失敗:%1$s - %1$d個のアプリをインポート - 、%1$d個スキップ - 、%1$d個失敗 署名キーが変更されました このアプリの署名証明書が初回インストール以降に変更されました。\n\nこれは開発者が署名キーを変更したか、バイナリが改ざんされた可能性があります。\n\n期待値:%1$s\n受信値:%2$s それでもインストール @@ -593,8 +466,6 @@ 複数のアセットが利用可能 このリリースには複数のインストール可能なファイルがあります。リストを確認し、お使いのデバイスに合ったものを選択してください。 情報 - 再試行 - 自動検出:%1$s 言語を選択 パッケージの不一致: APKは%1$sですが、インストール済みアプリは%2$sです。更新がブロックされました。 署名キーの不一致: 更新は別の開発者によって署名されています。更新がブロックされました。 @@ -607,27 +478,13 @@ ワイド 超ワイド - 閲覧済みリポジトリを非表示 - すでに閲覧したリポジトリをディスカバリーフィードから非表示にします - 閲覧履歴をクリア - すべての閲覧済みリポジトリをリセットしてフィードに再表示します 閲覧履歴をクリアしました 閲覧済み あなたが所有するリポジトリ リポジトリを非表示 - リポジトリを非表示にしました - 元に戻す - プライバシー - 検索の改善に協力 - リセット可能な分析 ID に関連付けられた使用データ(検索、インストール、操作)を共有します。アカウント情報は共有されません。 - 分析IDをリセット - 新しい匿名IDを生成し、過去のテレメトリとのリンクを切断します。 - 分析IDをリセットしました 最近閲覧した 訪問したリポジトリ - ダウンロード済みパッケージ - GitHubリリースからのAPKとインストーラー すべて削除 すべてのダウンロードを削除しますか? すべてのAPKとインストーラーが完全に削除されます(%1$s)。いつでも再ダウンロードできます。 @@ -638,9 +495,7 @@ すべてクリア 削除 - 調整 調整 - プレリリース アセットフィルター @@ -698,8 +553,6 @@ インストール準備完了 インストール(準備完了) - - 翻訳 README の翻訳に使用するサービスを選択します。 翻訳サービス Google は世界中で設定不要で動作します。Youdao は中国本土からアクセスできますが、Youdao 開発者ポータルから API 認証情報が必要です。 @@ -742,8 +595,6 @@ ghp_… または github_pat_… ログイン キャンセル - トークンを表示 - トークンを非表示 トークンを貼り付けてください。 GitHub のトークンではないようです。トークンは ghp_ または github_pat_ で始まります。 このトークンは無効か、取り消されています。 @@ -764,13 +615,11 @@ リリースチャンネルを選択 このアプリの安定版とベータビルドを切り替えるにはタップ。 了解 - このアプリのベータリリースを切り替え %1$s の安定版に切り替え %1$d か月間、安定版リリースなし %1$d 日間、安定版リリースなし プレリリースは活発ですが、プロジェクトはしばらく安定版をリリースしていません。ベータ版が安定版リリースに至らない可能性があります。 %1$s 以降の変更点 - — %1$s — このリポジトリからリンクを解除 - その他のオプション このアプリのリンクを解除しますか? このリポジトリからインストールされたものとして %1$s の追跡を停止します。アプリはデバイスに残ります — リンクのみ削除されます。 リンクを解除 @@ -879,7 +726,6 @@ - フィードバックを送信 フィードバックを送信 閉じる カテゴリ @@ -917,15 +763,12 @@ ═══════════════════════════════════════════════════════════════ --> カスタムフォージ - GHSに認識させたいForgejo / Giteaホストを追加 - %1$d 件追加済み カスタムフォージ ForgejoまたはGiteaインスタンスのホスト名を入力(例: git.example.com)。手動リンクシートでこれらのホストのURLが受け入れられます。 追加 カスタムフォージはまだありません。 完了 - ダウンロードミラー ダウンロードミラー リリースアセットのダウンロードに使用されます。GitHub API の呼び出しは常に直接行われます。ほとんどのユーザーは「GitHub 直接」のままにしておくことをお勧めします。 公式 @@ -941,7 +784,6 @@ テンプレートには {url} がちょうど 1 回含まれている必要があります 保存 選択したものをテスト - テスト中… %1$dms で到達 ミラーが %1$d を返しました 5s 後にタイムアウト @@ -949,12 +791,6 @@ 失敗: %1$s すべてのミラーが使えない場合は、5 分で自前のミラーをホストできます — ドキュメントをご覧ください。 %1$s は利用できなくなりました。GitHub 直接に切り替えました。 - チェックサムが一致しません — ファイルが改ざんされている可能性があります - より速いミラーを試しますか? - 低速なネットワークを使用している一部のユーザーには、コミュニティプロキシの方が効果的な場合があります。 - 選択する - 後で - 今後表示しない スター済みから追加 @@ -965,13 +801,7 @@ セキュリティ ステータス アンケート - 確認後に閉じる - %1$d日 いま共有することはありません。 - %1$d時間 - たった今 - %1$s前に更新 - %1$d分 無効化できません 表示するアイテム ミュート設定を開く @@ -1028,7 +858,6 @@ 折りたたみ 展開 最新 - アップデートあり %1$sの保留中インストーラーを破棄してアプリ一覧から行を削除しますか?ダウンロード済みのAPKは削除されます。 保留中のインストールを破棄しますか? 権限を付与 @@ -1114,7 +943,6 @@ 新機能 %1$sの新機能 新機能 - 変更履歴は英語です。Issuesでの翻訳投稿を歓迎します。 バージョン %1$s · %2$s @@ -1136,7 +964,6 @@ 設定を更新できませんでした 非表示のリポジトリ ホームと検索で非表示にしたリポジトリを管理。 - %1$d 件非表示 非表示のリポジトリはありません ホームや検索でリポジトリカードを長押しすると検索結果から非表示にできます。 表示する @@ -1152,11 +979,272 @@ Windows macOS Linux - 他のプラットフォーム — ブラウザで開き、転送用に保存します このデバイス 転送用 Android をオープンに Google の開発者検証ポリシーが Android のオープンな配布を脅かしています。GitHub Store は連合を支持します。 詳しく見る バナーを閉じる + + 左側のカードをタップすると詳細がここに表示されます。 + リポジトリを選択 + インストール待ち + 詳細設定 + クイック操作 + 設定 + ステータス + 左側のアプリを選択すると、詳細と操作が表示されます。 + アプリを選択 + インストール済み + 最新 + リポジトリを開く + バリアントを選択 + リストを隠す + リストを見る + すべてをまとめて確認・更新できます。 + 1 件の更新があります + %1$d 件の更新があります + 戻る + 戻る + 画像 + すべて見る › + + %1$d 件のホスト + + Codeberg、gitea.com、git.disroot.org はデフォルトで検索されるため、ここに追加する必要はありません。このリストは自分でホストしている Forgejo や Gitea 専用です。 + 概要 + 開発者 + 統計 + プロフィールを見る + 新着情報 + スキップして後で確認 + すべてのご報告に目を通しています。 + 取得 + 第%1$d位 + スター付きから + 注目のリリース + 人気上位 + トレンド + 見つける + すべて見る + もっと見る + トークンを追加 + 戻る + キャンセル + 削除 + 保存 + トークンを追加 + 検出: %1$s + フォージのアドレス + 既存のトークンを維持するには空欄のままにしてください。新しい値を入力すると置き換えられます。 + %1$s のトークンを置き換え + %1$s に接続します + + %1$d 件のトークン + + + をタップして個人アクセストークンを追加してください + 保存されたトークンはありません + フォージホスト(GitHub、Codeberg、Forgejo)ごとの個人アクセストークン + 表示名(任意) + 個人アクセストークン + アプリ経由で GitHub にサインイン済みです — こちらが推奨される方法です。ブラウザでのサインインがうまくいかない場合(制限付きネットワーク、ブラウザなし)や、GitHub 以外のフォージでのみ、ここにトークンを追加してください。 + トークンページを開く + その他のフォージ + Forgejo、Gitea、セルフホスト + トークンを貼り付け + トークンを追加するホスト… + ラベルを編集 + 無効 ・ %1$s + その他 + %1$s で管理 + トークンを置き換え + 残り %1$s + 有効 ・ %1$s + 確認 + %1$s のトークンを保存しました + 認証トークン + 元に戻す + %1$s のトークンを復元できませんでした + %1$s のトークンを削除しました + 検証に失敗しました: %1$s + 不明なエラー + ホストが無効です + ホストは必須です + トークンは必須です + トークンが短すぎます + GitHub Store について + フィードバックを送る… + オープンソースライセンス + ヘルプ + プライバシーポリシー + アプリ設定、テーマ、ネットワーク、翻訳、詳細オプション。 + 一部の変更を反映するには再起動が必要です。 + 後で + 言語 テーマ + 対象: %1$s + 今すぐ再起動 + フィルターを解除 + 完了 + フィルター + すべてリセット + 言語 + プラットフォーム + 並び替え + ソース + 結果を絞り込む + アプリ + 接続 + インストールと更新 + 外観 + プライバシーとデータ + + %1$d 件のアプリ + + リポジトリを開いたときに README とリリースノートを自動翻訳します。アプリの言語を翻訳先として使用します。 + アプリの言語に従う(%1$s) + 翻訳先 + リポジトリを自動翻訳 + キャンセル + 言語を変更 + 検出された言語: %1$s + 再試行 + 原文を表示 + 訳文を表示 + このページを別の言語で表示します。 + 原文を表示中 + %1$s に翻訳しました + 翻訳中… + 翻訳先言語 + 翻訳 + %1$s に翻訳 + 認証キー + 無料の DeepL キーを取得 → + deepl.com/pro-api からサインアップしてください。無料プランのキーは :fx で終わり、自動的に無料エンドポイントを使用します。Pro プランは入力テキストを非公開に保ちますが、Free プランでは送信されたテキストがモデル改善に使われる場合があります。 + キーを保存 + DeepL キーを保存しました + API キー(任意) + インスタンス URL + 公開されている Disroot ミラー経由で初期状態で動作します — 各欄を空のままにすれば使用されます。よりプライバシーを重視する場合は、ご自身のセルフホスト URL を貼り付けてください。API キーはインスタンスが要求する場合にのみ必要です。 + 設定を保存 + LibreTranslate の設定を保存しました + 無料の Azure キーを取得 → + Azure Translator はプライバシーに配慮しています(デフォルトで No-Trace — 入力テキストは保存も学習にも使われません)。無料プランは月間 200 万文字までカバーします。Region は非グローバルリソースの場合のみ必要です。 + サブスクリプションキー + Region(例: westeurope。グローバルの場合は空欄) + 認証情報を保存 + Microsoft Translator の認証情報を保存しました + DeepL + LibreTranslate + Microsoft + GitHub Store + アプリで使用しているライブラリ。 + オープンソースライセンス + github-store.org で表示。 + プライバシーポリシー + このアプリのソースコードを見る。 + GitHub のソースコード + GitHub、Codeberg、Forgejo リリース向けのクロスプラットフォーム アプリストア。 + 過去のリリースノート。 + 新着情報 + 下記で個別に上書きしない限り、すべての通信に適用されます。 + カスタム + 以下から接続モードを選択してください。多くの方は「プロキシなし」のままで問題ありません。 + アプリのインターネット接続方法 + メイン接続 + HTTP/HTTPS + 大半の社内プロキシ。 + プロキシなし + SOCKS5 + Tor、SSH トンネル。 + システム + 各スコープはデフォルトでメイン接続を使用します。「カスタム」を選ぶと、個別の設定を保持できます。 + スコープ別の上書き + 完全な URL を貼り付け + 完全なプロキシ URL を貼り付ければ、フォームを自動で入力します。 + この URL を使う + この URL を読み取れませんでした。 + scheme://user:pass@host:port + プロキシ URL を貼り付け + GitHub API、検索、リポジトリ詳細。 + 検索とメタデータ + APK とアセットのダウンロード。 + ダウンロード + DeepL、Microsoft、LibreTranslate の通信。 + 翻訳 + テスト + メインを使う + アクセストークン + アプリ情報 + 外観 + 接続 + フィードバックを送る + インストール方法 + 言語 + プライバシー + ソース + ストレージ + タップして管理 + 翻訳 + 更新の挙動 + サイレントインストールとインストーラー帰属は Android 限定の機能です。デスクトップ版のインストールは OS のパッケージマネージャー経由で行われます。 + インストール方法は Android 専用です + 言語を切り替えるとアプリが再起動します。 + いずれかの項目をタップするとプロジェクトページが開きます。 + GitHub Store はこれらのライブラリに支えられています。 + オープンソースライセンス + 閲覧履歴 + 開いたことのあるリポジトリの記録を消去します。 + スターやお気に入りは解除されません。 + 消去 + 閲覧履歴を消去しますか? + 閲覧履歴を消去 + github.com または codeberg.org のリンクをコピーすると、開くかどうかを確認します。 + クリップボードからリポジトリのリンクを検出 + フィードと検索からミュートしたリポジトリ。 + 非表示のリポジトリ + 既読のリポジトリをフィードと検索から除外します。 + 既読のリポジトリを隠す 識別子は収集しません。 OS とプラットフォーム。 「%1$s」に一致する設定はありません。 + 設定を検索 + Forgejo または Gitea ホストを追加 + 追加したホスト + GitHub ミラー + デフォルトでは GitHub を検索します。地域ミラー経由でルーティングしたり、独自の Forgejo / Gitea ホストを追加したりできます。 + アプリがリポジトリを探す場所 + デフォルト(github.com) + 更新を素早く再開できるよう、インストーラーを保持しています。 + 消去 + ダウンロード済み APK + 0 B + 使用中: %1$s + この画面は進行中のリデザインの一部です。設定は引き続き動作します — 旧来のレイアウトからここへ移動している途中です。 + 今後のアップデートで実装予定 + + %1$d 件非表示 + + つながる + コミュニティに参加 + 6 か所 + ビジネスのお問い合わせ + パートナーシップ、プレス、連携。 + お問い合わせ + ライブラリ + アップデート + アカウント + モード + 表示 + ダイナミック + 他のサインイン方法 + オプションを隠す + ディスカバリー対象プラットフォーム + ホームフィードを特定のプラットフォームで絞り込みます。すべてオフで全表示。 + アプリ情報 + バージョン、コミュニティ、法的情報 + ここでインストール可能 + このプラットフォーム向けのインストール可能なアセットを持つリポジトリはありません + %1$d 時間ごと + 毎日 + %1$d 日ごと + 毎週 + 2 週間ごと + 30 日ごと diff --git a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml index 54e372517..eb0b7607b 100644 --- a/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml +++ b/core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml @@ -3,8 +3,6 @@ GitHub Store - - 설치된 앱 뒤로 이동 업데이트 확인 @@ -12,7 +10,6 @@ %1$s을(를) 실행할 수 없습니다 %1$s을(를) 열지 못했습니다 %1$s 업데이트 실패: %2$s - 업데이트 실패 모두 업데이트 실패: %1$s 모든 앱이 성공적으로 업데이트되었습니다 사용 가능한 업데이트가 없습니다 @@ -60,12 +57,9 @@ GitHub로 로그인 - 취소됨 알 수 없는 오류 - 언어: - 저장소 탐색 저장소, 설명 검색… @@ -113,46 +107,31 @@ 프로필 - - 외관 - 언어 - 앱의 UI 언어를 재정의합니다. - 앱 언어 - 앱 전체의 메뉴, 버튼, 메시지를 변경합니다. GitHub의 콘텐츠는 변경하지 않습니다. 시스템 따름 새 언어를 적용하려면 다시 시작하세요. 다시 시작 - 정보 - 네트워크 테마 색상 AMOLED 블랙 테마 다크 모드용 순수 블랙 배경 - 선택된 색상: %1$s - - 버전 - 도움말 및 지원 로그아웃 성공적으로 로그아웃되었습니다. 이동 중... - 캐시가 성공적으로 삭제되었습니다 경고! 정말 로그아웃하시겠습니까? - 동적 - 오션 - 퍼플 포레스트 - 슬레이트 - 앰버 + Nord + Cream + Plum 저장소 열기 @@ -160,8 +139,6 @@ 다운로드 취소 설치 옵션 보기 - - 상세 정보를 불러오는 중 오류 발생 다시 시도 설명이 없습니다. 릴리스 노트가 없습니다. @@ -170,7 +147,6 @@ 이 앱 정보 설치 로그 - 작성자 새로운 기능 @@ -178,8 +154,6 @@ 업데이트 가능 사용 불가 최신 버전 설치 - 재설치 - 앱 업데이트 다운로드 중 @@ -208,14 +182,9 @@ GitHub에 더 이상 결과가 없습니다 GitHub에서 가져오기 실패. 다시 시도하세요. - - %1$s 작성 - • 설치됨: %1$s 아키텍처 호환 %1$s(으)로 업데이트 - - 상세 정보를 불러오지 못했습니다 설치 파일이 다운로드 폴더에 저장되었습니다 @@ -241,30 +210,13 @@ 오류: %1$s - .%1$s 파일 형식은 지원되지 않습니다 - 다운로드된 파일을 찾을 수 없습니다 - 인기 - 핫 릴리스 - 가장 인기 있는 - 개인정보 - 미디어 - 생산성 - 네트워크 - 개발 도구 저장소를 찾는 중... - 더 불러오는 중... - 더 이상 저장소가 없습니다 다시 시도 저장소를 불러오지 못했습니다 - 상세 보기 - 방금 업데이트됨 - %1$d시간 전 업데이트됨 - 어제 업데이트됨 - %1$d일 전 업데이트됨 %1$s에 업데이트됨 요청 한도 초과 @@ -313,8 +265,6 @@ 닫기 별표 저장소 동기화에 실패했습니다 - 개발자 프로필 - 개발자 프로필 열기 저장소를 불러오지 못했습니다 @@ -328,7 +278,6 @@ 저장소 검색… 검색 지우기 - 전체 릴리스 포함 설치됨 즐겨찾기 @@ -340,7 +289,6 @@ 저장소 - 저장소 총 %2$d개 중 %1$d개의 저장소 표시 @@ -348,8 +296,6 @@ 설치된 저장소가 없습니다 즐겨찾기 저장소가 없습니다 - - %1$s 업데이트됨 릴리스 있음 @@ -371,7 +317,7 @@ 검색 - + 라이브러리 프로필 포크 @@ -402,35 +348,21 @@ 마지막 확인: %1$s - 확인한 적 없음 방금 %1$d분 전 %1$d시간 전 업데이트 확인 중… - - 프록시 유형 - 없음 - 시스템 - HTTP - SOCKS 호스트 포트 사용자 이름 (선택 사항) 비밀번호 (선택 사항) 프록시 저장 프록시 설정이 저장되었습니다 - 기기의 프록시 설정을 사용합니다 - 포트는 1–65535 사이여야 합니다 - 직접 연결, 프록시 없음 프록시 설정을 저장하지 못했습니다 프록시 호스트가 필요합니다 유효한 호스트 이름 또는 IP 주소를 입력하세요 잘못된 프록시 포트 - 비밀번호 표시 - 비밀번호 숨기기 - 테스트 - 테스트 중… 연결 성공 (%1$d ms) 호스트를 확인할 수 없습니다. 프록시 주소를 확인하세요. 프록시 서버에 연결할 수 없습니다. @@ -438,20 +370,8 @@ 프록시 인증이 필요합니다. 예기치 않은 응답: HTTP %1$d 연결 테스트 실패 - 각 카테고리는 자체 프록시를 사용할 수 있습니다. 독립적으로 구성하십시오. - 탐색 (GitHub API) - 홈, 검색, 저장소 상세, 업데이트 확인 - 다운로드 - APK 다운로드 및 자동 업데이트 - 번역 - README 번역 서비스 - - 이 앱 추적 - 앱이 추적 목록에 추가되었습니다 - 앱 추적 실패: %1$s - 이미 추적 중인 앱입니다 GitHub에 로그인 @@ -468,7 +388,6 @@ 다시 로그인 게스트로 계속 로컬 세션과 캐시 데이터가 삭제됩니다. 접근을 완전히 취소하려면 GitHub Settings > Applications를 방문하세요. - 코드 만료까지 %1$s 디바이스 코드가 만료되었습니다. 새 코드를 받으려면 다시 로그인해 주세요. 인터넷 연결을 확인하고 다시 시도하세요. @@ -485,70 +404,32 @@ 링크 공유에 실패했습니다 링크가 클립보드에 복사되었습니다 - - 번역 - 번역 중… - 원문 보기 - %1$s로 번역됨 번역 대상… 언어 검색 - 언어 변경 번역 실패. 다시 시도해주세요. GitHub 링크 열기 클립보드에서 GitHub 링크 감지됨 - 클립보드 링크 자동 감지 - 검색을 열 때 클립보드에서 GitHub 링크를 자동으로 감지 감지된 링크 앱에서 열기 클립보드에서 GitHub 링크를 찾을 수 없습니다 - 저장 공간 - 캐시 지우기 - 현재 크기: 지우기 - GitHub Store 지원 - 프로젝트 지원하기 - 영원히 무료.\n여러분이 후원합니다. - 여러분의 후원이 GitHub Store를 무료로, 독립적으로, 활발히 유지보수되도록 합니다 — 광고 없음, 추적 없음, 오픈소스 앱을 설치하는 더 나은 방법입니다. - 학업을 마치면서 이 프로젝트를 혼자 개발하고 유지하고 있습니다. 모든 기여 — 크든 작든 — 서버 비용, 개발, 그리고 모두를 위해 앱을 광고 없이 유지하는 데 직접 사용됩니다. - GitHub Store에 투표하세요! - GitHub Store가 KotlinConf 2026 Golden Kodee Awards 후보에 올랐습니다. - 1. 등록 - 2. 투표 - 투표 마감: 3월 22일 - 1. 플랫폼에 등록 (Google로 계속) - 2. 아래에서 투표 버튼 누르기 - 3. Usmon Narzullayev를 찾아 투표 클릭 - GitHub Sponsors - 월간 또는 일회성 — 개발을 직접 후원 - Buy Me a Coffee - 간편한 일회성 기여 - 다른 도움 방법 - 저장소에 스타 주기 - 가시성을 높이고 다른 사람들이 찾을 수 있도록 도움 - 버그 신고 - 모든 신고가 모두를 위해 앱을 개선합니다 - 친구와 공유 - 입소문이 최고의 마케팅입니다 - 모든 기여 — 금전적이든 아니든 — 진정한 변화를 만듭니다. 함께해 주셔서 감사합니다. - - 설치 기본 표준 시스템 설치 대화 상자 Shizuku @@ -565,16 +446,10 @@ 앱 자동 업데이트 Shizuku를 통해 백그라운드에서 자동으로 업데이트 다운로드 및 설치 - 업데이트 업데이트 확인 주기 백그라운드에서 앱 업데이트를 확인하는 빈도 백그라운드 업데이트 확인 백그라운드에서 주기적으로 업데이트를 확인합니다. 배터리를 절약하려면 끄세요 — 앱 세부 정보 화면에서 언제든 수동으로 확인할 수 있습니다. - 3시간 - 6시간 - 12시간 - 24시간 - 백그라운드 업데이트 허용 이 기기는 백그라운드 작업을 적극적으로 종료해요. 배터리 최적화에서 GitHub Store를 화이트리스트에 추가하면 예약된 업데이트 확인과 사일런트 설치가 안정적으로 동작해요. 설정 열기 @@ -589,18 +464,13 @@ 확인 중… 연결 및 추적 최신 릴리스 확인 중… - 확인용 APK 다운로드 중… 서명 키 확인 중… 패키지 이름 불일치: APK는 %1$s이지만 선택된 앱은 %2$s입니다 서명 키 불일치: 이 저장소의 APK는 다른 개발자가 서명했습니다 설치 파일 선택 설치된 앱과 대조할 APK를 선택하세요 - 다운로드 실패 내보내기 가져오기 - 앱 가져오기 - 내보낸 JSON을 붙여넣어 추적 중인 앱을 복원하세요 - 내보낸 JSON을 여기에 붙여넣기… 기본 베타 채널 새로 추적한 앱은 기본적으로 베타 빌드를 포함합니다. 이미 추적 중인 앱은 자체 설정을 유지합니다(앱 세부 정보 화면에서 전환). 앱을 제거하시겠습니까? @@ -613,9 +483,6 @@ %1$s이(가) %2$s/%3$s에 연결됨 내보내기 실패: %1$s 가져오기 실패: %1$s - %1$d개의 앱을 가져왔습니다 - , %1$d개 건너뜀 - , %1$d개 실패 서명 키가 변경됨 이 앱의 서명 인증서가 처음 설치된 이후 변경되었습니다.\n\n개발자가 서명 키를 교체했거나 바이너리가 변조되었을 수 있습니다.\n\n예상: %1$s\n수신: %2$s 그래도 설치 @@ -628,8 +495,6 @@ 여러 에셋 사용 가능 이 릴리스에 여러 설치 가능한 파일이 있습니다. 목록을 검토하고 기기에 맞는 파일을 선택하세요. 정보 - 재시도 - 자동 감지: %1$s 언어 선택 패키지 불일치: APK는 %1$s이지만 설치된 앱은 %2$s입니다. 업데이트가 차단되었습니다. 서명 키 불일치: 업데이트가 다른 개발자에 의해 서명되었습니다. 업데이트가 차단되었습니다. @@ -642,27 +507,13 @@ 넓게 매우 넓게 - 본 저장소 숨기기 - 이미 본 저장소를 디스커버리 피드에서 숨깁니다 - 조회 기록 삭제 - 모든 조회 기록을 초기화하여 피드에 다시 표시합니다 조회 기록이 삭제되었습니다 확인함 내가 소유한 저장소 저장소 숨기기 - 저장소가 숨겨졌습니다 - 실행 취소 - 개인 정보 보호 - 검색 개선에 도움 주기 - 재설정 가능한 분석 ID에 연결된 사용 데이터(검색, 설치, 상호작용)를 공유합니다. 계정 정보는 공유되지 않습니다. - 분석 ID 재설정 - 새 익명 ID를 생성하여 과거 원격 측정과의 연결을 끊습니다. - 분석 ID가 재설정되었습니다 최근 본 항목 방문한 저장소 - 다운로드된 패키지 - GitHub 릴리스의 APK 및 설치 파일 모두 삭제 모든 다운로드를 삭제하시겠습니까? 모든 APK 및 설치 파일이 영구적으로 삭제됩니다 (%1$s). 언제든 다시 다운로드할 수 있습니다. @@ -673,9 +524,7 @@ 모두 지우기 삭제 - 조정 조정 - 프리릴리스 에셋 필터 @@ -733,8 +582,6 @@ 설치 준비 완료 설치 (준비됨) - - 번역 README 번역에 사용할 서비스를 선택하세요. 번역 서비스 Google은 전 세계적으로 설정 없이 작동합니다. Youdao는 중국 본토에서 작동하지만 Youdao 개발자 포털의 API 자격 증명이 필요합니다. @@ -777,8 +624,6 @@ ghp_… 또는 github_pat_… 로그인 취소 - 토큰 표시 - 토큰 숨기기 토큰을 붙여넣어 주세요. GitHub 토큰이 아닌 것 같습니다. 토큰은 ghp_ 또는 github_pat_로 시작합니다. 이 토큰은 유효하지 않거나 취소되었습니다. @@ -799,13 +644,11 @@ 릴리스 채널 선택 이 앱의 안정 릴리스와 베타 빌드를 전환하려면 탭하세요. 확인 - 이 앱의 베타 릴리스 전환 %1$s 안정 버전으로 전환 %1$d개월간 안정 릴리스 없음 %1$d일간 안정 릴리스 없음 사전 릴리스가 활발하지만 프로젝트가 한동안 안정 빌드를 출시하지 않았습니다. 베타가 안정 릴리스로 이어지지 않을 수 있습니다. %1$s 이후 변경 사항 - — %1$s — 이 저장소에서 연결 해제 - 더 많은 옵션 이 앱의 연결을 해제하시겠습니까? 이 저장소에서 설치된 앱으로 %1$s 추적을 중단합니다. 앱은 기기에 그대로 남습니다 — 연결만 제거됩니다. 연결 해제 @@ -914,7 +755,6 @@ - 피드백 보내기 피드백 보내기 닫기 카테고리 @@ -952,15 +792,12 @@ ═══════════════════════════════════════════════════════════════ --> 사용자 포지 - GHS가 인식할 Forgejo / Gitea 호스트를 추가하세요 - %1$d개 추가됨 사용자 포지 Forgejo 또는 Gitea 인스턴스의 호스트명을 입력하세요 (예: git.example.com). 수동 연결 시트에서 이러한 호스트의 URL을 GHS가 받습니다. 추가 아직 사용자 포지가 없습니다. 완료 - 다운로드 미러 다운로드 미러 릴리스 파일 다운로드에 사용됩니다. GitHub API 호출은 항상 직접 이루어집니다. 대부분의 사용자는 GitHub 직접으로 두는 것이 좋습니다. 공식 @@ -976,7 +813,6 @@ 템플릿에는 {url}이 정확히 한 번 포함되어야 합니다 저장 선택한 항목 테스트 - 테스트 중… %1$dms에 도달 미러가 %1$d를 반환했습니다 5s 후 타임아웃 @@ -984,12 +820,6 @@ 실패: %1$s 모든 미러가 다운되었나요? 5분 안에 직접 호스팅할 수 있습니다 — 문서를 참조하세요. %1$s를 더 이상 사용할 수 없어 GitHub 직접으로 전환했습니다. - 체크섬 불일치 — 파일이 변조되었을 수 있습니다 - 더 빠른 미러를 사용해 볼까요? - 느린 네트워크에서 일부 사용자는 커뮤니티 프록시를 사용할 때 더 나은 경험을 합니다. - 선택하기 - 나중에 - 다시 묻지 않기 별표한 항목에서 추가 @@ -1000,13 +830,7 @@ 보안 상태 설문조사 - 확인 후 닫기 - %1$d일 지금 공유할 내용이 없습니다. - %1$d시간 - 방금 전 - %1$s 전에 새로 고침됨 - %1$d분 비활성화할 수 없음 표시할 항목 알림 끄기 설정 열기 @@ -1063,7 +887,6 @@ 접힘 펼침 최신 - 업데이트 사용 가능 %1$s의 보관된 설치 프로그램을 삭제하고 앱 목록에서 행을 제거하시겠습니까? 다운로드된 APK가 삭제됩니다. 대기 중인 설치를 삭제할까요? 권한 부여 @@ -1149,7 +972,6 @@ 신규 %1$s의 새로운 기능 새로운 기능 - 변경 로그는 영어로 제공됩니다. Issues를 통한 번역을 환영합니다. 버전 %1$s · %2$s @@ -1171,7 +993,6 @@ 설정을 업데이트할 수 없습니다 숨긴 저장소 홈과 검색에서 숨긴 저장소를 관리합니다. - %1$d개 숨김 숨긴 저장소 없음 홈 또는 검색에서 저장소 카드를 길게 눌러 탐색에서 숨길 수 있습니다. 표시 @@ -1187,11 +1008,273 @@ Windows macOS Linux - 다른 플랫폼 — 전송용 저장을 위해 브라우저에서 열립니다 내 기기 전송용 Android를 열린 상태로 Google의 개발자 인증 정책이 Android의 개방적인 배포를 위협합니다. GitHub Store는 연합을 지지합니다. 자세히 보기 배너 닫기 + + + 왼쪽의 카드를 탭하면 여기에서 세부 정보를 볼 수 있어요. + 저장소를 선택하세요 + 설치 준비 완료 + 고급 설정 + 빠른 작업 + 설정 + 상태 + 왼쪽에서 앱을 선택하면 세부 정보와 작업을 볼 수 있어요. + 앱을 선택하세요 + 설치됨 + 최신 + 저장소 열기 + 변형 선택 + 목록 숨기기 + 목록 보기 + 한 번에 검토하거나 모두 업데이트하세요. + 1개 업데이트 사용 가능 + %1$d개 업데이트 사용 가능 + 뒤로 + 뒤로 + 이미지 + 전체 보기 › + + %1$d개 호스트 + + Codeberg, gitea.com 및 git.disroot.org는 이미 기본으로 검색됩니다 — 여기에 추가할 필요가 없어요. 이 목록은 직접 호스팅하는 Forgejo 또는 Gitea에만 사용하세요. + 정보 + 개발자 + 통계 + 프로필 보기 + 새로운 기능 + 건너뛰고 나중에 검토 + 모든 신고를 확인합니다. + 받기 + #%1$d + 내 별표 저장소 + 인기 릴리스 + 가장 인기 있음 + 지금 인기 있는 항목 + 둘러보기 + 전체 보기 + 더 보기 + 토큰 추가 + 뒤로 + 취소 + 삭제 + 저장 + 토큰 추가 + 감지됨: %1$s + 포지 주소 + 비워두면 기존 토큰이 유지됩니다. 새 값을 입력하면 교체됩니다. + %1$s 토큰 교체 + %1$s에 연결됩니다 + + %1$d개 토큰 + + +를 탭하여 개인 액세스 토큰을 추가하세요 + 저장된 토큰이 없습니다 + 포지 호스트별 개인 액세스 토큰 (GitHub, Codeberg, Forgejo) + 표시 이름 (선택 사항) + 개인 액세스 토큰 + 앱을 통해 GitHub에 로그인되어 있습니다 — 권장 방법이에요. 브라우저 로그인이 불가능한 경우(제한된 네트워크, 브라우저 없음)나 GitHub 외 다른 포지에 한해 여기에 토큰을 추가하세요. + 토큰 페이지 열기 + 다른 포지 + Forgejo, Gitea, 자체 호스팅 + 토큰 붙여넣기 + 토큰 추가 대상… + 라벨 편집 + 유효하지 않음 · %1$s + 더 보기 + %1$s에서 관리 + 토큰 교체 + %1$s 남음 + 유효 · %1$s + 확인 + %1$s 토큰이 저장되었습니다 + 인증 토큰 + 실행 취소 + %1$s 토큰을 복원할 수 없습니다 + %1$s 토큰이 제거되었습니다 + 유효성 검사 실패: %1$s + 알 수 없는 오류 + 잘못된 호스트 + 호스트가 필요합니다 + 토큰이 필요합니다 + 토큰이 너무 짧습니다 + GitHub Store 정보 + 피드백 보내기… + 오픈 소스 라이선스 + 도움말 + 개인정보처리방침 + 앱 설정, 테마, 네트워크, 번역, 고급 옵션. + 일부 변경 사항을 적용하려면 다시 시작해야 합니다. + 나중에 + 언어 테마 + 영향: %1$s + 지금 다시 시작 + 필터 제거 + 완료 + 필터 + 모두 초기화 + 언어 + 플랫폼 + 정렬 기준 + 출처 + 결과 필터링 + + 연결 + 설치 및 업데이트 + 모양 및 느낌 + 개인정보 및 데이터 + + %1$d개 앱 + + 저장소를 열 때 README와 릴리스 노트를 자동으로 번역합니다. 앱 언어가 대상으로 사용됩니다. + 앱 언어 따름 (%1$s) + 번역 대상 + 저장소 자동 번역 + 취소 + 언어 변경 + 감지된 원문: %1$s + 다시 시도 + 원문 보기 + 번역 보기 + 이 페이지를 다른 언어로 표시합니다. + 원문 표시 중 + %1$s(으)로 번역됨 + 번역 중… + 대상 언어 + 번역 + %1$s(으)로 번역 + 인증 키 + 무료 DeepL 키 받기 → + deepl.com/pro-api에서 가입하세요. 무료 등급 키는 :fx로 끝나며 무료 엔드포인트를 자동으로 사용합니다. Pro 등급은 텍스트를 비공개로 유지합니다. 무료 등급은 제출된 텍스트를 모델 개선에 사용할 수 있습니다. + 키 저장 + DeepL 키가 저장되었습니다 + API 키 (선택 사항) + 인스턴스 URL + 번들된 Disroot 미러를 통해 기본으로 동작합니다 — 필드를 비워두면 사용됩니다. 더 강한 프라이버시를 원하면 자체 호스팅 URL을 붙여넣으세요. API 키는 인스턴스에서 요구하는 경우에만 필요합니다. + 설정 저장 + LibreTranslate 설정이 저장되었습니다 + 무료 Azure 키 받기 → + Azure Translator는 프라이버시를 존중합니다 (기본 No-Trace — 텍스트가 저장되거나 학습에 사용되지 않습니다). 무료 등급은 월 200만 자를 제공합니다. 지역은 글로벌이 아닌 리소스에만 필요합니다. + 구독 키 + 지역 (예: westeurope, 글로벌은 비워두세요) + 자격 증명 저장 + Microsoft Translator 자격 증명이 저장되었습니다 + DeepL + LibreTranslate + Microsoft + GitHub Store + 앱에서 사용하는 라이브러리. + 오픈 소스 라이선스 + github-store.org에서 보기. + 개인정보처리방침 + 이 앱의 소스 보기. + GitHub의 소스 코드 + GitHub, Codeberg, Forgejo 릴리스를 위한 크로스 플랫폼 앱 스토어. + 지난 릴리스 노트. + 새로운 기능 + 아래에서 재정의하지 않는 한 모든 트래픽에 적용됩니다. + 사용자 지정 + 아래에서 연결 모드를 선택하세요. 대부분의 사용자는 프록시 없음으로 둡니다. + 앱이 인터넷에 연결되는 방식 + 주 연결 + HTTP/HTTPS + 대부분의 기업용 프록시. + 프록시 없음 + SOCKS5 + Tor, SSH 터널. + 시스템 + 각 스코프는 기본적으로 주 연결을 사용합니다. 자체 설정을 유지하려면 '사용자 지정'을 선택하세요. + 스코프별 재정의 + 전체 URL 붙여넣기 + 전체 프록시 URL을 붙여넣으면 양식을 자동으로 채워 드립니다. + 이 URL 사용 + URL을 읽을 수 없었어요. + scheme://user:pass@host:port + 프록시 URL 붙여넣기 + GitHub API, 검색, 저장소 세부 정보. + 검색 및 메타데이터 + APK 및 에셋 다운로드. + 다운로드 + DeepL, Microsoft, LibreTranslate 호출. + 번역 + 테스트 + 주 연결 사용 + 액세스 토큰 + 앱 정보 + 모양 + 연결 + 피드백 보내기 + 설치 방법 + 언어 + 개인정보 + 출처 + 저장소 + 관리하려면 탭하세요 + 번역 + 업데이트 동작 + 사일런트 설치 프로그램과 설치 프로그램 출처 설정은 Android 전용 기능입니다. 데스크톱 설치는 OS 패키지 관리자를 통해 이루어집니다. + 설치 방법은 Android 전용입니다 + 언어를 전환하면 앱이 다시 시작됩니다. + 항목을 탭하면 해당 프로젝트 페이지가 열립니다. + GitHub Store는 이 라이브러리들에 의지하고 있어요. + 오픈 소스 라이선스 + 탐색 기록 + 이미 열어본 저장소 기록을 잊습니다. + 별표나 즐겨찾기는 해제되지 않습니다. + 지우기 + 조회 기록을 지우시겠어요? + 조회 기록 지우기 + github.com 또는 codeberg.org 링크를 복사하면 열도록 안내합니다. + 클립보드에서 저장소 링크 감지 + 피드와 검색에서 숨긴 저장소입니다. + 숨긴 저장소 + 피드와 검색에서 본 저장소를 건너뜁니다. + 이미 본 저장소 숨기기 식별자 없음. OS 및 플랫폼. '%1$s'와(과) 일치하는 설정이 없습니다. + 설정 검색 + Forgejo 또는 Gitea 호스트 추가 + 추가된 호스트 + GitHub 미러 + 앱은 기본적으로 GitHub를 검색합니다. 지역 미러를 통해 라우팅하거나 사용자 지정 Forgejo / Gitea 호스트를 추가하세요. + 앱이 저장소를 찾는 위치 + 기본 (github.com) + 업데이트를 빠르게 재개할 수 있도록 설치 파일을 보관합니다. + 지우기 + 다운로드된 APK + 0 B + 사용 중: %1$s + 이 화면은 진행 중인 디자인 개편의 일부입니다. 설정은 정상 동작하며 — 이전 레이아웃에서 이곳으로 옮겨지는 중일 뿐입니다. + 후속 업데이트 예정 + + %1$d개 숨김 + + 연결 + 커뮤니티 참여 + 6곳 + 비즈니스 문의 + 파트너십, 보도, 연동. + 문의 + 라이브러리 + 업데이트 + 계정 + 모드 + 디스플레이 + 다이내믹 + 추가 로그인 옵션 + 옵션 숨기기 + 디스커버리 플랫폼 + 홈 피드를 특정 플랫폼으로 필터링합니다. 모두 꺼두면 전체 표시. + 정보 + 버전, 커뮤니티, 법적 고지 + 여기서 설치 가능 + 이 플랫폼용 설치 가능한 자산이 있는 저장소가 없습니다 + %1$d 시간마다 + 매일 + %1$d 일마다 + 매주 + 2주마다 + 30일마다 diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index 4d7e789e0..a4bc7e853 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -2,14 +2,12 @@ GitHub Store - Zainstalowane aplikacje Cofnij Sprawdź aktualizacje Nie można uruchomić %1$s Nie udało się otworzyć %1$s Nie udało się zaktualizować %1$s: %2$s - Aktualizacja nie powiodła się Aktualizacja wszystkich nie powiodła się: %1$s Wszystkie aplikacje zostały pomyślnie zaktualizowane Brak dostępnych aktualizacji @@ -50,12 +48,9 @@ Zaloguj się przez GitHub - Anulowano Nieznany błąd - Język: - Odkrywaj repozytoria Szukaj repozytorium, opisu… Filtruj według języka @@ -99,39 +94,26 @@ Profil - WYGLĄD - JĘZYK - Zmień język interfejsu aplikacji. - Język aplikacji - Zmienia menu, przyciski i komunikaty w całej aplikacji. Nie zmienia treści pochodzącej z GitHub. Taki jak system Uruchom ponownie, aby zastosować nowy język. Uruchom ponownie - O APLIKACJI - SIEĆ Kolor motywu Motyw AMOLED Black Czyste czarne tło dla trybu ciemnego - Wybrany kolor: %1$s - Wersja - Pomoc i wsparcie Wyloguj się Wylogowano pomyślnie, przekierowywanie... - Pamięć podręczna wyczyszczona pomyślnie Ostrzeżenie! Czy na pewno chcesz się wylogować? - Dynamiczny - Ocean - Fioletowy Las - Łupek - Bursztyn + Nord + Cream + Plum Otwórz repozytorium Otwórz w przeglądarce @@ -139,22 +121,18 @@ Pokaż opcje instalacji Zgłoś problem - Błąd podczas ładowania szczegółów Ponów Brak opisu. Brak informacji o wydaniu. O tej aplikacji Logi instalacji - Autor Co nowego Zainstalowano Dostępna aktualizacja Niedostępne Zainstaluj najnowszą - Zainstaluj ponownie - Aktualizuj aplikację Pobieranie Aktualizowanie @@ -179,12 +157,9 @@ Brak więcej wyników na GitHub Nie udało się pobrać z GitHub. Spróbuj ponownie. - autor: %1$s - • Zainstalowana: %1$s Architektura zgodna Aktualizuj do %1$s - Nie udało się załadować szczegółów Instalator został zapisany w folderze Pobrane Rozpoczęto pobieranie @@ -207,30 +182,13 @@ Użyj aplikacji innej firmy do zainstalowania APK Błąd: %1$s - Typ pliku .%1$s nie jest obsługiwany - Nie znaleziono pobranego pliku - Na czasie - Gorące wydanie - Najpopularniejsze - Prywatność - Media - Produktywność - Sieć - Narzędzia deweloperskie Wyszukiwanie repozytoriów... - Ładowanie więcej... - Brak kolejnych repozytoriów Ponów Nie udało się załadować repozytoriów - Zobacz szczegóły - zaktualizowano przed chwilą - zaktualizowano %1$d godz. temu - zaktualizowano wczoraj - zaktualizowano %1$d dni temu zaktualizowano %1$s Limit zapytań przekroczony @@ -279,8 +237,6 @@ Zamknij Nie udało się zsynchronizować oznaczonych gwiazdką repozytoriów - Profil dewelopera - Otwórz profil dewelopera Nie udało się załadować repozytoriów @@ -294,7 +250,6 @@ Szukaj repozytoriów… Wyczyść wyszukiwanie - Wszystkie Z wydaniami Zainstalowane Ulubione @@ -306,7 +261,6 @@ repozytorium - repozytoriów Wyświetlanie %1$d z %2$d repozytoriów @@ -314,8 +268,6 @@ Brak zainstalowanych repozytoriów Brak ulubionych repozytoriów - - Zaktualizowano %1$s Ma wydanie @@ -336,7 +288,7 @@ Strona główna Szukaj - Aplikacje + Biblioteka Profil Fork @@ -367,35 +319,21 @@ Ostatnio sprawdzono: %1$s - Nigdy nie sprawdzano właśnie teraz %1$d min temu %1$d godz. temu Sprawdzanie aktualizacji… - - Typ proxy - Brak - Systemowy - HTTP - SOCKS Host Port Nazwa użytkownika (opcjonalnie) Hasło (opcjonalnie) Zapisz Proxy Ustawienia proxy zostały zapisane - Używa ustawień proxy urządzenia - Port musi być z zakresu 1–65535 - Połączenie bezpośrednie, bez proxy Nie udało się zapisać ustawień proxy Host proxy jest wymagany Wprowadź prawidłową nazwę hosta lub adres IP Nieprawidłowy port proxy - Pokaż hasło - Ukryj hasło - Testuj - Testowanie… Połączenie OK (%1$d ms) Nie można rozwiązać hosta. Sprawdź adres proxy. Nie można połączyć się z serwerem proxy. @@ -403,20 +341,8 @@ Wymagane uwierzytelnienie proxy. Nieoczekiwana odpowiedź: HTTP %1$d Test połączenia nie powiódł się - Każda kategoria może używać własnego proxy. Skonfiguruj je niezależnie. - Odkrywanie (GitHub API) - Strona główna, wyszukiwanie, szczegóły repo i sprawdzanie aktualizacji - Pobieranie - Pobieranie APK i automatyczne aktualizacje - Tłumaczenie - Usługa tłumaczenia README - - Śledź tę aplikację - Aplikacja dodana do listy śledzonych - Nie udało się śledzić aplikacji: %1$s - Aplikacja jest już śledzona Zaloguj się przez GitHub @@ -433,7 +359,6 @@ Zaloguj się ponownie Kontynuuj jako gość Spowoduje to wyczyszczenie lokalnej sesji i danych z pamięci podręcznej. Aby całkowicie cofnąć dostęp, odwiedź GitHub Settings > Applications. - Kod wygasa za %1$s Kod urządzenia wygasł. Spróbuj zalogować się ponownie, aby uzyskać nowy kod. Sprawdź połączenie internetowe i spróbuj ponownie. @@ -450,71 +375,33 @@ Nie udało się udostępnić linku Link skopiowany do schowka - - Tłumacz - Tłumaczenie… - Pokaż oryginał - Przetłumaczono na %1$s Tłumacz na… Szukaj języka - Zmień język Tłumaczenie nie powiodło się. Spróbuj ponownie. Otwórz link GitHub Wykryto link GitHub w schowku - Automatyczne wykrywanie linków ze schowka - Automatycznie wykrywaj linki GitHub ze schowka przy otwieraniu wyszukiwania Wykryte linki Otwórz w aplikacji Nie znaleziono linku GitHub w schowku - Przechowywanie - Wyczyść pamięć podręczną - Aktualny rozmiar: Wyczyść - Wesprzyj GitHub Store - Wesprzyj projekt - Darmowy na zawsze.\nFinansowany przez Ciebie. - Twoje wsparcie utrzymuje GitHub Store darmowym, niezależnym i aktywnie rozwijanym — bez reklam, bez śledzenia, po prostu lepszy sposób na instalowanie aplikacji open-source. - Tworzę i utrzymuję ten projekt samodzielnie, kończąc jednocześnie szkołę. Każdy wkład — duży czy mały — idzie bezpośrednio na koszty serwera, rozwój i utrzymanie aplikacji bez reklam dla wszystkich. - Głosuj na GitHub Store! - GitHub Store został nominowany do Golden Kodee Awards na KotlinConf 2026. - 1. Zarejestruj się - 2. Głosuj - Głosowanie kończy się 22 marca - 1. Zarejestruj się na platformie (Kontynuuj z Google) - 2. Kliknij Głosuj poniżej - 3. Znajdź Usmon Narzullayev i kliknij Głosuj - GitHub Sponsors - Miesięczne lub jednorazowe — bezpośrednio finansuje rozwój - Buy Me a Coffee - Szybki jednorazowy wkład - INNE SPOSOBY POMOCY - Dodaj gwiazdkę repozytorium - Zwiększa widoczność i pomaga innym go znaleźć - Zgłoś błąd - Każdy raport czyni aplikację lepszą dla wszystkich - Udostępnij znajomym - Polecenie to najlepszy marketing - Każdy wkład — finansowy lub nie — naprawdę robi różnicę. Dziękuję, że jesteś częścią tego. - - Instalacja Domyślny Standardowe okno instalacji systemowej Shizuku @@ -531,16 +418,10 @@ Automatyczna aktualizacja Automatycznie pobieraj i instaluj aktualizacje w tle przez Shizuku - Aktualizacje Częstotliwość sprawdzania Jak często sprawdzać aktualizacje aplikacji w tle Sprawdzanie aktualizacji w tle Okresowo sprawdza aktualizacje w tle. Wyłącz, aby oszczędzać baterię — zawsze możesz sprawdzić ręcznie z ekranu szczegółów dowolnej aplikacji. - 3g - 6g - 12g - 24g - Zezwól na aktualizacje w tle Twoje urządzenie agresywnie zamyka zadania w tle. Wyłącz optymalizację baterii dla GitHub Store, aby zaplanowane sprawdzenia aktualizacji i ciche instalacje działały niezawodnie. Otwórz ustawienia @@ -555,18 +436,13 @@ Weryfikacja… Połącz i śledź Sprawdzanie najnowszego wydania… - Pobieranie APK do weryfikacji… Weryfikacja klucza podpisu… Niezgodność nazwy pakietu: APK to %1$s, ale wybrana aplikacja to %2$s Niezgodność klucza podpisu: APK z tego repozytorium został podpisany przez innego programistę Wybierz instalator Wybierz APK do weryfikacji z zainstalowaną aplikacją - Pobieranie nie powiodło się Eksportuj Importuj - Importuj aplikacje - Wklej wyeksportowany JSON, aby przywrócić śledzone aplikacje - Wklej wyeksportowany JSON tutaj… Domyślny kanał beta Nowo śledzone aplikacje domyślnie zawierają wersje beta. Już śledzone aplikacje zachowują własne ustawienie (zmień je na ekranie Szczegółów aplikacji). Odinstalować aplikację? @@ -579,9 +455,6 @@ %1$s połączono z %2$s/%3$s Eksport nie powiódł się: %1$s Import nie powiódł się: %1$s - Zaimportowano %1$d aplikacji - , %1$d pominięto - , %1$d nie powiodło się Klucz podpisu zmieniony Certyfikat podpisu tej aplikacji zmienił się od pierwszej instalacji.\n\nMoże to oznaczać, że programista zmienił klucz podpisu lub plik binarny mógł zostać zmodyfikowany.\n\nOczekiwano: %1$s\nOtrzymano: %2$s Zainstaluj mimo to @@ -594,8 +467,6 @@ Dostępnych wiele zasobów Dla tego wydania dostępnych jest wiele plików do zainstalowania. Przejrzyj listę i wybierz odpowiedni dla swojego urządzenia. Informacje - Ponów - Wykryto automatycznie: %1$s Wybierz język Niezgodność pakietu: APK to %1$s, ale zainstalowana aplikacja to %2$s. Aktualizacja zablokowana. Niezgodność klucza podpisu: aktualizacja została podpisana przez innego programistę. Aktualizacja zablokowana. @@ -608,27 +479,13 @@ Szeroka Bardzo szeroka - Ukryj przeglądane repozytoria - Ukryj repozytoria, które już przeglądałeś, z kanałów odkrywania - Wyczyść historię przeglądania - Zresetuj wszystkie przeglądane repozytoria, aby ponownie pojawiły się w kanałach Historia przeglądania wyczyszczona Przeglądane Jesteś właścicielem tego repo Ukryj repozytorium - Repozytorium ukryte - Cofnij - Prywatność - Pomóż ulepszyć wyszukiwanie - Udostępniaj dane o użyciu (wyszukiwania, instalacje, interakcje) powiązane z resetowalnym identyfikatorem analitycznym. Żadne dane konta nie są udostępniane. - Zresetuj ID analityki - Wygeneruj nowy anonimowy ID, zrywając powiązanie z poprzednią telemetrią. - ID analityki zresetowany Ostatnio oglądane Repozytoria, które odwiedziłeś - Pobrane pakiety - APK i instalatory z wydań GitHub Usuń wszystko Usunąć wszystkie pobrania? Spowoduje to trwałe usunięcie wszystkich APK i instalatorów (%1$s). Możesz je pobrać ponownie w dowolnym momencie. @@ -639,9 +496,7 @@ Wyczyść wszystko Usuń - Ustawienia Ustawienia - Wersje wstępne Filtr zasobów @@ -705,8 +560,6 @@ Gotowe do instalacji Zainstaluj (gotowe) - - Tłumaczenie Wybierz usługę używaną do tłumaczenia plików README. Usługa tłumaczenia Google działa globalnie bez konfiguracji. Youdao działa z Chin kontynentalnych, ale wymaga poświadczeń API z portalu deweloperskiego Youdao. @@ -749,8 +602,6 @@ ghp_… lub github_pat_… Zaloguj się Anuluj - Pokaż token - Ukryj token Wklej token. To nie wygląda na token GitHuba. Tokeny zaczynają się od ghp_ lub github_pat_. Ten token jest nieprawidłowy lub został unieważniony. @@ -772,13 +623,11 @@ Wybierz kanał wydań Dotknij, aby przełączać między stabilnymi wydaniami a wersjami beta tej aplikacji. OK - Przełącz wydania beta dla tej aplikacji Przejdź do stabilnej wersji %1$s Brak stabilnego wydania od %1$d miesięcy Brak stabilnego wydania od %1$d dni Są aktywne wersje przedpremierowe, ale projekt od dłuższego czasu nie wydał stabilnej kompilacji. Wersje beta mogą nie prowadzić do stabilnego wydania. Co zmieniło się od %1$s - — %1$s — Odłącz od tego repozytorium - Więcej opcji Odłączyć tę aplikację? Przestaniemy śledzić %1$s jako zainstalowaną z tego repozytorium. Aplikacja pozostaje na urządzeniu — usuwane jest tylko połączenie. Odłącz @@ -905,7 +752,6 @@ - Wyślij opinię Wyślij opinię Zamknij Kategoria @@ -943,15 +789,12 @@ ═══════════════════════════════════════════════════════════════ --> Niestandardowe forge - Dodaj hosty Forgejo / Gitea, które GHS ma rozpoznawać - Dodano %1$d Niestandardowe forge Wpisz nazwę hosta instancji Forgejo lub Gitea (np. git.example.com). GHS przyjmie URL z tych hostów w formularzu ręcznego powiązania. Dodaj Brak niestandardowych forge. Gotowe - Serwer lustrzany pobierania Serwer lustrzany pobierania Używany do pobierania plików wydań. Wywołania API GitHub zawsze przechodzą bezpośrednio. Większość użytkowników powinna pozostawić to ustawienie na GitHub bezpośrednio. Oficjalny @@ -967,7 +810,6 @@ Szablon musi zawierać {url} dokładnie jeden raz Zapisz Testuj wybrany - Testowanie… Osiągnięto w %1$dms Serwer lustrzany zwrócił %1$d Przekroczono limit czasu po 5s @@ -975,12 +817,6 @@ Niepowodzenie: %1$s Wszystkie serwery lustrzane niedostępne? Możesz uruchomić własny w 5 minut — sprawdź dokumentację. %1$s nie jest już dostępny, przełączono na GitHub bezpośrednio. - Niezgodność sumy kontrolnej — plik mógł zostać zmodyfikowany - Wypróbować szybszy serwer lustrzany? - Niektórzy użytkownicy z wolnymi sieciami osiągają lepsze wyniki z proxy społeczności. - Wybierz jeden - Może później - Nie pytaj ponownie Dodaj z oznaczonych gwiazdką @@ -991,13 +827,7 @@ Bezpieczeństwo Status Ankiety - Zamknij po potwierdzeniu - %1$d d Aktualnie nic do udostępnienia. - %1$d h - przed chwilą - Odświeżono %1$s temu - %1$d min Nie można wyłączyć Pokaż elementy z Otwórz ustawienia wyciszenia @@ -1054,7 +884,6 @@ Zwinięte Rozwinięte Aktualne - Dostępne aktualizacje Odrzucić zaparkowany instalator dla %1$s i usunąć wiersz z listy aplikacji? Pobrany APK zostanie usunięty. Odrzucić oczekującą instalację? Udziel uprawnienia @@ -1140,7 +969,6 @@ Nowe Co nowego w %1$s Co nowego - Changelog jest w języku angielskim. Tłumaczenia mile widziane przez Issues. Wersja %1$s · %2$s @@ -1162,7 +990,6 @@ Nie udało się zmienić preferencji Ukryte repozytoria Zarządzaj repozytoriami ukrytymi na Ekranie głównym i w Wyszukiwarce. - %1$d ukryte Brak ukrytych repozytoriów Przytrzymaj kartę repozytorium na Ekranie głównym lub w Wyszukiwarce, aby ukryć ją z odkrywania. Pokaż @@ -1178,11 +1005,285 @@ Windows macOS Linux - Inna platforma — otwiera się w przeglądarce, aby zapisać do transferu Twoje urządzenie Do transferu Niech Android pozostanie otwarty Polityka weryfikacji deweloperów Google zagraża otwartej dystrybucji Androida. GitHub Store popiera koalicję. Dowiedz się więcej Zamknij baner + + + Dotknij dowolnej karty po lewej, aby zobaczyć jej szczegóły tutaj. + Wybierz repozytorium + Gotowe do instalacji + Ustawienia zaawansowane + Szybkie akcje + Ustawienia + Status + Wybierz dowolną aplikację po lewej, aby zobaczyć jej szczegóły i akcje. + Wybierz aplikację + Zainstalowana + Najnowsza + Otwórz repozytorium + Wybierz wariant + Ukryj listę + Zobacz listę + Przejrzyj lub zaktualizuj wszystko naraz. + 1 dostępna aktualizacja + Dostępnych aktualizacji: %1$d + Wróć + Wróć + Obraz + Zobacz wszystkie › + + %1$d host + %1$d hosty + %1$d hostów + %1$d hostów + + Codeberg, gitea.com i git.disroot.org są już domyślnie przeszukiwane — nie musisz ich tu dodawać. Tej listy używaj tylko dla własnych, samodzielnie hostowanych instancji Forgejo lub Gitea. + Informacje + Deweloper + Statystyki + Zobacz profil + Co nowego + Pomiń i przejrzyj później + Czytamy każdą wiadomość. + Pobierz + #%1$d + Z Twoich gwiazdek + Gorące wydania + Najpopularniejsze + Na czasie + Odkrywaj + Zobacz wszystkie + Zobacz więcej + Dodaj token + Wróć + Anuluj + Usuń + Zapisz + Dodaj token + Wykryto: %1$s + Adres forge + Pozostaw puste, aby zachować istniejący token. Wpisz nową wartość, aby go zastąpić. + Zastąp token dla %1$s + Połączy się z %1$s + + %1$d token + %1$d tokeny + %1$d tokenów + %1$d tokenów + + Dotknij +, aby dodać Personal Access Token + Brak zapisanych tokenów + Personal Access Tokens dla poszczególnych hostów forge (GitHub, Codeberg, Forgejo) + Nazwa wyświetlana (opcjonalnie) + Personal Access Token + Jesteś zalogowany na GitHub w aplikacji — to zalecana ścieżka. Dodaj token tutaj tylko jeśli logowanie przez przeglądarkę nie zadziała (restrykcyjna sieć, brak przeglądarki) lub dla forge innych niż GitHub. + Otwórz stronę tokenów + Inny forge + Forgejo, Gitea, własny hosting + Wklej token + Dodaj token dla… + Edytuj etykietę + Nieprawidłowy · %1$s + Więcej + Zarządzaj na %1$s + Zastąp token + pozostało %1$s + Prawidłowy · %1$s + Sprawdź + Zapisano token dla %1$s + Tokeny uwierzytelniania + Cofnij + Nie udało się przywrócić tokena dla %1$s + Usunięto token dla %1$s + Weryfikacja nie powiodła się: %1$s + nieznany błąd + Nieprawidłowy host + Host jest wymagany + Token jest wymagany + Token zbyt krótki + O GitHub Store + Wyślij opinię… + Licencje open source + Pomoc + Polityka prywatności + Ustawienia aplikacji, motyw, sieć, tłumaczenie, opcje zaawansowane. + Niektóre zmiany wymagają ponownego uruchomienia, aby zostały zastosowane. + Później + język motyw + Dotyczy: %1$s + Uruchom ponownie teraz + Usuń filtr + Gotowe + Filtry + Zresetuj wszystko + Język + Platforma + Sortuj według + Źródło + Filtruj wyniki + Aplikacja + Łączność + Instalacje i aktualizacje + Wygląd + Prywatność i dane + + %1$d aplikacja + %1$d aplikacje + %1$d aplikacji + %1$d aplikacji + + Tłumacz automatycznie README i informacje o wydaniu przy otwieraniu repozytorium. Używa języka aplikacji jako docelowego. + Zgodnie z językiem aplikacji (%1$s) + Tłumacz na + Automatyczne tłumaczenie repozytoriów + Anuluj + Zmień język + Wykryte źródło: %1$s + Ponów + Pokaż oryginał + Pokaż tłumaczenie + Wyświetl tę stronę w innym języku. + Pokazuję oryginał + Przetłumaczono na %1$s + Tłumaczenie… + Język docelowy + Przetłumacz + Przetłumacz na %1$s + Klucz uwierzytelniający + Zdobądź darmowy klucz DeepL → + Zarejestruj się na deepl.com/pro-api. Klucze darmowego planu kończą się na :fx i automatycznie używają darmowego endpointu. Plan Pro nie udostępnia Twojego tekstu; w planie darmowym przesłany tekst może być wykorzystany do trenowania modeli. + Zapisz klucz + Klucz DeepL zapisany + Klucz API (opcjonalnie) + URL instancji + Działa od ręki przez publiczny serwer lustrzany Disroot — pozostaw pola puste, aby z niego korzystać. Aby zwiększyć prywatność, wklej własny adres samodzielnego hostingu. Klucz API potrzebny tylko, jeśli instancja go wymaga. + Zapisz ustawienia + Ustawienia LibreTranslate zapisane + Zdobądź darmowy klucz Azure → + Azure Translator szanuje prywatność (domyślnie No-Trace — Twój tekst nie jest przechowywany ani używany do trenowania). Darmowy plan obejmuje 2 mln znaków miesięcznie. Region wymagany tylko dla zasobów innych niż globalne. + Klucz subskrypcji + Region (np. westeurope, pozostaw puste dla Global) + Zapisz poświadczenia + Poświadczenia Microsoft Translator zapisane + DeepL + LibreTranslate + Microsoft + GitHub Store + Biblioteki używane w aplikacji. + Licencje open source + Zobacz na github-store.org. + Polityka prywatności + Zobacz źródło tej aplikacji. + Kod źródłowy na GitHub + Wieloplatformowy sklep z aplikacjami dla wydań GitHub, Codeberg i Forgejo. + Wcześniejsze informacje o wydaniach. + Co nowego + Dotyczy całego ruchu, chyba że zostanie zastąpione poniżej. + Niestandardowe + Wybierz tryb połączenia poniżej. Większość użytkowników pozostawia opcję Bez proxy. + Jak aplikacja łączy się z internetem + Główne połączenie + HTTP/HTTPS + Większość korporacyjnych proxy. + Bez proxy + SOCKS5 + Tor, tunele SSH. + Systemowe + Każdy zakres domyślnie używa głównego połączenia. Wybierz 'Niestandardowe', aby zachować własne ustawienia. + Nadpisania per-zakres + Wklej pełny URL + Wklej pełny URL proxy, a wypełnimy formularz za Ciebie. + Użyj tego URL + Nie udało się odczytać tego URL. + scheme://user:pass@host:port + Wklej URL proxy + API GitHub, wyszukiwanie, szczegóły repozytorium. + Wyszukiwanie i metadane + Pobieranie APK i zasobów. + Pobieranie + Wywołania DeepL, Microsoft, LibreTranslate. + Tłumaczenie + Testuj + Użyj głównego + Tokeny dostępu + O aplikacji + Wygląd + Połączenie + Wyślij opinię + Metoda instalacji + Język + Prywatność + Źródła + Pamięć + Dotknij, aby zarządzać + Tłumaczenie + Zachowanie aktualizacji + Ciche instalatory i atrybucja instalatora to funkcje wyłącznie dla Androida. Instalacje na komputerze przechodzą przez menedżera pakietów systemu. + Metoda instalacji dostępna tylko na Androidzie + Aplikacja uruchamia się ponownie po zmianie języka. + Dotknij dowolnego wpisu, aby otworzyć stronę projektu. + GitHub Store opiera się na tych bibliotekach. + Licencje open source + Historia przeglądania + Zapomnij, które repozytoria zostały już otwarte. + To nie usunie gwiazdek ani ulubionych. + Wyczyść + Wyczyścić historię przeglądania? + Wyczyść historię przeglądania + Gdy skopiujesz link github.com lub codeberg.org, zaproponujemy jego otwarcie. + Wykrywaj linki do repo w schowku + Repozytoria wyciszone w kanałach i wyszukiwarce. + Ukryte repozytoria + Pomijaj obejrzane repo w kanałach i wynikach wyszukiwania. + Ukrywaj repo, które już widziałem Bez identyfikatorów. System i platforma. Żadne ustawienie nie pasuje do '%1$s'. + Szukaj ustawień + Dodaj host Forgejo lub Gitea + Dodane hosty + Serwer lustrzany GitHub + Aplikacja domyślnie przeszukuje GitHub. Możesz kierować ruch przez regionalny serwer lustrzany lub dodać własne hosty Forgejo / Gitea. + Gdzie aplikacja szuka repozytoriów + Domyślny (github.com) + Trzymamy instalatory, aby aktualizacje były szybsze. + Wyczyść + Pobrane pliki APK + 0 B + Zajmuje: %1$s + Ten ekran jest częścią trwającego przeprojektowania. Ustawienia nadal działają — są tylko przenoszone tutaj ze starego układu. + Wkrótce w kolejnej aktualizacji + + %1$d repo + %1$d repo + %1$d repo + %1$d repo + + Połącz się + Dołącz do społeczności + 6 miejsc + Sprawy biznesowe + Współprace, prasa, integracje. + Kontakt + Biblioteka + Aktualizacje + Konto + Tryb + Wyświetlanie + Dynamiczny + Więcej opcji logowania + Ukryj opcje + Platformy odkrywania + Filtruj kanał Home według platformy. Pozostaw wszystkie wyłączone, aby zobaczyć wszystko. + Informacje + Wersja, społeczność, informacje prawne + Instalowalne tutaj + Brak repozytoriów z plikami instalowalnymi dla tej platformy + Co %1$d godziny + Codziennie + Co %1$d dni + Co tydzień + Co 2 tygodnie + Co 30 dni diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index d38a3a4af..3bd81a022 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -1,14 +1,12 @@ GitHub Store - Установленные приложения Назад Проверить обновления Не удалось запустить %1$s Не удалось открыть %1$s Не удалось обновить %1$s: %2$s - Ошибка обновления Ошибка обновления всех: %1$s Все приложения успешно обновлены Обновлений нет @@ -32,30 +30,27 @@ Ожидание авторизации… Вход выполнен! - Теперь вы можете использовать приложение. Перенаправление… + Теперь ты можешь использовать приложение. Перенаправление… Попробовать снова Ошибка: %1$s - Введите этот код на GitHub: + Введи этот код на GitHub: Скопировать код Открыть GitHub - Откройте полный\nдоступ + Открой полный\nдоступ Больше запросов - Войдите, чтобы получить более высокий лимит API и избежать прерываний. + Войди, чтобы получить более высокий лимит API и избежать прерываний. Войти через GitHub - Отменено Неизвестная ошибка - Язык: - Поиск репозиториев Поиск по репозиторию, описанию… Фильтр по языку @@ -98,58 +93,41 @@ Профиль - ВНЕШНИЙ ВИД - ЯЗЫК - Переопределить язык интерфейса приложения. - Язык приложения - Изменяет меню, кнопки и сообщения во всём приложении. Не изменяет содержимое с GitHub. Как в системе - Перезапустите, чтобы применить новый язык. + Перезапусти, чтобы применить новый язык. Перезапустить - О ПРИЛОЖЕНИИ - СЕТЬ Цвет темы AMOLED чёрная тема Чёрный фон для тёмного режима - Выбранный цвет: %1$s - Версия - Помощь и поддержка Выйти - Вы успешно вышли, перенаправление... - Кэш успешно очищен + Ты успешно вышел, перенаправление... Внимание! - Вы уверены, что хотите выйти? + Точно хочешь выйти? - Динамическая - Океан - Фиолетовая Лесная - Сланцевая - Янтарная + Nord + Cream + Plum Открыть репозиторий Отменить загрузку - Ошибка загрузки данных Повторить Описание отсутствует. Об этом приложении Журнал установки - Автор Что нового Установлено Доступно обновление Недоступно Установить последнюю - Переустановить - Обновить приложение Сообщить о проблеме Загрузка @@ -173,10 +151,8 @@ Найти ещё на GitHub Поиск на GitHub… Больше результатов на GitHub нет - Не удалось загрузить с GitHub. Попробуйте снова. + Не удалось загрузить с GitHub. Попробуй снова. - от %1$s - • Установлено: %1$s Совместимо с архитектурой Обновить до %1$s @@ -184,7 +160,6 @@ Открыть в браузере Показать параметры установки - Не удалось загрузить данные Установщик сохранён в папку Загрузки Загрузка начата @@ -203,48 +178,31 @@ Разрешение на установку заблокировано политикой устройства Открыто во внешнем установщике Разрешение на установку недоступно - APK был успешно загружен, но это устройство не разрешает прямую установку. Хотите открыть его с помощью внешнего установщика? + APK был успешно загружен, но это устройство не разрешает прямую установку. Хочешь открыть его с помощью внешнего установщика? Открыть во внешнем установщике Использовать стороннее приложение для установки APK Ошибка: %1$s - Тип файла .%1$s не поддерживается - Загруженный файл не найден - В тренде - Горячий релиз - Самые популярные - Конфиденциальность - Медиа - Продуктивность - Сеть - Инструменты разработчика Поиск репозиториев... - Загрузка... - Больше репозиториев нет Повторить Не удалось загрузить репозитории - Подробнее - обновлено только что - обновлено %1$d ч. назад - обновлено вчера - обновлено %1$d дн. назад обновлено %1$s Превышен лимит запросов - Вы использовали все %1$d API-запросов. - Вы использовали все %1$d бесплатных API-запросов. + Ты использовал все %1$d API-запросов. + Ты использовал все %1$d бесплатных API-запросов. Сброс через %1$d мин - 💡 Войдите, чтобы получить 5 000 запросов в час вместо 60! + 💡 Войди, чтобы получить 5 000 запросов в час вместо 60! Войти ОК Закрыть Системный шрифт - Используйте шрифт вашего устройства для лучшей читаемости + Используй шрифт своего устройства для лучшей читаемости Светлая Тёмная @@ -265,13 +223,13 @@ Избранные репозитории Репозиторий добавлен в избранное Репозиторий не в избранном - Вы можете добавить репозиторий в избранное на GitHub - Вы можете убрать репозиторий из избранного на GitHub + Ты можешь добавить репозиторий в избранное на GitHub + Ты можешь убрать репозиторий из избранного на GitHub Требуется вход Нет избранных репозиториев - Войдите через GitHub, чтобы увидеть избранные репозитории - Отмечайте репозитории с установочными релизами на GitHub + Войди через GitHub, чтобы увидеть избранные репозитории + Отмечай репозитории с установочными релизами на GitHub Последняя синхронизация Только что %1$d мин назад @@ -280,8 +238,6 @@ Закрыть Не удалось синхронизировать избранные репозитории - Профиль разработчика - Открытый профиль разработчика Не удалось загрузить репозитории @@ -295,7 +251,6 @@ Поиск репозиториев… Очистить поиск - Все С релизами Установленные Избранные @@ -307,7 +262,6 @@ репозиторий - репозиториев Показано %1$d из %2$d репозиториев @@ -315,8 +269,6 @@ Нет установленных репозиториев Нет избранных репозиториев - - Обновлено %1$s Есть релиз @@ -338,7 +290,7 @@ Главная Поиск - Приложения + Библиотека Профиль Форк @@ -369,77 +321,50 @@ Последняя проверка: %1$s - Не проверялось только что %1$d мин назад %1$d ч назад Проверка обновлений… - - Тип прокси - Нет - Системный - HTTP - SOCKS Хост Порт Имя пользователя (необязательно) Пароль (необязательно) Сохранить прокси Настройки прокси сохранены - Использует прокси-настройки устройства - Порт должен быть 1–65535 - Прямое подключение, без прокси Не удалось сохранить настройки прокси Требуется хост прокси - Введите корректное имя хоста или IP-адрес + Введи корректное имя хоста или IP-адрес Недопустимый порт прокси - Показать пароль - Скрыть пароль - Проверить - Проверка… Соединение в порядке (%1$d мс) - Не удалось разрешить хост. Проверьте адрес прокси. + Не удалось разрешить хост. Проверь адрес прокси. Не удалось подключиться к прокси-серверу. Время ожидания истекло. Требуется аутентификация прокси. Неожиданный ответ: HTTP %1$d Не удалось проверить соединение - Каждая категория может использовать свой собственный прокси. Настройте их независимо. - Обнаружение (GitHub API) - Главная, поиск, детали репозитория и проверка обновлений - Загрузки - Загрузка APK и автоматические обновления - Перевод - Сервис перевода README - - Отслеживать приложение - Приложение добавлено в список отслеживания - Не удалось отследить приложение: %1$s - Приложение уже отслеживается Войти через GitHub - Откройте полный доступ. Управляйте приложениями, синхронизируйте настройки и просматривайте быстрее. + Открой полный доступ. Управляй приложениями, синхронизируй настройки и просматривай быстрее. Репозитории Войти - Ваши избранные репозитории на GitHub - Ваши избранные репозитории, сохранённые локально + Твои избранные репозитории на GitHub + Твои избранные репозитории, сохранённые локально Сессия истекла - Ваша сессия GitHub истекла или токен был отозван. Пожалуйста, войдите снова для продолжения использования авторизованных функций. - Вы можете продолжить просмотр как гость с ограниченным количеством API-запросов. + Твоя сессия GitHub истекла или токен был отозван. Пожалуйста, войди снова, чтобы продолжить пользоваться авторизованными функциями. + Ты можешь продолжить просмотр как гость с ограниченным количеством API-запросов. Войти снова Продолжить как гость - Это очистит вашу локальную сессию и кэшированные данные. Чтобы полностью отозвать доступ, перейдите в GitHub Settings > Applications. - Код истекает через %1$s + Это очистит твою локальную сессию и кэшированные данные. Чтобы полностью отозвать доступ, перейди в GitHub Settings > Applications. Срок действия кода устройства истёк. - Пожалуйста, попробуйте войти снова для получения нового кода. - Проверьте подключение к интернету и попробуйте снова. - Вы отклонили запрос авторизации. Попробуйте снова, если это было непреднамеренно. + Пожалуйста, попробуй войти снова, чтобы получить новый код. + Проверь подключение к интернету и попробуй снова. + Ты отклонил запрос авторизации. Попробуй снова, если это было непреднамеренно. Я уже авторизовался Проверка… Превышен лимит — повтор через %1$d с @@ -452,69 +377,31 @@ Не удалось поделиться ссылкой Ссылка скопирована в буфер обмена - - Перевести - Перевод… - Показать оригинал - Переведено на %1$s Перевести на… Поиск языка - Изменить язык - Ошибка перевода. Попробуйте ещё раз. + Ошибка перевода. Попробуй ещё раз. Открыть ссылку GitHub Обнаружена ссылка GitHub в буфере обмена - Автоопределение ссылок из буфера - Автоматически определять ссылки GitHub из буфера обмена при открытии поиска Обнаруженные ссылки Открыть в приложении Ссылка GitHub не найдена в буфере обмена - Хранение - Очистить кэш - Текущий размер: Очистить - Поддержать GitHub Store - Поддержать проект - Бесплатно навсегда.\nБлагодаря вам. - Ваша поддержка позволяет GitHub Store оставаться бесплатным, независимым и активно развиваемым — без рекламы, без отслеживания, просто лучший способ устанавливать open-source приложения. - Я разрабатываю и поддерживаю этот проект в одиночку, заканчивая учёбу. Каждый вклад — большой или маленький — идёт напрямую на серверные расходы, разработку и поддержание приложения без рекламы для всех. - Голосуйте за GitHub Store! - GitHub Store номинирован на Golden Kodee Awards на KotlinConf 2026. - 1. Зарегистрироваться - 2. Проголосовать - Голосование до 22 марта - 1. Зарегистрируйтесь на платформе (Войти через Google) - 2. Нажмите «Голосовать» ниже - 3. Найдите Usmon Narzullayev и нажмите «Голосовать» - GitHub Sponsors - Ежемесячно или разово — напрямую финансирует разработку - Buy Me a Coffee - Быстрый разовый вклад - ДРУГИЕ СПОСОБЫ ПОМОЧЬ - Поставить звезду репозиторию - Повышает видимость и помогает другим его найти - Сообщить об ошибке - Каждый отчёт делает приложение лучше для всех - Поделиться с друзьями - Сарафанное радио — лучший маркетинг - Каждый вклад — финансовый или нет — действительно имеет значение. Спасибо, что вы с нами. - - Установка По умолчанию Стандартный системный диалог установки Shizuku @@ -524,64 +411,50 @@ Требуется разрешение Готов Предоставить разрешение - Установите Shizuku для тихой установки - Запустите Shizuku для тихой установки + Установи Shizuku для тихой установки + Запусти Shizuku для тихой установки Установка через Shizuku не удалась, используется стандартный установщик Автообновление приложений Автоматически загружать и устанавливать обновления в фоне через Shizuku - Обновления Интервал проверки обновлений Как часто проверять обновления приложения в фоне Фоновая проверка обновлений - Периодически проверяет обновления в фоне. Отключите для экономии батареи — всегда можно проверить вручную из экрана деталей любого приложения. - - - 12ч - 24ч - + Периодически проверяет обновления в фоне. Отключи для экономии батареи — всегда можно проверить вручную из экрана деталей любого приложения. Разрешить обновления в фоне - Ваше устройство агрессивно завершает фоновые задачи. Исключите GitHub Store из оптимизации батареи, чтобы запланированные проверки обновлений и тихая установка работали надёжно. + Твоё устройство агрессивно завершает фоновые задачи. Исключи GitHub Store из оптимизации батареи, чтобы запланированные проверки обновлений и тихая установка работали надёжно. Открыть настройки Позже Отслеживать приложение Привязать приложение к репозиторию - Выберите установленное приложение для привязки к репозиторию GitHub + Выбери установленное приложение для привязки к репозиторию GitHub Поиск приложений… URL репозитория GitHub github.com/owner/repo Проверка… Привязать и отслеживать Проверка последнего релиза… - Загрузка APK для проверки… Проверка ключа подписи… Несоответствие имени пакета: APK — %1$s, а выбранное приложение — %2$s Несоответствие ключа подписи: APK в этом репозитории подписан другим разработчиком - Выберите установщик - Выберите APK для проверки соответствия установленному приложению - Ошибка загрузки + Выбери установщик + Выбери APK, чтобы проверить соответствие установленному приложению Экспорт Импорт - Импорт приложений - Вставьте экспортированный JSON для восстановления отслеживаемых приложений - Вставьте экспортированный JSON… Канал бета по умолчанию - Вновь отслеживаемые приложения по умолчанию включают бета-сборки. Ранее отслеживаемые приложения сохраняют своё значение (переключите на экране Подробностей приложения). + Вновь отслеживаемые приложения по умолчанию включают бета-сборки. Ранее отслеживаемые приложения сохраняют своё значение (переключи на экране Подробностей приложения). Удалить приложение? - Вы уверены, что хотите удалить %1$s? Это действие нельзя отменить, данные приложения могут быть утеряны. - Неверный URL GitHub. Используйте формат: github.com/owner/repo + Точно хочешь удалить %1$s? Это действие нельзя отменить, данные приложения могут быть утеряны. + Неверный URL GitHub. Используй формат: github.com/owner/repo Репозиторий не найден: %1$s/%2$s - Превышен лимит запросов GitHub API. Попробуйте позже. + Превышен лимит запросов GitHub API. Попробуй позже. Не удалось привязать: %1$s Не удалось загрузить установленные приложения %1$s привязано к %2$s/%3$s Ошибка экспорта: %1$s Ошибка импорта: %1$s - Импортировано %1$d приложений - , %1$d пропущено - , %1$d с ошибкой Ключ подписи изменён Сертификат подписи этого приложения изменился с момента первой установки.\n\nЭто может означать, что разработчик сменил ключ подписи, или бинарный файл был изменён.\n\nОжидалось: %1$s\nПолучено: %2$s Всё равно установить @@ -592,10 +465,8 @@ Нет ресурсов, связанных с этим релизом Выбрать вариант ресурса Доступно несколько ресурсов - Для этого релиза доступно несколько устанавливаемых файлов. Просмотрите список и выберите подходящий для вашего устройства. + Для этого релиза доступно несколько устанавливаемых файлов. Просмотри список и выбери подходящий для своего устройства. Информация - Повторить - Автоопределение: %1$s Выбрать язык Несоответствие пакета: APK — %1$s, но установленное приложение — %2$s. Обновление заблокировано. Несоответствие ключа подписи: обновление подписано другим разработчиком. Обновление заблокировано. @@ -608,30 +479,16 @@ Широкая Очень широкая - Скрыть просмотренные репозитории - Скрыть уже просмотренные репозитории из лент обнаружения - Очистить историю просмотров - Сбросить все просмотренные репозитории, чтобы они снова появились в лентах История просмотров очищена Просмотрено - Ваш репозиторий + Твой репозиторий Скрыть репозиторий - Репозиторий скрыт - Отменить - Конфиденциальность - Помочь улучшить поиск - Отправлять данные об использовании (поиски, установки, взаимодействия), связанные со сбрасываемым идентификатором аналитики. Данные учётной записи не передаются. - Сбросить ID аналитики - Сгенерировать новый анонимный ID, разорвав связь с прошлой телеметрией. - ID аналитики сброшен Недавно просмотренные - Репозитории, которые вы посещали - Загруженные пакеты - APK и установщики из релизов GitHub + Репозитории, которые ты посещал Удалить всё Удалить все загрузки? - Это навсегда удалит все APK и установщики (%1$s). Вы сможете скачать их снова в любое время. + Это навсегда удалит все APK и установщики (%1$s). Ты сможешь скачать их снова в любое время. Все загруженные пакеты удалены. Не удалось проверить @@ -639,9 +496,7 @@ Очистить всё Удалить - Настройки Настройки - Пре-релизы Фильтр ассетов @@ -659,14 +514,14 @@ Возврат к старым релизам Просматривать предыдущие релизы, пока не найдётся соответствующий фильтру. Необходимо для монорепо, где последний релиз принадлежит другому приложению. Расширенный фильтр - Настройте, как это приложение определяется в репозитории. Используйте эти настройки, если репо содержит несколько приложений. + Настрой, как это приложение определяется в репозитории. Используй эти настройки, если репо содержит несколько приложений. Открыть расширенный фильтр Сохранить Предпросмотр Обновить предпросмотр - Введите фильтр, чтобы увидеть подходящие ассеты. - В последних релизах нет подходящих ассетов. Включите возврат к старым релизам или измените regex. - Не удалось загрузить предпросмотр. Проверьте подключение и попробуйте снова. + Введи фильтр, чтобы увидеть подходящие ассеты. + В последних релизах нет подходящих ассетов. Включи возврат к старым релизам или измени regex. + Не удалось загрузить предпросмотр. Проверь подключение и попробуй снова. Совпадение в %1$s · %2$d ассет Совпадение в %1$s · %2$d ассета @@ -676,22 +531,22 @@ Предпочтительный вариант - Выберите, какой вариант APK будет устанавливаться при обновлениях. Выбор запоминается между релизами. + Выбери, какой вариант APK будет устанавливаться при обновлениях. Выбор запоминается между релизами. В последнем релизе нет устанавливаемых ассетов. - В этом релизе нет вариантов с тегом версии, которые можно закрепить. Автоматический селектор продолжит выбирать лучший ассет для вашего устройства. - Не удалось загрузить варианты. Проверьте подключение и попробуйте снова. - Не удалось сохранить выбор. Попробуйте снова. - Вариант изменился — выберите снова + В этом релизе нет вариантов с тегом версии, которые можно закрепить. Автоматический селектор продолжит выбирать лучший ассет для твоего устройства. + Не удалось загрузить варианты. Проверь подключение и попробуй снова. + Не удалось сохранить выбор. Попробуй снова. + Вариант изменился — выбери снова Было: %1$s Автоматически - Пусть GitHub Store сам выберет лучший вариант для вашего устройства + Пусть GitHub Store сам выберет лучший вариант для твоего устройства Примечание Совет Важно Предупреждение Осторожно Закреплено: %1$s - Вариант изменился — нажмите Обновить, чтобы выбрать снова + Вариант изменился — нажми Обновить, чтобы выбрать снова Вариант: %1$s Открепить Закреплено @@ -705,15 +560,13 @@ Готово к установке Установить (готово) - - Перевод - Выберите сервис для перевода README. + Выбери сервис для перевода README. Сервис перевода Google работает глобально без настройки. Youdao работает из материкового Китая, но требует учётных данных API с портала разработчиков Youdao. Google Youdao Сервис перевода обновлён - Зарегистрируйтесь на ai.youdao.com, чтобы получить App Key и App Secret. Бесплатного тарифа достаточно для обычного использования. + Зарегистрируйся на ai.youdao.com, чтобы получить App Key и App Secret. Бесплатного тарифа достаточно для обычного использования. App Key App Secret Сохранить учётные данные @@ -721,12 +574,12 @@ Не удалось загрузить релизы - Проверьте подключение и попробуйте снова. + Проверь подключение и попробуй снова. Релизы ещё не опубликованы Загрузка релизов… - Проблемы со входом? Используйте Personal Access Token + Проблемы со входом? Используй Personal Access Token Использовать код устройства F-Droid / Obtainium Установлено вручную @@ -743,19 +596,17 @@ Поиск Вручную Вход с Personal Access Token - Создайте Personal Access Token (classic или fine-grained) на устройстве, где доступен GitHub, и вставьте его ниже. Требуется доступ на чтение к репозиториям и вашему профилю. + Создай Personal Access Token (classic или fine-grained) на устройстве, где доступен GitHub, и вставь его ниже. Требуется доступ на чтение к репозиториям и твоему профилю. Открыть настройки токенов GitHub Personal Access Token ghp_… или github_pat_… Войти Отмена - Показать токен - Скрыть токен - Вставьте токен. + Вставь токен. Это не похоже на токен GitHub. Токены начинаются с ghp_ или github_pat_. Этот токен недействителен или отозван. У этого токена нет необходимых прав. - Не удалось сохранить токен. Попробуйте снова. + Не удалось сохранить токен. Попробуй снова. Включить бета-версии Только стабильные - Выберите канал релизов - Нажмите, чтобы переключаться между стабильными релизами и бета-сборками этого приложения. + Выбери канал релизов + Нажми, чтобы переключаться между стабильными релизами и бета-сборками этого приложения. Понятно - Переключить бета-релизы для этого приложения Перейти на стабильную %1$s Нет стабильного релиза уже %1$d месяцев Нет стабильного релиза уже %1$d дней Есть активные предварительные версии, но проект давно не выпускал стабильную сборку. Бета-версии могут так и не перейти в стабильный релиз. Что изменилось с %1$s - — %1$s — Отвязать от этого репозитория - Другие параметры Отвязать это приложение? Мы перестанем отслеживать %1$s как установленное из этого репозитория. Приложение останется на устройстве — удаляется только связь. Отвязать Отвязано. При следующем сканировании мы предложим совпадение. - Не удалось отвязать — попробуйте ещё раз. + Не удалось отвязать — попробуй ещё раз. Связан с %1$s/%2$s - Привязано вручную — проверено вами. Отвяжите, чтобы пересканировать. + Привязано вручную — проверено тобой. Отвяжи, чтобы пересканировать. Обновить Обновить (%1$dс) Дополнительно - Только что обновлено — повторите через %1$d с. - Сервис обновления занят — повторите через %1$d с. + Только что обновлено — повтори через %1$d с. + Сервис обновления занят — повтори через %1$d с. Этот репозиторий заархивирован на GitHub. Этот репозиторий больше не существует на GitHub. - Не удалось обновить. Повторите попытку позже. - Ошибка обновления. Повторите попытку. + Не удалось обновить. Повтори попытку позже. + Ошибка обновления. Повтори попытку. - Отправить отзыв Отправить отзыв Закрыть Категория @@ -943,15 +789,12 @@ ═══════════════════════════════════════════════════════════════ --> Свои форджи - Добавьте хосты Forgejo / Gitea, которые GHS должен распознавать - Добавлено: %1$d Свои форджи - Введите имя хоста Forgejo или Gitea (например git.example.com). GHS будет принимать URL с этих хостов в форме ручной привязки. + Введи имя хоста Forgejo или Gitea (например git.example.com). GHS будет принимать URL с этих хостов в форме ручной привязки. Добавить Пока нет своих форджей. Готово - Зеркало для загрузки Зеркало для загрузки Используется для загрузки файлов релизов. Обращения к API GitHub всегда идут напрямую. Большинству пользователей следует оставить настройку «Напрямую через GitHub». Официальное @@ -967,20 +810,13 @@ Шаблон должен содержать {url} ровно один раз Сохранить Проверить выбранное - Проверка… Достигнуто за %1$dms Зеркало вернуло %1$d Превышено время ожидания (5s) Не удалось разрешить имя хоста Ошибка: %1$s - Все зеркала недоступны? За 5 минут можно развернуть своё — смотрите документацию. + Все зеркала недоступны? За 5 минут можно развернуть своё — смотри документацию. %1$s больше недоступно, выполнен переход на прямой доступ через GitHub. - Несоответствие контрольной суммы — файл мог быть изменён - Попробовать более быстрое зеркало? - Некоторым пользователям с медленным соединением лучше подходит прокси сообщества. - Выбрать - Может быть, позже - Больше не спрашивать Добавить из избранных @@ -991,13 +827,7 @@ Безопасность Статус Опросы - Закрыть после подтверждения - %1$d дн Сейчас нечем поделиться. - %1$d ч - только что - Обновлено %1$s назад - %1$d мин Нельзя отключить Показывать элементы из Открыть настройки отключения @@ -1010,9 +840,9 @@ Объявления Подробнее Проверить APK - Разрешения, подпись, компоненты — посмотрите, что именно объявляет APK. + Разрешения, подпись, компоненты — посмотри, что именно объявляет APK. Понятно - Загляните внутрь перед установкой + Загляни внутрь перед установкой Совместимость Компоненты Отладочная сборка — подозрительно для релиза. @@ -1054,32 +884,31 @@ Свёрнут Развёрнут Актуально - Доступны обновления Отбросить отложенный установщик для %1$s и удалить строку из списка приложений? Загруженный APK будет удалён. Отбросить ожидающую установку? Предоставить разрешение - Установите Dhizuku и активируйте его как Device Owner для тихих установок - Активируйте Dhizuku как Device Owner для тихих установок + Установи Dhizuku и активируй его как Device Owner для тихих установок + Активируй Dhizuku как Device Owner для тихих установок Root Тихая установка через Magisk, KernelSU или APatch root Предоставлен Разрешить root Нет root Разрешить root-доступ - Root недоступен на этом устройстве. Сначала установите Magisk, KernelSU или APatch. + Root недоступен на этом устройстве. Сначала установи Magisk, KernelSU или APatch. Dhizuku не установлен Dhizuku не активен Требуется разрешение Готов Отбросить Назад - Не удалось загрузить этот репо. Используйте кнопку «Повторить» ниже. + Не удалось загрузить этот репо. Нажми «Повторить» ниже. Репозиторий мог стать приватным, быть переименован или удалён. - Проверьте подключение и попробуйте снова. - GitHub или наш бэкенд достиг квоты. Попробуйте чуть позже. + Проверь подключение и попробуй снова. + GitHub или наш бэкенд достиг квоты. Попробуй чуть позже. Что-то пошло не так Репозиторий не найден - Вы офлайн + Ты офлайн Достигнут лимит запросов Экспорт для Obtainium от %1$s @@ -1099,7 +928,7 @@ Это не похоже на экспорт GitHub Store или Obtainium. Вот начало файла: Не удалось прочитать этот файл Применить - Используйте корректное имя Android-пакета (например, com.example.installer) + Используй корректное имя Android-пакета (например, com.example.installer) Имя пакета установщика Приложения видят, какой установщик поместил их на устройство. По умолчанию это системная оболочка. Некоторые приложения отслеживают смену установщика и могут отказаться запускаться или не пройти проверки безопасности (например, Play Integrity, банковские приложения). @@ -1112,19 +941,19 @@ Dhizuku Тихая установка через Device Owner — обходит подсказки установки OEM Лимит достигнут — повтор через %1$d с. - Войдите в GitHub для большей квоты. - Релизы временно недоступны. Попробуйте чуть позже. + Войди в GitHub для большей квоты. + Релизы временно недоступны. Попробуй чуть позже. Отслеживается APK - Нет избранных репо. Отметьте репо звездой на GitHub, чтобы увидеть их здесь. + Нет избранных репо. Отметь репо звездой на GitHub, чтобы увидеть их здесь. Показывать репо без релизов APK %1$d избранных · %2$d с APK · %3$d уже отслеживается Ничего не найдено. Проверка %1$d / %2$d… - Достигнут лимит GitHub. Возобновите, чтобы продолжить проверку. + Достигнут лимит GitHub. Возобнови, чтобы продолжить проверку. Возобновить Поиск по владельцу / репо / описанию - Войдите в GitHub, чтобы увидеть избранные репо. + Войди в GitHub, чтобы увидеть избранные репо. А → Я Недавно отмеченные Больше звёзд @@ -1140,21 +969,20 @@ Новое Что нового в %1$s Что нового - Журнал изменений на английском. Переводы приветствуются через Issues. Версия %1$s · %2$s Пропустить эту версию Не пропускать - Версия %1$s пропущена — вы получите уведомление, когда выйдет более новая + Версия %1$s пропущена — ты получишь уведомление, когда выйдет более новая Уведомление об обновлении снова включено Не удалось обновить настройку Не удалось восстановить уведомление об обновлении Пропущенные обновления - Версии, которые вы решили игнорировать + Версии, которые ты решил игнорировать Пропущенные обновления Нет пропущенных версий - Когда вы пропускаете обновление на экране приложений, запись появляется здесь, чтобы её можно было отменить. + Когда ты пропускаешь обновление на экране приложений, запись появляется здесь, чтобы её можно было отменить. Пропущена %1$s Установлена: %1$s Не пропускать @@ -1162,14 +990,13 @@ Не удалось обновить настройку Скрытые репозитории Управление репозиториями, скрытыми с Главной и Поиска. - %1$d скрыто Нет скрытых репозиториев - Удерживайте карточку репозитория на Главной или в Поиске, чтобы скрыть её из обзора. + Удерживай карточку репозитория на Главной или в Поиске, чтобы скрыть её из обзора. Показать Показать все %1$s снова виден Все репозитории снова видны - Не удалось показать. Повторите попытку. + Не удалось показать. Повтори попытку. Открыть на GitHub Отметить как просмотренный Снять отметку @@ -1178,11 +1005,285 @@ Windows macOS Linux - Другая платформа — открывается в браузере для сохранения и переноса Это устройство Для переноса Сохраним Android открытым Политика верификации разработчиков Google угрожает открытому распространению Android. GitHub Store поддерживает коалицию. Подробнее Закрыть баннер + + + Нажми на любую карточку слева, чтобы увидеть детали здесь. + Выбери репозиторий + Готово к установке + Расширенные настройки + Быстрые действия + Настройки + Статус + Выбери любое приложение слева, чтобы увидеть его детали и действия. + Выбери приложение + Установлено + Последняя + Открыть репозиторий + Выбрать вариант + Скрыть список + Показать список + Просмотри или обнови всё сразу. + Доступно 1 обновление + Доступно обновлений: %1$d + Назад + Назад + Изображение + Все › + + %1$d хост + %1$d хоста + %1$d хостов + %1$d хоста + + Codeberg, gitea.com и git.disroot.org уже доступны по умолчанию — добавлять их сюда не нужно. Используй этот список только для собственного Forgejo или Gitea. + О репозитории + Разработчик + Статистика + Профиль + Что нового + Пропустить и посмотреть позже + Мы читаем каждый отзыв. + Получить + #%1$d + Из твоих избранных + Свежие релизы + Самые популярные + В тренде + Обзор + Показать все + Показать ещё + Добавить токен + Назад + Отмена + Удалить + Сохранить + Добавить токен + Определено: %1$s + Адрес форджа + Оставь пустым, чтобы сохранить текущий токен. Введи новое значение, чтобы заменить его. + Заменить токен для %1$s + Подключение к %1$s + + %1$d токен + %1$d токена + %1$d токенов + %1$d токена + + Нажми +, чтобы добавить персональный токен доступа + Токены не сохранены + Персональные токены для каждого хоста (GitHub, Codeberg, Forgejo) + Отображаемое имя (необязательно) + Персональный токен доступа + Ты уже вошёл в GitHub через приложение — это рекомендуемый путь. Добавляй токен сюда, только если вход через браузер не работает (ограниченная сеть, нет браузера) или для других форджей, кроме GitHub. + Открыть страницу токена + Другой фордж + Forgejo, Gitea, собственный + Вставить токен + Добавить токен для… + Изменить метку + Недействителен · %1$s + Ещё + Управлять на %1$s + Заменить токен + Осталось %1$s + Действителен · %1$s + Проверить + Токен сохранён для %1$s + Токены аутентификации + Отменить + Не удалось восстановить токен для %1$s + Токен для %1$s удалён + Проверка не пройдена: %1$s + неизвестная ошибка + Неверный хост + Требуется хост + Требуется токен + Токен слишком короткий + О GitHub Store + Отправить отзыв… + Лицензии открытого ПО + Помощь + Политика конфиденциальности + Настройки приложения, тема, сеть, перевод, расширенные параметры. + Некоторым изменениям нужен перезапуск. + Позже + язык тема + Затронуто: %1$s + Перезапустить + Убрать фильтр + Готово + Фильтры + Сбросить + Язык + Платформа + Сортировка + Источник + Фильтр результатов + Приложение + Подключение + Установка и обновления + Внешний вид + Приватность и данные + + %1$d приложение + %1$d приложения + %1$d приложений + %1$d приложения + + Автоматически переводить README и заметки о выпуске при открытии репозитория. В качестве цели используется язык приложения. + Следовать языку приложения (%1$s) + Переводить на + Автоперевод репозиториев + Отмена + Сменить язык + Исходный язык: %1$s + Повторить + Показать оригинал + Показать перевод + Покажи эту страницу на другом языке. + Показан оригинал + Переведено на %1$s + Перевод… + Язык перевода + Перевод + Перевести на %1$s + Ключ авторизации + Получить бесплатный ключ DeepL → + Зарегистрируйся на deepl.com/pro-api. Ключи бесплатного тарифа оканчиваются на :fx и автоматически используют бесплатный эндпоинт. Pro-тариф сохраняет приватность текста; Free-тариф может использовать отправленный текст для улучшения моделей. + Сохранить ключ + Ключ DeepL сохранён + API-ключ (необязательно) + URL инстанса + Работает «из коробки» через публичное зеркало Disroot — оставь поля пустыми, чтобы использовать его. Для большей приватности укажи URL своего инстанса. API-ключ нужен, только если этого требует инстанс. + Сохранить настройки + Настройки LibreTranslate сохранены + Получить бесплатный ключ Azure → + Azure Translator уважает приватность (No-Trace по умолчанию — текст не сохраняется и не используется для обучения). Бесплатный тариф — до 2M символов в месяц. Регион нужен только для не-глобальных ресурсов. + Ключ подписки + Регион (например, westeurope, оставь пустым для Global) + Сохранить учётные данные + Учётные данные Microsoft Translator сохранены + DeepL + LibreTranslate + Microsoft + GitHub Store + Библиотеки, используемые в приложении. + Лицензии открытого ПО + Открыть на github-store.org. + Политика конфиденциальности + Посмотреть исходный код приложения. + Исходный код на GitHub + Кросс-платформенный магазин приложений для релизов GitHub, Codeberg и Forgejo. + Заметки прошлых релизов. + Что нового + Применяется ко всему трафику, если ниже не задано иное. + Своё + Выбери режим подключения ниже. Большинству подойдёт «Без прокси». + Как приложение выходит в интернет + Основное подключение + HTTP/HTTPS + Большинство корпоративных прокси. + Без прокси + SOCKS5 + Tor, SSH-туннели. + Системный + Каждая область по умолчанию использует основное подключение. Выбери «Своё», чтобы задать отдельные настройки. + Переопределения по областям + Вставить полный URL + Вставь полный URL прокси, и мы заполним форму за тебя. + Использовать этот URL + Не удалось прочитать этот URL. + scheme://user:pass@host:port + Вставить URL прокси + API GitHub, поиск, детали репозитория. + Поиск и метаданные + Загрузка APK и ассетов. + Загрузки + Запросы к DeepL, Microsoft, LibreTranslate. + Перевод + Проверить + Как основное + Токены доступа + О приложении + Внешний вид + Подключение + Отправить отзыв + Способ установки + Язык + Приватность + Источники + Хранилище + Нажми, чтобы настроить + Перевод + Обновления + Тихие установщики и атрибуция установщика — это функции Android. На десктопе установка идёт через системный менеджер пакетов. + Способ установки доступен только на Android + Приложение перезапустится при смене языка. + Нажми на любую запись, чтобы открыть её страницу проекта. + GitHub Store построен на этих библиотеках. + Лицензии открытого ПО + История просмотров + Забыть, какие репо ты уже открывал. + Это не уберёт звёзды и не снимет с избранного. + Очистить + Очистить историю просмотров? + Очистить историю просмотров + Когда ты копируешь ссылку на github.com или codeberg.org, мы предложим её открыть. + Замечать ссылки на репо в буфере обмена + Репо, которые ты убрал из ленты и поиска. + Скрытые репозитории + Пропускать просмотренные репо в ленте и поиске. + Скрывать уже просмотренные репо Никаких идентификаторов. ОС и платформа. Под «%1$s» ничего не нашлось. + Поиск по настройкам + Добавить хост Forgejo или Gitea + Добавленные хосты + Зеркало GitHub + По умолчанию приложение ищет на GitHub. Можно направить через региональное зеркало или добавить свои хосты Forgejo / Gitea. + Где приложение ищет репозитории + По умолчанию (github.com) + Установщики остаются на диске, чтобы обновления возобновлялись быстро. + Очистить + Загруженные APK + 0 Б + Занято: %1$s + Этот экран — часть продолжающегося редизайна. Сами настройки работают — их просто переносят сюда со старого экрана. + Появится в следующем обновлении + + %1$d репозиторий скрыт + %1$d репозитория скрыто + %1$d репозиториев скрыто + %1$d репозитория скрыто + + Связь + Присоединяйся к сообществу + 6 мест + Деловые запросы + Партнёрства, пресса, интеграции. + Написать + Библиотека + Обновления + Аккаунт + Режим + Отображение + Динамичная + Другие способы входа + Скрыть параметры + Платформы для подборки + Фильтрация главной ленты по платформам. Если все выключены, показываются все. + О приложении + Версия, сообщество, юридическая информация + Можно установить здесь + Нет репозиториев с устанавливаемыми сборками для этой платформы + Каждые %1$d часов + Ежедневно + Каждые %1$d дней + Еженедельно + Раз в 2 недели + Каждые 30 дней diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index 3fb7b6fa5..6a8073e41 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -3,8 +3,6 @@ GitHub Store - - Yüklü Uygulamalar Geri git Güncellemeleri kontrol et @@ -12,7 +10,6 @@ %1$s çalıştırılamadı %1$s açılamadı %1$s: %2$s güncellenemedi - Güncelleme başarısız Tümünü güncelleme başarısız: %1$s Tüm uygulamalar başarılı şekilde güncellendi Güncelleme yok @@ -60,12 +57,9 @@ Github ile giriş yap - İptal edildi Bilinmeyen hata - Dil: - Repoları keşfet Repo, açıklama ara... @@ -114,46 +108,31 @@ Profil - - GÖRÜNÜM - DİL - Uygulama arayüz dilini geçersiz kılar. - Uygulama dili - Uygulamadaki menüleri, düğmeleri ve mesajları değiştirir. GitHub'dan gelen içeriği değiştirmez. Sistemi izle Yeni dili uygulamak için yeniden başlatın. Yeniden başlat - HAKKINDA - Tema Rengi AMOLED Siyah Tema Karanlık mod için saf siyah arka plan - Seçilmiş renk: %1$s - - Sürüm - Yardım & Destek Çıkış Yap Başarılı şekilde çıkış yapıldı, yönlendiriliyor... - Önbellek başarıyla temizlendi Uyarı! Çıkmak istediğinden emin misin? - Dinamik - Deniz - Mor Orman - Arduvaz - Amber + Nord + Cream + Plum Repoyu Aç @@ -161,8 +140,6 @@ İndirmeyi iptal et Yükleme seçeneklerini göster - - Detaylar yüklenirken hata Tekrar dene Açıklama sağlanmadı. Sürüm notu yok @@ -171,7 +148,6 @@ Bu uygulama hakkında Yükleme günlükleri - Yazar Neler Yeni @@ -179,8 +155,6 @@ Güncelleme mevcut Mevcut değil En son sürümü yükle - Tekrar yükle - Uygulamayı güncelle İndiriliyor @@ -209,14 +183,9 @@ GitHub'ta başka sonuç yok GitHub'tan getirilemedi. Tekrar deneyin. - - %1$s - Yüklenmiş: %1$s Mimari uyumlu %1$s'e güncelle - - Detaylar yüklenemedi Yükleyici, İndirilenler klasörüne kaydedildi @@ -242,30 +211,13 @@ Hata: %1$s - Tür .%1$s desteklenmiyor - İndirilen dosya bulunamadı - Trendler - Yeni sürüm - En popüler - Gizlilik - Medya - Üretkenlik - - Geliştirici araçları Repolar bulunuyor... - Daha fazla yükleniyor... - Başka repo yok Tekrar dene Repolar yüklenemedi - Detayları Görüntüle - şimdi güncellendi - %1$d saat önce güncellendi - dün güncellendi - %1$d gün önce güncellendi %1$s tarihinde güncellendi Hız Sınırı Aşıldı @@ -314,8 +266,6 @@ Kapat Yıldızlı repoları eşitlerken hata - Geliştirici Profili - Geliştirici profilini aç Repolar yüklenirken hata Profil yüklenirken hata @@ -328,7 +278,6 @@ Repo ara… Aramayı temizle - Hepsi Yayınlanmış İndirilmiş Favoriler @@ -340,7 +289,6 @@ repo - repolar %2$d repodan %1$d tanesi gösteriliyor @@ -348,8 +296,6 @@ Yüklü repo yok Favori repo yok - - %1$s güncellendi Yayınlanmış %1$d y önce @@ -370,7 +316,7 @@ Ana Sayfa Ara - Uygulamalar + Kütüphane Profil Çatalla @@ -401,35 +347,21 @@ Son kontrol: %1$s - Hiç kontrol edilmedi az önce %1$d dk önce %1$d sa önce Güncellemeler kontrol ediliyor… - - Proxy Türü - Yok - Sistem - HTTP - SOCKS Ana Bilgisayar Port Kullanıcı adı (isteğe bağlı) Şifre (isteğe bağlı) Proxy'yi Kaydet Proxy ayarları kaydedildi - Cihazınızın proxy ayarlarını kullanır - Port 1–65535 arası olmalı - Doğrudan bağlantı, proxy yok Proxy ayarları kaydedilemedi Proxy ana bilgisayarı gerekli Geçerli bir ana bilgisayar adı veya IP adresi girin Geçersiz proxy portu - Şifreyi göster - Şifreyi gizle - Test - Test ediliyor… Bağlantı tamam (%1$d ms) Sunucu adresi çözümlenemedi. Proxy adresini kontrol edin. Proxy sunucusuna ulaşılamadı. @@ -437,20 +369,8 @@ Proxy kimlik doğrulaması gerekiyor. Beklenmeyen yanıt: HTTP %1$d Bağlantı testi başarısız - Her kategori kendi proxy'sini kullanabilir. Bunları bağımsız olarak yapılandırın. - Keşif (GitHub API) - Ana sayfa, arama, depo ayrıntıları ve güncelleme denetimleri - İndirmeler - APK indirmeleri ve otomatik güncellemeler - Çeviri - README çeviri hizmeti - - Bu uygulamayı izle - Uygulama izleme listesine eklendi - Uygulama izlenemedi: %1$s - Uygulama zaten izleniyor GitHub ile giriş yap @@ -467,7 +387,6 @@ Tekrar Giriş Yap Misafir olarak devam et Bu işlem yerel oturumunuzu ve önbellek verilerinizi temizleyecektir. Erişimi tamamen iptal etmek için GitHub Settings > Applications sayfasını ziyaret edin. - Kodun süresi %1$s sonra dolacak Cihaz kodunun süresi doldu. Yeni bir kod almak için lütfen tekrar giriş yapmayı deneyin. Lütfen internet bağlantınızı kontrol edin ve tekrar deneyin. @@ -484,71 +403,33 @@ Bağlantı paylaşılamadı Bağlantı panoya kopyalandı - - Çevir - Çevriliyor… - Orijinali göster - %1$s diline çevrildi Şuna çevir… Dil ara - Dili değiştir Çeviri başarısız. Lütfen tekrar deneyin. GitHub bağlantısını aç Panoda GitHub bağlantısı algılandı - Pano bağlantılarını otomatik algıla - Arama açılırken panodan GitHub bağlantılarını otomatik olarak algıla Algılanan bağlantılar Uygulamada aç Panoda GitHub bağlantısı bulunamadı - Depolama - Önbelleği Temizle - Geçerli boyut: Temizle - GitHub Store'u Destekle - Projeyi destekle - Sonsuza dek ücretsiz.\nSizin desteğinizle. - Desteğiniz GitHub Store'u ücretsiz, bağımsız ve aktif olarak güncel tutar — reklam yok, izleme yok, sadece açık kaynak uygulamaları yüklemenin daha iyi bir yolu. - Okulu bitirirken bu projeyi tek başıma geliştiriyor ve sürdürüyorum. Her katkı — büyük ya da küçük — doğrudan sunucu maliyetlerine, geliştirmeye ve uygulamayı herkes için reklamsız tutmaya gidiyor. - GitHub Store için oy ver! - GitHub Store KotlinConf 2026 Golden Kodee Awards için aday gösterildi. - 1. Kayıt ol - 2. Oy ver - Oylama 22 Mart'ta kapanıyor - 1. Platformda kayıt ol (Google ile devam et) - 2. Aşağıdan Oy Ver'e dokun - 3. Usmon Narzullayev'i bul ve Oy Ver'e tıkla - GitHub Sponsors - Aylık veya tek seferlik — doğrudan geliştirmeyi finanse eder - Buy Me a Coffee - Hızlı tek seferlik katkı - YARDIM ETMENİN DİĞER YOLLARI - Depoya yıldız ver - Görünürlüğü artırır ve başkalarının bulmasına yardımcı olur - Hata bildir - Her bildirim uygulamayı herkes için daha iyi yapar - Arkadaşlarınla paylaş - Ağızdan ağıza en iyi pazarlamadır - Her katkı — finansal olsun ya da olmasın — gerçek bir fark yaratır. Bunun parçası olduğunuz için teşekkürler. - - Kurulum Varsayılan Standart sistem kurulum penceresi Shizuku @@ -565,16 +446,10 @@ Uygulamaları otomatik güncelle Shizuku aracılığıyla arka planda otomatik olarak güncellemeleri indirin ve yükleyin - Güncellemeler Güncelleme kontrol aralığı Arka planda uygulama güncellemelerinin ne sıklıkla kontrol edileceği Arka planda güncelleme kontrolü Arka planda düzenli aralıklarla güncellemeleri kontrol eder. Pil tasarrufu için kapatın — her zaman uygulamanın detay ekranından manuel olarak kontrol edebilirsiniz. - 3s - 6s - 12s - 24s - Arka planda güncellemelere izin ver Cihazın arka plan görevlerini agresif şekilde kapatıyor. GitHub Store'u pil optimizasyonundan hariç tut ki zamanlanmış güncelleme kontrolleri ve sessiz kurulumlar güvenilir biçimde çalışsın. Ayarları aç @@ -589,18 +464,13 @@ Doğrulanıyor… Bağla ve takip et Son sürüm kontrol ediliyor… - Doğrulama için APK indiriliyor… İmza anahtarı doğrulanıyor… Paket adı uyuşmuyor: APK %1$s, ancak seçilen uygulama %2$s İmza anahtarı uyuşmuyor: bu depodaki APK farklı bir geliştirici tarafından imzalanmış Yükleyici seçin Yüklü uygulamanızla doğrulamak için APK seçin - İndirme başarısız Dışa aktar İçe aktar - Uygulamaları içe aktar - Takip edilen uygulamaları geri yüklemek için dışa aktarılan JSON'u yapıştırın - Dışa aktarılan JSON'u buraya yapıştırın… Varsayılan beta kanalı Yeni takip edilen uygulamalar varsayılan olarak beta sürümleri içerir. Önceden takip edilen uygulamalar kendi ayarını korur (uygulamanın Ayrıntılar ekranından değiştirin). Uygulama kaldırılsın mı? @@ -613,9 +483,6 @@ %1$s, %2$s/%3$s ile bağlandı Dışa aktarma başarısız: %1$s İçe aktarma başarısız: %1$s - %1$d uygulama içe aktarıldı - , %1$d atlandı - , %1$d başarısız İmza anahtarı değişti Bu uygulamanın imza sertifikası ilk kurulumdan bu yana değişti.\n\nBu, geliştiricinin imza anahtarını değiştirdiği veya dosyanın değiştirilmiş olabileceği anlamına gelebilir.\n\nBeklenen: %1$s\nAlınan: %2$s Yine de yükle @@ -628,8 +495,6 @@ Birden fazla dosya mevcut Bu sürüm için birden fazla kurulabilir dosya mevcut. Listeyi inceleyin ve cihazınıza uygun olanı seçin. Bilgi - Tekrar dene - Otomatik algılanan: %1$s Dil seçin Paket uyumsuzluğu: APK %1$s, ancak yüklü uygulama %2$s. Güncelleme engellendi. İmza anahtarı uyumsuzluğu: güncelleme farklı bir geliştirici tarafından imzalanmış. Güncelleme engellendi. @@ -642,27 +507,13 @@ Geniş Çok geniş - Görülen depoları gizle - Zaten görüntülediğiniz depoları keşif akışlarından gizleyin - Görüntüleme geçmişini temizle - Tüm görüntülenen depoları sıfırlayarak akışlarda tekrar görünmelerini sağlayın Görüntüleme geçmişi temizlendi Görüntülendi Bu deponun sahibisin Depoyu gizle - Depo gizlendi - Geri al - Gizlilik - Aramayı iyileştirmeye yardım et - Sıfırlanabilir bir analiz kimliğiyle ilişkilendirilmiş kullanım verilerini (aramalar, yüklemeler, etkileşimler) paylaş. Hesap ayrıntıları paylaşılmaz. - Analitik kimliğini sıfırla - Yeni anonim kimlik oluştur, geçmiş telemetri ile bağı kopar. - Analitik kimliği sıfırlandı Son görüntülenenler Ziyaret ettiğiniz depolar - İndirilen paketler - GitHub sürümlerinden APK ve kurulum dosyaları Tümünü sil Tüm indirmeler silinsin mi? Bu işlem tüm APK ve kurulum dosyalarını kalıcı olarak silecek (%1$s). İstediğiniz zaman yeniden indirebilirsiniz. @@ -673,9 +524,7 @@ Tümünü temizle Kaldır - İnce Ayarlar İnce Ayarlar - Ön sürümler Varlık filtresi @@ -735,8 +584,6 @@ Yüklemeye hazır Yükle (hazır) - - Çeviri README çevirisi için kullanılacak hizmeti seçin. Çeviri Hizmeti Google yapılandırma gerektirmeden her yerde çalışır. Youdao Çin anakarasından çalışır ancak Youdao geliştirici portalından API kimlik bilgileri gerekir. @@ -779,8 +626,6 @@ ghp_… veya github_pat_… Giriş yap İptal - Token'ı göster - Token'ı gizle Lütfen bir token yapıştırın. Bu bir GitHub token'ına benzemiyor. Tokenlar ghp_ veya github_pat_ ile başlar. Bu token geçersiz veya iptal edilmiş. @@ -801,13 +646,11 @@ Sürüm kanalını seçin Bu uygulamanın kararlı sürümleri ile beta sürümleri arasında geçiş yapmak için dokunun. Anladım - Bu uygulama için beta sürümlerini aç/kapat %1$s kararlı sürümüne geç %1$d aydır kararlı sürüm yok %1$d gündür kararlı sürüm yok Etkin ön sürümler mevcut, ancak proje bir süredir kararlı bir derleme yayınlamadı. Beta sürümler kararlı bir sürüme ulaşmayabilir. %1$s sürümünden bu yana değişenler - — %1$s — Bu depodan bağlantıyı kes - Daha fazla seçenek Bu uygulamanın bağlantısı kesilsin mi? %1$s uygulamasını bu depodan yüklü olarak takip etmeyi bırakacağız. Uygulama cihazınızda kalır — yalnızca bağlantı kaldırılır. Bağlantıyı kes @@ -921,7 +762,6 @@ - Geri bildirim gönder Geri bildirim gönder Kapat Kategori @@ -959,15 +799,12 @@ ═══════════════════════════════════════════════════════════════ --> Özel forgeler - GHS'in tanımasını istediğin Forgejo / Gitea hostlarını ekle - %1$d eklendi Özel forgeler Bir Forgejo veya Gitea örneğinin host adını gir (örn. git.example.com). GHS, manuel bağlama sayfasında bu hostlardan URL kabul eder. Ekle Henüz özel forge yok. Bitti - İndirme Aynası İndirme Aynası Sürüm dosyalarını indirmek için kullanılır. GitHub API çağrıları her zaman doğrudan gider. Çoğu kullanıcı bunu Doğrudan GitHub olarak bırakmalıdır. Resmi @@ -983,7 +820,6 @@ Şablon {url} ifadesini tam olarak bir kez içermelidir Kaydet Seçileni test et - Test ediliyor… %1$dms'de ulaşıldı Ayna %1$d döndürdü 5s sonra zaman aşımı @@ -991,12 +827,6 @@ Başarısız: %1$s Tüm aynalar mı çöktü? 5 dakikada kendinizinkini barındırabilirsiniz — belgelere bakın. %1$s artık kullanılamıyor, Doğrudan GitHub'a geçildi. - Sağlama toplamı uyumsuzluğu — dosya değiştirilmiş olabilir - Daha hızlı bir ayna deneyin mi? - Yavaş ağlardaki bazı kullanıcılar topluluk proxy'siyle daha iyi sonuç alıyor. - Birini seç - Belki daha sonra - Bir daha sorma Yıldızlılardan ekle @@ -1007,13 +837,7 @@ Güvenlik Durum Anketler - Onayladıktan sonra kapat - %1$d g Şu anda paylaşılacak bir şey yok. - %1$d sa - az önce - %1$s önce yenilendi - %1$d dk Devre dışı bırakılamaz Şuradan öğeleri göster Sessize alma ayarlarını aç @@ -1070,7 +894,6 @@ Daraltılmış Genişletilmiş Güncel - Güncellemeler mevcut %1$s için park edilmiş yükleyiciyi silip uygulama listenizden satırı kaldırmak istiyor musunuz? İndirilen APK silinecek. Bekleyen kurulumu sil? İzin ver @@ -1156,7 +979,6 @@ Yeni %1$s sürümünde neler yeni Neler yeni - Changelog İngilizcedir. Çeviriler Issues üzerinden memnuniyetle karşılanır. Sürüm %1$s · %2$s @@ -1178,7 +1000,6 @@ Tercih güncellenemedi Gizli depolar Ana ekran ve Arama'da gizlediğin depoları yönet. - %1$d gizli Gizli depo yok Ana ekran veya Arama'da bir depo kartına basılı tutarak keşiften gizleyin. Göster @@ -1194,11 +1015,277 @@ Windows macOS Linux - Diğer platform — aktarma için kaydetmek üzere tarayıcıda açılır Cihazınız Aktarım için Android'i Açık Tutun Google'ın geliştirici doğrulama politikası açık Android dağıtımını tehdit ediyor. GitHub Store koalisyonu destekliyor. Daha fazla bilgi Banner'ı kapat + + + + %1$d host + %1$d host + + + %1$d repo + %1$d repo + + + %1$d token + %1$d token + + + %1$d uygulama + %1$d uygulama + + Ayrıntılarını burada görüntülemek için soldaki herhangi bir karta dokunun. + Bir depo seçin + Kurulmaya hazır + Gelişmiş ayarlar + Hızlı işlemler + Ayarlar + Durum + Ayrıntılarını ve işlemlerini görmek için soldaki herhangi bir uygulamayı seçin. + Bir uygulama seçin + Yüklü + En son + Depoyu aç + Varyant seç + Listeyi gizle + Listeyi gör + Tümünü tek seferde incele veya güncelle. + 1 güncelleme mevcut + %1$d güncelleme mevcut + Geri + Geri + Resim + Tümünü gör › + Codeberg, gitea.com ve git.disroot.org zaten varsayılan olarak aranır — bunları buraya eklemenize gerek yok. Bu listeyi yalnızca kendi self-hosted Forgejo veya Gitea örneğiniz için kullanın. + Hakkında + Geliştirici + İstatistikler + Profili gör + Neler yeni + Atla ve sonra incele + Her raporu okuyoruz. + Al + #%1$d + Yıldızlılarınızdan + Popüler sürümler + En popüler + Şu anda trend + Keşfet + Tümünü gör + Daha fazla gör + Token ekle + Geri + İptal + Sil + Kaydet + Token ekle + Algılandı: %1$s + Forge adresi + Mevcut tokeni korumak için boş bırakın. Değiştirmek için yeni bir değer girin. + %1$s için tokeni değiştir + %1$s adresine bağlanacak + Bir personal access token eklemek için + simgesine dokunun + Kaydedilmiş token yok + Her forge hostu için personal access token (GitHub, Codeberg, Forgejo) + Görünen ad (isteğe bağlı) + Personal access token + Uygulamadan GitHub'a giriş yaptınız — bu önerilen yoldur. Buraya yalnızca tarayıcı girişi çalışmıyorsa (kısıtlı ağ, tarayıcı yok) veya GitHub dışındaki forgeler için token ekleyin. + Token sayfasını aç + Diğer forge + Forgejo, Gitea, self-hosted + Token yapıştır + Şuna token ekle… + Etiketi düzenle + Geçersiz · %1$s + Daha fazla + %1$s üzerinde yönet + Tokeni değiştir + %1$s kaldı + Geçerli · %1$s + Kontrol et + %1$s için token kaydedildi + Kimlik doğrulama tokenleri + Geri al + %1$s için token geri yüklenemedi + %1$s için token kaldırıldı + Doğrulama başarısız: %1$s + bilinmeyen hata + Geçersiz host + Host gerekli + Token gerekli + Token çok kısa + GitHub Store Hakkında + Geri bildirim gönder… + Açık kaynak lisansları + Yardım + Gizlilik politikası + Uygulama ayarları, tema, ağ, çeviri, gelişmiş seçenekler. + Bazı değişikliklerin uygulanması için yeniden başlatma gerekir. + Sonra + dil tema + Etkilenen: %1$s + Şimdi yeniden başlat + Filtreyi kaldır + Tamam + Filtreler + Tümünü sıfırla + Dil + Platform + Sırala + Kaynak + Sonuçları filtrele + Uygulama + Bağlantı + Kurulum ve güncellemeler + Görünüm + Gizlilik ve veri + Bir depo açıldığında README ve sürüm notları otomatik çevrilir. Uygulama dilinizi hedef olarak kullanır. + Uygulama dilini izle (%1$s) + Şuna çevir + Depoları otomatik çevir + İptal + Dili değiştir + Algılanan kaynak: %1$s + Tekrar dene + Orijinali göster + Çeviriyi göster + Bu sayfayı başka bir dilde göster. + Orijinal gösteriliyor + %1$s diline çevrildi + Çevriliyor… + Hedef dil + Çevir + %1$s diline çevir + Yetkilendirme anahtarı + Ücretsiz DeepL anahtarı alın → + deepl.com/pro-api adresinden kaydolun. Ücretsiz anahtarlar :fx ile biter ve otomatik olarak ücretsiz uç noktayı kullanır. Pro katmanı metninizi gizli tutar; Ücretsiz katman gönderilen metni model geliştirmek için kullanabilir. + Anahtarı kaydet + DeepL anahtarı kaydedildi + API anahtarı (isteğe bağlı) + Sunucu URL'si + Public Disroot aynası aracılığıyla kutudan çıkar çıkmaz çalışır — kullanmak için alanları boş bırakın. Daha fazla gizlilik için kendi self-hosted URL'nizi yapıştırın. API anahtarı yalnızca sunucu istiyorsa gereklidir. + Ayarları kaydet + LibreTranslate ayarları kaydedildi + Ücretsiz Azure anahtarı alın → + Azure Translator gizliliğe saygılıdır (varsayılan olarak No-Trace — metniniz asla saklanmaz veya eğitim için kullanılmaz). Ücretsiz katman aylık 2M karakteri kapsar. Bölge yalnızca global olmayan kaynaklar için gereklidir. + Abonelik anahtarı + Bölge (ör. westeurope, Global için boş bırakın) + Kimlik bilgilerini kaydet + Microsoft Translator kimlik bilgileri kaydedildi + DeepL + LibreTranslate + Microsoft + GitHub Store + Uygulamada kullanılan kütüphaneler. + Açık kaynak lisansları + github-store.org üzerinde gör. + Gizlilik politikası + Bu uygulamanın kaynağını gör. + GitHub'da kaynak kodu + GitHub, Codeberg ve Forgejo sürümleri için çapraz platform uygulama mağazası. + Geçmiş sürüm notları. + Neler yeni + Aşağıdan geçersiz kılınmadıkça tüm trafiğe uygulanır. + Özel + Aşağıdan bir bağlantı modu seçin. Çoğu kullanıcı bunu Proxy yok olarak bırakır. + Uygulama internete nasıl ulaşır + Ana bağlantı + HTTP/HTTPS + Çoğu kurumsal proxy. + Proxy yok + SOCKS5 + Tor, SSH tünelleri. + Sistem + Her kapsam varsayılan olarak ana bağlantıyı kullanır. Kendi ayarlarını korumak için 'Özel'i seçin. + Kapsama göre geçersiz kılmalar + Tam URL yapıştır + Tam bir proxy URL'si yapıştırın, formu sizin için dolduralım. + Bu URL'yi kullan + Bu URL okunamadı. + scheme://user:pass@host:port + Proxy URL'sini yapıştır + GitHub API, arama, repo ayrıntıları. + Arama ve meta veri + APK ve dosya indirmeleri. + İndirmeler + DeepL, Microsoft, LibreTranslate çağrıları. + Çeviri + Test + Ana bağlantıyı kullan + Erişim tokenleri + Uygulama bilgisi + Görünüm + Bağlantı + Geri bildirim gönder + Kurulum yöntemi + Dil + Gizlilik + Kaynaklar + Depolama + Yönetmek için dokunun + Çeviri + Güncelleme davranışı + Sessiz yükleyiciler ve yükleyici atfı yalnızca Android özellikleridir. Masaüstü kurulumlar işletim sistemi paket yöneticisi üzerinden yapılır. + Kurulum yöntemi yalnızca Android + Dili değiştirdiğinizde uygulama yeniden başlar. + Proje sayfasını açmak için herhangi bir girişe dokunun. + GitHub Store bu kütüphaneler üzerinde yükselir. + Açık kaynak lisansları + Tarama geçmişi + Hangi repoları açtığınızı unut. + Bu işlem hiçbir şeyin yıldızını veya favorisini kaldırmaz. + Temizle + Görüntüleme geçmişi temizlensin mi? + Görüntüleme geçmişini temizle + github.com veya codeberg.org bağlantısı kopyaladığınızda, açmanız için size soracağız. + Panodaki repo bağlantılarını algıla + Akışlardan ve aramadan sessize aldığınız repolar. + Gizli depolar + Akışlarda ve aramada görülen repoları atla. + Daha önce görüntülediğim repoları gizle Tanımlayıcı yok. İşletim sistemi ve platform. '%1$s' ile eşleşen ayar yok. + Ayarları ara + Bir Forgejo veya Gitea hostu ekle + Eklenen hostlar + GitHub aynası + Uygulama varsayılan olarak GitHub'da arama yapar. Bölgesel bir aynaya yönlendirin veya özel Forgejo / Gitea hostları ekleyin. + Uygulama repoları nerede arar + Varsayılan (github.com) + Güncellemelerin hızlı devam etmesi için yükleyicileri saklarız. + Temizle + İndirilen APK'lar + 0 B + Kullanılıyor: %1$s + Bu ekran sürmekte olan yeniden tasarımın bir parçası. Ayarlar hâlâ çalışıyor — sadece eski düzenden buraya taşınıyor. + Yakında bir sonraki sürümde + Bağlan + Topluluğa katıl + 6 yer + Ticari sorular + Ortaklıklar, basın, entegrasyonlar. + İletişim + Kitaplık + Güncellemeler + Hesap + Mod + Görüntü + Dinamik + Diğer giriş seçenekleri + Seçenekleri gizle + Keşif platformları + Ana akışı platforma göre filtrele. Hepsini kapalı bırakırsan her şey gösterilir. + Hakkında + Sürüm, topluluk, yasal + Burada kurulabilir + Bu platform için kurulabilir varlığı olan depo yok + %1$d saatte bir + Günlük + %1$d günde bir + Haftalık + 2 haftada bir + 30 günde bir diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index 579d1d41c..005d5a2b9 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -1,14 +1,12 @@ GitHub Store - 已安装的应用 返回 检查更新 无法启动 %1$s 无法打开 %1$s 更新 %1$s 失败:%2$s - 更新失败 全部更新失败:%1$s 所有应用已成功更新 暂无可用更新 @@ -50,12 +48,9 @@ 使用 GitHub 登录 - 已取消 未知错误 - 语言: - 发现仓库 搜索仓库、描述… 按语言筛选 @@ -100,50 +95,34 @@ 个人资料 - 外观 - 语言 - 覆盖应用界面语言。 - 应用语言 - 更改应用中的菜单、按钮和消息。不会更改来自 GitHub 的内容。 跟随系统 重新启动以应用新语言。 重新启动 - 关于 - 网络 主题颜色 AMOLED 黑色主题 深色模式下的纯黑背景 - 已选择颜色:%1$s - 版本 - 帮助与支持 退出登录 已成功退出,正在跳转… - 缓存已成功清除 警告! 确定要退出登录吗? - 动态 - 海洋 - 紫色 森林 - 石板 - 琥珀 + Nord + Cream + Plum - 加载详情失败 关于此应用 安装日志 - 作者 更新内容 已安装 有可用更新 安装最新版本 - 重新安装 正在下载 正在更新 @@ -162,13 +141,10 @@ GitHub 上没有更多结果 从 GitHub 获取失败,请重试。 - 由 %1$s - • 已安装:%1$s 架构兼容 更新到 %1$s 报告问题 - 无法加载详情 安装程序已保存到下载文件夹 开始下载 @@ -191,30 +167,13 @@ 使用第三方应用安装APK 错误:%1$s - 不支持的文件类型 .%1$s - 未找到下载的文件 - 热门 - 热门发布 - 最受欢迎 - 隐私 - 媒体 - 生产力 - 网络 - 开发工具 正在查找仓库… - 加载中… - 没有更多仓库了 重试 加载仓库失败 - 查看详情 - 刚刚更新 - %1$d 小时前更新 - 昨天更新 - %1$d 天前更新 %1$s 更新 请求次数已达上限 @@ -263,8 +222,6 @@ 关闭 同步收藏的仓库失败 - 开发者简介 - 打开开发者个人资料 加载仓库失败 @@ -278,7 +235,6 @@ 搜索仓库… 清除搜索 - 全部 有发布版 已安装 收藏 @@ -290,7 +246,6 @@ 个仓库 - 个仓库 显示 %2$d 个中的 %1$d 个仓库 @@ -298,8 +253,6 @@ 没有已安装的仓库 没有收藏的仓库 - - %1$s前更新 有发布版 @@ -321,7 +274,7 @@ 首页 搜索 - 应用 + 应用库 个人资料 分叉 @@ -346,7 +299,6 @@ 不可用 - 更新应用 等待安装 @@ -370,35 +322,21 @@ 上次检查:%1$s - 从未检查 刚刚 %1$d 分钟前 %1$d 小时前 正在检查更新… - - 代理类型 - - 系统代理 - HTTP - SOCKS 主机地址 端口 用户名(可选) 密码(可选) 保存代理 代理设置已保存 - 使用设备的代理设置 - 端口必须为 1–65535 - 直连,不使用代理 无法保存代理设置 必须填写代理主机 请输入有效的主机名或 IP 地址 无效的代理端口 - 显示密码 - 隐藏密码 - 测试 - 测试中… 连接正常 (%1$d ms) 无法解析主机。请检查代理地址。 无法连接到代理服务器。 @@ -406,20 +344,8 @@ 需要代理身份验证。 意外响应:HTTP %1$d 连接测试失败 - 每个类别都可以使用自己的代理。请独立配置它们。 - 发现 (GitHub API) - 主页、搜索、仓库详情和更新检查 - 下载 - APK 下载和自动更新 - 翻译 - README 翻译服务 - - 跟踪此应用 - 应用已添加到跟踪列表 - 跟踪应用失败:%1$s - 该应用已在跟踪中 登录 GitHub @@ -436,7 +362,6 @@ 重新登录 以访客身份继续 这将清除您的本地会话和缓存数据。要完全撤销访问权限,请访问 GitHub Settings > Applications。 - 验证码将在 %1$s 后过期 设备验证码已过期。 请重新登录以获取新的验证码。 请检查您的网络连接并重试。 @@ -453,68 +378,30 @@ 无法分享链接 链接已复制到剪贴板 - - 翻译 - 翻译中… - 显示原文 - 已翻译为%1$s 翻译为… 搜索语言 - 更改语言 翻译失败,请重试。 打开 GitHub 链接 在剪贴板中检测到 GitHub 链接 - 自动检测剪贴板链接 - 打开搜索时自动检测剪贴板中的 GitHub 链接 检测到的链接 在应用中打开 剪贴板中未找到 GitHub 链接 - 存储 - 清除缓存 - 当前大小: 清除 - 支持 GitHub Store - 支持项目 - 永久免费。\n由你资助。 - 你的支持让 GitHub Store 保持免费、独立且持续更新——无广告、无跟踪,只是一种更好的方式来安装开源应用。 - 我在完成学业的同时独自开发和维护这个项目。每一份贡献——无论大小——都直接用于服务器成本、开发以及让应用对所有人保持免费无广告。 - 为 GitHub Store 投票! - GitHub Store 已被提名为 KotlinConf 2026 Golden Kodee Awards。 - 1. 注册 - 2. 投票 - 投票截止:3月22日 - 1. 在奖项平台注册(使用 Google 登录) - 2. 点击下方“投票” - 3. 找到 Usmon Narzullayev 并点击投票 - GitHub Sponsors - 按月或一次性——直接资助开发 - Buy Me a Coffee - 快速一次性贡献 - 其他帮助方式 - 为仓库加星 - 提升曝光度,帮助更多人发现它 - 报告问题 - 每一个反馈都让应用对所有人更好 - 分享给朋友 - 口碑是最好的推广 - 每一份贡献——无论是否金钱——都带来真正的改变。感谢你成为其中的一员。 - - 安装 默认 标准系统安装对话框 Shizuku @@ -531,16 +418,10 @@ 自动更新应用 通过 Shizuku 在后台自动下载并安装更新 - 更新 更新检查间隔 在后台检查应用更新的频率 后台检查更新 在后台周期性检查更新。可关闭以节省电量 — 你随时可在任意应用的详情页手动检查。 - 3小时 - 6小时 - 12小时 - 24小时 - 允许后台更新 你的设备会积极结束后台任务。把 GitHub Store 加入电池优化白名单,定时更新检查和静默安装才能稳定运行。 打开设置 @@ -555,18 +436,13 @@ 验证中… 链接并追踪 检查最新版本… - 正在下载APK进行验证… 正在验证签名密钥… 包名不匹配:APK为%1$s,但所选应用为%2$s 签名密钥不匹配:此仓库中的APK由不同的开发者签名 选择安装包 选择APK以与已安装的应用进行验证 - 下载失败 导出 导入 - 导入应用 - 粘贴导出的JSON以恢复跟踪的应用 - 在此粘贴导出的JSON… 默认测试版渠道 新追踪的应用默认包含测试版。已追踪的应用保留各自设置(在应用详情页切换)。 卸载应用? @@ -579,9 +455,6 @@ %1$s已链接到%2$s/%3$s 导出失败:%1$s 导入失败:%1$s - 已导入%1$d个应用 - ,%1$d个已跳过 - ,%1$d个失败 签名密钥已更改 此应用的签名证书自首次安装以来已更改。\n\n这可能意味着开发者更换了签名密钥,或者二进制文件可能已被篡改。\n\n预期:%1$s\n收到:%2$s 仍然安装 @@ -594,8 +467,6 @@ 多个资源可用 此版本有多个可安装文件。请查看列表并选择适合您设备的文件。 信息 - 重试 - 自动检测:%1$s 选择语言 包名不匹配:APK 为 %1$s,但已安装的应用为 %2$s。更新已阻止。 签名密钥不匹配:更新由不同的开发者签名。更新已阻止。 @@ -608,27 +479,13 @@ 超宽 - 隐藏已浏览的仓库 - 在发现信息流中隐藏你已经浏览过的仓库 - 清除浏览记录 - 重置所有已浏览的仓库,使其重新出现在信息流中 浏览记录已清除 已浏览 你拥有此仓库 隐藏仓库 - 仓库已隐藏 - 撤销 - 隐私 - 帮助改进搜索 - 分享与可重置分析 ID 关联的使用数据(搜索、安装、交互)。不分享账户详情。 - 重置分析 ID - 生成新的匿名 ID,切断与过去遥测数据的联系。 - 分析 ID 已重置 最近查看 你访问过的仓库 - 已下载的包 - 来自 GitHub 发布的 APK 和安装程序 全部删除 删除所有下载? 这将永久删除所有 APK 和安装程序(%1$s)。你可以随时重新下载。 @@ -639,9 +496,7 @@ 清除全部 移除 - 调整 调整 - 预发布版本 资产过滤器 @@ -699,8 +554,6 @@ 准备安装 安装(已就绪) - - 翻译 选择用于翻译 README 的服务。 翻译服务 Google 全球可用,无需配置。有道可从中国大陆访问,但需要从有道开发者门户获取 API 凭据。 @@ -743,8 +596,6 @@ ghp_… 或 github_pat_… 登录 取消 - 显示令牌 - 隐藏令牌 请粘贴令牌。 这不像是 GitHub 令牌。令牌以 ghp_ 或 github_pat_ 开头。 该令牌无效或已被吊销。 @@ -766,13 +617,11 @@ 选择发布渠道 点按以在此应用的稳定版和测试版之间切换。 知道了 - 切换此应用的测试版发布 切换到 %1$s 稳定版 %1$d 个月内无稳定版发布 %1$d 天内无稳定版发布 目前有活跃的预发布版本,但该项目已有一段时间未发布稳定版。测试版可能不会收敛为稳定版。 自 %1$s 以来的变更 - — %1$s — 从此仓库取消关联 - 更多选项 取消关联此应用? 我们将停止追踪 %1$s 作为从此仓库安装的应用。该应用仍保留在您的设备上 — 仅删除关联。 取消关联 @@ -881,7 +728,6 @@ - 发送反馈 发送反馈 关闭 类别 @@ -919,15 +765,12 @@ ═══════════════════════════════════════════════════════════════ --> 自定义代码托管 - 添加你希望 GHS 识别的 Forgejo / Gitea 主机 - 已添加 %1$d 个 自定义代码托管 输入 Forgejo 或 Gitea 实例的主机名(例如 git.example.com)。在手动关联表单中,GHS 将接受这些主机的链接。 添加 尚无自定义代码托管。 完成 - 下载镜像 下载镜像 用于下载发布资源。GitHub API 调用始终直接进行。大多数用户应保持"直连 GitHub"设置。 官方 @@ -943,7 +786,6 @@ 模板必须恰好包含一次 {url} 保存 测试所选 - 测试中… 已在 %1$dms 内到达 镜像返回 %1$d 5s 后超时 @@ -951,12 +793,6 @@ 失败:%1$s 所有镜像都无法使用?您可以在 5 分钟内自行托管 — 请参阅文档。 %1$s 已不再可用,已切换到直连 GitHub。 - 校验和不匹配 — 文件可能已被篡改 - 尝试更快的镜像? - 网络较慢的部分用户使用社区代理效果更好。 - 选择一个 - 稍后再说 - 不再询问 从星标添加 @@ -967,13 +803,7 @@ 安全 状态 调查 - 确认后关闭 - %1$d 天 当前没有可分享的内容。 - %1$d 小时 - 刚刚 - %1$s 前刷新 - %1$d 分钟 无法关闭 显示来自 打开静音设置 @@ -1030,7 +860,6 @@ 已折叠 已展开 已是最新 - 有可用更新 放弃 %1$s 的暂存安装包并从应用列表中移除该行?已下载的 APK 将被删除。 放弃待安装项? 授予权限 @@ -1116,7 +945,6 @@ 新增 %1$s 的更新内容 更新内容 - 更新日志为英文。欢迎通过 Issues 提交翻译。 版本 %1$s · %2$s @@ -1138,7 +966,6 @@ 无法更新偏好设置 已隐藏的仓库 管理从首页和搜索中隐藏的仓库。 - 已隐藏 %1$d 个 没有隐藏的仓库 在首页或搜索中长按仓库卡片即可从发现中隐藏。 取消隐藏 @@ -1154,11 +981,273 @@ Windows macOS Linux - 其他平台 — 在浏览器中打开以保存并转移 你的设备 用于转移 保持 Android 开放 Google 的开发者验证政策威胁 Android 的开放分发。GitHub Store 与联盟同行。 了解更多 关闭横幅 + + + 点按左侧任意卡片,在此查看详情。 + 选择一个仓库 + 可以安装 + 高级设置 + 快捷操作 + 设置 + 状态 + 在左侧选择任意应用,即可查看详情和操作。 + 选择一个应用 + 已安装 + 最新 + 打开仓库 + 选择变体 + 隐藏列表 + 查看列表 + 逐个审查或一键全部更新。 + 1 个可用更新 + %1$d 个可用更新 + 返回 + 返回 + 图片 + 查看全部 › + + %1$d 个主机 + + Codeberg、gitea.com 和 git.disroot.org 默认已被搜索 — 无需在此添加。此列表仅用于你自己的 Forgejo 或 Gitea 自建实例。 + 关于 + 开发者 + 统计 + 查看资料 + 更新内容 + 跳过,稍后查看 + 每一条反馈我们都会阅读。 + 获取 + #%1$d + 来自你的星标 + 热门发布 + 最受欢迎 + 当下流行 + 发现 + 查看全部 + 查看更多 + 添加令牌 + 返回 + 取消 + 删除 + 保存 + 添加令牌 + 检测到:%1$s + 代码托管地址 + 留空可保留现有令牌。输入新值以替换。 + 替换 %1$s 的令牌 + 将连接到 %1$s + + %1$d 个令牌 + + 点按 + 添加个人访问令牌 + 尚未保存任何令牌 + 每个代码托管站点的个人访问令牌(GitHub、Codeberg、Forgejo) + 显示名称(可选) + 个人访问令牌 + 你已通过应用使用 GitHub 登录 — 这是推荐方式。仅在浏览器登录无法使用(受限网络、无浏览器)或针对 GitHub 之外的代码托管站点时,才在此添加令牌。 + 打开令牌页面 + 其他代码托管 + Forgejo、Gitea、自建实例 + 粘贴令牌 + 为以下站点添加令牌… + 编辑标签 + 无效 · %1$s + 更多 + 在 %1$s 上管理 + 替换令牌 + 剩余 %1$s + 有效 · %1$s + 检查 + 已保存 %1$s 的令牌 + 认证令牌 + 撤销 + 无法恢复 %1$s 的令牌 + 已移除 %1$s 的令牌 + 验证失败:%1$s + 未知错误 + 无效的主机 + 必须填写主机 + 必须填写令牌 + 令牌太短 + 关于 GitHub Store + 发送反馈… + 开源许可证 + 帮助 + 隐私政策 + 应用设置、主题、网络、翻译、高级选项。 + 部分更改需要重启后生效。 + 稍后 + 语言 主题 + 受影响:%1$s + 立即重启 + 移除筛选 + 完成 + 筛选 + 全部重置 + 语言 + 平台 + 排序方式 + 来源 + 筛选结果 + 应用 + 连接 + 安装与更新 + 外观与显示 + 隐私与数据 + + %1$d 个应用 + + 打开仓库时自动翻译 README 和发布说明。以应用语言为目标语言。 + 跟随应用语言(%1$s) + 翻译为 + 自动翻译仓库 + 取消 + 更改语言 + 检测到的源语言:%1$s + 重试 + 显示原文 + 显示译文 + 将此页面渲染为其他语言。 + 显示原文 + 已翻译为 %1$s + 正在翻译… + 目标语言 + 翻译 + 翻译为 %1$s + Auth 密钥 + 获取免费 DeepL 密钥 → + 在 deepl.com/pro-api 注册。免费版密钥以 :fx 结尾,会自动使用免费端点。专业版会保持你的文本私密;免费版可能使用提交的文本来改进模型。 + 保存密钥 + DeepL 密钥已保存 + API 密钥(可选) + 实例 URL + 通过公共 Disroot 镜像开箱即用 — 留空字段即可使用。为获得更高隐私性,可粘贴自建 URL。仅当实例要求时才需要 API 密钥。 + 保存设置 + LibreTranslate 设置已保存 + 获取免费 Azure 密钥 → + Azure Translator 注重隐私(默认 No-Trace — 你的文本从不存储,也不用于训练)。免费版每月可翻译 200 万字符。仅非全球资源需要填写区域。 + 订阅密钥 + 区域(例如 westeurope,全球留空) + 保存凭据 + Microsoft Translator 凭据已保存 + DeepL + LibreTranslate + Microsoft + GitHub Store + 应用使用的开源库。 + 开源许可证 + 在 github-store.org 上查看。 + 隐私政策 + 查看此应用的源代码。 + GitHub 上的源代码 + 面向 GitHub、Codeberg 和 Forgejo 发布版的跨平台应用商店。 + 过往发布说明。 + 更新内容 + 除非下方覆盖,否则适用于全部流量。 + 自定义 + 从下方选择一种连接模式。大多数人保持\"不使用代理\"即可。 + 应用如何连接到互联网 + 主连接 + HTTP/HTTPS + 大多数企业代理。 + 不使用代理 + SOCKS5 + Tor、SSH 隧道。 + 系统 + 每个范围默认使用主连接。选择\"自定义\"以保留各自的设置。 + 分范围覆盖 + 粘贴完整 URL + 粘贴完整代理 URL,我们会自动填入表单。 + 使用此 URL + 无法解析该 URL。 + scheme://user:pass@host:port + 粘贴代理 URL + GitHub API、搜索、仓库详情。 + 搜索与元数据 + APK 和资源下载。 + 下载 + DeepL、Microsoft、LibreTranslate 请求。 + 翻译 + 测试 + 使用主连接 + 访问令牌 + 应用信息 + 外观 + 连接 + 发送反馈 + 安装方式 + 语言 + 隐私 + 来源 + 存储 + 点按管理 + 翻译 + 更新行为 + 静默安装器和安装器归属仅限 Android。桌面端安装通过系统包管理器完成。 + 安装方式仅限 Android + 切换语言时应用会重启。 + 点按任意条目以打开其项目页面。 + GitHub Store 依托于这些开源库。 + 开源许可证 + 浏览记录 + 忘记你打开过哪些仓库。 + 这不会取消任何星标或收藏。 + 清除 + 清除浏览记录? + 清除浏览记录 + 当你复制 github.com 或 codeberg.org 链接时,我们会提示你打开。 + 检测剪贴板中的仓库链接 + 你从信息流和搜索中静音的仓库。 + 已隐藏的仓库 + 在信息流和搜索中跳过已查看的仓库。 + 隐藏我已查看过的仓库 不收集标识符。 操作系统和平台。 没有设置匹配 '%1$s'。 + 搜索设置 + 添加 Forgejo 或 Gitea 主机 + 已添加的主机 + GitHub 镜像 + 应用默认搜索 GitHub。可通过区域镜像路由,或添加自定义的 Forgejo / Gitea 主机。 + 应用从哪里查找仓库 + 默认 (github.com) + 我们会保留安装包,以便更新可以快速继续。 + 清除 + 已下载的 APK + 0 B + 占用:%1$s + 此页面属于正在进行的重新设计。设置依然有效 — 只是正从旧布局迁移到这里。 + 后续更新中 + + 已隐藏 %1$d 个 + + 连接 + 加入社区 + 6 个平台 + 商务咨询 + 合作、媒体、集成。 + 联系 + 媒体库 + 更新 + 账户 + 模式 + 显示 + 动态 + 更多登录方式 + 隐藏选项 + 发现平台 + 按平台筛选首页内容。全部关闭则显示全部。 + 关于 + 版本、社区、法律 + 可在此安装 + 没有适用于当前平台的可安装资源仓库 + 每 %1$d 小时 + 每天 + 每 %1$d 天 + 每周 + 每 2 周 + 每 30 天 diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 21dea498c..1a5449c1f 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -3,8 +3,6 @@ GitHub Store - - Installed Apps Navigate back Check for updates @@ -12,7 +10,6 @@ Cannot launch %1$s Failed to open %1$s Failed to update %1$s: %2$s - Update failed Update all failed: %1$s All apps updated successfully No updates available @@ -64,12 +61,9 @@ Sign in with GitHub - Cancelled Unknown error - Language: - Discover Repositories Search repo, description… @@ -118,54 +112,29 @@ Profile - - APPEARANCE - LANGUAGE - Override the app's UI language. - App language - Changes menus, buttons, and messages throughout the app. Does not change content coming from GitHub. Follow system Restart to apply the new language. Restart - NETWORK - ABOUT - Theme Color - AMOLED Black Theme - Pure black background for dark mode - Selected color: %1$s + Palette + True black (AMOLED) + Pure-black background — saves power on OLED screens. - - Version - Help & Support Logout - - Proxy Type - None - System - HTTP - SOCKS Host Port Username (optional) Password (optional) - Save Proxy + Save Proxy settings saved - Uses your device's proxy settings - Port must be 1–65535 - Direct connection, no proxy Failed to save proxy settings Proxy host is required Enter a valid hostname or IP address Invalid proxy port - Show password - Hide password - Test - Testing… Connection OK (%1$d ms) Could not resolve host. Check the proxy address. Could not reach the proxy server. @@ -173,13 +142,6 @@ Proxy authentication required. Unexpected response: HTTP %1$d Connection test failed - Each category can use its own proxy. Configure them independently. - Discovery (GitHub API) - Home, search, repo details, and update checks - Downloads - APK downloads and auto-updates - Translation - README translation service Logged out successfully, redirecting... @@ -189,12 +151,11 @@ Are you sure you want to log out? - Dynamic - Ocean - Purple Forest - Slate - Amber + Dynamic + Nord + Cream + Plum Open repository @@ -202,8 +163,6 @@ Cancel download Show install options - - Error loading details Something went wrong You're offline Rate limit hit @@ -220,7 +179,6 @@ About this app Install logs - Author What’s New @@ -250,8 +208,6 @@ ghp_… or github_pat_… Sign in Cancel - Show token - Hide token Please paste a token. That doesn't look like a GitHub token. Tokens start with ghp_ or github_pat_. That token is invalid or has been revoked. @@ -262,8 +218,6 @@ No releases published yet Loading releases… Install latest - Reinstall - Update app Report issue @@ -313,14 +267,9 @@ No more results on GitHub Failed to fetch from GitHub. Try again. - - by %1$s - • Installed: %1$s Architecture compatible Update to %1$s - - Failed to load details Installer was saved into the Downloads folder @@ -348,30 +297,32 @@ Error: %1$s - Asset type .%1$s not supported - Downloaded file not found - Trending - Hot release - Most popular - Privacy - Media - Productivity - Network - Dev Tools Finding repositories... - Loading more... - No more repositories Retry Failed to load repositories - View Details - updated just now - updated %1$d hour(s) ago - updated yesterday - updated %1$d day(s) ago + Discover + Hot releases + Trending now + Most popular + From your stars + #%1$d + Get + Back + Image + See all › + View all + View more + + View profile + Developer + Stats + About + What's new + updated on %1$s Rate Limit Exceeded @@ -425,8 +376,6 @@ Dismiss Failed to sync starred repos - Developer Profile - Open developer profile Failed to load repositories Failed to load profile @@ -439,8 +388,8 @@ Search repositories… Clear search - All - With Releases + With releases + Installable here Installed Favorites Sort @@ -451,16 +400,14 @@ repository - repositories Showing %1$d of %2$d repositories - No repositories with installable releases + No repositories with releases + No repositories with installable assets for this platform No installed repositories No favorite repositories - - Updated %1$s Has Release %1$d y ago @@ -481,9 +428,8 @@ Home Search - Apps + Library Profile - Tweaks Fork @@ -501,13 +447,11 @@ Choose your release channel Tap to switch between stable releases and beta builds for this app. Got it - Toggle beta releases for this app Switch to stable %1$s No stable release in %1$d months No stable release in %1$d days Active pre-releases but the project hasn't shipped a stable build in a while. Betas may not converge to a stable release. What's changed since %1$s - — %1$s — No version Versions Assets @@ -521,17 +465,11 @@ Last checked: %1$s - Never checked just now %1$d min ago %1$d h ago Checking for updates… - - Track this app - App added to tracking list - Failed to track app: %1$s - App is already being tracked Sign in to GitHub @@ -551,8 +489,6 @@ This will clear your local session and cached data. To fully revoke access, visit GitHub Settings > Applications. - - Code expires in %1$s The device code has expired. Please try signing in again to get a new code. Please check your internet connection and try again. @@ -569,82 +505,38 @@ Failed to share link Link copied to clipboard - - Translate - Translating… - Show original - Translated to %1$s Translate to… Search language - Change language Translation failed. Please try again. - Retry - Auto-detected: %1$s Select language Open GitHub Link GitHub link detected in clipboard - Auto-detect clipboard links - Automatically detect GitHub links from clipboard when opening search Detected Links Open in app No GitHub link found in clipboard - Storage - Downloaded Packages - APKs and installers from GitHub releases - Current size: Delete All Delete all downloads? This will permanently remove all downloaded APKs and installers (%1$s). You can re-download them anytime. All downloaded packages have been deleted. - - Clear cache Clear - The cache is gradually cleared. - Support GitHub Store - Support the project - Free forever.\nFunded by you. - Your support keeps GitHub Store free, independent, and actively maintained — no ads, no tracking, just a better way to install open-source apps. - I build and maintain this project solo while finishing school. Every contribution — big or small — goes directly toward server costs, development, and keeping the app ad-free for everyone. - Vote for GitHub Store! - GitHub Store has been nominated for the Golden Kodee Awards at KotlinConf 2026. - 1. Register - 2. Vote - Voting until March 22 - 1. Register on the platform (Sign in with Google) - 2. Click “Vote” below - 3. Find Usmon Narzullayev and click “Vote” - GitHub Sponsors - Monthly or one-time — directly funds development - Buy Me a Coffee - Quick one-time contribution - OTHER WAYS TO HELP - Star the repository - Boosts visibility and helps others find it - Report a bug - Every report makes the app better for everyone - Share with friends - Word of mouth is the best marketing - Every contribution — financial or not — makes a real difference. Thank you for being part of this. - - Installation - Default - Standard system install dialog + System installer + Asks each time. Works on every device. Shizuku Silent install without prompts Shizuku is not installed @@ -671,8 +563,8 @@ No root Allow root access Root isn't available on this device. Install Magisk, KernelSU, or APatch first. - Auto-update apps - Automatically download and install updates in background via the selected silent installer + Install updates in the background + Apply downloaded updates without notifying you each time. Installer attribution Apps can see which installer placed them on your device. By default this is the system shell. @@ -686,15 +578,16 @@ Use a valid Android package name (e.g. com.example.installer) Some apps detect when their installer changes and may refuse to run, or fail security checks (e.g. Play Integrity, banking apps). - Updates - Update check interval - How often to check for app updates in background - Background update check - Periodically check for updates in the background. Turn off to save battery — you can still check manually from any app's details screen. - 3h - 6h - 12h - 24h + Check every + How often to look for new releases. + Check automatically + Look for new releases on a schedule. Turn off to save battery — you can still check manually from any app's details screen. + Every %1$d hours + Daily + Every %1$d days + Weekly + Every 2 weeks + Every 30 days Allow background updates Your device aggressively kills background tasks. Whitelist GitHub Store from battery optimization so scheduled update checks and silent installs can run reliably. @@ -710,13 +603,11 @@ Validating… Link & Track Checking latest release… - Downloading APK for verification… Verifying signing key… Package name mismatch: the APK is %1$s, but the selected app is %2$s Signing key mismatch: the APK in this repository was signed by a different developer than the installed app Select installer Pick the APK to verify against your installed app - Download failed Export library Export for Obtainium Import from file @@ -752,9 +643,6 @@ GitHub rate limit reached. Resume to keep checking. Resume Removing a star on GitHub won't untrack the app. - Import apps - Paste the exported JSON to restore your tracked apps - Paste exported JSON here… Default beta channel Newly tracked apps include beta builds by default. Already-tracked apps keep their own per-app channel setting (toggle it on the app's Details screen). @@ -771,9 +659,6 @@ %1$s linked to %2$s/%3$s Export failed: %1$s Import failed: %1$s - Imported %1$d apps - , %1$d skipped - , %1$d failed Package mismatch: the APK is %1$s, but the installed app is %2$s. Update blocked. Signing key mismatch: the update was signed by a different developer. Update blocked. @@ -787,10 +672,6 @@ Wide Extra wide - Hide Seen Repositories - Hide repositories you have already viewed from discovery feeds - Clear Seen History - Reset all seen repositories so they appear again in feeds Seen history cleared Viewed You own this repo @@ -803,14 +684,10 @@ Windows macOS Linux - Other platform — opens in browser to save for transfer Your device For transfer - Repository hidden - Undo Hidden repositories Manage repositories you hid from Home and Search. - %1$d hidden No hidden repositories Long-press a repository card on Home or Search to hide it from discovery. Unhide @@ -819,14 +696,6 @@ All repositories unhidden Could not unhide. Try again. - - Privacy - Help improve search - Share usage data (searches, installs, interactions) associated with a resettable analytics ID. No account details are shared. - Reset analytics ID - Generate a new anonymous ID, severing the link to past telemetry. - Analytics ID reset - Recent searches Clear all @@ -834,8 +703,6 @@ Tweaks - - Pre-releases Asset filter @@ -848,6 +715,22 @@ Showing %1$d of %2$d asset Showing %1$d of %2$d assets + + %1$d host + %1$d hosts + + + %1$d token + %1$d tokens + + + %1$d app + %1$d apps + + + %1$d repo + %1$d repos + Fall back to older releases Walk back through past releases until one matches the filter. Required for monorepos where the latest release belongs to a sibling app. Advanced filter @@ -897,8 +780,6 @@ Ready to install Install (ready) - - Translation Choose the service used to translate README content. Translation Service Google works globally without configuration. Youdao works from mainland China but requires API credentials from Youdao's developer portal. @@ -946,7 +827,6 @@ Skip Link Installed via %1$s - We think this is %1$s · %2$d We think this is Tap to find a repo by %1$s @@ -1019,7 +899,6 @@ Unlink from this repo - More options Unlink this app? We'll stop tracking %1$s as installed from this repo. The app stays on your device — only the link is removed. Unlink @@ -1039,8 +918,6 @@ Couldn't refresh. Try again shortly. Refresh failed. Try again. - - Send feedback Send feedback Close @@ -1085,8 +962,6 @@ Custom forges - Add Forgejo / Gitea hosts you want GHS to recognise - %1$d added Custom forges Enter the hostname of a Forgejo or Gitea instance (e.g. git.example.com). GHS will accept URLs from these hosts in the manual-link sheet. Codeberg, gitea.com and git.disroot.org are already searched by default — you don't need to add them here. Use this list only for your own self-hosted Forgejo or Gitea. @@ -1094,8 +969,6 @@ No custom forges yet. Done - - Download Mirror Download Mirror Used for downloading release assets. GitHub API calls always go direct. Most users should leave this on Direct GitHub. @@ -1119,7 +992,6 @@ Test selected - Testing… Reached in %1$dms Mirror returned %1$d Timed out after 5s @@ -1131,17 +1003,8 @@ %1$s is no longer available, switched to Direct GitHub. - Checksum mismatch — file may have been tampered with - - Try a faster mirror? - Some users on slow networks have better luck with a community proxy. - Pick one - Maybe later - Don't ask again - - Updates available Up to date · %1$d Expand section @@ -1158,6 +1021,96 @@ updates ignored Ignore updates Discard + 1 update available + %1$d updates available + Review or update everything at once. + See list + Hide list + Ready to install + Pick an app + Select any app on the left to see its details and actions. + Status + Quick actions + Settings + Open repository + Installed + Latest + Pick variant + Advanced settings + Pick a repository + Tap any card on the left to view its details here. + Translate + Render this page in another language. + Translating… + Translated to %1$s + Showing original + Target language + Translate to %1$s + Show original + Show translation + Cancel + Retry + Detected source: %1$s + Change language + Filters + Filter results + Source + Platform + Language + Sort by + Reset all + Done + Remove filter + App settings, theme, network, translation, advanced options. + + + Connectivity + Privacy & data + App + Installs & updates + Look & feel + Library + Updates + Account + Mode + Display + More sign-in options + Hide options + Discovery platforms + Filter Home feed to specific platforms. Leave all off to see everything. + About + Version, community, legal + Appearance + Language + Connection + Sources + Translation + Install method + Update behavior + Storage + Privacy + Access tokens + App info + Send feedback + Tap to manage + Search settings + No settings match '%1$s'. + We read every report. + Some changes need a restart to apply. + Affected: %1$s + language + theme Restart now + Later + Install method is Android-only + Silent installers and installer attribution are Android features. Desktop installs go through the OS package manager. + Coming in a follow-up + This screen is part of the in-progress redesign. The settings still work — they're just being moved here from the old layout. + Back + Help + About GitHub Store + Send feedback… + Open source licenses + Privacy policy APK Inspect @@ -1199,18 +1152,12 @@ Heads up Got it View previous versions - Changelog is in English. Translations welcome via Issues. No changelog entries yet. Highlights from recent releases. Announcements News, surveys, and important notices. Nothing to share right now. - Last refreshed %1$s ago Couldn't refresh — showing cached items. - just now - %1$d min - %1$d h - %1$d d I've read this Acknowledged Read more @@ -1225,7 +1172,6 @@ Info Important Critical - Close after acknowledgment Open mute settings @@ -1253,19 +1199,14 @@ Auto-translate repositories Translate README and release notes automatically when opening a repository. Uses your app language as the target. - Target: %1$s - Reset Translate into Following app language (%1$s) Authentication tokens Personal access tokens per forge host (GitHub, Codeberg, Forgejo) - Personal access tokens stored encrypted per forge host. Used to authenticate direct API calls. No tokens stored Tap + to add a personal access token - Add token - Host (e.g. github.com) Personal access token Display name (optional) Host required @@ -1276,11 +1217,8 @@ Cancel Back Add token - Validate Delete Saved token for %1$s - Removed token for %1$s - Token accepted (login=%1$s, scopes=%2$s) Validation failed: %1$s @@ -1308,4 +1246,82 @@ Add token unknown error Couldn't restore token for %1$s + + + How the app reaches the internet + Pick a connection mode below. Most people leave this on No proxy. + Main connection + Per-scope overrides + Each scope uses the main connection by default. Choose 'Custom' to keep its own settings. + Applies to all traffic unless overridden below. + Paste full URL + Paste proxy URL + Paste a full proxy URL and we'll fill the form for you. + scheme://user:pass@host:port + Couldn't read that URL. + Use this URL + Search & metadata + GitHub API, search, repo details. + Downloads + APK and asset downloads. + Translation + DeepL, Microsoft, LibreTranslate calls. + Use main + Custom + Test + No proxy + System + HTTP/HTTPS + SOCKS5 + Most corporate proxies. + Tor, SSH tunnels. + + Where the app looks for repositories + The app searches GitHub by default. Route through a regional mirror or add custom Forgejo / Gitea hosts. + Added hosts + Default (github.com) + Add a Forgejo or Gitea host + GitHub mirror + + Browsing history + Usage data Detect repo links in clipboard + When you copy a github.com or codeberg.org link, we'll prompt to open it. + Hide repos I've already viewed + Skip seen repos in feeds and search. + Clear viewed history + Forget which repos you've already opened. + Clear viewed history? + This won't unstar or unfavorite anything. + Clear + Hidden repositories + Repos you've muted from feeds and search. + + Downloaded APKs + We keep installers around so updates resume fast. + Using: %1$s + Clear + 0 B + + GitHub Store + Cross-platform app store for GitHub, Codeberg, and Forgejo releases. + Connect + Join the community + 6 places + Business inquiries + Partnerships, press, integrations. + Contact + What's new + Past release notes. + Open source licenses + Libraries used in the app. + Privacy policy + View on github-store.org. + Source code on GitHub + View this app's source. + + The app restarts when you switch language. + + Open source licenses + GitHub Store stands on these libraries. + Tap any entry to open its project page. diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/color/AvatarColorStore.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/color/AvatarColorStore.kt new file mode 100644 index 000000000..37af0c8c0 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/color/AvatarColorStore.kt @@ -0,0 +1,34 @@ +package zed.rainxch.core.presentation.color + +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.ui.graphics.Color +import coil3.Image +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +object AvatarColorStore { + private val cache: SnapshotStateMap = mutableStateMapOf() + private val inflight: MutableSet = mutableSetOf() + private val mutex = Mutex() + + fun colorFor(url: String): Color? = cache[url] + + suspend fun computeIfAbsent(url: String, image: Image) { + mutex.withLock { + if (cache.containsKey(url) || url in inflight) return + inflight.add(url) + } + val color = withContext(Dispatchers.Default) { + runCatching { computeDominantFromImage(image) }.getOrNull() + } + mutex.withLock { + inflight.remove(url) + if (color != null) cache[url] = color + } + } +} + +expect fun computeDominantFromImage(image: Image): Color? diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/color/DominantColorMath.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/color/DominantColorMath.kt new file mode 100644 index 000000000..d1d0ed7d4 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/color/DominantColorMath.kt @@ -0,0 +1,49 @@ +package zed.rainxch.core.presentation.color + +import androidx.compose.ui.graphics.Color + +internal fun dominantFromArgb(pixels: IntArray): Color? { + val strict = averageColor(pixels, lumMin = 24, lumMax = 232, chromaMin = 20) + if (strict != null) return strict + val relaxed = averageColor(pixels, lumMin = 10, lumMax = 245, chromaMin = 8) + if (relaxed != null) return relaxed + val anyColor = averageColor(pixels, lumMin = 4, lumMax = 251, chromaMin = 0) + return anyColor +} + +private fun averageColor( + pixels: IntArray, + lumMin: Int, + lumMax: Int, + chromaMin: Int, +): Color? { + var rSum = 0L + var gSum = 0L + var bSum = 0L + var count = 0L + for (px in pixels) { + val alpha = (px ushr 24) and 0xFF + if (alpha < 128) continue + val r = (px ushr 16) and 0xFF + val g = (px ushr 8) and 0xFF + val b = px and 0xFF + val luminance = (r + g + b) / 3 + if (luminance < lumMin || luminance > lumMax) continue + if (chromaMin > 0) { + val maxC = maxOf(r, g, b) + val minC = minOf(r, g, b) + if (maxC - minC < chromaMin) continue + } + rSum += r + gSum += g + bSum += b + count += 1 + } + if (count < 6L) return null + return Color( + red = (rSum / count).toInt(), + green = (gSum / count).toInt(), + blue = (bSum / count).toInt(), + alpha = 255, + ) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/color/RememberAvatarColor.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/color/RememberAvatarColor.kt new file mode 100644 index 000000000..94605b463 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/color/RememberAvatarColor.kt @@ -0,0 +1,10 @@ +package zed.rainxch.core.presentation.color + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +@Composable +fun avatarColorFor(url: String?, fallback: Color): Color { + if (url.isNullOrBlank()) return fallback + return AvatarColorStore.colorFor(url) ?: fallback +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/ExpressiveCard.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/ExpressiveCard.kt index 6f365696c..5c44b7d80 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/ExpressiveCard.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/ExpressiveCard.kt @@ -1,18 +1,17 @@ package zed.rainxch.core.presentation.components +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp - -private val EXPRESSIVE_CARD_SHAPE = RoundedCornerShape(32.dp) +import zed.rainxch.core.presentation.theme.tokens.Radii @OptIn(ExperimentalFoundationApi::class) @Composable @@ -22,60 +21,26 @@ fun ExpressiveCard( onLongClick: (() -> Unit)? = null, content: @Composable () -> Unit, ) { - // Long-press without tap leaves the gesture orphaned: the card looks - // tappable but only responds to a hold. Fail loud so the API contract - // is obvious at the call site. check(onLongClick == null || onClick != null) { "ExpressiveCard: onLongClick requires onClick" } - when { - onClick != null && onLongClick != null -> { - // ElevatedCard's built-in `onClick` doesn't expose long-press; - // route both gestures through `combinedClickable`. Clip the - // modifier chain to the card shape FIRST so the ripple - // respects the 32.dp rounded corners — without the clip the - // ripple draws as a square overlapping the card edges. - ElevatedCard( - modifier = - modifier - .fillMaxWidth() - .clip(EXPRESSIVE_CARD_SHAPE) - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick, - ), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - shape = EXPRESSIVE_CARD_SHAPE, - content = { content() }, - ) - } - - onClick != null -> { - ElevatedCard( - modifier = modifier.fillMaxWidth(), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - onClick = onClick, - shape = EXPRESSIVE_CARD_SHAPE, - content = { content() }, - ) - } - - else -> { - ElevatedCard( - modifier = modifier.fillMaxWidth(), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - shape = EXPRESSIVE_CARD_SHAPE, - content = { content() }, - ) - } + val baseModifier = modifier.fillMaxWidth().clip(Radii.row) + val clickModifier = when { + onClick != null && onLongClick != null -> baseModifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + onClick != null -> baseModifier.combinedClickable(onClick = onClick) + else -> baseModifier } + Surface( + modifier = clickModifier, + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + ), + content = content, + ) } diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/FloatingPill.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/FloatingPill.kt new file mode 100644 index 000000000..40a3028b4 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/FloatingPill.kt @@ -0,0 +1,41 @@ +package zed.rainxch.core.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun FloatingPill( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + horizontalPadding: Dp = 12.dp, + verticalPadding: Dp = 10.dp, + content: @Composable () -> Unit, +) { + val shape = RoundedCornerShape(50) + Box( + modifier = modifier + .clip(shape) + .background(MaterialTheme.colorScheme.surface) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = shape, + ) + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + contentAlignment = Alignment.Center, + ) { + content() + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/GitHubStoreImage.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/GitHubStoreImage.kt index 02a41408c..3989056eb 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/GitHubStoreImage.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/GitHubStoreImage.kt @@ -2,8 +2,6 @@ package zed.rainxch.core.presentation.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.CircularWavyProgressIndicator @@ -11,20 +9,24 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp import com.skydoves.landscapist.coil3.CoilImage +import com.skydoves.landscapist.coil3.CoilImageState import com.skydoves.landscapist.components.rememberImageComponent import com.skydoves.landscapist.crossfade.CrossfadePlugin +import kotlinx.coroutines.launch +import zed.rainxch.core.presentation.color.AvatarColorStore @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun GitHubStoreImage( imageModel: () -> Any?, modifier: Modifier = Modifier, + extractDominantFor: String? = null, ) { + val scope = rememberCoroutineScope() CoilImage( imageModel = imageModel, modifier = modifier, @@ -49,6 +51,14 @@ fun GitHubStoreImage( ) } }, + onImageStateChanged = { state -> + if (extractDominantFor != null && state is CoilImageState.Success) { + val image = state.image + if (image != null) { + scope.launch { AvatarColorStore.computeIfAbsent(extractDominantFor, image) } + } + } + }, component = rememberImageComponent { CrossfadePlugin() diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt index ba599b5d1..74b7ea1a9 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt @@ -1,6 +1,7 @@ package zed.rainxch.core.presentation.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.outlined.CallSplit import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Verified @@ -75,7 +77,6 @@ import zed.rainxch.githubstore.core.presentation.res.hide_repository import zed.rainxch.githubstore.core.presentation.res.mark_as_unviewed import zed.rainxch.githubstore.core.presentation.res.mark_as_viewed import zed.rainxch.githubstore.core.presentation.res.open_on_github -import zed.rainxch.githubstore.core.presentation.res.home_view_details import zed.rainxch.githubstore.core.presentation.res.installed import zed.rainxch.githubstore.core.presentation.res.open_in_browser import zed.rainxch.githubstore.core.presentation.res.seen_badge @@ -87,6 +88,7 @@ import zed.rainxch.githubstore.core.presentation.res.update_available ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class, + androidx.compose.foundation.ExperimentalFoundationApi::class, ) @Composable fun RepositoryCard( @@ -99,7 +101,6 @@ fun RepositoryCard( onToggleSeen: (() -> Unit)? = null, ) { val uriHandler = LocalUriHandler.current - val contentAlpha by animateFloatAsState( targetValue = if (discoveryRepositoryUi.isSeen) 0.55f else 1f, animationSpec = tween(durationMillis = 300), @@ -108,260 +109,176 @@ fun RepositoryCard( var showActionsSheet by remember { mutableStateOf(false) } val sheetEnabled = onHideClick != null + val repo = discoveryRepositoryUi.repository - ExpressiveCard( - onClick = onClick, - onLongClick = if (sheetEnabled) { - { showActionsSheet = true } - } else { - null - }, - modifier = modifier, + Surface( + modifier = modifier + .fillMaxWidth() + .alpha(contentAlpha), + shape = zed.rainxch.core.presentation.theme.tokens.Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = androidx.compose.foundation.BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f), + ), ) { - Box(modifier = Modifier.alpha(contentAlpha)) { - if (discoveryRepositoryUi.isFavourite) { - Icon( - imageVector = Icons.Default.Favorite, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), - modifier = - Modifier - .size(120.dp) - .align(Alignment.BottomStart) - .offset(x = (-32).dp, y = 32.dp), + Column( + modifier = Modifier + .combinedClickable( + onClick = onClick, + onLongClick = if (sheetEnabled) { + { showActionsSheet = true } + } else null, ) - } - - if (discoveryRepositoryUi.isStarred) { - Icon( - imageVector = Icons.Default.Star, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.08f), - modifier = - Modifier - .size(120.dp) - .align(Alignment.TopEnd) - .offset(x = 32.dp, y = (-32).dp), - ) - } - - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), + .padding(14.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + GitHubStoreImage( + imageModel = { repo.owner.avatarUrl }, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .clickable { onDeveloperClick(repo.owner.login) }, + extractDominantFor = repo.owner.avatarUrl, + ) + Column(modifier = Modifier.weight(1f)) { Row( - modifier = - Modifier - .clip(CircleShape) - .clickable(onClick = { - onDeveloperClick(discoveryRepositoryUi.repository.owner.login) - }) - .padding(horizontal = 4.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - GitHubStoreImage( - imageModel = { discoveryRepositoryUi.repository.owner.avatarUrl }, - modifier = - Modifier - .size(40.dp) - .clip(CircleShape), - ) - Text( - text = discoveryRepositoryUi.repository.owner.login, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.outline, + text = repo.name, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, maxLines = 1, - softWrap = false, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - - if (discoveryRepositoryUi.isCurrentUserOwner) { - OfficialBadge() - } + if (discoveryRepositoryUi.isCurrentUserOwner) OfficialBadge() + if (repo.isFork) ForkBadge() } - - Text( - text = "/", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.outline, - softWrap = false, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - modifier = Modifier.weight(1f), - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = discoveryRepositoryUi.repository.name, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - - if (discoveryRepositoryUi.repository.isFork) { - ForkBadge() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = repo.owner.login, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clip(CircleShape) + .clickable { onDeveloperClick(repo.owner.login) } + .padding(vertical = 2.dp, horizontal = 2.dp) + .weight(1f, fill = false), + ) + Text( + text = " · ${formatReleasedAt(repo.updatedAt)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } + } - Spacer(modifier = Modifier.height(4.dp)) - - discoveryRepositoryUi.repository.description?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyLarge, - softWrap = true, - ) - } + repo.description?.takeIf { it.isNotBlank() }?.let { desc -> + Spacer(Modifier.height(10.dp)) + Text( + text = desc, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(12.dp)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), - ) { - InfoChip( - icon = Icons.Outlined.StarOutline, - text = formatCount(discoveryRepositoryUi.repository.stargazersCount.toLong()), + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (repo.stargazersCount > 0) { + zed.rainxch.core.presentation.components.chips.StatChip( + label = formatCount(repo.stargazersCount.toLong()), + leading = { + Icon( + imageVector = Icons.Outlined.StarOutline, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, ) - - InfoChip( - icon = Icons.AutoMirrored.Outlined.CallSplit, - text = formatCount(discoveryRepositoryUi.repository.forksCount.toLong()), + } + if (repo.forksCount > 0) { + zed.rainxch.core.presentation.components.chips.StatChip( + label = formatCount(repo.forksCount.toLong()), + leading = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.CallSplit, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, ) - - if (discoveryRepositoryUi.repository.downloadCount > 0) { - InfoChip( - icon = Icons.Outlined.Download, - text = formatCount(discoveryRepositoryUi.repository.downloadCount), - ) - } - - discoveryRepositoryUi.repository.language?.let { - InfoChip( - icon = Icons.Outlined.Code, - text = it, - ) - } } - - if (discoveryRepositoryUi.isInstalled || discoveryRepositoryUi.isSeen) { - Spacer(Modifier.height(12.dp)) - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (discoveryRepositoryUi.isInstalled) { - InstallStatusBadge( - isUpdateAvailable = discoveryRepositoryUi.isUpdateAvailable, + if (repo.downloadCount > 0) { + zed.rainxch.core.presentation.components.chips.StatChip( + label = formatCount(repo.downloadCount), + leading = { + Icon( + imageVector = Icons.Outlined.Download, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - } - - if (discoveryRepositoryUi.isSeen) { - SeenBadge() - } - } + }, + ) } - - if (discoveryRepositoryUi.repository.availablePlatforms.isNotEmpty()) { - Spacer(Modifier.height(12.dp)) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - discoveryRepositoryUi.repository.availablePlatforms.forEach { platform -> - PlatformChip(platform = platform) + val platformKinds = repo.availablePlatforms + .mapNotNull { platform -> + when (platform) { + DiscoveryPlatform.Android -> zed.rainxch.core.presentation.vocabulary.PlatformKind.ANDROID + DiscoveryPlatform.Windows -> zed.rainxch.core.presentation.vocabulary.PlatformKind.WINDOWS + DiscoveryPlatform.Macos -> zed.rainxch.core.presentation.vocabulary.PlatformKind.MACOS + DiscoveryPlatform.Linux -> zed.rainxch.core.presentation.vocabulary.PlatformKind.LINUX + else -> null } } + if (platformKinds.isNotEmpty()) { + zed.rainxch.core.presentation.components.chips.PlatformsChip( + platforms = platformKinds.let { + kotlinx.collections.immutable.persistentListOf() + .addAll(it) + }, + ) } + } - Spacer(Modifier.height(8.dp)) - - val releasedAtText = - buildAnnotatedString { - if (hasWeekNotPassed(discoveryRepositoryUi.repository.updatedAt)) { - append("🔥 ") - } - - append(formatReleasedAt(discoveryRepositoryUi.repository.updatedAt)) - } - - Text( - text = releasedAtText, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.outline, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - ) - - Spacer(Modifier.height(12.dp)) - + if (discoveryRepositoryUi.isInstalled || discoveryRepositoryUi.isSeen) { + Spacer(Modifier.height(10.dp)) Row( - modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - GithubStoreButton( - text = stringResource(Res.string.home_view_details), - onClick = onClick, - modifier = Modifier.weight(1f), - ) - - IconButton( - onClick = onShareClick, - colors = - IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - shapes = IconButtonDefaults.shapes(), - ) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = stringResource(Res.string.share_repository), + if (discoveryRepositoryUi.isInstalled) { + InstallStatusBadge( + isUpdateAvailable = discoveryRepositoryUi.isUpdateAvailable, ) } - - IconButton( - onClick = { - uriHandler.openUri(discoveryRepositoryUi.repository.htmlUrl) - }, - colors = - IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - shapes = IconButtonDefaults.shapes(), - ) { - Icon( - imageVector = Icons.Default.OpenInBrowser, - contentDescription = stringResource(Res.string.open_in_browser), - ) + if (discoveryRepositoryUi.isSeen) { + SeenBadge() } } } @@ -414,8 +331,7 @@ private fun RepositoryActionsBottomSheet( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, ) { Column(modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)) { - // Context header so the user can verify which repo they're - // acting on without the card behind the sheet. + Row( modifier = Modifier @@ -524,10 +440,15 @@ fun PlatformChip( onClick: (() -> Unit)? = null, ) { Surface( - modifier = - if (onClick != null) modifier.clickable(onClick = onClick) else modifier, + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceContainerHighest, + border = if (onClick != null) { + androidx.compose.foundation.BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), + ) + } else null, ) { FlowRow( modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), @@ -550,6 +471,14 @@ fun PlatformChip( fontWeight = FontWeight.Medium, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), ) + if (onClick != null) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp).padding(end = 4.dp), + ) + } } } } diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/ScrollbarContainer.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/ScrollbarContainer.kt index c3ccfaa59..a060cc128 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/ScrollbarContainer.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/ScrollbarContainer.kt @@ -5,11 +5,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -/** - * Wraps content with a platform-appropriate scrollbar. - * On Desktop (JVM), adds a VerticalScrollbar when [enabled] is true. - * On Android, renders only the [content] (no scrollbar). - */ @Composable expect fun ScrollbarContainer( listState: LazyListState, @@ -18,9 +13,6 @@ expect fun ScrollbarContainer( content: @Composable () -> Unit, ) -/** - * Overload for [LazyStaggeredGridState]. - */ @Composable expect fun ScrollbarContainer( gridState: LazyStaggeredGridState, diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/adaptive/AdaptiveListDetail.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/adaptive/AdaptiveListDetail.kt new file mode 100644 index 000000000..b28246ef0 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/adaptive/AdaptiveListDetail.kt @@ -0,0 +1,243 @@ +package zed.rainxch.core.presentation.components.adaptive + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.OpenInBrowser +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Immutable +data class AdaptiveDetailArgs( + val repositoryId: Long = -1L, + val owner: String? = null, + val repo: String? = null, + val isComingFromUpdate: Boolean = false, + val sourceHost: String? = null, +) + +@Stable +class AdaptiveListDetailState( + initial: AdaptiveDetailArgs? = null, +) { + var currentArgs: AdaptiveDetailArgs? by mutableStateOf(initial) + private set + + fun select(args: AdaptiveDetailArgs) { + currentArgs = args + } + + fun clear() { + currentArgs = null + } +} + +@Composable +fun rememberAdaptiveListDetailState(): AdaptiveListDetailState = + rememberSaveable(saver = AdaptiveListDetailStateSaver) { + AdaptiveListDetailState() + } + +private val AdaptiveListDetailStateSaver: Saver = + Saver( + save = { state -> + val args = state.currentArgs ?: return@Saver booleanArrayOf(false) + listOf( + true, + args.repositoryId, + args.owner ?: "", + args.repo ?: "", + args.isComingFromUpdate, + args.sourceHost ?: "", + ) + }, + restore = { saved -> + if (saved is BooleanArray || saved !is List<*> || saved.isEmpty() || saved[0] != true) { + AdaptiveListDetailState(initial = null) + } else { + AdaptiveListDetailState( + initial = AdaptiveDetailArgs( + repositoryId = (saved[1] as? Long) ?: 0L, + owner = (saved[2] as? String)?.takeIf { it.isNotEmpty() }, + repo = (saved[3] as? String)?.takeIf { it.isNotEmpty() }, + isComingFromUpdate = (saved[4] as? Boolean) ?: false, + sourceHost = (saved[5] as? String)?.takeIf { it.isNotEmpty() }, + ), + ) + } + }, + ) + +@Composable +fun AdaptiveListDetailScaffold( + state: AdaptiveListDetailState, + list: @Composable (isExpanded: Boolean) -> Unit, + detail: @Composable (AdaptiveDetailArgs) -> Unit, + emptyPaneTitle: String, + emptyPaneSubtitle: String, + modifier: Modifier = Modifier, + initialListFraction: Float = 0.46f, + minListWidthDp: Int = 480, + minDetailWidthDp: Int = 600, + expandedBreakpointDp: Int = 840, +) { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val totalWidthDp = maxWidth.value + val effectiveBreakpoint = + maxOf(expandedBreakpointDp.toFloat(), (minListWidthDp + minDetailWidthDp).toFloat()) + val isExpanded = totalWidthDp >= effectiveBreakpoint + if (!isExpanded) { + Box(modifier = Modifier.fillMaxSize()) { list(false) } + return@BoxWithConstraints + } + + val minList = minListWidthDp.toFloat() + val maxList = (totalWidthDp - minDetailWidthDp).coerceAtLeast(minList) + + var listWidthDp by rememberSaveable { + mutableFloatStateOf((totalWidthDp * initialListFraction).coerceIn(minList, maxList)) + } + val clamped = listWidthDp.coerceIn(minList, maxList) + val density = LocalDensity.current + + Row(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .width(clamped.dp) + .fillMaxHeight(), + ) { + list(true) + } + PaneResizeHandle( + onDrag = { deltaPx -> + val deltaDp = with(density) { deltaPx.toDp().value } + listWidthDp = (listWidthDp + deltaDp).coerceIn(minList, maxList) + }, + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) { + val args = state.currentArgs + if (args == null) { + EmptyDetailPlaceholder( + title = emptyPaneTitle, + subtitle = emptyPaneSubtitle, + ) + } else { + detail(args) + } + } + } + } +} + +@Composable +private fun PaneResizeHandle( + onDrag: (Float) -> Unit, +) { + val draggableState = rememberDraggableState(onDelta = onDrag) + Box( + modifier = Modifier + .fillMaxHeight() + .width(10.dp) + .pointerHoverIcon(PointerIcon.Crosshair) + .draggable( + state = draggableState, + orientation = Orientation.Horizontal, + ), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + ) + Box( + modifier = Modifier + .width(4.dp) + .height(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.85f)), + ) + } +} + +@Composable +private fun EmptyDetailPlaceholder( + title: String, + subtitle: String, +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(32.dp), + ) { + Icon( + imageVector = Icons.Outlined.OpenInBrowser, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(56.dp), + ) + Spacer(Modifier.height(14.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } +} + +fun AdaptiveDetailArgs.matches(other: AdaptiveDetailArgs): Boolean = + repositoryId == other.repositoryId && + owner == other.owner && + repo == other.repo && + sourceHost == other.sourceHost diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt index 958693b96..5aa5078a7 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementCard.kt @@ -25,7 +25,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -189,13 +191,12 @@ private fun ExpandableBody(body: String) { }, ) if (!expanded && isOverflowing) { - TextButton(onClick = { expanded = true }) { - Text( - text = stringResource(Res.string.announcements_read_more), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - } + GhsButton( + onClick = { expanded = true }, + label = stringResource(Res.string.announcements_read_more), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } } } @@ -214,27 +215,31 @@ private fun ActionRow( verticalAlignment = Alignment.CenterVertically, ) { if (!announcement.ctaUrl.isNullOrBlank()) { - TextButton(onClick = onCtaClick) { - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - val resolvedLabel = announcement.ctaLabel?.takeIf { it.isNotBlank() } - ?: stringResource(Res.string.announcements_read_more) - Text(text = resolvedLabel) - } + val resolvedLabel = announcement.ctaLabel?.takeIf { it.isNotBlank() } + ?: stringResource(Res.string.announcements_read_more) + GhsButton( + onClick = onCtaClick, + label = resolvedLabel, + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + leadingIcon = Icons.AutoMirrored.Filled.OpenInNew, + ) } Spacer(Modifier.weight(1f)) if (announcement.requiresAcknowledgment && !isAcknowledged) { - TextButton(onClick = onAcknowledgeClick) { - Text(text = stringResource(Res.string.announcements_acknowledge)) - } + GhsButton( + onClick = onAcknowledgeClick, + label = stringResource(Res.string.announcements_acknowledge), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } else if (announcement.dismissible) { - TextButton(onClick = onDismissClick) { - Text(text = stringResource(Res.string.dismiss)) - } + GhsButton( + onClick = onDismissClick, + label = stringResource(Res.string.dismiss), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } } } diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementsRoot.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementsRoot.kt index ba232b7d4..23c24a41b 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementsRoot.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/AnnouncementsRoot.kt @@ -72,8 +72,10 @@ fun AnnouncementsRoot( title = { Text( text = stringResource(Res.string.announcements_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, ) }, navigationIcon = { diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/CriticalAnnouncementModal.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/CriticalAnnouncementModal.kt index 8c9c80231..17cb1ace7 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/CriticalAnnouncementModal.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/announcements/CriticalAnnouncementModal.kt @@ -10,12 +10,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Security import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,7 +36,7 @@ fun CriticalAnnouncementModal( onOpenDetails: () -> Unit, ) { AlertDialog( - onDismissRequest = { /* non-dismissible */ }, + onDismissRequest = { }, icon = { Icon( imageVector = Icons.Filled.Security, @@ -65,30 +64,24 @@ fun CriticalAnnouncementModal( } }, confirmButton = { - Button( + GhsButton( onClick = onAcknowledge, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), + label = stringResource(Res.string.announcements_acknowledge), + variant = GhsButtonVariant.Destructive, modifier = Modifier.fillMaxWidth(), - ) { - Text(text = stringResource(Res.string.announcements_acknowledge)) - } + ) }, dismissButton = if (!announcement.ctaUrl.isNullOrBlank()) { { - TextButton( + GhsButton( onClick = onOpenDetails, + label = announcement.ctaLabel + ?: stringResource(Res.string.announcements_view_details), + variant = GhsButtonVariant.Text, modifier = Modifier .fillMaxWidth() .padding(top = 8.dp), - ) { - Text( - text = announcement.ctaLabel - ?: stringResource(Res.string.announcements_view_details), - ) - } + ) } } else { null diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/GhsButton.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/GhsButton.kt new file mode 100644 index 000000000..c68cb9591 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/GhsButton.kt @@ -0,0 +1,224 @@ +package zed.rainxch.core.presentation.components.buttons + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape + +enum class GhsButtonVariant { + Primary, + Tonal, + Outline, + Text, + Destructive, +} + +enum class GhsButtonSize { + Sm, + Md, + Lg, +} + +@Composable +fun GhsButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + variant: GhsButtonVariant = GhsButtonVariant.Primary, + size: GhsButtonSize = GhsButtonSize.Md, + enabled: Boolean = true, + loading: Boolean = false, + containerColorOverride: Color? = null, + contentColorOverride: Color? = null, + content: @Composable RowScope.() -> Unit, +) { + val cs = MaterialTheme.colorScheme + val isDark = cs.background.luminance() < 0.5f + val container: Color = containerColorOverride ?: when (variant) { + GhsButtonVariant.Primary -> if (isDark) cs.primaryContainer else cs.primary + GhsButtonVariant.Tonal -> cs.secondaryContainer + GhsButtonVariant.Outline -> Color.Transparent + GhsButtonVariant.Text -> Color.Transparent + GhsButtonVariant.Destructive -> cs.errorContainer + } + val contentColor: Color = contentColorOverride ?: when (variant) { + GhsButtonVariant.Primary -> if (isDark) cs.onPrimaryContainer else cs.onPrimary + GhsButtonVariant.Tonal -> cs.onSecondaryContainer + GhsButtonVariant.Outline -> cs.onSurface + GhsButtonVariant.Text -> cs.primary + GhsButtonVariant.Destructive -> cs.onErrorContainer + } + val borderColor: Color? = when (variant) { + GhsButtonVariant.Outline -> cs.outline + else -> null + } + val shape: Shape = when (variant) { + GhsButtonVariant.Primary -> WonkySquircleShape.CtaPrimary + GhsButtonVariant.Destructive -> WonkySquircleShape.CtaPrimary + GhsButtonVariant.Tonal -> RoundedCornerShape(50) + GhsButtonVariant.Outline -> RoundedCornerShape(50) + GhsButtonVariant.Text -> RoundedCornerShape(50) + } + val minHeight: Dp = when (size) { + GhsButtonSize.Sm -> 36.dp + GhsButtonSize.Md -> 44.dp + GhsButtonSize.Lg -> 52.dp + } + val hPadding: Dp = when (size) { + GhsButtonSize.Sm -> 12.dp + GhsButtonSize.Md -> 18.dp + GhsButtonSize.Lg -> 22.dp + } + val vPadding: Dp = when (size) { + GhsButtonSize.Sm -> 6.dp + GhsButtonSize.Md -> 10.dp + GhsButtonSize.Lg -> 14.dp + } + val fontSize = when (size) { + GhsButtonSize.Sm -> 13.sp + GhsButtonSize.Md -> 14.sp + GhsButtonSize.Lg -> 16.sp + } + + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed && enabled && !loading) 0.97f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh, + ), + label = "ghs-button-press", + ) + val alpha = if (enabled && !loading) 1f else 0.38f + + var rowModifier = modifier + .scale(scale) + .clip(shape) + .background(container.copy(alpha = container.alpha * alpha)) + if (borderColor != null) { + rowModifier = rowModifier.border( + width = 1.dp, + color = borderColor.copy(alpha = alpha), + shape = shape, + ) + } + rowModifier = rowModifier + .clickable( + enabled = enabled && !loading, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .heightIn(min = minHeight) + .padding(horizontal = hPadding, vertical = vPadding) + + Row( + modifier = rowModifier, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider(LocalContentColor provides contentColor.copy(alpha = alpha)) { + ProvideTextStyle( + value = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = fontSize, + ), + ) { + content() + } + } + } +} + +@Composable +fun GhsButton( + onClick: () -> Unit, + label: String, + modifier: Modifier = Modifier, + variant: GhsButtonVariant = GhsButtonVariant.Primary, + size: GhsButtonSize = GhsButtonSize.Md, + enabled: Boolean = true, + loading: Boolean = false, + leadingIcon: ImageVector? = null, + trailingIcon: ImageVector? = null, + containerColorOverride: Color? = null, + contentColorOverride: Color? = null, +) { + val iconSize = when (size) { + GhsButtonSize.Sm -> 16.dp + GhsButtonSize.Md -> 18.dp + GhsButtonSize.Lg -> 20.dp + } + GhsButton( + onClick = onClick, + modifier = modifier, + variant = variant, + size = size, + enabled = enabled, + loading = loading, + containerColorOverride = containerColorOverride, + contentColorOverride = contentColorOverride, + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(iconSize), + strokeWidth = 2.dp, + color = LocalContentColor.current, + ) + } else if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + modifier = Modifier.size(iconSize), + ) + } + Text( + text = label, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + if (trailingIcon != null && !loading) { + Icon( + imageVector = trailingIcon, + contentDescription = null, + modifier = Modifier.size(iconSize), + ) + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/IconButton.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/IconButton.kt new file mode 100644 index 000000000..97617edf7 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/IconButton.kt @@ -0,0 +1,30 @@ +package zed.rainxch.core.presentation.components.buttons + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.unit.dp + +@Composable +fun IconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + sizeDp: Int = 36, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .clip(CircleShape) + .clickable(enabled = enabled, onClick = onClick) + .size(sizeDp.dp.coerceAtLeast(48.dp)), + contentAlignment = Alignment.Center, + ) { + content() + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/OutlineButton.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/OutlineButton.kt new file mode 100644 index 000000000..70658f84c --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/OutlineButton.kt @@ -0,0 +1,52 @@ +package zed.rainxch.core.presentation.components.buttons + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +@Composable +fun OutlineButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable () -> Unit, +) { + val pillShape = RoundedCornerShape(50) + Row( + modifier = modifier + .clip(pillShape) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = if (enabled) 1f else 0.38f), + shape = pillShape, + ) + .clickable(enabled = enabled, onClick = onClick) + .heightIn(min = 40.dp) + .padding(horizontal = 14.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurface.copy( + alpha = if (enabled) 1f else 0.38f, + ), + ) { + ProvideTextStyle(MaterialTheme.typography.labelLarge) { + content() + } + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/PrimaryButton.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/PrimaryButton.kt new file mode 100644 index 000000000..093319b8b --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/PrimaryButton.kt @@ -0,0 +1,75 @@ +package zed.rainxch.core.presentation.components.buttons + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape + +@Composable +fun PrimaryButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: WonkySquircleShape = WonkySquircleShape.CtaPrimary, + backgroundColor: Color = MaterialTheme.colorScheme.primary, + contentColor: Color = Color.White, + content: @Composable () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.96f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessHigh), + label = "primary-button-press", + ) + Row( + modifier = modifier + .scale(scale) + .clip(shape) + .background(if (enabled) backgroundColor else backgroundColor.copy(alpha = 0.38f)) + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .heightIn(min = 48.dp) + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + ProvideTextStyle( + value = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, + ), + ) { + content() + } + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/TintedButton.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/TintedButton.kt new file mode 100644 index 000000000..4c9d511a4 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/buttons/TintedButton.kt @@ -0,0 +1,49 @@ +package zed.rainxch.core.presentation.components.buttons + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.tokens.Radii + +@Composable +fun TintedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer, + contentColor: Color = MaterialTheme.colorScheme.primary, + content: @Composable () -> Unit, +) { + Row( + modifier = modifier + .clip(Radii.cardSm) + .background(if (enabled) backgroundColor else backgroundColor.copy(alpha = 0.38f)) + .clickable(enabled = enabled, onClick = onClick) + .heightIn(min = 44.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + ProvideTextStyle( + value = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), + ) { + content() + } + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/CompactCard.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/CompactCard.kt new file mode 100644 index 000000000..30939df39 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/CompactCard.kt @@ -0,0 +1,32 @@ +package zed.rainxch.core.presentation.components.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.tokens.Radii + +@Composable +fun CompactCard( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val cs = MaterialTheme.colorScheme + Column( + modifier = modifier + .clip(Radii.card) + .background(cs.surface) + .border(width = 1.dp, color = cs.outline, shape = Radii.card) + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .padding(14.dp), + ) { + content() + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/LeadHeroCard.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/LeadHeroCard.kt new file mode 100644 index 000000000..677ec7302 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/LeadHeroCard.kt @@ -0,0 +1,50 @@ +package zed.rainxch.core.presentation.components.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape +import zed.rainxch.core.presentation.vocabulary.AppAccent + +@Composable +fun LeadHeroCard( + accent: AppAccent, + isDark: Boolean, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val cs = MaterialTheme.colorScheme + val baseTint = accent.tintFor(isDark) + Box( + modifier = modifier + .clip(WonkySquircleShape.CtaPrimary) + .background(if (isDark) cs.surface else baseTint) + .let { if (onClick != null) it.clickable(onClick = onClick) else it }, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.radialGradient( + colors = listOf(accent.c.copy(alpha = 0.14f), accent.c.copy(alpha = 0f)), + center = Offset(x = 200f, y = 60f), + radius = 480f, + ), + ), + ) + Column(modifier = Modifier.padding(20.dp)) { + content() + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/RepoStripeCard.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/RepoStripeCard.kt new file mode 100644 index 000000000..dfcb075f8 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/RepoStripeCard.kt @@ -0,0 +1,220 @@ +package zed.rainxch.core.presentation.components.cards + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.lerp +import androidx.compose.runtime.getValue +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.theme.shapes.CornerRadii +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape + +private val RepoStripeShape = WonkySquircleShape( + topStart = CornerRadii(28.dp, 22.dp), + topEnd = CornerRadii(22.dp, 28.dp), + bottomEnd = CornerRadii(28.dp, 22.dp), + bottomStart = CornerRadii(22.dp, 28.dp), +) + +private fun Color.normalizedForStripe(isDark: Boolean): Color { + val r = (red * 255f).toInt().coerceIn(0, 255) + val g = (green * 255f).toInt().coerceIn(0, 255) + val b = (blue * 255f).toInt().coerceIn(0, 255) + val lum = (r + g + b) / 3 + val target = if (isDark) 170 else 140 + val minLum = if (isDark) 90 else 70 + if (lum >= minLum) return this + val factor = target.toFloat() / lum.coerceAtLeast(1).toFloat() + return Color( + red = (r * factor).toInt().coerceIn(0, 255), + green = (g * factor).toInt().coerceIn(0, 255), + blue = (b * factor).toInt().coerceIn(0, 255), + alpha = 255, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RepoStripeCard( + accent: Color, + ownerLogin: String, + name: String, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + stripeTrailing: (@Composable () -> Unit)? = null, + avatar: @Composable () -> Unit, + chips: @Composable () -> Unit, + languagePill: (@Composable () -> Unit)? = null, + cta: @Composable () -> Unit, +) { + val borderColor = MaterialTheme.colorScheme.outline + val isDark = isSystemInDarkTheme() + val surface = MaterialTheme.colorScheme.surface + val normalizedTarget = remember(accent, isDark) { accent.normalizedForStripe(isDark) } + val tintTargetFraction = if (isDark) 0.10f else 0.06f + val animatedAccent by animateColorAsState( + targetValue = normalizedTarget, + animationSpec = tween(durationMillis = 1800, easing = LinearOutSlowInEasing), + label = "repo-stripe-accent", + ) + val animatedSurface by animateColorAsState( + targetValue = lerp(surface, normalizedTarget, tintTargetFraction), + animationSpec = tween(durationMillis = 1800, easing = LinearOutSlowInEasing), + label = "repo-stripe-surface", + ) + val stripeBase = if (isDark) animatedAccent.copy(alpha = 0.18f) else animatedAccent.copy(alpha = 0.12f) + val stripeLineThick = if (isDark) animatedAccent.copy(alpha = 0.45f) else animatedAccent.copy(alpha = 0.55f) + val stripeLineThin = if (isDark) animatedAccent.copy(alpha = 0.22f) else animatedAccent.copy(alpha = 0.30f) + val avatarBg = if (isDark) animatedAccent.copy(alpha = 0.18f) else animatedAccent.copy(alpha = 0.14f) + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RepoStripeShape) + .background(animatedSurface) + .border(width = 1.5.dp, color = borderColor, shape = RepoStripeShape) + .combinedClickable(onClick = onClick, onLongClick = onLongClick), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + .clipToBounds() + .drawBehind { + drawRect(color = stripeBase) + val thick = 9.dp.toPx() + val thin = 2.5.dp.toPx() + val gapAfterThick = 10.dp.toPx() + val gapBetweenThin = 6.dp.toPx() + val cycle = thick + gapAfterThick + thin + gapBetweenThin + thin + gapAfterThick + var x = -size.height + while (x < size.width + size.height) { + val baseY = size.height + val endX = x + size.height + drawLine( + color = stripeLineThick, + start = Offset(x, baseY), + end = Offset(endX, 0f), + strokeWidth = thick, + cap = StrokeCap.Round, + ) + var xt = x + thick + gapAfterThick + drawLine( + color = stripeLineThin, + start = Offset(xt, baseY), + end = Offset(xt + size.height, 0f), + strokeWidth = thin, + cap = StrokeCap.Round, + ) + xt += thin + gapBetweenThin + drawLine( + color = stripeLineThin, + start = Offset(xt, baseY), + end = Offset(xt + size.height, 0f), + strokeWidth = thin, + cap = StrokeCap.Round, + ) + x += cycle + } + }, + ) { + if (stripeTrailing != null) { + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 14.dp), + ) { + stripeTrailing() + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 96.dp, end = 14.dp, top = 12.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = ownerLogin, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 12.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = name, + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(10.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 1.dp, + ) + Spacer(Modifier.height(10.dp)) + chips() + if (languagePill != null) { + Spacer(Modifier.height(6.dp)) + languagePill() + } + } + cta() + } + } + Box( + modifier = Modifier + .align(Alignment.TopStart) + .offset(x = 14.dp, y = 26.dp) + .size(72.dp) + .clip(CircleShape) + .background(avatarBg), + contentAlignment = Alignment.Center, + ) { + avatar() + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/RowCard.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/RowCard.kt new file mode 100644 index 000000000..8acf9346f --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/RowCard.kt @@ -0,0 +1,34 @@ +package zed.rainxch.core.presentation.components.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +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.unit.dp +import zed.rainxch.core.presentation.theme.tokens.Radii + +@Composable +fun RowCard( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = modifier + .clip(Radii.row) + .background(MaterialTheme.colorScheme.surface) + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(11.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/VitalSignsGrid.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/VitalSignsGrid.kt new file mode 100644 index 000000000..b5a433b72 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/VitalSignsGrid.kt @@ -0,0 +1,94 @@ +package zed.rainxch.core.presentation.components.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.theme.tokens.Radii + +@Composable +fun VitalSignsGrid( + released: VitalTile, + maintained: VitalTile, + stars: VitalTile, + permissions: VitalTile, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + VitalTileBox(released, Modifier.weight(1f)) + VitalTileBox(maintained, Modifier.weight(1f)) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + VitalTileBox(stars, Modifier.weight(1f)) + VitalTileBox(permissions, Modifier.weight(1f)) + } + } +} + +data class VitalTile( + val label: String, + val value: String, + val valueColor: Color? = null, + val glyph: @Composable () -> Unit = {}, +) + +@Composable +private fun VitalTileBox(tile: VitalTile, modifier: Modifier = Modifier) { + val cs = MaterialTheme.colorScheme + Column( + modifier = modifier + .clip(Radii.cardSm) + .background(cs.surfaceContainer) + .border(width = 1.dp, color = cs.outline, shape = Radii.cardSm) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box(modifier = Modifier, contentAlignment = Alignment.Center) { + tile.glyph() + } + Text( + text = tile.value, + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + ), + color = tile.valueColor ?: cs.onSurface, + ) + Text( + text = tile.label.uppercase(), + color = cs.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 9.5.sp, + letterSpacing = 0.04.em(), + ), + ) + } +} + +private fun Double.em() = androidx.compose.ui.unit.TextUnit(this.toFloat(), androidx.compose.ui.unit.TextUnitType.Em) diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/WaxSealTrustCard.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/WaxSealTrustCard.kt new file mode 100644 index 000000000..c10bb5320 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/cards/WaxSealTrustCard.kt @@ -0,0 +1,70 @@ +package zed.rainxch.core.presentation.components.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.theme.geistMono +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.vocabulary.WaxSeal +import zed.rainxch.core.presentation.vocabulary.WaxSealState + +@Composable +fun WaxSealTrustCard( + state: WaxSealState, + fingerprintDetail: String, + modifier: Modifier = Modifier, + stateLabel: String = defaultLabel(state), +) { + val cs = MaterialTheme.colorScheme + val (bg, border) = when (state) { + WaxSealState.INTACT -> cs.tertiaryContainer to cs.tertiary + WaxSealState.CRACKED -> cs.errorContainer to cs.error + WaxSealState.OPEN -> cs.surface to cs.outline + } + Row( + modifier = modifier + .clip(Radii.card) + .background(bg) + .border(width = 1.dp, color = border, shape = Radii.card) + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + WaxSeal(state = state, sizeDp = 38) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stateLabel, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 17.sp, + ), + color = cs.onSurface, + ) + Text( + text = fingerprintDetail, + fontFamily = geistMono, + color = cs.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + ) + } + } +} + +private fun defaultLabel(state: WaxSealState): String = when (state) { + WaxSealState.INTACT -> "Sealed" + WaxSealState.CRACKED -> "Broken seal" + WaxSealState.OPEN -> "Unsigned" +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/AddChip.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/AddChip.kt new file mode 100644 index 000000000..35f4862d2 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/AddChip.kt @@ -0,0 +1,71 @@ +package zed.rainxch.core.presentation.components.chips + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.draw.drawWithCache +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.theme.tokens.Radii + +@Composable +fun AddChip( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val borderColor = MaterialTheme.colorScheme.outline + Row( + modifier = modifier + .clip(Radii.chip) + .clickable(onClick = onClick) + .drawWithCache { + val stroke = Stroke( + width = 1.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f.dp.toPx(), 3f.dp.toPx())), + ) + onDrawWithContent { + drawContent() + drawRoundRect( + color = borderColor, + cornerRadius = androidx.compose.ui.geometry.CornerRadius( + x = 11.dp.toPx(), + y = 8.dp.toPx(), + ), + style = stroke, + ) + } + } + .padding(horizontal = 12.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "+", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + ), + ) + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/FilterChip.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/FilterChip.kt new file mode 100644 index 000000000..9cdca67d8 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/FilterChip.kt @@ -0,0 +1,63 @@ +package zed.rainxch.core.presentation.components.chips + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.theme.tokens.Radii + +@Composable +fun FilterChip( + label: String, + active: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onDismiss: (() -> Unit)? = null, +) { + val bg: Color = if (active) MaterialTheme.colorScheme.primaryContainer else Color.Transparent + val fg: Color = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + val border: Color = if (active) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.33f) + } else { + MaterialTheme.colorScheme.outline + } + Row( + modifier = modifier + .clip(Radii.chip) + .background(bg) + .border(width = 1.dp, color = border, shape = Radii.chip) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + color = fg, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ), + ) + if (active && onDismiss != null) { + Text( + text = "×", + color = fg, + modifier = Modifier.clickable(onClick = onDismiss), + fontSize = 14.sp, + ) + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/PlatformsChip.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/PlatformsChip.kt new file mode 100644 index 000000000..657fc5ce6 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/PlatformsChip.kt @@ -0,0 +1,42 @@ +package zed.rainxch.core.presentation.components.chips + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +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.graphics.Color +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.vocabulary.PlatformGlyph +import zed.rainxch.core.presentation.vocabulary.PlatformKind + +@Composable +fun PlatformsChip( + platforms: ImmutableList, + modifier: Modifier = Modifier, + background: Color = Color.Transparent, + border: Color = MaterialTheme.colorScheme.outline, + glyphSizeDp: Int = 14, +) { + if (platforms.isEmpty()) return + Row( + modifier = modifier + .clip(Radii.chip) + .background(background) + .border(width = 1.dp, color = border, shape = Radii.chip) + .padding(horizontal = 10.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + platforms.forEach { kind -> + PlatformGlyph(kind = kind, supported = true, sizeDp = glyphSizeDp) + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/StatChip.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/StatChip.kt new file mode 100644 index 000000000..6c5361a59 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chips/StatChip.kt @@ -0,0 +1,48 @@ +package zed.rainxch.core.presentation.components.chips + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.theme.tokens.Radii + +@Composable +fun StatChip( + label: String, + modifier: Modifier = Modifier, + leading: (@Composable () -> Unit)? = null, + background: Color = Color.Transparent, + border: Color = MaterialTheme.colorScheme.outline, + contentColor: Color = MaterialTheme.colorScheme.onSurface, +) { + Row( + modifier = modifier + .clip(Radii.chip) + .background(background) + .border(width = 1.dp, color = border, shape = Radii.chip) + .padding(horizontal = 10.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leading != null) leading() + Text( + text = label, + color = contentColor, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ), + ) + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chrome/GhsHomeTopBar.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chrome/GhsHomeTopBar.kt new file mode 100644 index 000000000..13f8f756e --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/chrome/GhsHomeTopBar.kt @@ -0,0 +1,54 @@ +package zed.rainxch.core.presentation.components.chrome + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun GhsHomeTopBar( + title: String, + modifier: Modifier = Modifier, + applyStatusBarPadding: Boolean = true, + actions: @Composable (RowScope.() -> Unit)? = null, +) { + val container = modifier + .fillMaxWidth() + .let { if (applyStatusBarPadding) it.statusBarsPadding() else it } + .padding(horizontal = 16.dp, vertical = 12.dp) + Row( + modifier = container, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 26.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f, fill = false), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (actions != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + content = actions, + ) + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/hub/GhsEntryRow.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/hub/GhsEntryRow.kt new file mode 100644 index 000000000..73159790b --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/hub/GhsEntryRow.kt @@ -0,0 +1,171 @@ +package zed.rainxch.core.presentation.components.hub + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.tokens.Radii + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GhsEntryRow( + title: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + badge: (@Composable () -> Unit)? = null, + accentColor: Color = Color.Unspecified, + onLongClick: (() -> Unit)? = null, + destructive: Boolean = false, + trailingChevron: Boolean = true, +) { + val effectiveAccent = + when { + destructive -> MaterialTheme.colorScheme.error + accentColor == Color.Unspecified -> MaterialTheme.colorScheme.onSurfaceVariant + else -> accentColor + } + val tileBackground = + when { + destructive -> MaterialTheme.colorScheme.error.copy(alpha = 0.14f) + accentColor == Color.Unspecified -> MaterialTheme.colorScheme.surfaceContainerHigh + else -> accentColor.copy(alpha = 0.14f) + } + val titleColor = + if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + val clickMod = + if (onLongClick != null) { + Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick) + } else { + Modifier.combinedClickable(onClick = onClick) + } + Surface( + modifier = modifier + .fillMaxWidth() + .clip(Radii.row) + .then(clickMod) + .semantics { + role = Role.Button + contentDescription = buildString { + append(title) + if (!subtitle.isNullOrBlank()) { + append(". ") + append(subtitle) + } + append(". Double-tap to open.") + } + }, + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(Radii.chip) + .background(tileBackground), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = effectiveAccent, + modifier = Modifier.size(22.dp), + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = titleColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (badge != null) { + badge() + } + + if (trailingChevron) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } + } +} + +@Composable +fun GhsEntryBadge(text: String) { + Surface( + shape = Radii.chip, + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/hub/GhsSectionHeader.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/hub/GhsSectionHeader.kt new file mode 100644 index 000000000..4ed5c0bc5 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/hub/GhsSectionHeader.kt @@ -0,0 +1,31 @@ +package zed.rainxch.core.presentation.components.hub + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.vocabulary.Squiggle + +@Composable +fun GhsSectionHeader(text: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(start = 4.dp, top = 12.dp, bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + ), + color = MaterialTheme.colorScheme.onBackground, + ) + Squiggle() + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/inputs/GhsTextField.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/inputs/GhsTextField.kt new file mode 100644 index 000000000..4622e79ca --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/inputs/GhsTextField.kt @@ -0,0 +1,189 @@ +package zed.rainxch.core.presentation.components.inputs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.tokens.Radii + +@Composable +fun GhsTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + placeholder: String? = null, + leadingIcon: ImageVector? = null, + trailingIcon: (@Composable () -> Unit)? = null, + supportingText: String? = null, + isError: Boolean = false, + enabled: Boolean = true, + singleLine: Boolean = true, + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + readOnly: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + var focused by remember { mutableStateOf(false) } + val borderColor = when { + isError -> MaterialTheme.colorScheme.error + focused -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.outline + } + val contentColor = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Column(modifier = modifier) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.chip), + shape = Radii.chip, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = androidx.compose.foundation.BorderStroke( + width = if (focused) 1.5.dp else 1.dp, + color = borderColor, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + + Column(modifier = Modifier.weight(1f)) { + if (!label.isNullOrBlank() && (focused || value.isNotEmpty())) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = if (focused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + Box(contentAlignment = Alignment.CenterStart) { + if (value.isEmpty() && !focused) { + val showText = label ?: placeholder + if (showText != null) { + Text( + text = showText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused }, + singleLine = singleLine, + minLines = minLines, + maxLines = maxLines, + readOnly = readOnly, + enabled = enabled, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + textStyle = LocalTextStyle.current.merge( + TextStyle(color = contentColor), + ).copy( + fontSize = MaterialTheme.typography.bodyLarge.fontSize, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + ) + } + } + + if (trailingIcon != null) { + trailingIcon() + } + } + } + + if (!supportingText.isNullOrBlank()) { + Text( + text = supportingText, + style = MaterialTheme.typography.labelSmall, + color = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 12.dp, top = 4.dp), + ) + } + } +} + +@Composable +fun GhsPasswordVisibilityIcon( + visible: Boolean, + onToggle: () -> Unit, +) { + IconButton(onClick = onToggle, modifier = Modifier.size(36.dp)) { + Icon( + imageVector = if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } +} + +fun passwordVisualTransformation(visible: Boolean): VisualTransformation = + if (visible) VisualTransformation.None else PasswordVisualTransformation() diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsBottomSheet.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsBottomSheet.kt new file mode 100644 index 000000000..bd16dddc5 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsBottomSheet.kt @@ -0,0 +1,64 @@ +package zed.rainxch.core.presentation.components.overlays + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +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.unit.dp +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GhsBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(), + dragHandle: @Composable (() -> Unit)? = { DefaultDragHandle() }, + content: @Composable () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + modifier = modifier, + shape = WonkySquircleShape.Sheet, + containerColor = MaterialTheme.colorScheme.surface, + scrimColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), + dragHandle = dragHandle, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 20.dp), + ) { + content() + } + } +} + +@Composable +private fun DefaultDragHandle() { + Box( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + contentAlignment = Alignment.TopCenter, + ) { + Box( + modifier = Modifier + .size(width = 36.dp, height = 4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)), + ) + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsConfirmDialog.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsConfirmDialog.kt new file mode 100644 index 000000000..6b88bb4f7 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsConfirmDialog.kt @@ -0,0 +1,98 @@ +package zed.rainxch.core.presentation.components.overlays + +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 +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape +import zed.rainxch.core.presentation.vocabulary.Squiggle + +@Composable +fun GhsConfirmDialog( + title: String, + body: String, + confirmLabel: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + cancelLabel: String = "Cancel", + destructive: Boolean = false, + leading: (@Composable () -> Unit)? = null, + note: String? = null, +) { + Dialog(onDismissRequest = onDismiss) { + val cs = MaterialTheme.colorScheme + Column( + modifier = Modifier + .widthIn(max = 400.dp) + .clip(WonkySquircleShape.Dialog) + .background(cs.surface) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (leading != null) { + Box(modifier = Modifier.padding(bottom = 4.dp)) { leading() } + } + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + ), + color = cs.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = body, + color = cs.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + if (!note.isNullOrBlank()) { + Text( + text = note, + color = cs.outline, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + } + Squiggle() + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + GhsButton( + onClick = onDismiss, + label = cancelLabel, + variant = GhsButtonVariant.Outline, + size = GhsButtonSize.Sm, + ) + GhsButton( + onClick = onConfirm, + label = confirmLabel, + variant = if (destructive) GhsButtonVariant.Destructive else GhsButtonVariant.Primary, + size = GhsButtonSize.Sm, + ) + } + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsDropdownMenu.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsDropdownMenu.kt new file mode 100644 index 000000000..9c863cd48 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsDropdownMenu.kt @@ -0,0 +1,149 @@ +package zed.rainxch.core.presentation.components.overlays + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupProperties + +@Composable +fun GhsDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(0.dp, 8.dp), + scrollState: ScrollState = rememberScrollState(), + properties: PopupProperties = PopupProperties(focusable = true), + content: @Composable () -> Unit, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = modifier.widthIn(min = 240.dp), + offset = offset, + scrollState = scrollState, + properties = properties, + shape = RoundedCornerShape(14.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp, + shadowElevation = 6.dp, + border = BorderStroke( + width = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), + ), + content = { content() }, + ) +} + +@Composable +fun GhsDropdownMenuItem( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + subtitle: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + minHeight: Dp = 40.dp, +) { + val resolvedColor = if (enabled) contentColor else contentColor.copy(alpha = 0.38f) + val mutedColor = if (enabled) { + contentColor.copy(alpha = 0.55f) + } else { + contentColor.copy(alpha = 0.30f) + } + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val pressTint = if (pressed) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.10f) + } else { + Color.Transparent + } + Row( + modifier = modifier + .fillMaxWidth() + .background(pressTint) + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .heightIn(min = minHeight) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (leadingIcon != null) { + CompositionLocalProvider(LocalContentColor provides resolvedColor) { + leadingIcon() + } + } + androidx.compose.foundation.layout.Column(modifier = Modifier.weight(1f, fill = false)) { + CompositionLocalProvider(LocalContentColor provides resolvedColor) { + ProvideTextStyle( + value = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + ), + ) { + Text(text = text, color = resolvedColor) + } + } + if (subtitle != null) { + Text( + text = subtitle, + color = mutedColor, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + ) + } + } + if (trailingIcon != null) { + Spacer(modifier = Modifier.weight(1f)) + CompositionLocalProvider(LocalContentColor provides resolvedColor) { + trailingIcon() + } + } + } +} + +@Composable +fun GhsDropdownMenuDivider() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)), + ) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsFullScreenSheet.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsFullScreenSheet.kt new file mode 100644 index 000000000..81c88ff1c --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsFullScreenSheet.kt @@ -0,0 +1,51 @@ +package zed.rainxch.core.presentation.components.overlays + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.components.buttons.IconButton + +@Composable +fun GhsFullScreenSheet( + onBack: () -> Unit, + modifier: Modifier = Modifier, + backContentDescription: String = "Back", + content: @Composable () -> Unit, +) { + val cs = MaterialTheme.colorScheme + Column( + modifier = modifier + .fillMaxSize() + .background(cs.background), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = backContentDescription, + tint = cs.onSurface, + ) + } + } + Box(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp)) { + content() + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsToast.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsToast.kt new file mode 100644 index 000000000..20f85a8c8 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/overlays/GhsToast.kt @@ -0,0 +1,50 @@ +package zed.rainxch.core.presentation.components.overlays + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +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.graphics.Color +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape + +enum class ToastTint { Default, Success, Error, Info } + +@Composable +fun GhsToast( + modifier: Modifier = Modifier, + tint: ToastTint = ToastTint.Default, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + content: @Composable () -> Unit, +) { + val cs = MaterialTheme.colorScheme + val (bg, border) = when (tint) { + ToastTint.Default -> cs.surface to cs.outline + ToastTint.Success -> cs.tertiaryContainer to cs.tertiary.copy(alpha = 0.55f) + ToastTint.Error -> cs.errorContainer to cs.error.copy(alpha = 0.55f) + ToastTint.Info -> cs.primaryContainer to cs.primary.copy(alpha = 0.55f) + } + Row( + modifier = modifier + .clip(WonkySquircleShape.Toast) + .background(bg) + .border(width = 1.dp, color = border, shape = WonkySquircleShape.Toast) + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leading?.invoke() + ProvideTextStyle(value = MaterialTheme.typography.bodyMedium) { + content() + } + trailing?.invoke() + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/section/Banner.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/section/Banner.kt new file mode 100644 index 000000000..24289ede2 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/section/Banner.kt @@ -0,0 +1,49 @@ +package zed.rainxch.core.presentation.components.section + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +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.graphics.Color +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.tokens.Radii + +enum class BannerTint { Info, Success, Warning, Danger } + +@Composable +fun Banner( + tint: BannerTint = BannerTint.Info, + modifier: Modifier = Modifier, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + content: @Composable () -> Unit, +) { + val cs = MaterialTheme.colorScheme + val (bg, border) = when (tint) { + BannerTint.Info -> cs.primaryContainer to cs.primary.copy(alpha = 0.2f) + BannerTint.Success -> cs.tertiaryContainer to cs.tertiary.copy(alpha = 0.2f) + BannerTint.Warning -> Color(0xFFFFE6CC) to Color(0xFFC49652).copy(alpha = 0.4f) + BannerTint.Danger -> cs.errorContainer to cs.error.copy(alpha = 0.2f) + } + Row( + modifier = modifier + .clip(Radii.card) + .background(bg) + .border(width = 1.dp, color = border, shape = Radii.card) + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leading?.invoke() + androidx.compose.foundation.layout.Box(modifier = Modifier.weight(1f)) { + content() + } + trailing?.invoke() + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/section/SectionHeader.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/section/SectionHeader.kt new file mode 100644 index 000000000..a6c402fcb --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/section/SectionHeader.kt @@ -0,0 +1,73 @@ +package zed.rainxch.core.presentation.components.section + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.common_see_all +import zed.rainxch.core.presentation.vocabulary.Squiggle + +@Composable +fun SectionHeader( + title: String, + modifier: Modifier = Modifier, + leading: (@Composable () -> Unit)? = null, + subCount: String? = null, + onSeeAll: (() -> Unit)? = null, +) { + Column( + modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + modifier = Modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leading?.invoke() + + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + + if (subCount != null) { + Text( + text = "· $subCount", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } + + if (onSeeAll != null) { + Text( + text = stringResource(Res.string.common_see_all), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + modifier = Modifier + .clickable(onClick = onSeeAll) + .padding(start = 4.dp), + ) + } + } + Squiggle() + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/whatsnew/WhatsNewHistoryScreen.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/whatsnew/WhatsNewHistoryScreen.kt index 66875c3c3..02a4cc416 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/whatsnew/WhatsNewHistoryScreen.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/whatsnew/WhatsNewHistoryScreen.kt @@ -40,8 +40,10 @@ fun WhatsNewHistoryScreen( title = { Text( text = stringResource(Res.string.whats_new_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, ) }, navigationIcon = { diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/whatsnew/WhatsNewSheet.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/whatsnew/WhatsNewSheet.kt index 007a08d39..9112161c6 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/whatsnew/WhatsNewSheet.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/whatsnew/WhatsNewSheet.kt @@ -1,5 +1,6 @@ package zed.rainxch.core.presentation.components.whatsnew +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -11,14 +12,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -27,6 +27,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.WhatsNewEntry +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.core.domain.model.WhatsNewSection import zed.rainxch.core.domain.model.WhatsNewSectionType import zed.rainxch.githubstore.core.presentation.res.Res @@ -37,7 +38,6 @@ import zed.rainxch.githubstore.core.presentation.res.whats_new_section_heads_up import zed.rainxch.githubstore.core.presentation.res.whats_new_section_improved import zed.rainxch.githubstore.core.presentation.res.whats_new_section_new import zed.rainxch.githubstore.core.presentation.res.whats_new_sheet_heading -import zed.rainxch.githubstore.core.presentation.res.whats_new_translations_note import zed.rainxch.githubstore.core.presentation.res.whats_new_version_label @OptIn(ExperimentalMaterial3Api::class) @@ -71,24 +71,20 @@ fun WhatsNewSheet( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Button( + GhsButton( onClick = onDismiss, + label = stringResource(Res.string.whats_new_cta_dismiss), + variant = GhsButtonVariant.Primary, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - ) { - Text(text = stringResource(Res.string.whats_new_cta_dismiss)) - } + ) if (showHistoryAction) { - TextButton( + GhsButton( onClick = onViewHistory, + label = stringResource(Res.string.whats_new_cta_history), + variant = GhsButtonVariant.Text, modifier = Modifier.fillMaxWidth(), - ) { - Text(text = stringResource(Res.string.whats_new_cta_history)) - } + ) } } } @@ -122,9 +118,10 @@ private fun SheetHeader(entry: WhatsNewEntry) { @Composable fun WhatsNewEntryCard(entry: WhatsNewEntry) { Surface( - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), ) { Column( modifier = Modifier.padding(16.dp), @@ -187,7 +184,7 @@ private fun SectionLabel(type: WhatsNewSectionType) { } Text( - text = label.uppercase(), + text = label, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = color, diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/LocalScrollbarEnabled.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/LocalScrollbarEnabled.kt index 5453d907c..804bc7606 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/LocalScrollbarEnabled.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/LocalScrollbarEnabled.kt @@ -2,8 +2,4 @@ package zed.rainxch.core.presentation.locals import androidx.compose.runtime.compositionLocalOf -/** - * CompositionLocal providing whether the scrollbar should be shown. - * Defaults to false (no scrollbar). - */ val LocalScrollbarEnabled = compositionLocalOf { false } diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/SharedTransitionLocals.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/SharedTransitionLocals.kt new file mode 100644 index 000000000..ba57a856c --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/locals/SharedTransitionLocals.kt @@ -0,0 +1,11 @@ +package zed.rainxch.core.presentation.locals + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.staticCompositionLocalOf + +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = staticCompositionLocalOf { null } + +val LocalAnimatedVisibilityScope = staticCompositionLocalOf { null } diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/model/GithubRepoSummaryUi.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/model/GithubRepoSummaryUi.kt index a54873053..39ca35001 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/model/GithubRepoSummaryUi.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/model/GithubRepoSummaryUi.kt @@ -21,8 +21,6 @@ data class GithubRepoSummaryUi( val isFork: Boolean = false, val availablePlatforms: ImmutableList = persistentListOf(), val downloadCount: Long = 0, - // Non-GitHub forge host (codeberg.org, gitea.com, etc.). null for - // canonical GitHub repos. Travels with the model so DetailsScreen - // can route through the right API. + val sourceHost: String? = null, ) diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/AnimateColorScheme.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/AnimateColorScheme.kt new file mode 100644 index 000000000..262439df6 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/AnimateColorScheme.kt @@ -0,0 +1,92 @@ +package zed.rainxch.core.presentation.theme + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color + +@Composable +fun animateColorScheme( + target: ColorScheme, + spec: AnimationSpec = tween(durationMillis = 420), + label: String = "color_scheme", +): ColorScheme { + val primary by animateColorAsState(target.primary, spec, label = "$label.primary") + val onPrimary by animateColorAsState(target.onPrimary, spec, label = "$label.onPrimary") + val primaryContainer by animateColorAsState(target.primaryContainer, spec, label = "$label.primaryContainer") + val onPrimaryContainer by animateColorAsState(target.onPrimaryContainer, spec, label = "$label.onPrimaryContainer") + val inversePrimary by animateColorAsState(target.inversePrimary, spec, label = "$label.inversePrimary") + val secondary by animateColorAsState(target.secondary, spec, label = "$label.secondary") + val onSecondary by animateColorAsState(target.onSecondary, spec, label = "$label.onSecondary") + val secondaryContainer by animateColorAsState(target.secondaryContainer, spec, label = "$label.secondaryContainer") + val onSecondaryContainer by animateColorAsState(target.onSecondaryContainer, spec, label = "$label.onSecondaryContainer") + val tertiary by animateColorAsState(target.tertiary, spec, label = "$label.tertiary") + val onTertiary by animateColorAsState(target.onTertiary, spec, label = "$label.onTertiary") + val tertiaryContainer by animateColorAsState(target.tertiaryContainer, spec, label = "$label.tertiaryContainer") + val onTertiaryContainer by animateColorAsState(target.onTertiaryContainer, spec, label = "$label.onTertiaryContainer") + val background by animateColorAsState(target.background, spec, label = "$label.background") + val onBackground by animateColorAsState(target.onBackground, spec, label = "$label.onBackground") + val surface by animateColorAsState(target.surface, spec, label = "$label.surface") + val onSurface by animateColorAsState(target.onSurface, spec, label = "$label.onSurface") + val surfaceVariant by animateColorAsState(target.surfaceVariant, spec, label = "$label.surfaceVariant") + val onSurfaceVariant by animateColorAsState(target.onSurfaceVariant, spec, label = "$label.onSurfaceVariant") + val surfaceTint by animateColorAsState(target.surfaceTint, spec, label = "$label.surfaceTint") + val inverseSurface by animateColorAsState(target.inverseSurface, spec, label = "$label.inverseSurface") + val inverseOnSurface by animateColorAsState(target.inverseOnSurface, spec, label = "$label.inverseOnSurface") + val error by animateColorAsState(target.error, spec, label = "$label.error") + val onError by animateColorAsState(target.onError, spec, label = "$label.onError") + val errorContainer by animateColorAsState(target.errorContainer, spec, label = "$label.errorContainer") + val onErrorContainer by animateColorAsState(target.onErrorContainer, spec, label = "$label.onErrorContainer") + val outline by animateColorAsState(target.outline, spec, label = "$label.outline") + val outlineVariant by animateColorAsState(target.outlineVariant, spec, label = "$label.outlineVariant") + val scrim by animateColorAsState(target.scrim, spec, label = "$label.scrim") + val surfaceBright by animateColorAsState(target.surfaceBright, spec, label = "$label.surfaceBright") + val surfaceDim by animateColorAsState(target.surfaceDim, spec, label = "$label.surfaceDim") + val surfaceContainer by animateColorAsState(target.surfaceContainer, spec, label = "$label.surfaceContainer") + val surfaceContainerHigh by animateColorAsState(target.surfaceContainerHigh, spec, label = "$label.surfaceContainerHigh") + val surfaceContainerHighest by animateColorAsState(target.surfaceContainerHighest, spec, label = "$label.surfaceContainerHighest") + val surfaceContainerLow by animateColorAsState(target.surfaceContainerLow, spec, label = "$label.surfaceContainerLow") + val surfaceContainerLowest by animateColorAsState(target.surfaceContainerLowest, spec, label = "$label.surfaceContainerLowest") + + return target.copy( + primary = primary, + onPrimary = onPrimary, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + inversePrimary = inversePrimary, + secondary = secondary, + onSecondary = onSecondary, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSecondaryContainer, + tertiary = tertiary, + onTertiary = onTertiary, + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = onTertiaryContainer, + background = background, + onBackground = onBackground, + surface = surface, + onSurface = onSurface, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurfaceVariant, + surfaceTint = surfaceTint, + inverseSurface = inverseSurface, + inverseOnSurface = inverseOnSurface, + error = error, + onError = onError, + errorContainer = errorContainer, + onErrorContainer = onErrorContainer, + outline = outline, + outlineVariant = outlineVariant, + scrim = scrim, + surfaceBright = surfaceBright, + surfaceDim = surfaceDim, + surfaceContainer = surfaceContainer, + surfaceContainerHigh = surfaceContainerHigh, + surfaceContainerHighest = surfaceContainerHighest, + surfaceContainerLow = surfaceContainerLow, + surfaceContainerLowest = surfaceContainerLowest, + ) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Color.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Color.kt deleted file mode 100644 index f76a68801..000000000 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Color.kt +++ /dev/null @@ -1,90 +0,0 @@ -package zed.rainxch.core.presentation.theme - -import androidx.compose.material3.ColorScheme -import androidx.compose.ui.graphics.Color - -val primaryLight = Color(0xFF2A638A) -val onPrimaryLight = Color(0xFFFFFFFF) -val primaryContainerLight = Color(0xFFCBE6FF) -val onPrimaryContainerLight = Color(0xFF034B71) -val secondaryLight = Color(0xFF50606F) -val onSecondaryLight = Color(0xFFFFFFFF) -val secondaryContainerLight = Color(0xFFD4E4F6) -val onSecondaryContainerLight = Color(0xFF394856) -val tertiaryLight = Color(0xFF66587B) -val onTertiaryLight = Color(0xFFFFFFFF) -val tertiaryContainerLight = Color(0xFFECDCFF) -val onTertiaryContainerLight = Color(0xFF4E4162) -val errorLight = Color(0xFFBA1A1A) -val onErrorLight = Color(0xFFFFFFFF) -val errorContainerLight = Color(0xFFFFDAD6) -val onErrorContainerLight = Color(0xFF93000A) -val backgroundLight = Color(0xFFF7F9FF) -val onBackgroundLight = Color(0xFF181C20) -val surfaceLight = Color(0xFFF7F9FF) -val onSurfaceLight = Color(0xFF181C20) -val surfaceVariantLight = Color(0xFFDEE3EA) -val onSurfaceVariantLight = Color(0xFF42474D) -val outlineLight = Color(0xFF72787E) -val outlineVariantLight = Color(0xFFC1C7CE) -val scrimLight = Color(0xFF000000) -val inverseSurfaceLight = Color(0xFF2D3135) -val inverseOnSurfaceLight = Color(0xFFEEF1F6) -val inversePrimaryLight = Color(0xFF98CCF9) -val surfaceDimLight = Color(0xFFD7DADF) -val surfaceBrightLight = Color(0xFFF7F9FF) -val surfaceContainerLowestLight = Color(0xFFFFFFFF) -val surfaceContainerLowLight = Color(0xFFF1F4F9) -val surfaceContainerLight = Color(0xFFEBEEF3) -val surfaceContainerHighLight = Color(0xFFE6E8EE) -val surfaceContainerHighestLight = Color(0xFFE0E3E8) - -val primaryDark = Color(0xFF98CCF9) -val onPrimaryDark = Color(0xFF003350) -val primaryContainerDark = Color(0xFF034B71) -val onPrimaryContainerDark = Color(0xFFCBE6FF) -val secondaryDark = Color(0xFFB8C8D9) -val onSecondaryDark = Color(0xFF22323F) -val secondaryContainerDark = Color(0xFF394856) -val onSecondaryContainerDark = Color(0xFFD4E4F6) -val tertiaryDark = Color(0xFFD1BFE7) -val onTertiaryDark = Color(0xFF372B4A) -val tertiaryContainerDark = Color(0xFF4E4162) -val onTertiaryContainerDark = Color(0xFFECDCFF) -val errorDark = Color(0xFFFFB4AB) -val onErrorDark = Color(0xFF690005) -val errorContainerDark = Color(0xFF93000A) -val onErrorContainerDark = Color(0xFFFFDAD6) -val backgroundDark = Color(0xFF101417) -val onBackgroundDark = Color(0xFFE0E3E8) -val surfaceDark = Color(0xFF101417) -val onSurfaceDark = Color(0xFFE0E3E8) -val surfaceVariantDark = Color(0xFF42474D) -val onSurfaceVariantDark = Color(0xFFC1C7CE) -val outlineDark = Color(0xFF8C9198) -val outlineVariantDark = Color(0xFF42474D) -val scrimDark = Color(0xFF000000) -val inverseSurfaceDark = Color(0xFFE0E3E8) -val inverseOnSurfaceDark = Color(0xFF2D3135) -val inversePrimaryDark = Color(0xFF2A638A) -val surfaceDimDark = Color(0xFF101417) -val surfaceBrightDark = Color(0xFF363A3E) -val surfaceContainerLowestDark = Color(0xFF0B0F12) -val surfaceContainerLowDark = Color(0xFF181C20) -val surfaceContainerDark = Color(0xFF1C2024) -val surfaceContainerHighDark = Color(0xFF272A2E) -val surfaceContainerHighestDark = Color(0xFF313539) - -fun ColorScheme.toAmoled(): ColorScheme = - this.copy( - background = Color.Black, - surface = Color.Black, - surfaceContainer = Color(0xFF0A0A0A), - surfaceContainerLow = Color(0xFF050505), - surfaceContainerLowest = Color.Black, - surfaceContainerHigh = Color(0xFF121212), - surfaceContainerHighest = Color(0xFF1A1A1A), - surfaceDim = Color(0xFF0D0D0D), - surfaceBright = Color(0xFF1F1F1F), - surfaceVariant = Color(0xFF121212), - ) diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/DynamicColorScheme.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/DynamicColorScheme.kt new file mode 100644 index 000000000..b2ab32838 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/DynamicColorScheme.kt @@ -0,0 +1,9 @@ +package zed.rainxch.core.presentation.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +expect fun isDynamicColorAvailable(): Boolean + +@Composable +expect fun dynamicColorScheme(isDark: Boolean): ColorScheme? diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Locals.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Locals.kt new file mode 100644 index 000000000..6bf9d1849 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Locals.kt @@ -0,0 +1,108 @@ +package zed.rainxch.core.presentation.theme + +import androidx.compose.runtime.staticCompositionLocalOf +import zed.rainxch.core.presentation.theme.tokens.Tokens + +val LocalPalette = staticCompositionLocalOf { Tokens.Nord.light } + +val LocalStatusColors = staticCompositionLocalOf { defaultStatusColors } + +val LocalThresholds = staticCompositionLocalOf { defaultThresholds } + +val LocalMotion = staticCompositionLocalOf { defaultMotion } + +val LocalSpacing = staticCompositionLocalOf { defaultSpacing } + +data class StatusColors( + val freshnessHot: androidx.compose.ui.graphics.Color, + val freshnessFresh: androidx.compose.ui.graphics.Color, + val freshnessWarm: androidx.compose.ui.graphics.Color, + val freshnessCool: androidx.compose.ui.graphics.Color, + val freshnessDormant: androidx.compose.ui.graphics.Color, + val waxIntact: androidx.compose.ui.graphics.Color, + val waxCracked: androidx.compose.ui.graphics.Color, + val waxOpen: androidx.compose.ui.graphics.Color, + val permLow: androidx.compose.ui.graphics.Color, + val permModerate: androidx.compose.ui.graphics.Color, + val permHigh: androidx.compose.ui.graphics.Color, + val trendRising: androidx.compose.ui.graphics.Color, + val trendFlat: androidx.compose.ui.graphics.Color, + val trendFalling: androidx.compose.ui.graphics.Color, +) + +data class ThresholdSet( + val freshness: List, + val stars: List, + val maintenance: List, +) + +data class MotionTokens( + val tapHighlightMs: Int, + val paletteCrossfadeMs: Int, + val sheetSlideMs: Int, + val scrimFadeMs: Int, + val toastSlideMs: Int, + val toastFadeMs: Int, + val heartbeatScaleFrom: Float, + val heartbeatScaleTo: Float, + val heartbeatHaloFromScale: Float, + val heartbeatHaloToScale: Float, + val heartbeatHaloFromAlpha: Float, + val heartbeatHaloToAlpha: Float, +) + +data class SpacingTokens( + val xs: Int, + val sm: Int, + val md: Int, + val lg: Int, + val xl: Int, + val xxl: Int, +) + +internal val defaultStatusColors = StatusColors( + freshnessHot = Tokens.Status.Freshness.hot, + freshnessFresh = Tokens.Status.Freshness.fresh, + freshnessWarm = Tokens.Status.Freshness.warm, + freshnessCool = Tokens.Status.Freshness.cool, + freshnessDormant = Tokens.Status.Freshness.dormant, + waxIntact = Tokens.Status.Wax.intact, + waxCracked = Tokens.Status.Wax.cracked, + waxOpen = Tokens.Status.Wax.open, + permLow = Tokens.Status.Perm.low, + permModerate = Tokens.Status.Perm.moderate, + permHigh = Tokens.Status.Perm.high, + trendRising = Tokens.Status.Trend.rising, + trendFlat = Tokens.Status.Trend.flat, + trendFalling = Tokens.Status.Trend.falling, +) + +internal val defaultThresholds = ThresholdSet( + freshness = Tokens.Thresholds.freshness, + stars = Tokens.Thresholds.stars, + maintenance = Tokens.Thresholds.maintenance, +) + +internal val defaultMotion = MotionTokens( + tapHighlightMs = Tokens.Motion.tapHighlightMs, + paletteCrossfadeMs = Tokens.Motion.paletteCrossfadeMs, + sheetSlideMs = Tokens.Motion.sheetSlideMs, + scrimFadeMs = Tokens.Motion.scrimFadeMs, + toastSlideMs = Tokens.Motion.toastSlideMs, + toastFadeMs = Tokens.Motion.toastFadeMs, + heartbeatScaleFrom = Tokens.Motion.heartbeatScaleFrom, + heartbeatScaleTo = Tokens.Motion.heartbeatScaleTo, + heartbeatHaloFromScale = Tokens.Motion.heartbeatHaloFromScale, + heartbeatHaloToScale = Tokens.Motion.heartbeatHaloToScale, + heartbeatHaloFromAlpha = Tokens.Motion.heartbeatHaloFromAlpha, + heartbeatHaloToAlpha = Tokens.Motion.heartbeatHaloToAlpha, +) + +internal val defaultSpacing = SpacingTokens( + xs = Tokens.Spacing.xs, + sm = Tokens.Spacing.sm, + md = Tokens.Spacing.md, + lg = Tokens.Spacing.lg, + xl = Tokens.Spacing.xl, + xxl = Tokens.Spacing.xxl, +) diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Theme.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Theme.kt index 2687af250..25b669cf8 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Theme.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Theme.kt @@ -5,457 +5,101 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MotionScheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.FontTheme -import zed.rainxch.core.presentation.utils.darkScheme -import zed.rainxch.core.presentation.utils.lightScheme - -val oceanBlueLight = - lightColorScheme( - primary = primaryLight, - onPrimary = onPrimaryLight, - primaryContainer = primaryContainerLight, - onPrimaryContainer = onPrimaryContainerLight, - secondary = secondaryLight, - onSecondary = onSecondaryLight, - secondaryContainer = secondaryContainerLight, - onSecondaryContainer = onSecondaryContainerLight, - tertiary = tertiaryLight, - onTertiary = onTertiaryLight, - tertiaryContainer = tertiaryContainerLight, - onTertiaryContainer = onTertiaryContainerLight, - error = errorLight, - onError = onErrorLight, - errorContainer = errorContainerLight, - onErrorContainer = onErrorContainerLight, - background = backgroundLight, - onBackground = onBackgroundLight, - surface = surfaceLight, - onSurface = onSurfaceLight, - surfaceVariant = surfaceVariantLight, - onSurfaceVariant = onSurfaceVariantLight, - outline = outlineLight, - outlineVariant = outlineVariantLight, - scrim = scrimLight, - inverseSurface = inverseSurfaceLight, - inverseOnSurface = inverseOnSurfaceLight, - inversePrimary = inversePrimaryLight, - surfaceDim = surfaceDimLight, - surfaceBright = surfaceBrightLight, - surfaceContainerLowest = surfaceContainerLowestLight, - surfaceContainerLow = surfaceContainerLowLight, - surfaceContainer = surfaceContainerLight, - surfaceContainerHigh = surfaceContainerHighLight, - surfaceContainerHighest = surfaceContainerHighestLight, - ) - -val oceanBlueDark = - darkColorScheme( - primary = primaryDark, - onPrimary = onPrimaryDark, - primaryContainer = primaryContainerDark, - onPrimaryContainer = onPrimaryContainerDark, - secondary = secondaryDark, - onSecondary = onSecondaryDark, - secondaryContainer = secondaryContainerDark, - onSecondaryContainer = onSecondaryContainerDark, - tertiary = tertiaryDark, - onTertiary = onTertiaryDark, - tertiaryContainer = tertiaryContainerDark, - onTertiaryContainer = onTertiaryContainerDark, - error = errorDark, - onError = onErrorDark, - errorContainer = errorContainerDark, - onErrorContainer = onErrorContainerDark, - background = backgroundDark, - onBackground = onBackgroundDark, - surface = surfaceDark, - onSurface = onSurfaceDark, - surfaceVariant = surfaceVariantDark, - onSurfaceVariant = onSurfaceVariantDark, - outline = outlineDark, - outlineVariant = outlineVariantDark, - scrim = scrimDark, - inverseSurface = inverseSurfaceDark, - inverseOnSurface = inverseOnSurfaceDark, - inversePrimary = inversePrimaryDark, - surfaceDim = surfaceDimDark, - surfaceBright = surfaceBrightDark, - surfaceContainerLowest = surfaceContainerLowestDark, - surfaceContainerLow = surfaceContainerLowDark, - surfaceContainer = surfaceContainerDark, - surfaceContainerHigh = surfaceContainerHighDark, - surfaceContainerHighest = surfaceContainerHighestDark, - ) - -val deepPurpleLight = - lightColorScheme( - primary = Color(0xFF6750A4), - onPrimary = Color(0xFFFFFFFF), - primaryContainer = Color(0xFFE9DDFF), - onPrimaryContainer = Color(0xFF22005D), - secondary = Color(0xFF625B71), - onSecondary = Color(0xFFFFFFFF), - secondaryContainer = Color(0xFFE8DEF8), - onSecondaryContainer = Color(0xFF1E192B), - tertiary = Color(0xFF7E5260), - onTertiary = Color(0xFFFFFFFF), - tertiaryContainer = Color(0xFFFFD9E3), - onTertiaryContainer = Color(0xFF31101D), - error = Color(0xFFBA1A1A), - onError = Color(0xFFFFFFFF), - errorContainer = Color(0xFFFFDAD6), - onErrorContainer = Color(0xFF410002), - background = Color(0xFFFFFBFF), - onBackground = Color(0xFF1C1B1E), - surface = Color(0xFFFFFBFF), - onSurface = Color(0xFF1C1B1E), - surfaceVariant = Color(0xFFE7E0EB), - onSurfaceVariant = Color(0xFF49454E), - outline = Color(0xFF7A757F), - outlineVariant = Color(0xFFCAC4CF), - scrim = Color(0xFF000000), - inverseSurface = Color(0xFF313033), - inverseOnSurface = Color(0xFFF4EFF4), - inversePrimary = Color(0xFFCFBCFF), - surfaceDim = Color(0xFFDED8DD), - surfaceBright = Color(0xFFFFFBFF), - surfaceContainerLowest = Color(0xFFFFFFFF), - surfaceContainerLow = Color(0xFFF8F2F7), - surfaceContainer = Color(0xFFF2ECF1), - surfaceContainerHigh = Color(0xFFECE6EB), - surfaceContainerHighest = Color(0xFFE6E1E6), - ) - -val deepPurpleDark = - darkColorScheme( - primary = Color(0xFFCFBCFF), - onPrimary = Color(0xFF381E72), - primaryContainer = Color(0xFF4F378A), - onPrimaryContainer = Color(0xFFE9DDFF), - secondary = Color(0xFFCBC2DB), - onSecondary = Color(0xFF332D41), - secondaryContainer = Color(0xFF4A4458), - onSecondaryContainer = Color(0xFFE8DEF8), - tertiary = Color(0xFFEFB8C8), - onTertiary = Color(0xFF4A2532), - tertiaryContainer = Color(0xFF633B48), - onTertiaryContainer = Color(0xFFFFD9E3), - error = Color(0xFFFFB4AB), - onError = Color(0xFF690005), - errorContainer = Color(0xFF93000A), - onErrorContainer = Color(0xFFFFDAD6), - background = Color(0xFF141316), - onBackground = Color(0xFFE6E1E6), - surface = Color(0xFF141316), - onSurface = Color(0xFFE6E1E6), - surfaceVariant = Color(0xFF49454E), - onSurfaceVariant = Color(0xFFCAC4CF), - outline = Color(0xFF948F99), - outlineVariant = Color(0xFF49454E), - scrim = Color(0xFF000000), - inverseSurface = Color(0xFFE6E1E6), - inverseOnSurface = Color(0xFF313033), - inversePrimary = Color(0xFF6750A4), - surfaceDim = Color(0xFF141316), - surfaceBright = Color(0xFF3A383C), - surfaceContainerLowest = Color(0xFF0F0E11), - surfaceContainerLow = Color(0xFF1C1B1E), - surfaceContainer = Color(0xFF201F22), - surfaceContainerHigh = Color(0xFF2B292D), - surfaceContainerHighest = Color(0xFF363438), - ) - -// ============================================================================ -// FOREST GREEN THEME (Trusted & Verified) -// ============================================================================ -val forestGreenLight = - lightColorScheme( - primary = Color(0xFF356859), - onPrimary = Color(0xFFFFFFFF), - primaryContainer = Color(0xFFB8EED9), - onPrimaryContainer = Color(0xFF002019), - secondary = Color(0xFF4C6359), - onSecondary = Color(0xFFFFFFFF), - secondaryContainer = Color(0xFFCEE9DB), - onSecondaryContainer = Color(0xFF092018), - tertiary = Color(0xFF3F6373), - onTertiary = Color(0xFFFFFFFF), - tertiaryContainer = Color(0xFFC3E8FB), - onTertiaryContainer = Color(0xFF001F29), - error = Color(0xFFBA1A1A), - onError = Color(0xFFFFFFFF), - errorContainer = Color(0xFFFFDAD6), - onErrorContainer = Color(0xFF410002), - background = Color(0xFFF5FBF7), - onBackground = Color(0xFF171D1A), - surface = Color(0xFFF5FBF7), - onSurface = Color(0xFF171D1A), - surfaceVariant = Color(0xFFDBE5DD), - onSurfaceVariant = Color(0xFF404943), - outline = Color(0xFF707973), - outlineVariant = Color(0xFFBFC9C1), - scrim = Color(0xFF000000), - inverseSurface = Color(0xFF2C322F), - inverseOnSurface = Color(0xFFEDF2ED), - inversePrimary = Color(0xFF9CD1BD), - surfaceDim = Color(0xFFD6DBD8), - surfaceBright = Color(0xFFF5FBF7), - surfaceContainerLowest = Color(0xFFFFFFFF), - surfaceContainerLow = Color(0xFFF0F5F1), - surfaceContainer = Color(0xFFEAEFEB), - surfaceContainerHigh = Color(0xFFE4E9E6), - surfaceContainerHighest = Color(0xFFDFE4E0), - ) - -val forestGreenDark = - darkColorScheme( - primary = Color(0xFF9CD1BD), - onPrimary = Color(0xFF00382B), - primaryContainer = Color(0xFF1C4F41), - onPrimaryContainer = Color(0xFFB8EED9), - secondary = Color(0xFFB2CDBF), - onSecondary = Color(0xFF1D352C), - secondaryContainer = Color(0xFF344C42), - onSecondaryContainer = Color(0xFFCEE9DB), - tertiary = Color(0xFFA7CCDE), - onTertiary = Color(0xFF0C3443), - tertiaryContainer = Color(0xFF264B5B), - onTertiaryContainer = Color(0xFFC3E8FB), - error = Color(0xFFFFB4AB), - onError = Color(0xFF690005), - errorContainer = Color(0xFF93000A), - onErrorContainer = Color(0xFFFFDAD6), - background = Color(0xFF0F1512), - onBackground = Color(0xFFDFE4E0), - surface = Color(0xFF0F1512), - onSurface = Color(0xFFDFE4E0), - surfaceVariant = Color(0xFF404943), - onSurfaceVariant = Color(0xFFBFC9C1), - outline = Color(0xFF89938C), - outlineVariant = Color(0xFF404943), - scrim = Color(0xFF000000), - inverseSurface = Color(0xFFDFE4E0), - inverseOnSurface = Color(0xFF2C322F), - inversePrimary = Color(0xFF356859), - surfaceDim = Color(0xFF0F1512), - surfaceBright = Color(0xFF353B37), - surfaceContainerLowest = Color(0xFF0A100D), - surfaceContainerLow = Color(0xFF171D1A), - surfaceContainer = Color(0xFF1B211E), - surfaceContainerHigh = Color(0xFF262C28), - surfaceContainerHighest = Color(0xFF313733), - ) - -// ============================================================================ -// SLATE GRAY THEME (Minimal Developer) -// ============================================================================ -val slateGrayLight = - lightColorScheme( - primary = Color(0xFF535E6C), - onPrimary = Color(0xFFFFFFFF), - primaryContainer = Color(0xFFD7E3F3), - onPrimaryContainer = Color(0xFF101C27), - secondary = Color(0xFF565E6B), - onSecondary = Color(0xFFFFFFFF), - secondaryContainer = Color(0xFFDAE2F1), - onSecondaryContainer = Color(0xFF131C26), - tertiary = Color(0xFF6E5676), - onTertiary = Color(0xFFFFFFFF), - tertiaryContainer = Color(0xFFF7D9FF), - onTertiaryContainer = Color(0xFF281430), - error = Color(0xFFBA1A1A), - onError = Color(0xFFFFFFFF), - errorContainer = Color(0xFFFFDAD6), - onErrorContainer = Color(0xFF410002), - background = Color(0xFFF8F9FB), - onBackground = Color(0xFF191C1E), - surface = Color(0xFFF8F9FB), - onSurface = Color(0xFF191C1E), - surfaceVariant = Color(0xFFDFE2E9), - onSurfaceVariant = Color(0xFF43474E), - outline = Color(0xFF73777F), - outlineVariant = Color(0xFFC3C6CD), - scrim = Color(0xFF000000), - inverseSurface = Color(0xFF2E3133), - inverseOnSurface = Color(0xFFF0F0F3), - inversePrimary = Color(0xFFB4C7D9), - surfaceDim = Color(0xFFD9D9DC), - surfaceBright = Color(0xFFF8F9FB), - surfaceContainerLowest = Color(0xFFFFFFFF), - surfaceContainerLow = Color(0xFFF3F3F6), - surfaceContainer = Color(0xFFEDEDF0), - surfaceContainerHigh = Color(0xFFE7E8EA), - surfaceContainerHighest = Color(0xFFE2E2E5), - ) - -val slateGrayDark = - darkColorScheme( - primary = Color(0xFFB4C7D9), - onPrimary = Color(0xFF1F2F3D), - primaryContainer = Color(0xFF394654), - onPrimaryContainer = Color(0xFFD7E3F3), - secondary = Color(0xFFBEC6D5), - onSecondary = Color(0xFF28323B), - secondaryContainer = Color(0xFF3E4753), - onSecondaryContainer = Color(0xFFDAE2F1), - tertiary = Color(0xFFDABDE2), - onTertiary = Color(0xFF3E2946), - tertiaryContainer = Color(0xFF553F5D), - onTertiaryContainer = Color(0xFFF7D9FF), - error = Color(0xFFFFB4AB), - onError = Color(0xFF690005), - errorContainer = Color(0xFF93000A), - onErrorContainer = Color(0xFFFFDAD6), - background = Color(0xFF111416), - onBackground = Color(0xFFE2E2E5), - surface = Color(0xFF111416), - onSurface = Color(0xFFE2E2E5), - surfaceVariant = Color(0xFF43474E), - onSurfaceVariant = Color(0xFFC3C6CD), - outline = Color(0xFF8D9199), - outlineVariant = Color(0xFF43474E), - scrim = Color(0xFF000000), - inverseSurface = Color(0xFFE2E2E5), - inverseOnSurface = Color(0xFF2E3133), - inversePrimary = Color(0xFF535E6C), - surfaceDim = Color(0xFF111416), - surfaceBright = Color(0xFF37393B), - surfaceContainerLowest = Color(0xFF0C0F11), - surfaceContainerLow = Color(0xFF191C1E), - surfaceContainer = Color(0xFF1D2022), - surfaceContainerHigh = Color(0xFF282A2D), - surfaceContainerHighest = Color(0xFF333538), - ) - -// ============================================================================ -// AMBER ORANGE THEME (Energetic & Warm) -// ============================================================================ -val amberOrangeLight = - lightColorScheme( - primary = Color(0xFF8B5000), - onPrimary = Color(0xFFFFFFFF), - primaryContainer = Color(0xFFFFDCBE), - onPrimaryContainer = Color(0xFF2D1600), - secondary = Color(0xFF715A48), - onSecondary = Color(0xFFFFFFFF), - secondaryContainer = Color(0xFFFFDCBE), - onSecondaryContainer = Color(0xFF28190A), - tertiary = Color(0xFF54643D), - onTertiary = Color(0xFFFFFFFF), - tertiaryContainer = Color(0xFFD7E9B8), - onTertiaryContainer = Color(0xFF131F02), - error = Color(0xFFBA1A1A), - onError = Color(0xFFFFFFFF), - errorContainer = Color(0xFFFFDAD6), - onErrorContainer = Color(0xFF410002), - background = Color(0xFFFFFBFF), - onBackground = Color(0xFF201B16), - surface = Color(0xFFFFFBFF), - onSurface = Color(0xFF201B16), - surfaceVariant = Color(0xFFF2DFD1), - onSurfaceVariant = Color(0xFF51443A), - outline = Color(0xFF837469), - outlineVariant = Color(0xFFD5C3B6), - scrim = Color(0xFF000000), - inverseSurface = Color(0xFF36302A), - inverseOnSurface = Color(0xFFFAEFE7), - inversePrimary = Color(0xFFFFB870), - surfaceDim = Color(0xFFE4D9D1), - surfaceBright = Color(0xFFFFFBFF), - surfaceContainerLowest = Color(0xFFFFFFFF), - surfaceContainerLow = Color(0xFFFEF3EB), - surfaceContainer = Color(0xFFF8EDE5), - surfaceContainerHigh = Color(0xFFF2E7DF), - surfaceContainerHighest = Color(0xFFECE1DA), - ) - -val amberOrangeDark = - darkColorScheme( - primary = Color(0xFFFFB870), - onPrimary = Color(0xFF4B2800), - primaryContainer = Color(0xFF6A3C00), - onPrimaryContainer = Color(0xFFFFDCBE), - secondary = Color(0xFFE2C1A3), - onSecondary = Color(0xFF402D1D), - secondaryContainer = Color(0xFF584332), - onSecondaryContainer = Color(0xFFFFDCBE), - tertiary = Color(0xFFBBCD9E), - onTertiary = Color(0xFF273514), - tertiaryContainer = Color(0xFF3D4C28), - onTertiaryContainer = Color(0xFFD7E9B8), - error = Color(0xFFFFB4AB), - onError = Color(0xFF690005), - errorContainer = Color(0xFF93000A), - onErrorContainer = Color(0xFFFFDAD6), - background = Color(0xFF18130E), - onBackground = Color(0xFFECE1DA), - surface = Color(0xFF18130E), - onSurface = Color(0xFFECE1DA), - surfaceVariant = Color(0xFF51443A), - onSurfaceVariant = Color(0xFFD5C3B6), - outline = Color(0xFF9D8E82), - outlineVariant = Color(0xFF51443A), - scrim = Color(0xFF000000), - inverseSurface = Color(0xFFECE1DA), - inverseOnSurface = Color(0xFF36302A), - inversePrimary = Color(0xFF8B5000), - surfaceDim = Color(0xFF18130E), - surfaceBright = Color(0xFF3F3933), - surfaceContainerLowest = Color(0xFF120E09), - surfaceContainerLow = Color(0xFF201B16), - surfaceContainer = Color(0xFF241F1A), - surfaceContainerHigh = Color(0xFF2F2A24), - surfaceContainerHighest = Color(0xFF3A342E), - ) +import zed.rainxch.core.presentation.theme.tokens.Tokens +import zed.rainxch.core.presentation.theme.tokens.colorSchemeFor +import zed.rainxch.core.presentation.utils.toTokenPalette @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun GithubStoreTheme( isDarkTheme: Boolean = false, - appTheme: AppTheme = AppTheme.OCEAN, + appTheme: AppTheme = AppTheme.NORD, fontTheme: FontTheme = FontTheme.CUSTOM, isAmoledTheme: Boolean = false, content: @Composable () -> Unit, ) { - val baseColorScheme = - when { - appTheme == AppTheme.DYNAMIC -> { - getDynamicColorScheme(isDarkTheme) ?: run { - if (isDarkTheme) AppTheme.OCEAN.darkScheme else AppTheme.OCEAN.lightScheme - } - } - - isDarkTheme -> { - appTheme.darkScheme!! - } - - else -> { - appTheme.lightScheme!! - } - } - - val colorScheme = - if (isDarkTheme && isAmoledTheme) { - baseColorScheme?.toAmoled() - } else { - baseColorScheme - } + val mode = when { + !isDarkTheme -> Tokens.Mode.LIGHT + isAmoledTheme -> Tokens.Mode.AMOLED + else -> Tokens.Mode.DARK + } + val tokenPalette = appTheme.toTokenPalette() + val palette = Tokens.palette(tokenPalette, mode) + val dynamic = if (appTheme == AppTheme.DYNAMIC) { + dynamicColorScheme(isDark = isDarkTheme) + } else { + null + } + val baseScheme = colorSchemeFor(palette = tokenPalette, mode = mode) + val targetScheme = if (dynamic != null) { + applyDynamicSurfaces( + dynamic = dynamic, + isDark = isDarkTheme, + isAmoled = isAmoledTheme && isDarkTheme, + ) + } else { + baseScheme + } + val animatedScheme = animateColorScheme(target = targetScheme) + + CompositionLocalProvider( + LocalPalette provides palette, + LocalStatusColors provides defaultStatusColors, + LocalThresholds provides defaultThresholds, + LocalMotion provides defaultMotion, + LocalSpacing provides defaultSpacing, + ) { + MaterialExpressiveTheme( + colorScheme = animatedScheme, + typography = getAppTypography(fontTheme), + motionScheme = MotionScheme.expressive(), + shapes = MaterialTheme.shapes, + content = content, + ) + } +} - MaterialExpressiveTheme( - colorScheme = colorScheme, - typography = getAppTypography(fontTheme), - motionScheme = MotionScheme.expressive(), - shapes = MaterialTheme.shapes, - content = content, +private fun applyDynamicSurfaces( + dynamic: ColorScheme, + isDark: Boolean, + isAmoled: Boolean, +): ColorScheme { + val accent = dynamic.primary + val base = when { + isAmoled -> Color.Black + isDark -> Color(0xFF101319) + else -> Color.White + } + fun tint(ratio: Float): Color = blend(base, accent, ratio) + return dynamic.copy( + background = if (isAmoled) Color.Black else tint(if (isDark) 0.04f else 0.03f), + surface = if (isAmoled) Color(0xFF0B0F14) else tint(if (isDark) 0.04f else 0.03f), + surfaceVariant = tint(if (isDark) 0.15f else 0.11f), + surfaceTint = accent, + surfaceBright = tint(if (isDark) 0.18f else 0.02f), + surfaceDim = tint(if (isDark) 0.02f else 0.10f), + surfaceContainerLowest = if (isAmoled) Color.Black else if (isDark) base else Color.White, + surfaceContainerLow = tint(if (isDark) 0.07f else 0.06f), + surfaceContainer = tint(if (isDark) 0.10f else 0.09f), + surfaceContainerHigh = tint(if (isDark) 0.13f else 0.12f), + surfaceContainerHighest = tint(if (isDark) 0.17f else 0.15f), + outline = tint(if (isDark) 0.32f else 0.28f), + outlineVariant = tint(if (isDark) 0.20f else 0.16f), ) } -expect fun isDynamicColorAvailable(): Boolean - -@Composable -expect fun getDynamicColorScheme(darkTheme: Boolean): ColorScheme? +private fun blend(base: Color, accent: Color, ratio: Float): Color { + val r = ratio.coerceIn(0f, 1f) + val inv = 1f - r + return Color( + red = base.red * inv + accent.red * r, + green = base.green * inv + accent.green * r, + blue = base.blue * inv + accent.blue * r, + alpha = 1f, + ) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Type.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Type.kt index 2bfb6c410..9ba7f13a6 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Type.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/Type.kt @@ -2,59 +2,90 @@ package zed.rainxch.core.presentation.theme import androidx.compose.material3.Typography import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.Font import zed.rainxch.core.domain.model.FontTheme -import zed.rainxch.githubstore.core.presentation.res.* - -val jetbrainsMonoFontFamily - @Composable get() = - FontFamily( - Font(Res.font.jetbrains_mono_light, FontWeight.Light), - Font(Res.font.jetbrains_mono_regular, FontWeight.Normal), - Font(Res.font.jetbrains_mono_medium, FontWeight.Medium), - Font(Res.font.jetbrains_mono_semi_bold, FontWeight.SemiBold), - Font(Res.font.jetbrains_mono_bold, FontWeight.Bold), - ) - -val interFontFamily - @Composable get() = - FontFamily( - Font(Res.font.inter_light, FontWeight.Light), - Font(Res.font.inter_regular, FontWeight.Normal), - Font(Res.font.inter_medium, FontWeight.Medium), - Font(Res.font.inter_semi_bold, FontWeight.SemiBold), - Font(Res.font.inter_bold, FontWeight.Bold), - Font(Res.font.inter_black, FontWeight.Black), - ) - -val baseline = Typography() +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.geist +import zed.rainxch.githubstore.core.presentation.res.geist_mono + +val geist + @Composable get() = FontFamily( + Font(Res.font.geist, FontWeight.Normal, FontStyle.Normal), + Font(Res.font.geist, FontWeight.Medium, FontStyle.Normal), + Font(Res.font.geist, FontWeight.SemiBold, FontStyle.Normal), + Font(Res.font.geist, FontWeight.Bold, FontStyle.Normal), + Font(Res.font.geist, FontWeight.Black, FontStyle.Normal), + ) + +val geistMono + @Composable get() = FontFamily( + Font(Res.font.geist_mono, FontWeight.Normal), + Font(Res.font.geist_mono, FontWeight.Medium), + Font(Res.font.geist_mono, FontWeight.SemiBold), + Font(Res.font.geist_mono, FontWeight.Bold), + ) + +@Deprecated("Use geist", ReplaceWith("geist")) +val fraunces + @Composable get() = geist + +@Deprecated("Use geist", ReplaceWith("geist")) +val interTight + @Composable get() = geist + +@Deprecated("Use geistMono", ReplaceWith("geistMono")) +val jetbrainsMono + @Composable get() = geistMono + +private val baseline = Typography() @Composable -fun getAppTypography(fontTheme: FontTheme = FontTheme.CUSTOM): Typography = - when (fontTheme) { - FontTheme.SYSTEM -> { - baseline - } - - FontTheme.CUSTOM -> { - Typography( - displayLarge = baseline.displayLarge.copy(fontFamily = jetbrainsMonoFontFamily), - displayMedium = baseline.displayMedium.copy(fontFamily = jetbrainsMonoFontFamily), - displaySmall = baseline.displaySmall.copy(fontFamily = jetbrainsMonoFontFamily), - headlineLarge = baseline.headlineLarge.copy(fontFamily = jetbrainsMonoFontFamily), - headlineMedium = baseline.headlineMedium.copy(fontFamily = jetbrainsMonoFontFamily), - headlineSmall = baseline.headlineSmall.copy(fontFamily = jetbrainsMonoFontFamily), - titleLarge = baseline.titleLarge.copy(fontFamily = jetbrainsMonoFontFamily), - titleMedium = baseline.titleMedium.copy(fontFamily = jetbrainsMonoFontFamily), - titleSmall = baseline.titleSmall.copy(fontFamily = jetbrainsMonoFontFamily), - bodyLarge = baseline.bodyLarge.copy(fontFamily = interFontFamily), - bodyMedium = baseline.bodyMedium.copy(fontFamily = interFontFamily), - bodySmall = baseline.bodySmall.copy(fontFamily = interFontFamily), - labelLarge = baseline.labelLarge.copy(fontFamily = interFontFamily), - labelMedium = baseline.labelMedium.copy(fontFamily = interFontFamily), - labelSmall = baseline.labelSmall.copy(fontFamily = interFontFamily), - ) - } - } +fun getAppTypography(fontTheme: FontTheme = FontTheme.CUSTOM): Typography { + if (fontTheme == FontTheme.SYSTEM) return baseline + + val family = geist + + fun TextStyle.display(weight: FontWeight) = copy( + fontFamily = family, + fontWeight = weight, + fontStyle = FontStyle.Normal, + letterSpacing = (-0.022).em, + textDecoration = TextDecoration.None, + ) + + fun TextStyle.body(weight: FontWeight) = copy( + fontFamily = family, + fontWeight = weight, + fontStyle = FontStyle.Normal, + textDecoration = TextDecoration.None, + ) + + return Typography( + displayLarge = baseline.displayLarge.display(FontWeight.Bold).copy( + letterSpacing = (-0.028).em, + fontSize = 36.sp, + ), + displayMedium = baseline.displayMedium.display(FontWeight.Bold).copy(fontSize = 32.sp), + displaySmall = baseline.displaySmall.display(FontWeight.Bold).copy(fontSize = 28.sp), + headlineLarge = baseline.headlineLarge.display(FontWeight.SemiBold).copy(fontSize = 26.sp), + headlineMedium = baseline.headlineMedium.display(FontWeight.SemiBold).copy(fontSize = 22.sp), + headlineSmall = baseline.headlineSmall.display(FontWeight.SemiBold).copy(fontSize = 20.sp), + titleLarge = baseline.titleLarge.display(FontWeight.SemiBold).copy(fontSize = 18.sp), + titleMedium = baseline.titleMedium.display(FontWeight.SemiBold).copy(fontSize = 16.sp), + titleSmall = baseline.titleSmall.display(FontWeight.SemiBold).copy(fontSize = 14.sp), + + bodyLarge = baseline.bodyLarge.body(FontWeight.Normal).copy(fontSize = 14.sp), + bodyMedium = baseline.bodyMedium.body(FontWeight.Normal).copy(fontSize = 13.sp), + bodySmall = baseline.bodySmall.body(FontWeight.Medium).copy(fontSize = 12.sp), + labelLarge = baseline.labelLarge.body(FontWeight.SemiBold), + labelMedium = baseline.labelMedium.body(FontWeight.SemiBold), + labelSmall = baseline.labelSmall.body(FontWeight.SemiBold), + ) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/shapes/WonkySquircleShape.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/shapes/WonkySquircleShape.kt new file mode 100644 index 000000000..10fffe142 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/shapes/WonkySquircleShape.kt @@ -0,0 +1,149 @@ +package zed.rainxch.core.presentation.theme.shapes + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +class WonkySquircleShape( + private val topStart: CornerRadii, + private val topEnd: CornerRadii, + private val bottomEnd: CornerRadii, + private val bottomStart: CornerRadii, +) : Shape { + + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline { + with(density) { + val tsX = topStart.rx.toPx().coerceAtMost(size.width / 2f) + val tsY = topStart.ry.toPx().coerceAtMost(size.height / 2f) + val teX = topEnd.rx.toPx().coerceAtMost(size.width / 2f) + val teY = topEnd.ry.toPx().coerceAtMost(size.height / 2f) + val beX = bottomEnd.rx.toPx().coerceAtMost(size.width / 2f) + val beY = bottomEnd.ry.toPx().coerceAtMost(size.height / 2f) + val bsX = bottomStart.rx.toPx().coerceAtMost(size.width / 2f) + val bsY = bottomStart.ry.toPx().coerceAtMost(size.height / 2f) + + val path = Path().apply { + + moveTo(tsX, 0f) + + lineTo(size.width - teX, 0f) + arcToCorner( + cornerCenter = Offset(size.width - teX, teY), + radiusX = teX, + radiusY = teY, + startAngle = 270f, + sweep = 90f, + ) + + lineTo(size.width, size.height - beY) + arcToCorner( + cornerCenter = Offset(size.width - beX, size.height - beY), + radiusX = beX, + radiusY = beY, + startAngle = 0f, + sweep = 90f, + ) + + lineTo(bsX, size.height) + arcToCorner( + cornerCenter = Offset(bsX, size.height - bsY), + radiusX = bsX, + radiusY = bsY, + startAngle = 90f, + sweep = 90f, + ) + + lineTo(0f, tsY) + arcToCorner( + cornerCenter = Offset(tsX, tsY), + radiusX = tsX, + radiusY = tsY, + startAngle = 180f, + sweep = 90f, + ) + close() + } + return Outline.Generic(path) + } + } + + companion object { + + val CtaPrimary = WonkySquircleShape( + topStart = CornerRadii(20.dp, 16.dp), + topEnd = CornerRadii(14.dp, 22.dp), + bottomEnd = CornerRadii(22.dp, 14.dp), + bottomStart = CornerRadii(16.dp, 20.dp), + ) + + val CtaAlt = WonkySquircleShape( + topStart = CornerRadii(22.dp, 18.dp), + topEnd = CornerRadii(16.dp, 24.dp), + bottomEnd = CornerRadii(24.dp, 16.dp), + bottomStart = CornerRadii(18.dp, 22.dp), + ) + + val Search = WonkySquircleShape( + topStart = CornerRadii(24.dp, 18.dp), + topEnd = CornerRadii(18.dp, 24.dp), + bottomEnd = CornerRadii(26.dp, 20.dp), + bottomStart = CornerRadii(20.dp, 26.dp), + ) + + val Sheet = WonkySquircleShape( + topStart = CornerRadii(24.dp, 18.dp), + topEnd = CornerRadii(18.dp, 24.dp), + bottomEnd = CornerRadii(0.dp, 0.dp), + bottomStart = CornerRadii(0.dp, 0.dp), + ) + + val Dialog = WonkySquircleShape( + topStart = CornerRadii(28.dp, 22.dp), + topEnd = CornerRadii(22.dp, 28.dp), + bottomEnd = CornerRadii(26.dp, 24.dp), + bottomStart = CornerRadii(24.dp, 26.dp), + ) + + val Toast = WonkySquircleShape( + topStart = CornerRadii(18.dp, 14.dp), + topEnd = CornerRadii(14.dp, 22.dp), + bottomEnd = CornerRadii(22.dp, 16.dp), + bottomStart = CornerRadii(16.dp, 18.dp), + ) + } +} + +data class CornerRadii(val rx: Dp, val ry: Dp) + +private fun Path.arcToCorner( + cornerCenter: Offset, + radiusX: Float, + radiusY: Float, + startAngle: Float, + sweep: Float, +) { + if (radiusX == 0f || radiusY == 0f) return + val bounds = Rect( + left = cornerCenter.x - radiusX, + top = cornerCenter.y - radiusY, + right = cornerCenter.x + radiusX, + bottom = cornerCenter.y + radiusY, + ) + arcTo( + rect = bounds, + startAngleDegrees = startAngle, + sweepAngleDegrees = sweep, + forceMoveTo = false, + ) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/EmberPalette.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/EmberPalette.kt new file mode 100644 index 000000000..34701471e --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/EmberPalette.kt @@ -0,0 +1,11 @@ +package zed.rainxch.core.presentation.theme.tokens + +import androidx.compose.ui.graphics.Color + +object EmberPalette { + val Deep = Color(0xFFB3261E) + val Hot = Color(0xFFE65100) + val Warm = Color(0xFFFF9100) + val Amber = Color(0xFFFFC400) + val Ash = Color(0xFF2A1410) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/GhsAccents.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/GhsAccents.kt new file mode 100644 index 000000000..6c0617ed2 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/GhsAccents.kt @@ -0,0 +1,18 @@ +package zed.rainxch.core.presentation.theme.tokens + +import androidx.compose.ui.graphics.Color + +object GhsAccents { + val Lavender = Color(0xFFB19CD9) + val Mint = Color(0xFF88C9BF) + val Sky = Color(0xFF8FB8E0) + val Blush = Color(0xFFE89FA5) + val Peach = Color(0xFFE8B179) + val Sage = Color(0xFFA1C99B) + val Amber = Color(0xFFE2B358) + val Periwinkle = Color(0xFFA9A4E0) + val Rose = Color(0xFFD98AAA) + val Gold = Color(0xFFD9B856) + val Aqua = Color(0xFF7FBCC9) + val Tan = Color(0xFFC9A07C) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/Radii.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/Radii.kt new file mode 100644 index 000000000..cfea4433c --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/Radii.kt @@ -0,0 +1,21 @@ +package zed.rainxch.core.presentation.theme.tokens + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp + +object Radii { + val chip = shape(11, 8) + val row = shape(13, 10) + val cardSm = shape(15, 11) + val card = shape(18, 14) + val cardLg = shape(20, 15) + val hero = shape(24, 18) + val heroLg = shape(28, 22) + + fun shape(primary: Int, secondary: Int) = RoundedCornerShape( + topStart = primary.dp, + topEnd = secondary.dp, + bottomEnd = primary.dp, + bottomStart = secondary.dp, + ) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/Schemes.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/Schemes.kt new file mode 100644 index 000000000..c4bd7a4b6 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/Schemes.kt @@ -0,0 +1,89 @@ +package zed.rainxch.core.presentation.theme.tokens + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +fun toLightColorScheme(p: Tokens.PaletteColors): ColorScheme = lightColorScheme( + primary = p.primary, + onPrimary = Color.White, + primaryContainer = p.tintP, + onPrimaryContainer = p.ink, + secondary = p.primary, + onSecondary = Color.White, + secondaryContainer = p.tintP, + onSecondaryContainer = p.ink, + tertiary = p.success, + onTertiary = Color.White, + tertiaryContainer = p.successT, + onTertiaryContainer = p.ink, + error = p.danger, + onError = Color.White, + errorContainer = p.dangerT, + onErrorContainer = p.ink, + background = p.bg, + onBackground = p.ink, + surface = p.surface, + onSurface = p.ink, + surfaceVariant = p.surface2, + onSurfaceVariant = p.ink2, + outline = p.outline, + outlineVariant = p.outline, + scrim = Color.Black, + inverseSurface = p.ink, + inverseOnSurface = p.bg, + inversePrimary = p.tintP, + surfaceTint = p.primary, + surfaceBright = p.surface, + surfaceDim = p.bg, + surfaceContainerLowest = p.surface, + surfaceContainerLow = p.surface, + surfaceContainer = p.surface2, + surfaceContainerHigh = p.surface2, + surfaceContainerHighest = p.surface2, +) + +fun toDarkColorScheme(p: Tokens.PaletteColors): ColorScheme = darkColorScheme( + primary = p.primary, + onPrimary = p.ink, + primaryContainer = p.tintP, + onPrimaryContainer = p.ink, + secondary = p.primary, + onSecondary = p.ink, + secondaryContainer = p.tintP, + onSecondaryContainer = p.ink, + tertiary = p.success, + onTertiary = p.ink, + tertiaryContainer = p.successT, + onTertiaryContainer = p.ink, + error = p.danger, + onError = Color.White, + errorContainer = p.dangerT, + onErrorContainer = p.ink, + background = p.bg, + onBackground = p.ink, + surface = p.surface, + onSurface = p.ink, + surfaceVariant = p.surface2, + onSurfaceVariant = p.ink2, + outline = p.outline, + outlineVariant = p.outline, + scrim = Color.Black, + inverseSurface = p.ink, + inverseOnSurface = p.bg, + inversePrimary = p.tintP, + surfaceTint = p.primary, + surfaceBright = p.surface2, + surfaceDim = p.bg, + surfaceContainerLowest = p.bg, + surfaceContainerLow = p.surface, + surfaceContainer = p.surface, + surfaceContainerHigh = p.surface2, + surfaceContainerHighest = p.surface2, +) + +fun colorSchemeFor(palette: Tokens.Palette, mode: Tokens.Mode): ColorScheme { + val tokens = Tokens.palette(palette, mode) + return if (mode == Tokens.Mode.LIGHT) toLightColorScheme(tokens) else toDarkColorScheme(tokens) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/Tokens.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/Tokens.kt new file mode 100644 index 000000000..2c92db714 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/theme/tokens/Tokens.kt @@ -0,0 +1,301 @@ +package zed.rainxch.core.presentation.theme.tokens + +import androidx.compose.ui.graphics.Color + +object Tokens { + enum class Palette { DYNAMIC, NORD, CREAM, FOREST, PLUM } + + enum class Mode { LIGHT, DARK, AMOLED } + + data class PaletteColors( + val bg: Color, + val surface: Color, + val surface2: Color, + val ink: Color, + val ink2: Color, + val outline: Color, + val primary: Color, + val tintP: Color, + val success: Color, + val successT: Color, + val danger: Color, + val dangerT: Color, + ) + + object Nord { + val light = PaletteColors( + bg = Color(0xFFECEFF4), + surface = Color(0xFFF8FAFC), + surface2 = Color(0xFFE5E9F0), + ink = Color(0xFF2E3440), + ink2 = Color(0xFF4C566A), + outline = Color(0xFFD8DEE9), + primary = Color(0xFF5E81AC), + tintP = Color(0xFFD8E1EC), + success = Color(0xFFA3BE8C), + successT = Color(0xFFE1ECCF), + danger = Color(0xFFBF616A), + dangerT = Color(0xFFF2D6D7), + ) + val dark = PaletteColors( + bg = Color(0xFF242933), + surface = Color(0xFF2E3440), + surface2 = Color(0xFF3B4252), + ink = Color(0xFFECEFF4), + ink2 = Color(0xFFB8C0CC), + outline = Color(0xFF3B4252), + primary = Color(0xFF88C0D0), + tintP = Color(0xFF3B4252), + success = Color(0xFFA3BE8C), + successT = Color(0xFF3F4D3A), + danger = Color(0xFFBF616A), + dangerT = Color(0xFF4D2F32), + ) + val amoled = dark.copy( + bg = Color(0xFF000000), + surface = Color(0xFF0B0F14), + surface2 = Color(0xFF161B22), + ) + } + + object Cream { + val light = PaletteColors( + bg = Color(0xFFFBEFD8), + surface = Color(0xFFFFF9EC), + surface2 = Color(0xFFF3E4C6), + ink = Color(0xFF2B1F14), + ink2 = Color(0xFF7A6549), + outline = Color(0xFFE6D5B8), + primary = Color(0xFFB8542C), + tintP = Color(0xFFFFE7CB), + success = Color(0xFF7B8E4A), + successT = Color(0xFFE0E5CB), + danger = Color(0xFFB83A2C), + dangerT = Color(0xFFF3D7CF), + ) + val dark = PaletteColors( + bg = Color(0xFF241910), + surface = Color(0xFF332419), + surface2 = Color(0xFF473324), + ink = Color(0xFFFBEFD8), + ink2 = Color(0xFFC7B196), + outline = Color(0xFF473324), + primary = Color(0xFFE89968), + tintP = Color(0xFF473324), + success = Color(0xFFA3BE8C), + successT = Color(0xFF3F4D3A), + danger = Color(0xFFD26B5A), + dangerT = Color(0xFF4D2922), + ) + val amoled = dark.copy( + bg = Color(0xFF000000), + surface = Color(0xFF120A05), + surface2 = Color(0xFF1F140C), + ) + } + + object Forest { + val light = PaletteColors( + bg = Color(0xFFEDF1E8), + surface = Color(0xFFF7FAF3), + surface2 = Color(0xFFDDE5D2), + ink = Color(0xFF2D3A2C), + ink2 = Color(0xFF5A6A57), + outline = Color(0xFFCFD9C2), + primary = Color(0xFF6B8E5A), + tintP = Color(0xFFDCE7CE), + success = Color(0xFF6B8E5A), + successT = Color(0xFFDCE7CE), + danger = Color(0xFFB83A2C), + dangerT = Color(0xFFF3D6D2), + ) + val dark = PaletteColors( + bg = Color(0xFF1D241D), + surface = Color(0xFF272F26), + surface2 = Color(0xFF363F35), + ink = Color(0xFFEDF1E8), + ink2 = Color(0xFFB5C2AE), + outline = Color(0xFF363F35), + primary = Color(0xFFA3BE8C), + tintP = Color(0xFF363F35), + success = Color(0xFFA3BE8C), + successT = Color(0xFF3D4D3A), + danger = Color(0xFFD26B5A), + dangerT = Color(0xFF4D2922), + ) + val amoled = dark.copy( + bg = Color(0xFF000000), + surface = Color(0xFF0A0F0A), + surface2 = Color(0xFF131A12), + ) + } + + object Plum { + val light = PaletteColors( + bg = Color(0xFFEDE8EF), + surface = Color(0xFFF7F2F9), + surface2 = Color(0xFFDCD5E0), + ink = Color(0xFF322A36), + ink2 = Color(0xFF5C546A), + outline = Color(0xFFCFC8D6), + primary = Color(0xFF7E6BA8), + tintP = Color(0xFFDCD7E7), + success = Color(0xFF7B8E4A), + successT = Color(0xFFDBE5C7), + danger = Color(0xFFB83A2C), + dangerT = Color(0xFFF3D6D2), + ) + val dark = PaletteColors( + bg = Color(0xFF1D1A22), + surface = Color(0xFF26222D), + surface2 = Color(0xFF34303D), + ink = Color(0xFFEDE8EF), + ink2 = Color(0xFFB5ACBC), + outline = Color(0xFF34303D), + primary = Color(0xFFB39CD4), + tintP = Color(0xFF34303D), + success = Color(0xFFA3BE8C), + successT = Color(0xFF3F4D3A), + danger = Color(0xFFD26B5A), + dangerT = Color(0xFF4D2922), + ) + val amoled = dark.copy( + bg = Color(0xFF000000), + surface = Color(0xFF0C0A10), + surface2 = Color(0xFF161320), + ) + } + + fun palette(p: Palette, m: Mode): PaletteColors = when (p) { + Palette.DYNAMIC -> when (m) { + Mode.LIGHT -> Nord.light + Mode.DARK -> Nord.dark + Mode.AMOLED -> Nord.amoled + } + Palette.NORD -> when (m) { + Mode.LIGHT -> Nord.light + Mode.DARK -> Nord.dark + Mode.AMOLED -> Nord.amoled + } + Palette.CREAM -> when (m) { + Mode.LIGHT -> Cream.light + Mode.DARK -> Cream.dark + Mode.AMOLED -> Cream.amoled + } + Palette.FOREST -> when (m) { + Mode.LIGHT -> Forest.light + Mode.DARK -> Forest.dark + Mode.AMOLED -> Forest.amoled + } + Palette.PLUM -> when (m) { + Mode.LIGHT -> Plum.light + Mode.DARK -> Plum.dark + Mode.AMOLED -> Plum.amoled + } + } + + object Status { + object Freshness { + val hot = Color(0xFFE07856) + val fresh = Color(0xFF6BA068) + val warm = Color(0xFFC49652) + val cool = Color(0xFF8E8E8E) + val dormant = Color(0xFF9E9E9E) + } + object Wax { + val intact = Color(0xFF8B4A2B) + val cracked = Color(0xFFB83A2C) + val open = Color(0xFF8E8E8E) + } + object Perm { + val low = Color(0xFF6BA068) + val moderate = Color(0xFFC49652) + val high = Color(0xFFB83A2C) + } + object Trend { + val rising = Color(0xFF6BA068) + val flat = Color(0xFF8E8E8E) + val falling = Color(0xFFB83A2C) + } + } + + object Thresholds { + data class FreshnessBucket( + val maxDaysInclusive: Int?, + val state: String, + val ringFraction: Float, + val color: Color, + ) + val freshness = listOf( + FreshnessBucket(3, "hot", 1.00f, Status.Freshness.hot), + FreshnessBucket(30, "fresh", 0.78f, Status.Freshness.fresh), + FreshnessBucket(90, "warm", 0.55f, Status.Freshness.warm), + FreshnessBucket(365, "cool", 0.30f, Status.Freshness.cool), + FreshnessBucket(null, "dormant", 0.12f, Status.Freshness.dormant), + ) + + data class StarTier(val minStars: Int, val tier: Int) + val stars = listOf( + StarTier(0, 1), + StarTier(1000, 2), + StarTier(10000, 3), + StarTier(50000, 4), + StarTier(100000, 5), + ) + + data class MaintenanceBucket( + val maxDaysInclusive: Int?, + val state: String, + val heartbeatPeriodMs: Int?, + val color: Color, + ) + val maintenance = listOf( + MaintenanceBucket(1, "active", 1400, Status.Freshness.fresh), + MaintenanceBucket(7, "recent", 2400, Status.Freshness.fresh), + MaintenanceBucket(30, "quiet", 4200, Status.Freshness.warm), + MaintenanceBucket(null, "dormant", null, Status.Freshness.dormant), + ) + } + + object Motion { + const val tapHighlightMs = 120 + const val paletteCrossfadeMs = 250 + const val sheetSlideMs = 240 + const val scrimFadeMs = 180 + const val toastSlideMs = 200 + const val toastFadeMs = 240 + const val heartbeatScaleFrom = 1.0f + const val heartbeatScaleTo = 1.25f + const val heartbeatHaloFromScale = 1.0f + const val heartbeatHaloToScale = 2.4f + const val heartbeatHaloFromAlpha = 0.45f + const val heartbeatHaloToAlpha = 0.0f + } + + object Spacing { + val xs = 4 + val sm = 8 + val md = 12 + val lg = 16 + val xl = 24 + val xxl = 32 + } + + object Topics { + val supported = setOf( + "security", "privacy", "networking", "ai", "notes", + "audio", "video", "photo", "reader", + "messaging", "browser", "self-hosted", "backup", + "social", "launcher", + ) + } + + object Licenses { + val copyleft = setOf( + "AGPL-3.0", "GPL-3.0", "GPL-2.0", "LGPL-3.0", "LGPL-2.1", "MPL-2.0", + ) + val permissive = setOf( + "MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unlicense", + ) + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/AppThemeUtil.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/AppThemeUtil.kt index 9ab773176..8b450ea84 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/AppThemeUtil.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/AppThemeUtil.kt @@ -5,67 +5,43 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.AppTheme -import zed.rainxch.core.domain.model.AppTheme.AMBER -import zed.rainxch.core.domain.model.AppTheme.DYNAMIC -import zed.rainxch.core.domain.model.AppTheme.FOREST -import zed.rainxch.core.domain.model.AppTheme.OCEAN -import zed.rainxch.core.domain.model.AppTheme.PURPLE -import zed.rainxch.core.domain.model.AppTheme.SLATE -import zed.rainxch.core.presentation.theme.amberOrangeDark -import zed.rainxch.core.presentation.theme.amberOrangeLight -import zed.rainxch.core.presentation.theme.deepPurpleDark -import zed.rainxch.core.presentation.theme.deepPurpleLight -import zed.rainxch.core.presentation.theme.forestGreenDark -import zed.rainxch.core.presentation.theme.forestGreenLight -import zed.rainxch.core.presentation.theme.oceanBlueDark -import zed.rainxch.core.presentation.theme.oceanBlueLight -import zed.rainxch.core.presentation.theme.slateGrayDark -import zed.rainxch.core.presentation.theme.slateGrayLight -import zed.rainxch.githubstore.core.presentation.res.* +import zed.rainxch.core.presentation.theme.tokens.Tokens +import zed.rainxch.core.presentation.theme.tokens.colorSchemeFor +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.theme_cream +import zed.rainxch.githubstore.core.presentation.res.theme_dynamic +import zed.rainxch.githubstore.core.presentation.res.theme_forest +import zed.rainxch.githubstore.core.presentation.res.theme_nord +import zed.rainxch.githubstore.core.presentation.res.theme_plum -val AppTheme.lightScheme: ColorScheme? - get() = - when (this) { - DYNAMIC -> null - OCEAN -> oceanBlueLight - PURPLE -> deepPurpleLight - FOREST -> forestGreenLight - SLATE -> slateGrayLight - AMBER -> amberOrangeLight - } +fun AppTheme.toTokenPalette(): Tokens.Palette = when (this) { + AppTheme.DYNAMIC -> Tokens.Palette.DYNAMIC + AppTheme.NORD -> Tokens.Palette.NORD + AppTheme.CREAM -> Tokens.Palette.CREAM + AppTheme.FOREST -> Tokens.Palette.FOREST + AppTheme.PLUM -> Tokens.Palette.PLUM +} -val AppTheme.darkScheme: ColorScheme? - get() = - when (this) { - DYNAMIC -> null - OCEAN -> oceanBlueDark - PURPLE -> deepPurpleDark - FOREST -> forestGreenDark - SLATE -> slateGrayDark - AMBER -> amberOrangeDark - } +val AppTheme.lightScheme: ColorScheme + get() = colorSchemeFor(toTokenPalette(), Tokens.Mode.LIGHT) -val AppTheme.primaryColor: Color? - get() = - when (this) { - DYNAMIC -> null - OCEAN -> Color(0xFF2A638A) - PURPLE -> Color(0xFF6750A4) - FOREST -> Color(0xFF356859) - SLATE -> Color(0xFF535E6C) - AMBER -> Color(0xFF8B5000) - } +val AppTheme.darkScheme: ColorScheme + get() = colorSchemeFor(toTokenPalette(), Tokens.Mode.DARK) + +val AppTheme.amoledScheme: ColorScheme + get() = colorSchemeFor(toTokenPalette(), Tokens.Mode.AMOLED) + +val AppTheme.primaryColor: Color + get() = Tokens.palette(toTokenPalette(), Tokens.Mode.LIGHT).primary val AppTheme.displayName: String @Composable - get() = - stringResource( - when (this) { - DYNAMIC -> Res.string.theme_dynamic - OCEAN -> Res.string.theme_ocean - PURPLE -> Res.string.theme_purple - FOREST -> Res.string.theme_forest - SLATE -> Res.string.theme_slate - AMBER -> Res.string.theme_amber - }, - ) + get() = stringResource( + when (this) { + AppTheme.DYNAMIC -> Res.string.theme_dynamic + AppTheme.NORD -> Res.string.theme_nord + AppTheme.CREAM -> Res.string.theme_cream + AppTheme.FOREST -> Res.string.theme_forest + AppTheme.PLUM -> Res.string.theme_plum + }, + ) diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/ArrowKeyScroll.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/ArrowKeyScroll.kt index fe490c935..f513fb335 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/ArrowKeyScroll.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/ArrowKeyScroll.kt @@ -22,9 +22,6 @@ import kotlinx.coroutines.launch private const val ARROW_STEP_PX = 120f private const val PAGE_STEP_FRACTION = 0.9f -// When `autoFocus = true`, the modifier requests keyboard focus on first -// composition so arrow keys work without a prior click/tab. Pass `false` on -// screens where a TextField should keep focus (e.g. search inputs). @Composable fun Modifier.arrowKeyScroll( listState: LazyListState, @@ -38,9 +35,7 @@ fun Modifier.arrowKeyScroll( scrollToBottom = { val last = (listState.layoutInfo.totalItemsCount - 1).coerceAtLeast(0) listState.animateScrollToItem(last) - // `animateScrollToItem` aligns the target to the viewport start, - // which can leave empty space after the last item. A follow-up - // large-delta scroll is clamped to the real end. + listState.animateScrollBy(Float.MAX_VALUE) }, ) diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/ConstrainedContentWidth.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/ConstrainedContentWidth.kt new file mode 100644 index 000000000..8e9ca5b6a --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/ConstrainedContentWidth.kt @@ -0,0 +1,20 @@ +package zed.rainxch.core.presentation.utils + +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import zed.rainxch.core.domain.model.ContentWidth +import zed.rainxch.core.presentation.locals.LocalContentWidth + +@Composable +@ReadOnlyComposable +fun Modifier.constrainedContentWidth(): Modifier { + val cap = when (LocalContentWidth.current) { + ContentWidth.COMPACT -> 680.dp + ContentWidth.WIDE -> 960.dp + ContentWidth.EXTRA_WIDE -> null + } + return if (cap != null) widthIn(max = cap) else this +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/CountFormatter.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/CountFormatter.kt index d898f62fd..4dc386512 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/CountFormatter.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/CountFormatter.kt @@ -11,3 +11,11 @@ fun formatCount(count: Int): String = count >= 1000 -> stringResource(Res.string.count_thousands, count / 1000) else -> count.toString() } + +@Composable +fun formatCount(count: Long): String = + when { + count >= 1_000_000L -> stringResource(Res.string.count_millions, (count / 1_000_000L).toInt()) + count >= 1000L -> stringResource(Res.string.count_thousands, (count / 1000L).toInt()) + else -> count.toString() + } diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt index 0f4241ce5..6f39b5d90 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt @@ -14,15 +14,11 @@ import kotlin.time.Instant @OptIn(ExperimentalTime::class) private fun parseIsoInstantLenient(isoInstant: String): Instant? { - // Trim up front so `Instant.parse` doesn't choke on surrounding - // whitespace (which it treats as invalid) while `isBlank()` was - // already masking the empty-after-trim case. + val trimmed = isoInstant.trim() if (trimmed.isEmpty()) return null runCatching { return Instant.parse(trimmed) } - // Backend occasionally returns timestamps without seconds (e.g. "2024-10-16T17:00Z"). - // Retry after inserting ":00" before the timezone designator. val normalized = runCatching { val tzStart = trimmed.indexOfAny(charArrayOf('Z', '+', '-'), startIndex = 11) if (tzStart < 0) return@runCatching null diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/AppAccent.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/AppAccent.kt new file mode 100644 index 000000000..f5799aeaa --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/AppAccent.kt @@ -0,0 +1,101 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.ui.graphics.Color + +data class AppAccent(val c: Color, val lt: Color, val dtAlpha: Float = 0.22f) { + fun tintFor(isDark: Boolean): Color = if (isDark) c.copy(alpha = dtAlpha) else lt +} + +object AppAccentResolver { + private val FALLBACK = AppAccent(c = Color(0xFF5E81AC), lt = Color(0xFFD8E1EC)) + + private val TOPIC_ACCENTS: Map = buildMap { + + val photo = AppAccent(Color(0xFF5E81AC), Color(0xFFD8E1EC)) + listOf("photo", "photos", "gallery").forEach { put(it, photo) } + + val manga = AppAccent(Color(0xFF7E6BA8), Color(0xFFDCD7E7)) + listOf("manga", "comic", "reader").forEach { put(it, manga) } + + val security = AppAccent(Color(0xFF4C6E96), Color(0xFFCFDAE7)) + listOf("password-manager", "security", "vault").forEach { put(it, security) } + + val audio = AppAccent(Color(0xFF6B8E5A), Color(0xFFDCE7CE)) + listOf("podcast", "audio", "music").forEach { put(it, audio) } + + val book = AppAccent(Color(0xFF9B6B3C), Color(0xFFEDD9BB)) + listOf("book", "ebook", "koreader").forEach { put(it, book) } + + val messaging = AppAccent(Color(0xFFA35365), Color(0xFFEFCDD3)) + listOf("messaging", "chat", "signal").forEach { put(it, messaging) } + + val vpn = AppAccent(Color(0xFF5C7A8E), Color(0xFFD5DEE5)) + listOf("vpn", "network", "proxy").forEach { put(it, vpn) } + + val notes = AppAccent(Color(0xFF7A6549), Color(0xFFE5D9C6)) + listOf("note", "notes", "markdown").forEach { put(it, notes) } + + val backup = AppAccent(Color(0xFF5A6A57), Color(0xFFCBD3C9)) + listOf("backup", "sync").forEach { put(it, backup) } + + val selfHosted = AppAccent(Color(0xFF356859), Color(0xFFB8E0D2)) + listOf("self-hosted", "home-server").forEach { put(it, selfHosted) } + + val video = AppAccent(Color(0xFFB8542C), Color(0xFFFFE7CB)) + listOf("video", "media").forEach { put(it, video) } + } + + private val LANGUAGE_ACCENTS: Map = mapOf( + "Kotlin" to AppAccent(Color(0xFF7E6BA8), Color(0xFFDCD7E7)), + "Java" to AppAccent(Color(0xFFB8542C), Color(0xFFFFE7CB)), + "TypeScript" to AppAccent(Color(0xFF5E81AC), Color(0xFFD8E1EC)), + "JavaScript" to AppAccent(Color(0xFF5E81AC), Color(0xFFD8E1EC)), + "Python" to AppAccent(Color(0xFF356859), Color(0xFFB8E0D2)), + "Rust" to AppAccent(Color(0xFFA35346), Color(0xFFEDCFC9)), + "Go" to AppAccent(Color(0xFF5C7A8E), Color(0xFFD5DEE5)), + "C" to AppAccent(Color(0xFF7A6549), Color(0xFFE5D9C6)), + "C++" to AppAccent(Color(0xFF7A6549), Color(0xFFE5D9C6)), + "Swift" to AppAccent(Color(0xFFB8542C), Color(0xFFFFE7CB)), + "Dart" to AppAccent(Color(0xFF5E81AC), Color(0xFFD8E1EC)), + "Ruby" to AppAccent(Color(0xFFB83A2C), Color(0xFFF3D7CF)), + "Shell" to AppAccent(Color(0xFF6B8E5A), Color(0xFFDCE7CE)), + "Bash" to AppAccent(Color(0xFF6B8E5A), Color(0xFFDCE7CE)), + ) + + fun resolve( + backendHex: String? = null, + topics: List = emptyList(), + primaryLanguage: String? = null, + ): AppAccent { + + backendHex?.let { parseHexAccent(it) }?.let { return it } + + topics.forEach { t -> + TOPIC_ACCENTS[t.lowercase()]?.let { return it } + } + + primaryLanguage?.let { LANGUAGE_ACCENTS[it] }?.let { return it } + + return FALLBACK + } + + private fun parseHexAccent(hex: String): AppAccent? { + val cleaned = hex.removePrefix("#").trim() + if (cleaned.length != 6 && cleaned.length != 8) return null + val rgb = runCatching { + val intVal = cleaned.toLong(16) + if (cleaned.length == 6) (0xFF000000 or intVal).toULong() else intVal.toULong() + }.getOrNull() ?: return null + val c = Color(rgb.toLong()) + + val lt = blendTowardWhite(c, 0.78f) + return AppAccent(c, lt) + } + + private fun blendTowardWhite(c: Color, factor: Float): Color = Color( + red = c.red + (1f - c.red) * factor, + green = c.green + (1f - c.green) * factor, + blue = c.blue + (1f - c.blue) * factor, + alpha = c.alpha, + ) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/CookieShape.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/CookieShape.kt new file mode 100644 index 000000000..7842826de --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/CookieShape.kt @@ -0,0 +1,34 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection + +object CookieShape : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline { + val sx = size.width / 100f + val sy = size.height / 100f + val path = Path().apply { + moveTo(50f * sx, 4f * sy) + cubicTo(62f * sx, 4f * sy, 66f * sx, 12f * sy, 76f * sx, 12f * sy) + cubicTo(86f * sx, 12f * sy, 91f * sx, 22f * sy, 91f * sx, 32f * sy) + cubicTo(95f * sx, 40f * sy, 100f * sx, 50f * sy, 94f * sx, 58f * sy) + cubicTo(96f * sx, 70f * sy, 90f * sx, 82f * sy, 80f * sx, 86f * sy) + cubicTo(72f * sx, 90f * sy, 64f * sx, 96f * sy, 54f * sx, 96f * sy) + cubicTo(44f * sx, 96f * sy, 36f * sx, 95f * sy, 26f * sx, 92f * sy) + cubicTo(16f * sx, 90f * sy, 10f * sx, 80f * sy, 8f * sx, 70f * sy) + cubicTo(4f * sx, 62f * sy, 0f, 54f * sy, 6f * sx, 46f * sy) + cubicTo(6f * sx, 34f * sy, 12f * sx, 22f * sy, 22f * sx, 18f * sy) + cubicTo(32f * sx, 12f * sy, 38f * sx, 4f * sy, 50f * sx, 4f * sy) + close() + } + return Outline.Generic(path) + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/DownloadWeight.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/DownloadWeight.kt new file mode 100644 index 000000000..9bcd8a2c0 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/DownloadWeight.kt @@ -0,0 +1,39 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import kotlin.math.log10 +import kotlin.math.min + +@Composable +fun DownloadWeight( + downloads: Long, + modifier: Modifier = Modifier, + sizeDp: Int = 16, +) { + val ringColor = MaterialTheme.colorScheme.outline + val dotColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f) + val log = log10(downloads.coerceAtLeast(1L).toDouble()).toFloat() + Canvas(modifier = modifier.size(sizeDp.dp)) { + val cx = size.width / 2f + val cy = size.height / 2f + val ringRadius = size.width / 2f - 1.dp.toPx() + val dotRadius = min(size.width / 2f, 2.dp.toPx() + log * 1.6.dp.toPx()) + drawCircle( + color = ringColor, + radius = ringRadius, + center = androidx.compose.ui.geometry.Offset(cx, cy), + style = Stroke(width = 1.dp.toPx()), + ) + drawCircle( + color = dotColor, + radius = dotRadius, + center = androidx.compose.ui.geometry.Offset(cx, cy), + ) + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/Freshness.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/Freshness.kt new file mode 100644 index 000000000..2538ad404 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/Freshness.kt @@ -0,0 +1,22 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.ui.graphics.Color +import zed.rainxch.core.presentation.theme.tokens.Tokens + +enum class FreshnessState { HOT, FRESH, WARM, COOL, DORMANT } + +data class Freshness(val state: FreshnessState, val color: Color, val ringFraction: Float) + +fun freshnessOf(daysSinceRelease: Int): Freshness { + val b = Tokens.Thresholds.freshness.first { + it.maxDaysInclusive == null || daysSinceRelease <= it.maxDaysInclusive + } + val state = when (b.state) { + "hot" -> FreshnessState.HOT + "fresh" -> FreshnessState.FRESH + "warm" -> FreshnessState.WARM + "cool" -> FreshnessState.COOL + else -> FreshnessState.DORMANT + } + return Freshness(state, b.color, b.ringFraction) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/FreshnessRing.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/FreshnessRing.kt new file mode 100644 index 000000000..31a4576af --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/FreshnessRing.kt @@ -0,0 +1,83 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp + +@Composable +fun FreshnessRing( + daysSinceRelease: Int, + modifier: Modifier = Modifier, + sizeDp: Int = 64, + strokeDp: Float = if (sizeDp >= 60) 2.5f else 2f, + color: Color? = null, + content: @Composable () -> Unit, +) { + val f = freshnessOf(daysSinceRelease) + val ringColor = color ?: f.color + val ringSize = sizeDp + 14 + Box( + modifier = modifier.size(ringSize.dp), + contentAlignment = Alignment.Center, + ) { + FreshnessArc( + color = ringColor, + fraction = f.ringFraction, + strokeDp = strokeDp, + modifier = Modifier.size(ringSize.dp), + ) + Box( + modifier = Modifier.size(sizeDp.dp), + contentAlignment = Alignment.Center, + ) { + content() + } + } +} + +@Composable +private fun FreshnessArc( + color: Color, + fraction: Float, + strokeDp: Float, + modifier: Modifier, +) { + Canvas(modifier = modifier) { + val sw = strokeDp.dp.toPx() + val radius = (size.minDimension - sw - 2.dp.toPx()) / 2f + val topLeft = Offset( + (size.width - radius * 2) / 2f, + (size.height - radius * 2) / 2f, + ) + val arcSize = Size(radius * 2, radius * 2) + + drawArc( + color = color.copy(alpha = 0.14f), + startAngle = 0f, + sweepAngle = 360f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = sw, cap = StrokeCap.Round), + ) + + drawArc( + color = color, + startAngle = -90f, + sweepAngle = 360f * fraction, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = sw, cap = StrokeCap.Round), + ) + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/Heartbeat.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/Heartbeat.kt new file mode 100644 index 000000000..13c4124a6 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/Heartbeat.kt @@ -0,0 +1,116 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.LocalMotion +import zed.rainxch.core.presentation.theme.LocalStatusColors + +@Composable +fun Heartbeat( + daysSinceCommit: Int, + modifier: Modifier = Modifier, + sizeDp: Int = 8, +) { + val status = LocalStatusColors.current + val motion = LocalMotion.current + val periodMs = heartbeatPeriodMs(daysSinceCommit) + val color = heartbeatColor(daysSinceCommit, status) + + if (periodMs == null) { + + Box( + modifier = modifier + .size(sizeDp.dp) + .background(color.copy(alpha = 0.4f), CircleShape), + ) + return + } + + val transition = rememberInfiniteTransition(label = "heartbeat") + val scale by transition.animateFloat( + initialValue = motion.heartbeatScaleFrom, + targetValue = motion.heartbeatScaleTo, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = periodMs, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "heartbeat-scale", + ) + val haloScale by transition.animateFloat( + initialValue = motion.heartbeatHaloFromScale, + targetValue = motion.heartbeatHaloToScale, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = periodMs, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "heartbeat-halo-scale", + ) + val haloAlpha by transition.animateFloat( + initialValue = motion.heartbeatHaloFromAlpha, + targetValue = motion.heartbeatHaloToAlpha, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = periodMs, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "heartbeat-halo-alpha", + ) + + Box( + modifier = modifier.size(sizeDp.dp), + contentAlignment = Alignment.Center, + ) { + + Box( + modifier = Modifier + .size(sizeDp.dp) + .drawBehind { + val r = (size.minDimension / 2f) * haloScale + drawCircle( + color = color.copy(alpha = haloAlpha), + radius = r, + center = Offset(size.width / 2f, size.height / 2f), + ) + }, + ) + + Box( + modifier = Modifier + .size(sizeDp.dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .background(color, CircleShape), + ) + } +} + +private fun heartbeatPeriodMs(days: Int): Int? = when { + days <= 1 -> 1400 + days <= 7 -> 2400 + days <= 30 -> 4200 + else -> null +} + +private fun heartbeatColor(days: Int, status: zed.rainxch.core.presentation.theme.StatusColors): Color = when { + days <= 7 -> status.freshnessFresh + days <= 30 -> status.freshnessWarm + else -> status.freshnessDormant +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/LicensePosture.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/LicensePosture.kt new file mode 100644 index 000000000..f35e2652e --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/LicensePosture.kt @@ -0,0 +1,52 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.theme.geistMono +import zed.rainxch.core.presentation.theme.tokens.Tokens + +@Composable +fun LicensePosture( + spdx: String?, + modifier: Modifier = Modifier, + sizeDp: Int = 14, +) { + val heavy = spdx != null && spdx in Tokens.Licenses.copyleft + val ink = MaterialTheme.colorScheme.onSurface + val bg = MaterialTheme.colorScheme.background + val mono = geistMono + Box( + modifier = modifier + .size(sizeDp.dp) + .background( + color = if (heavy) ink else Color.Transparent, + shape = RoundedCornerShape(3.dp), + ) + .border( + width = 1.4.dp, + color = ink, + shape = RoundedCornerShape(3.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (heavy) "©" else "·", + color = if (heavy) bg else ink, + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = (sizeDp * 0.55f).sp, + ) + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/PermDot.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/PermDot.kt new file mode 100644 index 000000000..4223c4f99 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/PermDot.kt @@ -0,0 +1,41 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.LocalStatusColors + +enum class PermLevel { LOW, MODERATE, HIGH } + +@Composable +fun PermDot( + level: PermLevel, + modifier: Modifier = Modifier, + size: Int = 8, + ring: Boolean = false, +) { + val status = LocalStatusColors.current + val color = when (level) { + PermLevel.LOW -> status.permLow + PermLevel.MODERATE -> status.permModerate + PermLevel.HIGH -> status.permHigh + } + val ringExtraDp = if (ring) 6 else 0 + Canvas(modifier = modifier.size((size + ringExtraDp).dp)) { + val cx = this.size.width / 2f + val cy = this.size.height / 2f + val dotRadius = size.dp.toPx() / 2f + if (ring) { + drawCircle( + color = color.copy(alpha = 0.2f), + radius = dotRadius + 3.dp.toPx(), + center = Offset(cx, cy), + ) + } + drawCircle(color = color, radius = dotRadius, center = Offset(cx, cy)) + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/PlatformGlyph.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/PlatformGlyph.kt new file mode 100644 index 000000000..81bdfaede --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/PlatformGlyph.kt @@ -0,0 +1,205 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp + +enum class PlatformKind { ANDROID, WINDOWS, MACOS, LINUX } + +@Composable +fun PlatformGlyph( + kind: PlatformKind, + supported: Boolean, + modifier: Modifier = Modifier, + sizeDp: Int = 18, +) { + val ink = MaterialTheme.colorScheme.onSurface + val ink2 = MaterialTheme.colorScheme.onSurfaceVariant + val bg = MaterialTheme.colorScheme.background + val color = if (supported) ink else ink2 + val alpha = if (supported) 1f else 0.32f + Canvas(modifier = modifier.size(sizeDp.dp)) { + when (kind) { + PlatformKind.ANDROID -> drawAndroid(color, bg, supported, alpha) + PlatformKind.WINDOWS -> drawWindows(color, supported, alpha) + PlatformKind.MACOS -> drawMacos(color, supported, alpha) + PlatformKind.LINUX -> drawLinux(color, bg, supported, alpha) + } + } +} + +private fun DrawScope.drawAndroid(c: Color, bg: Color, on: Boolean, alpha: Float) { + val s = size.minDimension + val dash = if (on) null else PathEffect.dashPathEffect(floatArrayOf(2.dp.toPx(), 2.dp.toPx())) + val rect = androidx.compose.ui.geometry.Rect( + offset = Offset(s * (6f / 24f), s * (3.5f / 24f)), + size = Size(s * (12f / 24f), s * (17f / 24f)), + ) + val cr = CornerRadius(s * (2.6f / 24f), s * (2.6f / 24f)) + if (on) { + drawRoundRect(color = c.copy(alpha = alpha), topLeft = rect.topLeft, size = rect.size, cornerRadius = cr) + } else { + drawRoundRect( + color = c.copy(alpha = alpha), + topLeft = rect.topLeft, + size = rect.size, + cornerRadius = cr, + style = Stroke(width = 1.4f.dp.toPx(), pathEffect = dash), + ) + } + drawCircle( + color = if (on) bg else Color.Transparent, + radius = s * (0.9f / 24f), + center = Offset(s * (12f / 24f), s * (17.5f / 24f)), + ) + if (!on) { + drawCircle( + color = c.copy(alpha = alpha), + radius = s * (0.9f / 24f), + center = Offset(s * (12f / 24f), s * (17.5f / 24f)), + style = Stroke(width = 1f.dp.toPx()), + ) + } +} + +private fun DrawScope.drawWindows(c: Color, on: Boolean, alpha: Float) { + val s = size.minDimension + val dash = if (on) null else PathEffect.dashPathEffect(floatArrayOf(2.dp.toPx(), 2.dp.toPx())) + val cellSize = Size(s * (6f / 24f), s * (6f / 24f)) + val cr = CornerRadius(s * (0.5f / 24f), s * (0.5f / 24f)) + listOf(5f to 5f, 13f to 5f, 5f to 13f, 13f to 13f).forEach { (x, y) -> + val tl = Offset(s * (x / 24f), s * (y / 24f)) + if (on) { + drawRoundRect(color = c.copy(alpha = alpha), topLeft = tl, size = cellSize, cornerRadius = cr) + } else { + drawRoundRect( + color = c.copy(alpha = alpha), + topLeft = tl, + size = cellSize, + cornerRadius = cr, + style = Stroke(width = 1.4f.dp.toPx(), pathEffect = dash), + ) + } + } +} + +private fun DrawScope.drawMacos(c: Color, on: Boolean, alpha: Float) { + val s = size.minDimension + val u = s / 24f + val dash = if (on) null else PathEffect.dashPathEffect(floatArrayOf(2.dp.toPx(), 2.dp.toPx())) + + val body = Path().apply { + moveTo(7f * u, 7.5f * u) + cubicTo(4.5f * u, 7.5f * u, 2.5f * u, 10f * u, 2.5f * u, 13.5f * u) + cubicTo(2.5f * u, 17.5f * u, 4.5f * u, 21.5f * u, 7f * u, 21.5f * u) + cubicTo(8.2f * u, 21.5f * u, 9f * u, 20.8f * u, 10.5f * u, 20.8f * u) + cubicTo(12f * u, 20.8f * u, 12.5f * u, 21.5f * u, 14f * u, 21.5f * u) + cubicTo(15.3f * u, 21.5f * u, 16f * u, 21f * u, 17f * u, 20f * u) + cubicTo(18.5f * u, 18.4f * u, 19f * u, 16.5f * u, 19f * u, 16.4f * u) + cubicTo(18.9f * u, 16.4f * u, 16.5f * u, 15.5f * u, 16.5f * u, 12.8f * u) + cubicTo(16.5f * u, 10.6f * u, 18.3f * u, 9.4f * u, 18.4f * u, 9.3f * u) + cubicTo(17.3f * u, 7.7f * u, 15.6f * u, 7.5f * u, 15f * u, 7.5f * u) + cubicTo(13.5f * u, 7.4f * u, 12.2f * u, 8.3f * u, 11.5f * u, 8.3f * u) + cubicTo(10.8f * u, 8.3f * u, 9.7f * u, 7.5f * u, 8.5f * u, 7.5f * u) + cubicTo(8f * u, 7.5f * u, 7.5f * u, 7.5f * u, 7f * u, 7.5f * u) + close() + } + val leaf = Path().apply { + moveTo(11.4f * u, 6f * u) + cubicTo(12f * u, 4.6f * u, 13.1f * u, 3.8f * u, 14f * u, 3.6f * u) + cubicTo(14.3f * u, 4.7f * u, 14f * u, 6f * u, 13.3f * u, 6.9f * u) + cubicTo(12.7f * u, 7.7f * u, 11.7f * u, 8.2f * u, 10.8f * u, 8.2f * u) + cubicTo(10.6f * u, 7.4f * u, 10.9f * u, 6.7f * u, 11.4f * u, 6f * u) + close() + } + if (on) { + drawPath(body, c.copy(alpha = alpha)) + drawPath(leaf, c.copy(alpha = alpha)) + } else { + val stroke = Stroke(width = 1.4f.dp.toPx(), pathEffect = dash, join = StrokeJoin.Round) + drawPath(body, c.copy(alpha = alpha), style = stroke) + drawPath(leaf, c.copy(alpha = alpha), style = stroke) + } +} + +private fun DrawScope.drawLinux(c: Color, bg: Color, on: Boolean, alpha: Float) { + val s = size.minDimension + val u = s / 24f + val dash = if (on) null else PathEffect.dashPathEffect(floatArrayOf(2.dp.toPx(), 2.dp.toPx())) + + val body = Path().apply { + moveTo(12f * u, 2.5f * u) + cubicTo(9f * u, 2.5f * u, 7.5f * u, 4.5f * u, 7.5f * u, 7.5f * u) + cubicTo(7.5f * u, 8.5f * u, 7.8f * u, 9.2f * u, 8.1f * u, 9.8f * u) + cubicTo(6.5f * u, 10.3f * u, 5.2f * u, 12.2f * u, 5.2f * u, 14.5f * u) + cubicTo(5.2f * u, 17.5f * u, 6.2f * u, 20f * u, 8f * u, 21f * u) + cubicTo(9f * u, 21.7f * u, 10.5f * u, 22f * u, 12f * u, 22f * u) + cubicTo(13.5f * u, 22f * u, 15f * u, 21.7f * u, 16f * u, 21f * u) + cubicTo(17.8f * u, 20f * u, 18.8f * u, 17.5f * u, 18.8f * u, 14.5f * u) + cubicTo(18.8f * u, 12.2f * u, 17.5f * u, 10.3f * u, 15.9f * u, 9.8f * u) + cubicTo(16.2f * u, 9.2f * u, 16.5f * u, 8.5f * u, 16.5f * u, 7.5f * u) + cubicTo(16.5f * u, 4.5f * u, 15f * u, 2.5f * u, 12f * u, 2.5f * u) + close() + } + + val belly = Path().apply { + moveTo(12f * u, 12.5f * u) + cubicTo(10.3f * u, 12.5f * u, 9.5f * u, 14.5f * u, 9.5f * u, 16.8f * u) + cubicTo(9.5f * u, 19f * u, 10.5f * u, 20.5f * u, 12f * u, 20.5f * u) + cubicTo(13.5f * u, 20.5f * u, 14.5f * u, 19f * u, 14.5f * u, 16.8f * u) + cubicTo(14.5f * u, 14.5f * u, 13.7f * u, 12.5f * u, 12f * u, 12.5f * u) + close() + } + + val beak = Path().apply { + moveTo(10.4f * u, 7.8f * u) + lineTo(13.6f * u, 7.8f * u) + lineTo(12f * u, 9.4f * u) + close() + } + + val leftFoot = Path().apply { + moveTo(7f * u, 21.5f * u) + cubicTo(5.5f * u, 21.5f * u, 4.5f * u, 22f * u, 4.5f * u, 22.6f * u) + cubicTo(4.5f * u, 23.2f * u, 6.5f * u, 23.5f * u, 8.5f * u, 23f * u) + cubicTo(9.5f * u, 22.7f * u, 9.5f * u, 21.8f * u, 9f * u, 21.4f * u) + close() + } + val rightFoot = Path().apply { + moveTo(17f * u, 21.5f * u) + cubicTo(18.5f * u, 21.5f * u, 19.5f * u, 22f * u, 19.5f * u, 22.6f * u) + cubicTo(19.5f * u, 23.2f * u, 17.5f * u, 23.5f * u, 15.5f * u, 23f * u) + cubicTo(14.5f * u, 22.7f * u, 14.5f * u, 21.8f * u, 15f * u, 21.4f * u) + close() + } + + if (on) { + drawPath(leftFoot, c.copy(alpha = alpha)) + drawPath(rightFoot, c.copy(alpha = alpha)) + drawPath(body, c.copy(alpha = alpha)) + drawPath(belly, bg) + drawPath(beak, c.copy(alpha = alpha)) + drawCircle(bg, s * (0.85f / 24f), Offset(10.4f * u, 6.4f * u)) + drawCircle(bg, s * (0.85f / 24f), Offset(13.6f * u, 6.4f * u)) + drawCircle(c.copy(alpha = alpha), s * (0.35f / 24f), Offset(10.4f * u, 6.4f * u)) + drawCircle(c.copy(alpha = alpha), s * (0.35f / 24f), Offset(13.6f * u, 6.4f * u)) + } else { + val stroke = Stroke(width = 1.4f.dp.toPx(), pathEffect = dash, join = StrokeJoin.Round) + drawPath(leftFoot, c.copy(alpha = alpha), style = stroke) + drawPath(rightFoot, c.copy(alpha = alpha), style = stroke) + drawPath(body, c.copy(alpha = alpha), style = stroke) + drawPath(belly, c.copy(alpha = alpha), style = Stroke(width = 1f.dp.toPx(), pathEffect = dash)) + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/SignalBars.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/SignalBars.kt new file mode 100644 index 000000000..8f6eb043d --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/SignalBars.kt @@ -0,0 +1,40 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.LocalStatusColors + +@Composable +fun SignalBars( + level: Int, + modifier: Modifier = Modifier, + sizeDp: Int = 14, +) { + val activeColor = LocalStatusColors.current.freshnessFresh + val inactiveColor = MaterialTheme.colorScheme.outline + val clampedLevel = level.coerceIn(0, 4) + Canvas(modifier = modifier.size(width = (sizeDp + 4).dp, height = sizeDp.dp)) { + val barW = 2.5.dp.toPx() + val gap = 1.5.dp.toPx() + val cornerR = 1.dp.toPx() + for (i in 0 until 4) { + val barH = size.height * (0.3f + (i + 1) * 0.18f) + val left = i * (barW + gap) + val top = size.height - barH + val c = if (i < clampedLevel) activeColor else inactiveColor + drawRoundRect( + color = c, + topLeft = Offset(left, top), + size = Size(barW, barH), + cornerRadius = CornerRadius(cornerR, cornerR), + ) + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/Squiggle.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/Squiggle.kt new file mode 100644 index 000000000..5024568a5 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/Squiggle.kt @@ -0,0 +1,44 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp + +@Composable +fun Squiggle( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), +) { + Canvas(modifier = modifier.size(width = 40.dp, height = 5.dp)) { + val sx = size.width / 40f + val sy = size.height / 5f + val path = Path().apply { + moveTo(1f * sx, 3f * sy) + + quadraticTo(5f * sx, 0.5f * sy, 9f * sx, 3f * sy) + smoothQuadTo(17f * sx, 3f * sy, prevControl = Offset(5f * sx, 0.5f * sy), prevEnd = Offset(9f * sx, 3f * sy)) + smoothQuadTo(25f * sx, 3f * sy, prevControl = Offset(13f * sx, 5.5f * sy), prevEnd = Offset(17f * sx, 3f * sy)) + smoothQuadTo(33f * sx, 3f * sy, prevControl = Offset(21f * sx, 0.5f * sy), prevEnd = Offset(25f * sx, 3f * sy)) + smoothQuadTo(39f * sx, 3f * sy, prevControl = Offset(29f * sx, 5.5f * sy), prevEnd = Offset(33f * sx, 3f * sy)) + } + drawPath( + path = path, + color = color, + style = Stroke(width = 1.6f.dp.toPx(), cap = StrokeCap.Round), + ) + } +} + +private fun Path.smoothQuadTo(toX: Float, toY: Float, prevControl: Offset, prevEnd: Offset) { + val reflectedX = 2f * prevEnd.x - prevControl.x + val reflectedY = 2f * prevEnd.y - prevControl.y + quadraticTo(reflectedX, reflectedY, toX, toY) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/StarTier.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/StarTier.kt new file mode 100644 index 000000000..bf46f6285 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/StarTier.kt @@ -0,0 +1,47 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun StarTier( + stars: Int, + modifier: Modifier = Modifier, + size: Int = 11, + activeColor: Color = Color(0xFFC49652), + inactiveColor: Color = MaterialTheme.colorScheme.outline, +) { + val tier = starTierOf(stars) + val style = TextStyle(fontSize = size.sp) + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(1.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + for (i in 1..5) { + Text( + text = "★", + color = if (i <= tier) activeColor else inactiveColor, + style = style, + ) + } + } +} + +fun starTierOf(stars: Int): Int = when { + stars >= 100_000 -> 5 + stars >= 50_000 -> 4 + stars >= 10_000 -> 3 + stars >= 1_000 -> 2 + else -> 1 +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/TopicGlyph.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/TopicGlyph.kt new file mode 100644 index 000000000..794cb4681 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/TopicGlyph.kt @@ -0,0 +1,312 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.tokens.Tokens + +@Composable +fun TopicGlyph( + topic: String, + modifier: Modifier = Modifier, + sizeDp: Int = 14, + color: Color = LocalContentColor.current, +) { + val key = topic.lowercase() + if (key !in Tokens.Topics.supported) return + Canvas(modifier = modifier.size(sizeDp.dp)) { + val sw = 1.7f.dp.toPx() + val stroke = Stroke(width = sw, cap = StrokeCap.Round, join = StrokeJoin.Round) + when (key) { + "security" -> drawSecurity(color, stroke) + "privacy" -> drawPrivacy(color, stroke) + "networking" -> drawNetworking(color, stroke) + "ai" -> drawAi(color, stroke) + "notes" -> drawNotes(color, stroke) + "audio" -> drawAudio(color) + "video" -> drawVideo(color, stroke) + "photo" -> drawPhoto(color, stroke) + "reader" -> drawReader(color, stroke) + "messaging" -> drawMessaging(color, stroke) + "browser" -> drawBrowser(color, stroke) + "self-hosted" -> drawSelfHosted(color, stroke) + "backup" -> drawBackup(color, stroke) + "social" -> drawSocial(color, stroke) + "launcher" -> drawLauncher(color) + } + } +} + +private fun DrawScope.scaled(viewBoxValue: Float) = viewBoxValue / 24f * size.minDimension + +private fun DrawScope.drawSecurity(c: Color, s: Stroke) { + + val shackle = Path().apply { + moveTo(scaled(8f), scaled(11f)) + lineTo(scaled(8f), scaled(8f)) + cubicTo(scaled(8f), scaled(4f), scaled(16f), scaled(4f), scaled(16f), scaled(8f)) + lineTo(scaled(16f), scaled(11f)) + } + drawPath(shackle, c, style = s) + drawRoundRect( + color = c, + topLeft = Offset(scaled(5.5f), scaled(11f)), + size = Size(scaled(13f), scaled(9f)), + cornerRadius = CornerRadius(scaled(1.5f), scaled(1.5f)), + style = s, + ) + + drawCircle(color = c, radius = scaled(1.3f), center = Offset(scaled(12f), scaled(15f))) +} + +private fun DrawScope.drawPrivacy(c: Color, s: Stroke) { + + val eye = Path().apply { + moveTo(scaled(3f), scaled(12f)) + quadraticTo(scaled(12f), scaled(4.5f), scaled(21f), scaled(12f)) + quadraticTo(scaled(12f), scaled(19.5f), scaled(3f), scaled(12f)) + close() + } + drawPath(eye, c, style = s) + drawCircle(color = c, radius = scaled(2.2f), center = Offset(scaled(12f), scaled(12f))) + drawLine( + color = c, + start = Offset(scaled(4f), scaled(20f)), + end = Offset(scaled(20f), scaled(4f)), + strokeWidth = s.width * 1.4f, + cap = StrokeCap.Round, + ) +} + +private fun DrawScope.drawNetworking(c: Color, s: Stroke) { + + drawArc( + color = c, + startAngle = 215f, + sweepAngle = 110f, + useCenter = false, + topLeft = Offset(scaled(3f), scaled(7f)), + size = Size(scaled(18f), scaled(18f)), + style = s, + ) + drawArc( + color = c, + startAngle = 215f, + sweepAngle = 110f, + useCenter = false, + topLeft = Offset(scaled(6f), scaled(10f)), + size = Size(scaled(12f), scaled(12f)), + style = s, + ) + drawArc( + color = c, + startAngle = 215f, + sweepAngle = 110f, + useCenter = false, + topLeft = Offset(scaled(9f), scaled(13f)), + size = Size(scaled(6f), scaled(6f)), + style = s, + ) + drawCircle(color = c, radius = scaled(1f), center = Offset(scaled(12f), scaled(19f))) +} + +private fun DrawScope.drawAi(c: Color, s: Stroke) { + + val spark = Path().apply { + moveTo(scaled(12f), scaled(3f)) + lineTo(scaled(14f), scaled(10f)) + lineTo(scaled(21f), scaled(12f)) + lineTo(scaled(14f), scaled(14f)) + lineTo(scaled(12f), scaled(21f)) + lineTo(scaled(10f), scaled(14f)) + lineTo(scaled(3f), scaled(12f)) + lineTo(scaled(10f), scaled(10f)) + close() + } + drawPath(spark, c, style = s) +} + +private fun DrawScope.drawNotes(c: Color, s: Stroke) { + + drawRoundRect( + color = c, + topLeft = Offset(scaled(4f), scaled(5f)), + size = Size(scaled(12f), scaled(16f)), + cornerRadius = CornerRadius(scaled(1.5f), scaled(1.5f)), + style = s, + ) + val pencil = Path().apply { + moveTo(scaled(15f), scaled(8f)) + lineTo(scaled(21f), scaled(2f)) + lineTo(scaled(22.5f), scaled(3.5f)) + lineTo(scaled(16.5f), scaled(9.5f)) + close() + } + drawPath(pencil, c, style = s) + drawLine( + color = c, + start = Offset(scaled(7f), scaled(11f)), + end = Offset(scaled(13f), scaled(11f)), + strokeWidth = s.width, + cap = StrokeCap.Round, + ) + drawLine( + color = c, + start = Offset(scaled(7f), scaled(15f)), + end = Offset(scaled(13f), scaled(15f)), + strokeWidth = s.width, + cap = StrokeCap.Round, + ) +} + +private fun DrawScope.drawMessaging(c: Color, s: Stroke) { + + drawRoundRect( + color = c, + topLeft = Offset(scaled(3f), scaled(4f)), + size = Size(scaled(18f), scaled(13f)), + cornerRadius = CornerRadius(scaled(2.5f), scaled(2.5f)), + style = s, + ) + val tail = Path().apply { + moveTo(scaled(7f), scaled(17f)) + lineTo(scaled(6f), scaled(21f)) + lineTo(scaled(11f), scaled(17f)) + close() + } + drawPath(tail, c, style = s) +} + +private fun DrawScope.drawBrowser(c: Color, s: Stroke) { + + drawCircle(color = c, radius = scaled(8f), center = Offset(scaled(12f), scaled(12f)), style = s) + val needle = Path().apply { + moveTo(scaled(12f), scaled(6f)) + lineTo(scaled(14.5f), scaled(13f)) + lineTo(scaled(12f), scaled(11.5f)) + lineTo(scaled(9.5f), scaled(13f)) + close() + } + drawPath(needle, c) +} + +private fun DrawScope.drawSocial(c: Color, s: Stroke) { + + drawCircle(color = c, radius = scaled(2.4f), center = Offset(scaled(9f), scaled(8f)), style = s) + drawCircle(color = c, radius = scaled(2.4f), center = Offset(scaled(16f), scaled(9f)), style = s) + val shoulderA = Path().apply { + moveTo(scaled(4.5f), scaled(20f)) + cubicTo(scaled(4.5f), scaled(14f), scaled(13.5f), scaled(14f), scaled(13.5f), scaled(20f)) + } + drawPath(shoulderA, c, style = s) + val shoulderB = Path().apply { + moveTo(scaled(11.5f), scaled(20f)) + cubicTo(scaled(11.5f), scaled(15.5f), scaled(20.5f), scaled(15.5f), scaled(20.5f), scaled(20f)) + } + drawPath(shoulderB, c, style = s) +} + +private fun DrawScope.drawLauncher(c: Color) { + + val r = scaled(1.5f) + listOf(7f, 12f, 17f).forEach { cx -> + listOf(7f, 12f, 17f).forEach { cy -> + drawCircle(color = c, radius = r, center = Offset(scaled(cx), scaled(cy))) + } + } +} + +private fun DrawScope.drawSelfHosted(c: Color, s: Stroke) { + val p = Path().apply { + moveTo(scaled(4f), scaled(12f)) + lineTo(scaled(12f), scaled(5f)) + lineTo(scaled(20f), scaled(12f)) + lineTo(scaled(20f), scaled(19f)) + lineTo(scaled(4f), scaled(19f)) + close() + } + drawPath(p, c, style = s) +} + +private fun DrawScope.drawPhoto(c: Color, s: Stroke) { + drawRoundRect( + color = c, + topLeft = Offset(scaled(3f), scaled(6f)), + size = Size(scaled(18f), scaled(14f)), + cornerRadius = CornerRadius(scaled(1.5f), scaled(1.5f)), + style = s, + ) + drawCircle(color = c, radius = scaled(2f), center = Offset(scaled(9f), scaled(13f))) + val mountain = Path().apply { + moveTo(scaled(14f), scaled(16f)) + lineTo(scaled(17f), scaled(12f)) + lineTo(scaled(21f), scaled(17f)) + } + drawPath(mountain, c, style = s) +} + +private fun DrawScope.drawVideo(c: Color, s: Stroke) { + drawRoundRect( + color = c, + topLeft = Offset(scaled(3f), scaled(6f)), + size = Size(scaled(14f), scaled(12f)), + cornerRadius = CornerRadius(scaled(1.5f), scaled(1.5f)), + style = s, + ) + val triangle = Path().apply { + moveTo(scaled(17f), scaled(9f)) + lineTo(scaled(22f), scaled(7f)) + lineTo(scaled(22f), scaled(17f)) + lineTo(scaled(17f), scaled(15f)) + close() + } + drawPath(triangle, c) +} + +private fun DrawScope.drawAudio(c: Color) { + drawRect(color = c, topLeft = Offset(scaled(4f), scaled(10f)), size = Size(scaled(2.5f), scaled(8f))) + drawRect(color = c, topLeft = Offset(scaled(10.75f), scaled(6f)), size = Size(scaled(2.5f), scaled(14f))) + drawRect(color = c, topLeft = Offset(scaled(17.5f), scaled(3f)), size = Size(scaled(2.5f), scaled(18f))) +} + +private fun DrawScope.drawBackup(c: Color, s: Stroke) { + val arrow = Path().apply { + moveTo(scaled(12f), scaled(4f)) + lineTo(scaled(12f), scaled(14f)) + moveTo(scaled(8f), scaled(11f)) + lineTo(scaled(12f), scaled(15f)) + lineTo(scaled(16f), scaled(11f)) + } + drawPath(arrow, c, style = s) + val tray = Path().apply { + moveTo(scaled(4f), scaled(17f)) + lineTo(scaled(4f), scaled(20f)) + lineTo(scaled(20f), scaled(20f)) + lineTo(scaled(20f), scaled(17f)) + } + drawPath(tray, c, style = s) +} + +private fun DrawScope.drawReader(c: Color, s: Stroke) { + val p = Path().apply { + moveTo(scaled(6f), scaled(4f)) + lineTo(scaled(18f), scaled(4f)) + lineTo(scaled(18f), scaled(20f)) + lineTo(scaled(12f), scaled(17f)) + lineTo(scaled(6f), scaled(20f)) + close() + } + drawPath(p, c, style = s) +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/VersionDelta.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/VersionDelta.kt new file mode 100644 index 000000000..d5ec6000f --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/VersionDelta.kt @@ -0,0 +1,60 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.LocalStatusColors + +enum class VersionDeltaKind { PATCH, MINOR, MAJOR } + +@Composable +fun VersionDelta( + delta: VersionDeltaKind, + modifier: Modifier = Modifier, +) { + val status = LocalStatusColors.current + when (delta) { + VersionDeltaKind.PATCH -> Box( + modifier = modifier + .size(6.dp) + .background(status.freshnessFresh, CircleShape), + ) + VersionDeltaKind.MINOR -> Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(2.5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(2) { + Box( + modifier = Modifier + .size(6.dp) + .background(status.freshnessWarm, CircleShape), + ) + } + } + VersionDeltaKind.MAJOR -> Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(width = 9.dp, height = 9.dp) + .background(status.freshnessHot, RoundedCornerShape(1.5.dp)), + ) + Box( + modifier = Modifier + .size(width = 2.dp, height = 9.dp) + .background(status.freshnessHot), + ) + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/VersionStack.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/VersionStack.kt new file mode 100644 index 000000000..a5b8aa25d --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/VersionStack.kt @@ -0,0 +1,39 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.LocalStatusColors +import kotlin.math.min + +@Composable +fun VersionStack( + count: Int, + modifier: Modifier = Modifier, + color: Color? = null, + widthDp: Int = 22, +) { + val fillColor = color ?: LocalStatusColors.current.freshnessWarm + val n = min(count, 7) + if (n == 0) return + val heightDp = n * 3 + 5 + Canvas(modifier = modifier.size(width = widthDp.dp, height = heightDp.dp)) { + val barWidth = size.width + val barHeight = 4.dp.toPx() + val gap = 3.dp.toPx() + val barRadius = 1.5.dp.toPx() + for (i in 0 until n) { + val top = i * gap + val alpha = 0.35f + i * 0.08f + drawRoundRect( + color = fillColor.copy(alpha = alpha), + topLeft = androidx.compose.ui.geometry.Offset(0f, top), + size = androidx.compose.ui.geometry.Size(barWidth, barHeight), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius, barRadius), + ) + } + } +} diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/WaxSeal.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/WaxSeal.kt new file mode 100644 index 000000000..6c344297d --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/vocabulary/WaxSeal.kt @@ -0,0 +1,97 @@ +package zed.rainxch.core.presentation.vocabulary + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.LocalStatusColors + +enum class WaxSealState { INTACT, CRACKED, OPEN } + +@Composable +fun WaxSeal( + state: WaxSealState, + modifier: Modifier = Modifier, + sizeDp: Int = 22, +) { + val status = LocalStatusColors.current + Canvas(modifier = modifier.size(sizeDp.dp)) { + val s = size.minDimension + val r = s * (9.5f / 24f) + val center = Offset(size.width / 2f, size.height / 2f) + when (state) { + WaxSealState.INTACT -> { + drawCircle(color = status.waxIntact, radius = r, center = center) + drawCircle( + color = Color(0xFF5E2F18), + radius = r, + center = center, + style = Stroke(width = 0.5f.dp.toPx()), + ) + drawCircle( + color = Color(0xFFFCE8C8).copy(alpha = 0.55f), + radius = s * (6f / 24f), + center = center, + style = Stroke(width = 0.6f.dp.toPx()), + ) + + val path = Path().apply { + moveTo(size.width * (8.5f / 24f), size.height * (12f / 24f)) + lineTo(size.width * (11f / 24f), size.height * (14.3f / 24f)) + lineTo(size.width * (15.5f / 24f), size.height * (9.7f / 24f)) + } + drawPath( + path = path, + color = Color(0xFFFCE8C8), + style = Stroke( + width = 1.8f.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } + WaxSealState.CRACKED -> { + drawCircle(color = status.waxCracked, radius = r, center = center) + val crackPath = Path().apply { + moveTo(size.width * (9f / 24f), size.height * (4f / 24f)) + lineTo(size.width * (13f / 24f), size.height * (11f / 24f)) + lineTo(size.width * (8f / 24f), size.height * (14f / 24f)) + lineTo(size.width * (14f / 24f), size.height * (20f / 24f)) + } + drawPath( + path = crackPath, + color = Color(0xFFFBE2DD), + style = Stroke( + width = 2f.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } + WaxSealState.OPEN -> { + val dashEffect = PathEffect.dashPathEffect( + intervals = floatArrayOf(2.5f.dp.toPx(), 2f.dp.toPx()), + ) + drawCircle( + color = status.waxOpen, + radius = s * (9f / 24f), + center = center, + style = Stroke(width = 1.6f.dp.toPx(), pathEffect = dashEffect), + ) + drawCircle( + color = status.waxOpen.copy(alpha = 0.5f), + radius = s * (2f / 24f), + center = center, + ) + } + } + } +} diff --git a/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/color/DominantColorFromImage.jvm.kt b/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/color/DominantColorFromImage.jvm.kt new file mode 100644 index 000000000..158cf6b33 --- /dev/null +++ b/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/color/DominantColorFromImage.jvm.kt @@ -0,0 +1,38 @@ +package zed.rainxch.core.presentation.color + +import androidx.compose.ui.graphics.Color +import coil3.Image +import coil3.toBitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo + +private const val SampleSize = 48 + +actual fun computeDominantFromImage(image: Image): Color? { + val w = SampleSize + val h = SampleSize + val bitmap = runCatching { image.toBitmap(w, h) }.getOrNull() ?: return null + val info = ImageInfo( + width = w, + height = h, + colorType = ColorType.BGRA_8888, + alphaType = ColorAlphaType.UNPREMUL, + ) + val bytes = runCatching { + bitmap.readPixels(dstInfo = info, dstRowBytes = w * 4, srcX = 0, srcY = 0) + }.getOrNull() ?: return null + val pixels = IntArray(w * h) + var i = 0 + var p = 0 + while (i < pixels.size && p + 3 < bytes.size) { + val b = bytes[p].toInt() and 0xFF + val g = bytes[p + 1].toInt() and 0xFF + val r = bytes[p + 2].toInt() and 0xFF + val a = bytes[p + 3].toInt() and 0xFF + pixels[i] = (a shl 24) or (r shl 16) or (g shl 8) or b + i += 1 + p += 4 + } + return dominantFromArgb(pixels) +} diff --git a/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/components/ScrollbarContainer.jvm.kt b/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/components/ScrollbarContainer.jvm.kt index 67e24696e..69cb19699 100644 --- a/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/components/ScrollbarContainer.jvm.kt +++ b/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/components/ScrollbarContainer.jvm.kt @@ -86,10 +86,6 @@ actual fun ScrollbarContainer( } } -/** - * Custom [ScrollbarAdapter] for [LazyStaggeredGridState] since Compose Desktop - * does not provide a built-in [rememberScrollbarAdapter] overload for staggered grids. - */ private class StaggeredGridScrollbarAdapter( private val gridState: LazyStaggeredGridState, ) : ScrollbarAdapter { diff --git a/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/theme/Theme.jvm.kt b/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/theme/DynamicColorScheme.jvm.kt similarity index 73% rename from core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/theme/Theme.jvm.kt rename to core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/theme/DynamicColorScheme.jvm.kt index 2daa82905..b3aa09b4e 100644 --- a/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/theme/Theme.jvm.kt +++ b/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/theme/DynamicColorScheme.jvm.kt @@ -6,4 +6,4 @@ import androidx.compose.runtime.Composable actual fun isDynamicColorAvailable(): Boolean = false @Composable -actual fun getDynamicColorScheme(darkTheme: Boolean): ColorScheme? = null +actual fun dynamicColorScheme(isDark: Boolean): ColorScheme? = null diff --git a/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/utils/ApplyAndroidSystemBars.jvm.kt b/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/utils/ApplyAndroidSystemBars.jvm.kt index cc838031f..eb55194b4 100644 --- a/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/utils/ApplyAndroidSystemBars.jvm.kt +++ b/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/utils/ApplyAndroidSystemBars.jvm.kt @@ -4,5 +4,5 @@ import androidx.compose.runtime.Composable @Composable actual fun ApplyAndroidSystemBars(isDarkTheme: Boolean?) { - // No-op + } diff --git a/feature/apps/data/build.gradle.kts b/feature/apps/data/build.gradle.kts index f452adf3a..6a480bc1e 100644 --- a/feature/apps/data/build.gradle.kts +++ b/feature/apps/data/build.gradle.kts @@ -8,26 +8,16 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.apps.domain) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.datetime) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt index e5ca30f9e..635780ff8 100644 --- a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt +++ b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt @@ -255,20 +255,13 @@ class AppsRepositoryImpl( val now = Clock.System.now().toEpochMilliseconds() val globalPreRelease = tweaksRepository.getIncludePreReleases().first() val normalizedFilter = assetFilterRegex?.trim()?.takeIf { it.isNotEmpty() } - // Pre-derived fingerprint (from import) wins over re-deriving - // from the picked filename. Falls through to deriving fresh - // from the picked asset when there's nothing pre-computed. + val derivedVariant = preferredAssetVariant?.trim()?.takeIf { it.isNotEmpty() } ?: pickedAssetName?.let { AssetVariant.deriveFromPickedAsset(it, pickedAssetSiblingCount) } - // Multi-layer fingerprint: when coming from import, we already - // have these stored from the prior install. When coming from - // the link sheet picker, derive them fresh from the picked - // asset's filename so all three identity layers are populated - // atomically with the rest of the row. val freshFingerprint = if (preferredAssetTokens == null && assetGlobPattern == null && pickedAssetName != null) { AssetVariant.fingerprintFromPickedAsset(pickedAssetName, pickedAssetSiblingCount) @@ -573,10 +566,7 @@ class AppsRepositoryImpl( .filter { it.draft != true } .map { it.toDomain() } .filter { - // Use the same tag-aware pre-release filter as the - // GitHub path (`InstalledAppsRepositoryImpl`) so a - // `v2.0.0-rc1` release with a `prerelease: false` - // flag is still correctly excluded. + includePreReleases || !it.isEffectivelyPreRelease() } .maxByOrNull { it.publishedAt } diff --git a/feature/apps/domain/build.gradle.kts b/feature/apps/domain/build.gradle.kts index dee8adc57..4a38ddd7f 100644 --- a/feature/apps/domain/build.gradle.kts +++ b/feature/apps/domain/build.gradle.kts @@ -7,19 +7,9 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) - - implementation(projects.core.domain) implementation(libs.kotlinx.coroutines.core) - } - } - - androidMain { - dependencies { - } - } - jvmMain { - dependencies { + implementation(projects.core.domain) } } } diff --git a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt index 8246c7048..f061a49d5 100644 --- a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt +++ b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt @@ -33,44 +33,16 @@ interface AppsRepository { repoInfo: GithubRepoInfo, assetFilterRegex: String? = null, fallbackToOlderReleases: Boolean = false, - /** - * Filename of the asset the user picked in the link sheet (or null - * if no picker was shown — e.g. the repo had no installable assets - * and the link is purely for tracking). When set, the implementation - * derives a stable variant tag from it via `AssetVariant` and - * persists it as `preferredAssetVariant`, so subsequent updates - * stay on the same variant. - */ + pickedAssetName: String? = null, - /** - * How many installable assets were offered to the user when they - * picked. Single-asset releases (count == 1) skip variant memory - * entirely because there's nothing to pin. - */ + pickedAssetSiblingCount: Int = 0, - /** - * Direct variant tag (already extracted) — takes precedence over - * the [pickedAssetName] derivation. Used by the import path - * where we already have the tag from a previous export rather - * than a fresh asset filename to extract from. - */ + preferredAssetVariant: String? = null, - /** - * Pre-derived multi-layer fingerprint from a previous export. - * Takes precedence over deriving from [pickedAssetName] when - * non-null — preserves the exact identity layers the user - * pinned in their other install rather than recomputing from - * a possibly-different asset list. - */ + preferredAssetTokens: String? = null, assetGlobPattern: String? = null, - /** - * Zero-based index of the picked asset in the release's - * installable-asset list. Stored for the same-position fallback - * — when the resolver can't match any fingerprint layer in a - * fresh release but the new release has the same number of - * installable assets, this index is preferred. - */ + pickedAssetIndex: Int? = null, sourceHost: String? = null, ) diff --git a/feature/apps/presentation/build.gradle.kts b/feature/apps/presentation/build.gradle.kts index 547bb0666..25a387cd9 100644 --- a/feature/apps/presentation/build.gradle.kts +++ b/feature/apps/presentation/build.gradle.kts @@ -7,28 +7,17 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.datetime) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.apps.domain) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.jetbrains.compose.components.resources) - implementation(libs.bundles.landscapist) - implementation(libs.kotlinx.collections.immutable) - implementation(libs.kotlinx.datetime) - } - } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.jetbrains.compose.components.resources) } } } diff --git a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/components/InstalledAppIcon.android.kt b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/components/InstalledAppIcon.android.kt index 5b2fed6ff..49aaf699f 100644 --- a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/components/InstalledAppIcon.android.kt +++ b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/components/InstalledAppIcon.android.kt @@ -56,12 +56,7 @@ private fun resolveInstalledIcon( .toBitmap() .asImageBitmap() } catch (t: Throwable) { - // Wide catch: NameNotFoundException is the common case but - // PackageManager can also throw SecurityException on cross-user - // reads, plus toBitmap() can throw if the drawable can't be - // rasterized (unsupported drawable type, OOM on huge icons). - // Composition crash on the Apps screen is the worst-case - // outcome — silently fall back to the default icon instead. + Log.w(TAG, "failed to load installed icon for $packageName", t) null } @@ -71,10 +66,7 @@ private fun resolveApkIcon( apkFilePath: String, ): ImageBitmap? = try { - // PackageManager.getApplicationIcon(applicationInfo) needs sourceDir - // to point at the APK so loadIcon() resolves the embedded drawable. - // Without setting sourceDir/publicSourceDir loadIcon() returns the - // default Android boilerplate icon — useless as a fallback. + val info = getPackageArchiveInfoCompat(packageManager, apkFilePath) val appInfo = info?.applicationInfo ?: return null appInfo.sourceDir = apkFilePath diff --git a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt index 1bd88316c..f4bade984 100644 --- a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt +++ b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.platform.LocalContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -// keep in sync with AndroidExternalAppScanner.GRANT_THRESHOLD private const val GRANT_THRESHOLD = 30 @Composable @@ -20,8 +19,7 @@ actual fun rememberPackageVisibilityRequester(): PackageVisibilityRequester { private class AndroidPackageVisibilityRequester( private val context: Context, ) : PackageVisibilityRequester { - // pm.getInstalledPackages is a binder IPC and can take noticeable time on devices - // with many packages — keep it off the main thread. + override suspend fun isGranted(): Boolean = withContext(Dispatchers.IO) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return@withContext true @@ -31,14 +29,7 @@ private class AndroidPackageVisibilityRequester( } override suspend fun requestOrOpenSettings(): Boolean { - // No-op by contract. QUERY_ALL_PACKAGES is granted at install time - // via the manifest declaration — there is no user-grantable runtime - // toggle on stock Android, and `ACTION_APPLICATION_DETAILS_SETTINGS` - // does not surface the (non-existent) toggle either. Some OEMs - // (Samsung One UI 4+) expose a "Special access → Allow access to all - // apps" setting, but no public Intent reliably deep-links to it. - // Callers must rely on `isGranted()` and gracefully degrade when - // false; the scanner's heuristic-based degraded path handles this. + return false } } diff --git a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.android.kt b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.android.kt index d2e071c65..4c10bc96e 100644 --- a/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.android.kt +++ b/feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.android.kt @@ -8,10 +8,7 @@ import androidx.compose.ui.platform.LocalContext @Composable actual fun rememberSystemReducedMotion(): Boolean { val context = LocalContext.current - // Reading once at composition is fine — ANIMATOR_DURATION_SCALE is a - // global system setting that almost never flips while the wizard is - // on screen. If it does, the next composition (rotation, navigation) - // will pick up the new value. + return remember(context) { val scale = Settings.Global.getFloat( context.contentResolver, diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt index 3bae4a3ee..f8f5e4929 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt @@ -5,7 +5,6 @@ import zed.rainxch.apps.presentation.model.AppSortRule import zed.rainxch.apps.presentation.model.DeviceAppUi import zed.rainxch.apps.presentation.model.GithubAssetUi - sealed interface AppsAction { data object OnNavigateBackClick : AppsAction @@ -37,17 +36,16 @@ sealed interface AppsAction { data object OnRefresh : AppsAction - // Fired by AppsRoot on ON_RESUME. Routes through the existing - // cooldown-gated refresh path (`autoCheckForUpdatesIfNeeded`) so - // re-entering the Apps screen catches external installs that - // `PackageEventReceiver` may have missed — e.g. when GHS was - // background-killed by an aggressive OEM ROM and never received - // the PACKAGE_REPLACED broadcast. The 30-minute cooldown keeps - // rapid resumes from burning GitHub rate limits. data object OnLifecycleResume : AppsAction data object OnToggleUpToDateSection : AppsAction + data object OnToggleUpdatesSection : AppsAction + + data class OnTwoPaneSelect( + val packageName: String?, + ) : AppsAction + data class OnNavigateToRepo( val repoId: Long, val sourceHost: String? = null, @@ -59,11 +57,9 @@ sealed interface AppsAction { val app: InstalledAppUi, ) : AppsAction - // Uninstall confirmation data class OnUninstallConfirmed(val app: InstalledAppUi) : AppsAction data object OnDismissUninstallDialog : AppsAction - // Link app to repo data object OnAddByLinkClick : AppsAction data object OnDismissLinkSheet : AppsAction data class OnDeviceAppSearchChange(val query: String) : AppsAction @@ -71,10 +67,7 @@ sealed interface AppsAction { data class OnLinkSuggestionSelected( val owner: String, val repo: String, - // Non-null when the suggestion came from a Forgejo / Codeberg / - // custom-forge search. Drives the URL the VM hands to the - // validate-and-link path so we don't navigate to github.com for - // a Forgejo repo. + val sourceHost: String? = null, ) : AppsAction data object OnLinkEnterUrlManually : AppsAction @@ -86,30 +79,18 @@ sealed interface AppsAction { data class OnLinkAssetSelected(val asset: GithubAssetUi) : AppsAction data object OnBackToEnterUrl : AppsAction - /** Asset filter input on the link-sheet PickAsset step. */ data class OnLinkAssetFilterChanged(val filter: String) : AppsAction - /** Toggle for "fall back to older releases" on the link-sheet PickAsset step. */ data class OnLinkFallbackToggled(val enabled: Boolean) : AppsAction - // Per-app pre-release toggle data class OnTogglePreReleases(val packageName: String, val enabled: Boolean) : AppsAction - // Per-app update-check toggle (issue #536: ignore updates for individual apps) data class OnToggleUpdateCheck(val packageName: String, val enabled: Boolean) : AppsAction - // Per-app skip-this-version (issue #542: dismiss false-positive update prompts). - // [tag] is the upstream release tag the user is choosing to skip — the - // ViewModel persists it on the row and the periodic check suppresses the - // badge until a strictly newer release lands. data class OnSkipReleaseTag(val packageName: String, val tag: String) : AppsAction - // Clears the per-app skip; the next update check will surface the badge - // again if a release matching the user's filters is still ahead of the - // installed version. data class OnUnskipReleaseTag(val packageName: String) : AppsAction - // Per-app advanced settings sheet (monorepo) data class OnOpenAdvancedSettings(val app: InstalledAppUi) : AppsAction data object OnDismissAdvancedSettings : AppsAction data class OnAdvancedFilterChanged(val filter: String) : AppsAction @@ -118,7 +99,6 @@ sealed interface AppsAction { data object OnAdvancedClearFilter : AppsAction data object OnAdvancedRefreshPreview : AppsAction - // Variant picker dialog (preferred APK variant) data class OnOpenVariantPicker( val app: InstalledAppUi, val resumeUpdateAfterPick: Boolean = false, @@ -127,7 +107,6 @@ sealed interface AppsAction { data class OnVariantSelected(val variant: String?) : AppsAction data object OnResetVariantToAuto : AppsAction - // Export/Import data object OnExportApps : AppsAction data object OnExportObtainium : AppsAction data object OnImportApps : AppsAction @@ -136,45 +115,24 @@ sealed interface AppsAction { data object OnDismissKaoBanner : AppsAction data object OnKaoLearnMore : AppsAction - /** - * User tapped the "Install" affordance on a row whose download - * was previously deferred (the user navigated away from Details - * mid-download). The orchestrator parked the file; this action - * picks it up and runs the installer. - */ data class OnInstallPendingApp( val app: InstalledAppUi, ) : AppsAction - /** - * User tapped the Discard affordance on a pending row — opens the - * confirmation dialog without doing anything destructive yet. The - * actual cleanup runs only after [OnConfirmDiscardPendingInstall]. - */ data class OnDiscardPendingInstall( val app: InstalledAppUi, ) : AppsAction - /** - * User confirmed the Discard prompt. Cancels the orchestrator - * entry, deletes the parked APK file, and removes the DB row so - * the app stops appearing in the list. - */ data class OnConfirmDiscardPendingInstall( val app: InstalledAppUi, ) : AppsAction - /** User dismissed the Discard confirmation without confirming. */ data object OnDismissDiscardPendingDialog : AppsAction - // External import banner (E1) data object OnImportProposalReview : AppsAction data object OnImportProposalDismiss : AppsAction - // Manual rescan trigger from the apps screen overflow. Resets the banner - // dismiss watermark and routes the user into the import wizard, which - // runs a fresh scan + match resolution on entry. data object OnRescanForGithubApps : AppsAction data object OnAddFromStarredClick : AppsAction diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt index 1306b6909..ce456b134 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt @@ -14,11 +14,7 @@ sealed interface AppsEvent { data class NavigateToRepo( val repoId: Long, val sourceHost: String? = null, - // Forgejo / Codeberg repos use a synthetic 64-bit repoId - // (RepoIdCodec) that the backend / GitHub APIs don't recognise. - // When sourceHost is set we have to look the repo up by - // owner+name instead. Carry them through nav so the VM doesn't - // fall back to GitHub `/repositories/{id}` and 404. + val owner: String? = null, val repo: String? = null, ) : AppsEvent diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt index dd929c9ce..ee9186dd1 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt @@ -2,13 +2,18 @@ package zed.rainxch.apps.presentation +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.ui.unit.sp import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -40,14 +45,11 @@ import androidx.compose.material.icons.outlined.FileUpload import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenu +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton @@ -59,9 +61,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable @@ -77,7 +76,6 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -99,13 +97,20 @@ import zed.rainxch.apps.presentation.components.InstalledAppIcon import zed.rainxch.apps.presentation.components.LinkAppBottomSheet import zed.rainxch.apps.presentation.components.VariantPickerDialog import zed.rainxch.apps.presentation.components.KaoBanner +import zed.rainxch.apps.presentation.components.UpdatesBanner import zed.rainxch.apps.presentation.import.components.ImportProposalBanner import zed.rainxch.apps.presentation.model.AppItem import zed.rainxch.apps.presentation.model.AppSortRule import zed.rainxch.apps.presentation.model.UpdateAllProgress import zed.rainxch.apps.presentation.model.UpdateState import zed.rainxch.core.presentation.components.ExpressiveCard +import zed.rainxch.core.presentation.components.chrome.GhsHomeTopBar +import zed.rainxch.core.presentation.utils.constrainedContentWidth import zed.rainxch.core.presentation.components.ScrollbarContainer +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsTextField import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled import zed.rainxch.core.presentation.theme.GithubStoreTheme @@ -119,8 +124,8 @@ import zed.rainxch.githubstore.core.presentation.res.apps_compact_more_actions import zed.rainxch.githubstore.core.presentation.res.apps_ignore_updates import zed.rainxch.githubstore.core.presentation.res.apps_skip_version import zed.rainxch.githubstore.core.presentation.res.apps_skip_version_unskip +import zed.rainxch.githubstore.core.presentation.res.apps_section_pending_installs import zed.rainxch.githubstore.core.presentation.res.apps_section_up_to_date -import zed.rainxch.githubstore.core.presentation.res.apps_section_updates_available import zed.rainxch.githubstore.core.presentation.res.install import zed.rainxch.githubstore.core.presentation.res.ready_to_install import zed.rainxch.githubstore.core.presentation.res.variant_label_inline @@ -132,14 +137,13 @@ import zed.rainxch.githubstore.core.presentation.res.checking import zed.rainxch.githubstore.core.presentation.res.checking_for_updates import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_message import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_title -import zed.rainxch.githubstore.core.presentation.res.currently_updating import zed.rainxch.githubstore.core.presentation.res.downloading import zed.rainxch.githubstore.core.presentation.res.error_with_message import zed.rainxch.githubstore.core.presentation.res.export_apps import zed.rainxch.githubstore.core.presentation.res.export_apps_obtainium import zed.rainxch.githubstore.core.presentation.res.external_import_rescan_menu import zed.rainxch.githubstore.core.presentation.res.import_apps -import zed.rainxch.githubstore.core.presentation.res.installed_apps +import zed.rainxch.githubstore.core.presentation.res.bottom_nav_apps_title import zed.rainxch.githubstore.core.presentation.res.installing import zed.rainxch.githubstore.core.presentation.res.last_checked import zed.rainxch.githubstore.core.presentation.res.last_checked_hours_ago @@ -159,9 +163,7 @@ import zed.rainxch.githubstore.core.presentation.res.confirm_discard_pending_mes import zed.rainxch.githubstore.core.presentation.res.confirm_discard_pending_title import zed.rainxch.githubstore.core.presentation.res.discard_pending_install import zed.rainxch.githubstore.core.presentation.res.update -import zed.rainxch.githubstore.core.presentation.res.update_all import zed.rainxch.githubstore.core.presentation.res.updated_successfully -import zed.rainxch.githubstore.core.presentation.res.updating_x_of_y @Composable fun AppsRoot( @@ -175,11 +177,6 @@ fun AppsRoot( val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() - // Re-entry sync: fires the cooldown-gated update check whenever the - // user returns to the Apps screen. Catches external installs that - // `PackageEventReceiver` missed when GHS was background-killed by - // an aggressive OEM ROM. The VM debounces network calls via - // `UPDATE_CHECK_COOLDOWN_MS` (30 min) so rapid resumes are cheap. val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = @@ -210,10 +207,10 @@ fun AppsRoot( } } - is AppsEvent.AppLinkedSuccessfully -> { // handled by ShowSuccess + is AppsEvent.AppLinkedSuccessfully -> { } - is AppsEvent.ImportComplete -> { // handled by ShowSuccess + is AppsEvent.ImportComplete -> { } AppsEvent.NavigateToExternalImport -> { @@ -255,43 +252,54 @@ fun AppsScreen( Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.installed_apps), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - }, + GhsHomeTopBar( + title = stringResource(Res.string.bottom_nav_apps_title), actions = { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surface) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(50), + ), + verticalAlignment = Alignment.CenterVertically, + ) { Box { - IconButton(onClick = { showSortMenu = true }) { + Box( + modifier = Modifier + .clickable { showSortMenu = true } + .padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { Icon( imageVector = Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(Res.string.sort_apps), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp), ) } - DropdownMenu( + GhsDropdownMenu( expanded = showSortMenu, onDismissRequest = { showSortMenu = false }, ) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.sort_updates_first)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.sort_updates_first), onClick = { showSortMenu = false onAction(AppsAction.OnSortRuleSelected(AppSortRule.UpdatesFirst)) }, ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.sort_recently_updated)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.sort_recently_updated), onClick = { showSortMenu = false onAction(AppsAction.OnSortRuleSelected(AppSortRule.RecentlyUpdated)) }, ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.sort_name)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.sort_name), onClick = { showSortMenu = false onAction(AppsAction.OnSortRuleSelected(AppSortRule.Name)) @@ -300,28 +308,49 @@ fun AppsScreen( } } - IconButton( - onClick = { onAction(AppsAction.OnCheckAllForUpdates) }, + Box( + modifier = Modifier + .size(1.dp, 20.dp) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), + ) + Box( + modifier = Modifier + .clickable { onAction(AppsAction.OnCheckAllForUpdates) } + .padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Default.Refresh, contentDescription = stringResource(Res.string.check_for_updates), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp), ) } - + Box( + modifier = Modifier + .size(1.dp, 20.dp) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), + ) Box { - IconButton(onClick = { showOverflowMenu = true }) { + Box( + modifier = Modifier + .clickable { showOverflowMenu = true } + .padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { Icon( imageVector = Icons.Outlined.MoreVert, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp), ) } - DropdownMenu( + GhsDropdownMenu( expanded = showOverflowMenu, onDismissRequest = { showOverflowMenu = false }, ) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.export_apps)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.export_apps), onClick = { showOverflowMenu = false onAction(AppsAction.OnExportApps) @@ -330,8 +359,8 @@ fun AppsScreen( Icon(Icons.Outlined.FileUpload, contentDescription = null) }, ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.export_apps_obtainium)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.export_apps_obtainium), onClick = { showOverflowMenu = false onAction(AppsAction.OnExportObtainium) @@ -340,8 +369,8 @@ fun AppsScreen( Icon(Icons.Outlined.FileUpload, contentDescription = null) }, ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.import_apps)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.import_apps), onClick = { showOverflowMenu = false onAction(AppsAction.OnImportApps) @@ -350,8 +379,8 @@ fun AppsScreen( Icon(Icons.Outlined.FileDownload, contentDescription = null) }, ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.external_import_rescan_menu)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.external_import_rescan_menu), onClick = { showOverflowMenu = false onAction(AppsAction.OnRescanForGithubApps) @@ -360,8 +389,8 @@ fun AppsScreen( Icon(Icons.Outlined.Search, contentDescription = null) }, ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.add_from_starred_title)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.add_from_starred_title), onClick = { showOverflowMenu = false onAction(AppsAction.OnAddFromStarredClick) @@ -375,6 +404,7 @@ fun AppsScreen( ) } } + } }, ) }, @@ -402,7 +432,6 @@ fun AppsScreen( }, ) { innerPadding -> - // Link app bottom sheet if (state.showLinkSheet) { LinkAppBottomSheet( state = state, @@ -410,7 +439,6 @@ fun AppsScreen( ) } - // Per-app advanced settings (monorepo filter / fallback) if (state.advancedSettingsApp != null) { AdvancedAppSettingsBottomSheet( state = state, @@ -418,7 +446,6 @@ fun AppsScreen( ) } - // Variant picker dialog (shown for stale variants or explicit picks) if (state.variantPickerApp != null) { VariantPickerDialog( state = state, @@ -426,7 +453,6 @@ fun AppsScreen( ) } - // Import summary sheet state.importSummary?.let { summary -> zed.rainxch.apps.presentation.components.ImportSummarySheet( summary = summary, @@ -434,7 +460,6 @@ fun AppsScreen( ) } - // Uninstall confirmation dialog state.appPendingUninstall?.let { app -> AlertDialog( onDismissRequest = { onAction(AppsAction.OnDismissUninstallDialog) }, @@ -450,29 +475,24 @@ fun AppsScreen( ) }, confirmButton = { - TextButton( + GhsButton( onClick = { onAction(AppsAction.OnUninstallConfirmed(app)) }, - ) { - Text( - text = stringResource(Res.string.uninstall), - color = MaterialTheme.colorScheme.error, - ) - } + label = stringResource(Res.string.uninstall), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, dismissButton = { - TextButton( + GhsButton( onClick = { onAction(AppsAction.OnDismissUninstallDialog) }, - ) { - Text(text = stringResource(Res.string.cancel)) - } + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } - // Discard-pending-install confirmation dialog. Mirrors the - // uninstall flow because both branches blow away DB rows the - // user might still want — the discard target also deletes the - // parked APK from disk, so the prompt is doubly warranted. state.appPendingDiscard?.let { app -> AlertDialog( onDismissRequest = { onAction(AppsAction.OnDismissDiscardPendingDialog) }, @@ -491,21 +511,20 @@ fun AppsScreen( ) }, confirmButton = { - TextButton( + GhsButton( onClick = { onAction(AppsAction.OnConfirmDiscardPendingInstall(app)) }, - ) { - Text( - text = stringResource(Res.string.discard_pending_install), - color = MaterialTheme.colorScheme.error, - ) - } + label = stringResource(Res.string.discard_pending_install), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, dismissButton = { - TextButton( + GhsButton( onClick = { onAction(AppsAction.OnDismissDiscardPendingDialog) }, - ) { - Text(text = stringResource(Res.string.cancel)) - } + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } @@ -518,26 +537,22 @@ fun AppsScreen( .fillMaxSize() .padding(innerPadding), ) { - Column( + Box( modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, ) { - TextField( + Column( + modifier = Modifier.constrainedContentWidth().fillMaxHeight(), + ) { + GhsTextField( value = state.searchQuery, onValueChange = { onAction(AppsAction.OnSearchChange(it)) }, - leadingIcon = { - Icon(Icons.Default.Search, contentDescription = null) - }, - placeholder = { Text(stringResource(Res.string.search_your_apps)) }, + leadingIcon = Icons.Default.Search, + placeholder = stringResource(Res.string.search_your_apps), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), - shape = CircleShape, - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), ) if (state.isCheckingForUpdates) { @@ -567,46 +582,11 @@ fun AppsScreen( formatLastChecked(state.lastCheckedTimestamp), ), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) } - val hasUpdates = - state.apps.any { - it.installedApp.isUpdateAvailable && it.installedApp.updateCheckEnabled - } - if (hasUpdates && !state.isUpdatingAll) { - Button( - onClick = { onAction(AppsAction.OnUpdateAll) }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - enabled = state.updateAllButtonEnabled, - ) { - Icon( - imageVector = Icons.Default.Update, - contentDescription = null, - ) - - Spacer(Modifier.width(8.dp)) - - Text( - text = stringResource(Res.string.update_all), - ) - } - } - - if (state.isUpdatingAll && state.updateAllProgress != null) { - UpdateAllProgressCard( - progress = state.updateAllProgress, - onCancel = { - onAction(AppsAction.OnCancelUpdateAll) - }, - ) - } - when { state.isLoading -> { Box( @@ -634,16 +614,33 @@ fun AppsScreen( val listState = rememberLazyListState() val isScrollbarEnabled = LocalScrollbarEnabled.current - // Split filteredApps into the "Updates available" group (rich - // rows) and the "Up to date" group (compact rows) — issue #463. - // Sort order is already applied by the ViewModel. + val pendingGroup = + state.filteredApps.filter { + it.installedApp.isPendingInstall && + it.installedApp.pendingInstallFilePath != null + } val updatesGroup = state.filteredApps.filter { - it.installedApp.isUpdateAvailable && it.installedApp.updateCheckEnabled + it.installedApp.isUpdateAvailable && + it.installedApp.updateCheckEnabled && + !it.installedApp.isPendingInstall } val idleGroup = state.filteredApps.filter { - !it.installedApp.isUpdateAvailable || !it.installedApp.updateCheckEnabled + (!it.installedApp.isUpdateAvailable || !it.installedApp.updateCheckEnabled) && + !it.installedApp.isPendingInstall + } + + val onRowSelect: (zed.rainxch.apps.presentation.model.InstalledAppUi) -> Unit = + { app -> + onAction( + AppsAction.OnNavigateToRepo( + repoId = app.repoId, + sourceHost = app.sourceHost, + owner = app.repoOwner, + repo = app.repoName, + ), + ) } ScrollbarContainer( @@ -654,10 +651,7 @@ fun AppsScreen( LazyColumn( state = listState, modifier = Modifier.fillMaxSize().arrowKeyScroll(listState), - // Bottom inset clears the Add-by-link FAB so - // the last list item isn't hidden under it. - // FAB ≈ 56dp + 16dp scaffold inset + 16dp - // breathing room. + contentPadding = PaddingValues( start = 0.dp, end = 0.dp, @@ -689,19 +683,19 @@ fun AppsScreen( } } - if (updatesGroup.isNotEmpty()) { - item(key = "header-updates-available") { + if (pendingGroup.isNotEmpty()) { + item(key = "header-pending-installs") { AppsSectionHeader( - title = stringResource(Res.string.apps_section_updates_available), - count = updatesGroup.size, + title = stringResource(Res.string.apps_section_pending_installs), + count = pendingGroup.size, isExpanded = true, collapsible = false, onToggle = {}, ) } items( - items = updatesGroup, - key = { "rich-${it.installedApp.packageName}" }, + items = pendingGroup, + key = { "pending-${it.installedApp.packageName}" }, ) { appItem -> Box(modifier = Modifier.padding(horizontal = 16.dp)) { AppItemCard( @@ -710,16 +704,81 @@ fun AppsScreen( onUpdateClick = { onAction(AppsAction.OnUpdateApp(appItem.installedApp)) }, onCancelClick = { onAction(AppsAction.OnCancelUpdate(appItem.installedApp.packageName)) }, onUninstallClick = { onAction(AppsAction.OnUninstallApp(appItem.installedApp)) }, - onRepoClick = { + onRepoClick = { onRowSelect(appItem.installedApp) }, + onTogglePreReleases = { enabled -> + onAction(AppsAction.OnTogglePreReleases(appItem.installedApp.packageName, enabled)) + }, + onToggleUpdateCheck = { enabled -> + onAction(AppsAction.OnToggleUpdateCheck(appItem.installedApp.packageName, enabled)) + }, + onAdvancedSettingsClick = { + onAction(AppsAction.OnOpenAdvancedSettings(appItem.installedApp)) + }, + onPickVariantClick = { onAction( - AppsAction.OnNavigateToRepo( - repoId = appItem.installedApp.repoId, - sourceHost = appItem.installedApp.sourceHost, - owner = appItem.installedApp.repoOwner, - repo = appItem.installedApp.repoName, + AppsAction.OnOpenVariantPicker( + app = appItem.installedApp, + resumeUpdateAfterPick = false, ), ) }, + onInstallPendingClick = { + onAction(AppsAction.OnInstallPendingApp(appItem.installedApp)) + }, + onDiscardPendingClick = { + onAction(AppsAction.OnDiscardPendingInstall(appItem.installedApp)) + }, + onSkipVersionClick = { + val tag = + appItem.installedApp.latestVersion + ?: appItem.installedApp.latestVersionName + if (!tag.isNullOrBlank()) { + onAction( + AppsAction.OnSkipReleaseTag( + appItem.installedApp.packageName, + tag, + ), + ) + } + }, + onUnskipVersionClick = { + onAction(AppsAction.OnUnskipReleaseTag(appItem.installedApp.packageName)) + }, + ) + } + } + } + + if (updatesGroup.isNotEmpty() || state.isUpdatingAll) { + item(key = "updates-banner") { + Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + UpdatesBanner( + count = updatesGroup.size, + isExpanded = state.isUpdatesSectionExpanded, + isUpdatingAll = state.isUpdatingAll, + updateAllProgress = state.updateAllProgress, + updateAllEnabled = state.updateAllButtonEnabled, + onUpdateAll = { onAction(AppsAction.OnUpdateAll) }, + onCancelUpdateAll = { onAction(AppsAction.OnCancelUpdateAll) }, + onToggleExpanded = { onAction(AppsAction.OnToggleUpdatesSection) }, + ) + } + } + } + + if (updatesGroup.isNotEmpty() && state.isUpdatesSectionExpanded) { + items( + items = updatesGroup, + key = { "rich-${it.installedApp.packageName}" }, + ) { appItem -> + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + AppItemCard( + appItem = appItem, + onOpenClick = { onAction(AppsAction.OnOpenApp(appItem.installedApp)) }, + onUpdateClick = { onAction(AppsAction.OnUpdateApp(appItem.installedApp)) }, + onCancelClick = { onAction(AppsAction.OnCancelUpdate(appItem.installedApp.packageName)) }, + onUninstallClick = { onAction(AppsAction.OnUninstallApp(appItem.installedApp)) }, + onRepoClick = { onRowSelect(appItem.installedApp) }, onTogglePreReleases = { enabled -> onAction(AppsAction.OnTogglePreReleases(appItem.installedApp.packageName, enabled)) }, @@ -828,16 +887,7 @@ fun AppsScreen( ), ) }, - onRowClick = { - onAction( - AppsAction.OnNavigateToRepo( - repoId = appItem.installedApp.repoId, - sourceHost = appItem.installedApp.sourceHost, - owner = appItem.installedApp.repoOwner, - repo = appItem.installedApp.repoName, - ), - ) - }, + onRowClick = { onRowSelect(appItem.installedApp) }, ) } } @@ -852,64 +902,7 @@ fun AppsScreen( } } } - } - } -} - -@Composable -fun UpdateAllProgressCard( - progress: UpdateAllProgress, - onCancel: () -> Unit, -) { - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = - stringResource( - Res.string.updating_x_of_y, - progress.current, - progress.total, - ), - style = MaterialTheme.typography.titleMedium, - ) - IconButton(onClick = onCancel) { - Icon( - Icons.Default.Close, - contentDescription = stringResource(Res.string.cancel), - ) - } } - - Spacer(Modifier.height(8.dp)) - - Text( - text = - stringResource( - Res.string.currently_updating, - progress.currentAppName, - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(Modifier.height(12.dp)) - - LinearWavyProgressIndicator( - progress = { progress.current.toFloat() / progress.total }, - modifier = Modifier.fillMaxWidth(), - ) } } } @@ -1009,9 +1002,7 @@ fun AppItemCard( } when { - // Highest priority: a download finished while - // the user wasn't watching, the file is on disk - // and ready to be installed with one tap. + app.pendingInstallFilePath != null -> { Text( text = stringResource(Res.string.ready_to_install), @@ -1030,10 +1021,7 @@ fun AppItemCard( } app.preferredVariantStale -> { - // Tap-to-fix label: route through the same OnUpdateApp - // intercept that would have opened the picker anyway, - // but also surface a tappable hint here for users - // who notice the warning before tapping Update. + Text( text = stringResource(Res.string.variant_stale_hint), style = MaterialTheme.typography.bodySmall, @@ -1060,9 +1048,7 @@ fun AppItemCard( maxLines = 2, overflow = TextOverflow.Ellipsis, ) - // Show the pinned variant tag inline so users can - // see at a glance which APK they'll get when they - // tap Update. + if (!app.preferredAssetVariant.isNullOrBlank()) { Text( text = @@ -1125,10 +1111,7 @@ fun AppItemCard( Row( verticalAlignment = Alignment.CenterVertically, ) { - // Subtle visual cue when a monorepo filter is active — - // the icon tints to primary, so users can tell at a - // glance which apps have an active filter without - // having to open the sheet. + val advancedFilterDescription = stringResource(Res.string.advanced_settings_open) val hasFilter = @@ -1152,10 +1135,6 @@ fun AppItemCard( ) } - // Always-visible "Pick variant" entry point. Tints to - // primary when a variant is pinned (so users can see - // at a glance whether the app has a sticky pick) and - // to error when the pinned variant has gone stale. val pickVariantDescription = stringResource(Res.string.variant_picker_open) val hasPin = !app.preferredAssetVariant.isNullOrBlank() @@ -1205,38 +1184,32 @@ fun AppItemCard( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - DropdownMenu( + GhsDropdownMenu( expanded = showRowOverflow, onDismissRequest = { showRowOverflow = false }, ) { - DropdownMenuItem( - text = { - val baseLabel = stringResource(Res.string.apps_ignore_updates) - Text(text = if (!app.updateCheckEnabled) "$baseLabel ✓" else baseLabel) - }, - onClick = { - showRowOverflow = false - onToggleUpdateCheck(!app.updateCheckEnabled) - }, - ) + run { + val baseLabel = stringResource(Res.string.apps_ignore_updates) + GhsDropdownMenuItem( + text = if (!app.updateCheckEnabled) "$baseLabel ✓" else baseLabel, + onClick = { + showRowOverflow = false + onToggleUpdateCheck(!app.updateCheckEnabled) + }, + ) + } - // Skip-this-version is only meaningful when an - // update is currently being prompted (we have a - // latestVersion to skip). When the row already - // has a skipped tag stored, surface the unskip - // affordance instead so the user can revert the - // suppression without leaving the row. if (app.skippedReleaseTag != null) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.apps_skip_version_unskip)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.apps_skip_version_unskip), onClick = { showRowOverflow = false onUnskipVersionClick() }, ) } else if (app.isUpdateAvailable && !(app.latestVersion ?: app.latestVersionName).isNullOrBlank()) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.apps_skip_version)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.apps_skip_version), onClick = { showRowOverflow = false onSkipVersionClick() @@ -1358,52 +1331,27 @@ fun AppItemCard( when (appItem.updateState) { is UpdateState.Downloading, is UpdateState.Installing, is UpdateState.CheckingUpdate -> { - Button( + GhsButton( onClick = onCancelClick, + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Destructive, + leadingIcon = Icons.Default.Cancel, modifier = Modifier.weight(1f), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = stringResource(Res.string.cancel), - modifier = Modifier.size(18.dp), - ) - - Spacer(Modifier.width(4.dp)) - - Text( - text = stringResource(Res.string.cancel), - ) - } + ) } else -> { if (app.pendingInstallFilePath != null) { - // One-tap install for a deferred download. - // Bypasses the download phase entirely — - // the file is already on disk. - Button( + + GhsButton( onClick = onInstallPendingClick, - modifier = Modifier.weight(1f), + label = stringResource(Res.string.install), + variant = GhsButtonVariant.Primary, + leadingIcon = Icons.Default.Update, enabled = !isBusy, - ) { - Icon( - imageVector = Icons.Default.Update, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - text = stringResource(Res.string.install), - ) - } - // Quick escape hatch: user cancelled the - // system prompt and doesn't want this app. - // Discard removes the parked file + DB row. + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDiscardPendingClick) { Icon( imageVector = Icons.Default.Cancel, @@ -1412,63 +1360,31 @@ fun AppItemCard( ) } } else if (app.isUpdateAvailable && !app.isPendingInstall) { - Button( + GhsButton( onClick = onUpdateClick, + label = stringResource(Res.string.update), + variant = GhsButtonVariant.Primary, + leadingIcon = Icons.Default.Update, modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = Icons.Default.Update, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - text = stringResource(Res.string.update), - ) - } + ) } else if (app.isPendingInstall) { - // Pending row whose parked file is gone - // (legacy row from before we persisted the - // path, or file deleted out-of-band). The - // app isn't actually installed — Open would - // snackbar an error. Offer Discard so the - // user can clear the row. - Button( + + GhsButton( onClick = onDiscardPendingClick, + label = stringResource(Res.string.discard_pending_install), + variant = GhsButtonVariant.Destructive, + leadingIcon = Icons.Default.Cancel, modifier = Modifier.weight(1f), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - text = stringResource(Res.string.discard_pending_install), - ) - } + ) } else { - Button( - shapes = ButtonDefaults.shapes(), + GhsButton( onClick = onOpenClick, - modifier = Modifier.weight(1f), + label = stringResource(Res.string.open), + variant = GhsButtonVariant.Primary, + leadingIcon = Icons.AutoMirrored.Filled.OpenInNew, enabled = !isBusy, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - text = stringResource(Res.string.open), - ) - } + modifier = Modifier.weight(1f), + ) } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt index c53cf535e..078018c6b 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt @@ -27,13 +27,10 @@ data class AppsState( val isCheckingForUpdates: Boolean = false, val lastCheckedTimestamp: Long? = null, val isRefreshing: Boolean = false, - /** - * Whether the "Up to date" section is expanded. Default expanded so - * users with no updates pending still see their apps. Collapses - * independently of the (always-expanded) "Updates available" section. - */ + val isUpToDateSectionExpanded: Boolean = true, - // Link app to repo + val isUpdatesSectionExpanded: Boolean = true, + val showLinkSheet: Boolean = false, val linkStep: LinkStep = LinkStep.PickApp, val deviceApps: ImmutableList = persistentListOf(), @@ -50,13 +47,13 @@ data class AppsState( val linkSelectedAsset: GithubAssetUi? = null, val linkDownloadProgress: Int? = null, val fetchedRepoInfo: GithubRepoInfoUi? = null, - /** Filter input on the PickAsset step. Live-narrows [linkInstallableAssets]. */ + val linkAssetFilter: String = "", - /** Validation message for [linkAssetFilter] (invalid regex syntax). */ + val linkAssetFilterError: String? = null, - /** Whether linking should also enable fallback-to-older-releases. */ + val linkFallbackToOlder: Boolean = false, - // Per-app advanced settings (monorepo support) + val advancedSettingsApp: InstalledAppUi? = null, val advancedFilterDraft: String = "", val advancedFallbackDraft: Boolean = false, @@ -66,35 +63,31 @@ data class AppsState( val advancedPreviewTag: String? = null, val advancedPreviewMessage: String? = null, val advancedSavingFilter: Boolean = false, - // Variant picker dialog (shown when preferredVariantStale, when the - // user explicitly opens it from advanced settings, or when they tap - // Update on a stale-variant app) + val variantPickerApp: InstalledAppUi? = null, val variantPickerLoading: Boolean = false, val variantPickerOptions: ImmutableList = persistentListOf(), val variantPickerCurrentVariant: String? = null, val variantPickerError: String? = null, - /** - * Set when the picker is being shown specifically because the user - * tapped Update on a stale-variant app — after they pick we should - * automatically resume the update flow. - */ + val variantPickerResumeUpdateAfterPick: Boolean = false, - // Export/Import + val isExporting: Boolean = false, val isImporting: Boolean = false, val importSummary: zed.rainxch.apps.domain.model.ImportResult? = null, - // Uninstall confirmation + val appPendingUninstall: InstalledAppUi? = null, - // Discard-pending-install confirmation + val appPendingDiscard: InstalledAppUi? = null, - // External import banner (E1) + val pendingExternalImportCount: Int = 0, val showImportProposalBanner: Boolean = false, val isExternalImportInFlight: Boolean = false, - // Keep Android Open campaign banner + val showKaoBanner: Boolean = false, val linkSourceHost: String? = null, + + val twoPaneSelectedPackage: String? = null, ) { val filteredDeviceApps: ImmutableList get() { @@ -115,12 +108,6 @@ data class AppsState( ).toImmutableList() } - /** - * Live-filtered view of [linkInstallableAssets] for the link sheet's - * PickAsset step. When the filter is invalid we keep showing the full - * list so the user can still pick something — the error is surfaced via - * [linkAssetFilterError]. - */ val filteredLinkAssets: ImmutableList get() { val raw = linkAssetFilter.trim() diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index f747ff239..9a49e3c22 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -77,15 +77,9 @@ class AppsViewModel( private var updateAllJob: Job? = null private var lastAutoCheckTimestamp: Long = 0L - // Synchronous mirror of the persisted dismiss watermark. The persisted - // write goes through DataStore async, so the import-banner flow could - // re-emit with the OLD watermark and re-flash the banner before the - // write completes. This in-memory shadow is set BEFORE the launch and - // OR'd into shouldShow so the suppression is immediate. @Volatile private var localBannerDismissedAtCount: Int = 0 - /** Debounced re-runs of the live preview in the advanced settings sheet. */ private var advancedPreviewJob: Job? = null private val _state = MutableStateFlow(AppsState()) @@ -119,10 +113,7 @@ class AppsViewModel( count to dismissedAt } .collect { (count, dismissedAt) -> - // The effective watermark is the max of the persisted value and the - // synchronous local one. The local shadow is set immediately by the - // Review/Dismiss handlers so a flow emission that beats the DataStore - // write still sees a watermark that suppresses the banner. + val effectiveDismissedAt = maxOf(dismissedAt, localBannerDismissedAtCount) val shouldShow = count >= BANNER_THRESHOLD && count > effectiveDismissedAt _state.update { @@ -207,12 +198,7 @@ class AppsViewModel( private fun checkAllForUpdates() { viewModelScope.launch { - // Stamp the inflight guard at launch start — a second resume - // that races the network call finds the cooldown gate already - // tripped and exits early. `lastCheckedTimestamp` (the UI - // "last checked" indicator) still only advances on success so - // a failed check doesn't lie to the user — matches the - // `refresh()` semantics. + lastAutoCheckTimestamp = System.currentTimeMillis() _state.update { it.copy(isCheckingForUpdates = true) } try { @@ -289,10 +275,7 @@ class AppsViewModel( } is AppsAction.OnUpdateApp -> { - // If the app's pinned variant has gone missing in the - // latest release we don't know what to download — open - // the picker first and resume the update after the user - // chooses. Saves them from a wrong-variant install. + if (action.app.preferredVariantStale) { openVariantPicker(action.app, resumeUpdateAfterPick = true) } else { @@ -345,6 +328,14 @@ class AppsViewModel( _state.update { it.copy(isUpToDateSectionExpanded = !it.isUpToDateSectionExpanded) } } + AppsAction.OnToggleUpdatesSection -> { + _state.update { it.copy(isUpdatesSectionExpanded = !it.isUpdatesSectionExpanded) } + } + + is AppsAction.OnTwoPaneSelect -> { + _state.update { it.copy(twoPaneSelectedPackage = action.packageName) } + } + is AppsAction.OnNavigateToRepo -> { viewModelScope.launch { _events.send( @@ -607,10 +598,7 @@ class AppsViewModel( AppsAction.OnImportProposalReview -> { val current = _state.value.pendingExternalImportCount - // Set the local watermark BEFORE the launch so a racing flow - // emission can't recompute shouldShow=true on the stale persisted - // watermark. observePendingExternalImports OR's this with the - // persisted value via maxOf(). + localBannerDismissedAtCount = maxOf(localBannerDismissedAtCount, current) _state.update { it.copy(showImportProposalBanner = false) } viewModelScope.launch { @@ -629,9 +617,7 @@ class AppsViewModel( } AppsAction.OnRescanForGithubApps -> { - // Manual rescan resets the banner watermark so the user sees - // everything fresh; the wizard's startScanIfIdle then runs - // a full scan + match cycle on entry. + localBannerDismissedAtCount = 0 _state.update { it.copy(showImportProposalBanner = false) } viewModelScope.launch { @@ -771,11 +757,6 @@ class AppsViewModel( if (errorKey == null) schedulePreviewRefresh() } - /** - * Debounces preview refresh while the user is typing. We don't want to - * issue a fresh GitHub releases call on every keystroke — 350ms after - * input stops is plenty responsive without burning rate limit. - */ private fun schedulePreviewRefresh() { advancedPreviewJob?.cancel() advancedPreviewJob = @@ -790,8 +771,6 @@ class AppsViewModel( val draftFilter = _state.value.advancedFilterDraft val draftFallback = _state.value.advancedFallbackDraft - // Validate locally before hitting the network — invalid regex - // shows the error inline and aborts the preview. val parseResult = AssetFilter.parse(draftFilter) if (parseResult != null && parseResult.isFailure) { _state.update { @@ -853,13 +832,6 @@ class AppsViewModel( } } - /** - * Opens the variant picker for [app]. Fetches the current latest - * matching release (honouring the per-app filter / fallback) so the - * dialog can show real, current asset names — not the cached ones - * which might be stale or wrong. When [resumeUpdateAfterPick] is - * true, dispatch the update flow as soon as the user picks. - */ private fun openVariantPicker( app: InstalledAppUi, resumeUpdateAfterPick: Boolean, @@ -884,11 +856,7 @@ class AppsViewModel( includePreReleases = app.includePreReleases, fallbackToOlderReleases = app.fallbackToOlderReleases, ) - // Only assets whose filename has an extractable, non-empty - // variant tag are pinnable: an empty extract or null means - // we'd have nothing to remember release-over-release. The - // dialog filters its own list so users can't tap a row - // that would silently no-op. + val pinnableAssets = preview.matchedAssets.filter { asset -> AssetVariant.extract(asset.name)?.isNotEmpty() == true @@ -922,12 +890,6 @@ class AppsViewModel( } } - /** - * Persists the user's variant pick (or null to reset to auto), - * dismisses the dialog, and — if the picker was opened from a "tap - * Update on stale variant" flow — kicks the update off automatically - * with the freshly-resolved cached fields. - */ private fun saveVariantSelection(variant: String?) { val app = _state.value.variantPickerApp ?: return val resume = _state.value.variantPickerResumeUpdateAfterPick @@ -946,7 +908,6 @@ class AppsViewModel( return@launch } - // Dismiss the dialog regardless of whether we resume. _state.update { it.copy( variantPickerApp = null, @@ -959,15 +920,7 @@ class AppsViewModel( } if (resume) { - // Read the canonical InstalledApp directly from the - // repository rather than the in-memory state. The Flow - // that drives `_state.value.apps` propagates DAO writes - // asynchronously, so reading state right after - // setPreferredVariant — which itself runs an internal - // checkForUpdates write — can race and hand us the OLD - // pre-pick row, leading to an update with the wrong - // asset URL. A direct DAO read is synchronous and never - // races against pending Flow emissions. + val refreshed = runCatching { installedAppsRepository.getAppByPackage(app.packageName) } .getOrNull() @@ -984,7 +937,6 @@ class AppsViewModel( val draftFilter = _state.value.advancedFilterDraft.trim() val draftFallback = _state.value.advancedFallbackDraft - // Final regex validation — if it's broken we refuse to save. val parseResult = AssetFilter.parse(draftFilter) if (parseResult != null && parseResult.isFailure) { _state.update { it.copy(advancedFilterError = "invalid") } @@ -994,8 +946,7 @@ class AppsViewModel( viewModelScope.launch { _state.update { it.copy(advancedSavingFilter = true) } try { - // `setAssetFilter` persists and then re-checks internally, - // so the UI badge is refreshed without a second round-trip. + installedAppsRepository.setAssetFilter( packageName = app.packageName, regex = draftFilter.takeIf { it.isNotEmpty() }, @@ -1112,13 +1063,6 @@ class AppsViewModel( throw IllegalStateException("No installable assets found for this platform") } - // Honour the user's pinned variant first; fall back to - // the platform installer's auto-pick if the variant - // isn't present in this release. The auto-pick - // intentionally never throws here — checkForUpdates - // already flipped `preferredVariantStale=true` and the - // earlier intercept (see updateSingleApp entrypoint) - // would have routed us to the picker dialog instead. val variantMatch = AssetVariant.resolvePreferredAsset( assets = installableAssets, @@ -1166,13 +1110,6 @@ class AppsViewModel( updateAppState(app.packageName, UpdateState.Downloading) - // Route the download through the orchestrator so - // it survives this VM being torn down (user - // navigating away from the apps tab). Shizuku - // gets AlwaysInstall (silent install regardless - // of foreground state); regular installer gets - // InstallWhileForeground so the existing dialog/ - // installer dispatch below stays in charge. val installerType = try { tweaksRepository.getInstallerType().first() @@ -1254,10 +1191,6 @@ class AppsViewModel( throw e } - // Successful install — release the orchestrator - // entry so the apps row stops showing the - // download/install state. The DB sync continues - // via PackageEventReceiver. downloadOrchestrator.dismiss(app.packageName) try { installedAppsRepository.setPendingInstallFilePath(app.packageName, null) @@ -1471,10 +1404,7 @@ class AppsViewModel( packageName: String, progress: Int?, ) { - // Download progress is purely a per-row visual update; it doesn't - // affect sort order or search match. Avoid re-running the full - // filter+sort pass on every progress tick (which at 50+ apps with - // a download in flight ran ~100×/sec). Map both lists in place. + _state.update { currentState -> currentState.copy( apps = @@ -1504,30 +1434,6 @@ class AppsViewModel( logger.debug("Marked ${app.packageName} as pending install") } - /** - * Subscribes to the orchestrator's entry for [packageName] and - * suspends until it reaches a terminal stage. Mirrors progress - * via [onProgress] while downloading. - * - * Returns: - * - The file path when the orchestrator parks the file at - * [OrchestratorStage.AwaitingInstall] (regular installer path) - * - `null` when the orchestrator finishes its own install - * ([OrchestratorStage.Completed], the Shizuku/AlwaysInstall - * path) — the caller has nothing more to do - * - `null` when the entry is cancelled or fails — the caller - * treats this as "abort the local install logic" - * - * Implementation: forwards progress side-effects via a - * `transform` step, then `first { predicate }` finds the first - * emission whose stage is terminal. Avoids needing to throw out - * of `collect`. - */ - /** - * Result type for [waitForOrchestratorReady] so callers can - * distinguish "file is ready" from "orchestrator already installed" - * from "download failed". - */ private sealed interface OrchestratorResult { data class Ready(val filePath: String) : OrchestratorResult data object AlreadyInstalled : OrchestratorResult @@ -1565,16 +1471,6 @@ class AppsViewModel( } } - /** - * Triggers an install for an app whose download was previously - * deferred (the orchestrator parked the file in `AwaitingInstall` - * mode after the user navigated away mid-download). Used by the - * apps row "Install" button when [InstalledAppUi.pendingInstallFilePath] - * is non-null. - * - * Delegates to [DownloadOrchestrator.installPending] which - * handles validation-free install + DB cleanup. - */ private fun installPendingApp(app: InstalledAppUi) { if (activeUpdates.containsKey(app.packageName)) { logger.debug("Install already in progress for ${app.packageName}") @@ -1597,13 +1493,6 @@ class AppsViewModel( } } - /** - * User decided to drop a pending install — they cancelled the - * system installer prompt and don't want this app anymore. Cancels - * the orchestrator entry (this also nukes the parked APK file via - * the downloader's cancel path), removes the DB row, and clears - * any leftover pending notification. - */ private fun discardPendingInstall(app: InstalledAppUi) { viewModelScope.launch { try { @@ -1613,11 +1502,7 @@ class AppsViewModel( } catch (t: Throwable) { logger.warn("discardPendingInstall: orchestrator cancel failed: ${t.message}") } - // Belt-and-braces: cancel doesn't nuke the file when the - // entry was already in AwaitingInstall — the orchestrator - // already cleared its in-memory metadata before the user - // tapped Discard. Delete the parked file directly so the - // bytes don't leak. + app.pendingInstallFilePath?.let { path -> runCatching { File(path).takeIf { it.exists() }?.delete() } .onFailure { @@ -1631,11 +1516,7 @@ class AppsViewModel( } catch (e: CancellationException) { throw e } catch (t: Throwable) { - // Mirror uninstallApp's UX: a row-delete failure leaves - // the apps list pointing at metadata for an APK that's - // already been removed from disk, so the user has to - // know it didn't take. Log the cause and surface a - // localized error event for the snackbar host. + logger.error( "discardPendingInstall: row delete failed for ${app.packageName}: ${t.message}", ) @@ -1787,15 +1668,11 @@ class AppsViewModel( } private fun onLinkAssetFilterChanged(value: String) { - // Validate the regex on every keystroke so the user gets immediate - // feedback. The state's filteredLinkAssets getter falls back to the - // unfiltered list when the regex is invalid, so the picker stays - // usable even mid-typing. + val parseResult = AssetFilter.parse(value) val error = parseResult?.exceptionOrNull()?.let { _ -> - // Localized message comes from the UI layer; here we just - // signal that something is wrong. + "invalid" } _state.update { @@ -1806,22 +1683,6 @@ class AppsViewModel( } } - /** - * Picks a sensible default for the link-flow filter. Tries, in order: - * 1. The trailing segment of the package name (e.g. `io.ente.auth` → `auth`) - * 2. A token derived from the device app's display name (e.g. - * `Ente Auth` → `auth`) - * 3. [AssetFilter.suggestFromAssetName] on the first asset - * - * Every candidate is routed through [Regex.escape] before validation - * so metacharacters in package names or display words (think - * `My App (Beta)` → `(beta)`) are treated literally and never break - * regex compilation. - * - * Returns the first non-blank candidate that actually matches at least - * one of the available assets — otherwise null, which leaves the field - * empty so we don't pre-fill something useless. - */ private fun suggestFilterForLink( deviceAppName: String, packageName: String, @@ -1839,11 +1700,9 @@ class AppsViewModel( return if (assets.any { regex.containsMatchIn(it.name) }) escaped else null } - // 1. Last package segment (commonly the most distinctive token). val packageTail = packageName.substringAfterLast('.').lowercase() tryCandidate(packageTail)?.let { return it } - // 2. Significant words from the display name. deviceAppName .split(' ', '-', '_') .map { it.lowercase().trim() } @@ -1851,8 +1710,6 @@ class AppsViewModel( tryCandidate(token)?.let { return it } } - // 3. Heuristic on the first asset name (already escaped + anchored - // by AssetFilter.suggestFromAssetName). return firstAssetName?.let { AssetFilter.suggestFromAssetName(it) } } @@ -1969,11 +1826,6 @@ class AppsViewModel( return@launch } - // Seed an auto-suggestion based on the device app's package - // name first, then fall back to the first installable asset. - // This makes monorepo linking nearly zero-effort: pick "Ente - // Auth" → the filter pre-fills with "auth" so the picker - // already shows just the relevant APKs. val suggestedFilter = suggestFilterForLink( deviceAppName = selectedApp.appName, @@ -2024,11 +1876,6 @@ class AppsViewModel( val assetFilterRegex = _state.value.linkAssetFilter.takeIf { it.isNotBlank() } val fallbackToOlder = _state.value.linkFallbackToOlder - // The user explicitly picked this asset → trust the choice. - // Skip the re-download + signing-key verification dance (which - // burned bandwidth and time for nothing the user couldn't already - // confirm). The `installSource = MANUAL` field linkAppToRepo - // writes is what the Details banner reads to flag manual links. viewModelScope.launch { _state.update { it.copy( @@ -2174,11 +2021,6 @@ class AppsViewModel( override fun onCleared() { super.onCleared() - // Cancel local OBSERVERS only — the orchestrator entries - // keep running in the application scope. Each in-flight - // download is downgraded to DeferUntilUserAction so the - // user gets a notification when it's ready, instead of - // losing the work. updateAllJob?.cancel() val packageNames = activeUpdates.keys.toList() activeUpdates.values.forEach { it.cancel() } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AdvancedAppSettingsBottomSheet.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AdvancedAppSettingsBottomSheet.kt index 470a8574d..8e5f5b421 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AdvancedAppSettingsBottomSheet.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AdvancedAppSettingsBottomSheet.kt @@ -25,17 +25,17 @@ import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField +import zed.rainxch.core.presentation.components.inputs.GhsTextField +import zed.rainxch.core.presentation.components.overlays.GhsBottomSheet import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -51,15 +51,6 @@ import zed.rainxch.apps.presentation.AppsState import zed.rainxch.apps.presentation.model.GithubAssetUi import zed.rainxch.githubstore.core.presentation.res.* -/** - * Per-app advanced settings sheet for monorepo support. Shows: - * - Asset filter (regex) text field with inline validation - * - Fall-back-to-older-releases toggle - * - **Live preview** of which assets in the latest matching release the - * current draft would resolve to. This is the killer UX touch — users - * can iterate on the regex and immediately see the effect without - * having to save and run an update check. - */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AdvancedAppSettingsBottomSheet( @@ -67,17 +58,14 @@ fun AdvancedAppSettingsBottomSheet( onAction: (AppsAction) -> Unit, ) { val app = state.advancedSettingsApp ?: return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( + GhsBottomSheet( onDismissRequest = { onAction(AppsAction.OnDismissAdvancedSettings) }, - sheetState = sheetState, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(bottom = 24.dp), + .fillMaxWidth(), ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( @@ -112,48 +100,36 @@ fun AdvancedAppSettingsBottomSheet( Spacer(Modifier.height(20.dp)) - // === Asset filter === - OutlinedTextField( + val advancedSupporting = when { + state.advancedFilterError != null -> + stringResource(Res.string.asset_filter_invalid) + else -> stringResource(Res.string.asset_filter_help) + } + GhsTextField( value = state.advancedFilterDraft, onValueChange = { onAction(AppsAction.OnAdvancedFilterChanged(it)) }, modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(Res.string.asset_filter_label)) }, - placeholder = { Text(stringResource(Res.string.asset_filter_placeholder)) }, - leadingIcon = { - Icon(Icons.Default.FilterAlt, contentDescription = null) - }, + label = stringResource(Res.string.asset_filter_label), + placeholder = stringResource(Res.string.asset_filter_placeholder), + leadingIcon = Icons.Default.FilterAlt, trailingIcon = { if (state.advancedFilterDraft.isNotEmpty()) { - TextButton(onClick = { onAction(AppsAction.OnAdvancedClearFilter) }) { - Text(stringResource(Res.string.clear)) - } + GhsButton( + onClick = { onAction(AppsAction.OnAdvancedClearFilter) }, + label = stringResource(Res.string.clear), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } }, singleLine = true, isError = state.advancedFilterError != null, - supportingText = { - Text( - text = - when { - state.advancedFilterError != null -> - stringResource(Res.string.asset_filter_invalid) - else -> stringResource(Res.string.asset_filter_help) - }, - color = - if (state.advancedFilterError != null) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - }, + supportingText = advancedSupporting, enabled = !state.advancedSavingFilter, - shape = RoundedCornerShape(12.dp), ) Spacer(Modifier.height(12.dp)) - // === Fallback toggle === Row( modifier = Modifier .fillMaxWidth() @@ -185,17 +161,6 @@ fun AdvancedAppSettingsBottomSheet( ) Spacer(Modifier.height(16.dp)) - // === Preferred variant row === - // Tappable row that opens the variant picker dialog. Shows - // the currently-pinned variant tag (or "Auto" when none), - // and warns the user when the pin has gone stale. - // - // Cross-link copy: a one-liner above the row clarifies - // the *relationship* between the filter (which assets are - // even considered) and the variant pin (which of the - // matching assets gets installed) — these are the two - // axes a user is actually adjusting when they wonder why - // an update grabbed the wrong file. Text( text = stringResource(Res.string.advanced_filter_variant_relation), style = MaterialTheme.typography.bodySmall, @@ -214,7 +179,6 @@ fun AdvancedAppSettingsBottomSheet( ) Spacer(Modifier.height(16.dp)) - // === Live preview === PreviewSection( isLoading = state.advancedPreviewLoading, matchedAssets = state.advancedPreviewMatched, @@ -225,37 +189,25 @@ fun AdvancedAppSettingsBottomSheet( Spacer(Modifier.height(20.dp)) - // === Save / cancel buttons === Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - OutlinedButton( + GhsButton( onClick = { onAction(AppsAction.OnDismissAdvancedSettings) }, + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Outline, enabled = !state.advancedSavingFilter, modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - ) { - Text(stringResource(Res.string.cancel)) - } - FilledTonalButton( + ) + GhsButton( onClick = { onAction(AppsAction.OnAdvancedSaveFilter) }, + label = stringResource(Res.string.advanced_save), + variant = GhsButtonVariant.Tonal, enabled = !state.advancedSavingFilter && state.advancedFilterError == null, + loading = state.advancedSavingFilter, modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - ) { - if (state.advancedSavingFilter) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - Spacer(Modifier.width(8.dp)) - } - Text( - text = stringResource(Res.string.advanced_save), - fontWeight = FontWeight.Bold, - ) - } + ) } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AppDetailPane.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AppDetailPane.kt new file mode 100644 index 000000000..462df2521 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AppDetailPane.kt @@ -0,0 +1,571 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + +package zed.rainxch.apps.presentation.components + +import androidx.compose.foundation.BorderStroke +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.PaddingValues +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.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Update +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.apps.presentation.model.AppItem +import zed.rainxch.apps.presentation.model.UpdateState +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.apps_compact_status_pending_install +import zed.rainxch.githubstore.core.presentation.res.apps_compact_status_pre_release_on +import zed.rainxch.githubstore.core.presentation.res.apps_compact_status_ready_to_install +import zed.rainxch.githubstore.core.presentation.res.apps_compact_status_updates_ignored +import zed.rainxch.githubstore.core.presentation.res.apps_ignore_updates +import zed.rainxch.githubstore.core.presentation.res.apps_two_pane_detail_section_actions +import zed.rainxch.githubstore.core.presentation.res.apps_two_pane_detail_section_settings +import zed.rainxch.githubstore.core.presentation.res.apps_two_pane_detail_section_status +import zed.rainxch.githubstore.core.presentation.res.apps_two_pane_empty_subtitle +import zed.rainxch.githubstore.core.presentation.res.apps_two_pane_empty_title +import zed.rainxch.githubstore.core.presentation.res.apps_two_pane_advanced_settings +import zed.rainxch.githubstore.core.presentation.res.apps_two_pane_installed_label +import zed.rainxch.githubstore.core.presentation.res.apps_two_pane_latest_label +import zed.rainxch.githubstore.core.presentation.res.apps_two_pane_open_repo +import zed.rainxch.githubstore.core.presentation.res.apps_two_pane_pick_variant +import zed.rainxch.githubstore.core.presentation.res.cancel +import zed.rainxch.githubstore.core.presentation.res.discard_pending_install +import zed.rainxch.githubstore.core.presentation.res.downloading +import zed.rainxch.githubstore.core.presentation.res.error_with_message +import zed.rainxch.githubstore.core.presentation.res.install +import zed.rainxch.githubstore.core.presentation.res.installing +import zed.rainxch.githubstore.core.presentation.res.open +import zed.rainxch.githubstore.core.presentation.res.pre_release_badge +import zed.rainxch.githubstore.core.presentation.res.uninstall +import zed.rainxch.githubstore.core.presentation.res.update +import zed.rainxch.githubstore.core.presentation.res.variant_label_inline + +@Composable +fun AppDetailPane( + appItem: AppItem?, + onOpenApp: () -> Unit, + onUpdateApp: () -> Unit, + onCancelUpdate: () -> Unit, + onUninstall: () -> Unit, + onOpenRepo: () -> Unit, + onTogglePreReleases: (Boolean) -> Unit, + onToggleUpdateCheck: (Boolean) -> Unit, + onOpenAdvancedSettings: () -> Unit, + onPickVariant: () -> Unit, + onInstallPending: () -> Unit, + onDiscardPending: () -> Unit, + modifier: Modifier = Modifier, +) { + if (appItem == null) { + EmptyDetailPane(modifier = modifier) + return + } + + val app = appItem.installedApp + val isBusy = app.isPendingInstall || + appItem.updateState is UpdateState.Downloading || + appItem.updateState is UpdateState.Installing || + appItem.updateState is UpdateState.CheckingUpdate + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(8.dp)) + DetailHeader(appItem = appItem) + + Spacer(Modifier.height(16.dp)) + SectionLabel(stringResource(Res.string.apps_two_pane_detail_section_status)) + StatusBlock(appItem = appItem) + + Spacer(Modifier.height(20.dp)) + SectionLabel(stringResource(Res.string.apps_two_pane_detail_section_actions)) + PrimaryActionsRow( + appItem = appItem, + isBusy = isBusy, + onOpenApp = onOpenApp, + onUpdateApp = onUpdateApp, + onCancelUpdate = onCancelUpdate, + onInstallPending = onInstallPending, + onDiscardPending = onDiscardPending, + ) + + Spacer(Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + GhsButton( + onClick = onOpenRepo, + label = stringResource(Res.string.apps_two_pane_open_repo), + variant = GhsButtonVariant.Outline, + leadingIcon = Icons.AutoMirrored.Filled.OpenInNew, + modifier = Modifier.weight(1f), + ) + GhsButton( + onClick = onUninstall, + label = stringResource(Res.string.uninstall), + variant = GhsButtonVariant.Destructive, + enabled = !isBusy, + leadingIcon = Icons.Outlined.DeleteOutline, + ) + } + + Spacer(Modifier.height(20.dp)) + SectionLabel(stringResource(Res.string.apps_two_pane_detail_section_settings)) + SettingsBlock( + appItem = appItem, + onTogglePreReleases = onTogglePreReleases, + onToggleUpdateCheck = onToggleUpdateCheck, + onOpenAdvancedSettings = onOpenAdvancedSettings, + onPickVariant = onPickVariant, + ) + + Spacer(Modifier.height(24.dp)) + } +} + +@Composable +private fun DetailHeader(appItem: AppItem) { + val app = appItem.installedApp + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + InstalledAppIcon( + packageName = app.packageName, + appName = app.appName, + apkFilePath = app.pendingInstallFilePath, + modifier = Modifier.size(56.dp), + ) + } + Spacer(Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = app.appName, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + ), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = app.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = "${app.repoOwner}/${app.repoName}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun StatusBlock(appItem: AppItem) { + val app = appItem.installedApp + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(16.dp)) { + StatusRow( + label = stringResource(Res.string.apps_two_pane_installed_label), + value = app.installedVersion, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + modifier = Modifier.padding(vertical = 10.dp), + ) + StatusRow( + label = stringResource(Res.string.apps_two_pane_latest_label), + value = app.latestVersion ?: "—", + ) + if (app.preferredAssetVariant != null) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + modifier = Modifier.padding(vertical = 10.dp), + ) + StatusRow( + label = stringResource(Res.string.variant_label_inline), + value = app.preferredAssetVariant, + ) + } + + when (val state = appItem.updateState) { + is UpdateState.Downloading -> { + Spacer(Modifier.height(14.dp)) + StatusProgress( + label = stringResource(Res.string.downloading), + progress = null, + ) + } + is UpdateState.Installing -> { + Spacer(Modifier.height(14.dp)) + StatusProgress( + label = stringResource(Res.string.installing), + progress = null, + ) + } + is UpdateState.Error -> { + Spacer(Modifier.height(10.dp)) + Text( + text = stringResource(Res.string.error_with_message, state.message), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + else -> {} + } + + val pills = buildList { + if (app.isPendingInstall) add(stringResource(Res.string.apps_compact_status_pending_install)) + if (app.pendingInstallFilePath != null && !app.isPendingInstall) { + add(stringResource(Res.string.apps_compact_status_ready_to_install)) + } + if (app.includePreReleases) add(stringResource(Res.string.apps_compact_status_pre_release_on)) + if (!app.updateCheckEnabled) add(stringResource(Res.string.apps_compact_status_updates_ignored)) + } + if (pills.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + pills.forEach { label -> + StatusPill(label) + } + } + } + } + } +} + +@Composable +private fun StatusRow( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun StatusProgress( + label: String, + progress: Float?, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + if (progress != null) { + LinearWavyProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun StatusPill(label: String) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } +} + +@Composable +private fun PrimaryActionsRow( + appItem: AppItem, + isBusy: Boolean, + onOpenApp: () -> Unit, + onUpdateApp: () -> Unit, + onCancelUpdate: () -> Unit, + onInstallPending: () -> Unit, + onDiscardPending: () -> Unit, +) { + val app = appItem.installedApp + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + when (appItem.updateState) { + is UpdateState.Downloading, is UpdateState.Installing, is UpdateState.CheckingUpdate -> { + GhsButton( + onClick = onCancelUpdate, + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Destructive, + leadingIcon = Icons.Default.Cancel, + modifier = Modifier.weight(1f), + ) + } + else -> { + if (app.pendingInstallFilePath != null) { + GhsButton( + onClick = onInstallPending, + label = stringResource(Res.string.install), + variant = GhsButtonVariant.Primary, + enabled = !isBusy, + leadingIcon = Icons.Default.Update, + modifier = Modifier.weight(1f), + ) + GhsButton( + onClick = onDiscardPending, + label = stringResource(Res.string.discard_pending_install), + variant = GhsButtonVariant.Outline, + ) + } else if (app.isUpdateAvailable && !app.isPendingInstall) { + GhsButton( + onClick = onUpdateApp, + label = stringResource(Res.string.update), + variant = GhsButtonVariant.Primary, + leadingIcon = Icons.Default.Update, + modifier = Modifier.weight(1f), + ) + } else { + GhsButton( + onClick = onOpenApp, + label = stringResource(Res.string.open), + variant = GhsButtonVariant.Primary, + enabled = !isBusy, + leadingIcon = Icons.AutoMirrored.Filled.OpenInNew, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} + +@Composable +private fun SettingsBlock( + appItem: AppItem, + onTogglePreReleases: (Boolean) -> Unit, + onToggleUpdateCheck: (Boolean) -> Unit, + onOpenAdvancedSettings: () -> Unit, + onPickVariant: () -> Unit, +) { + val app = appItem.installedApp + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = Modifier.fillMaxWidth(), + ) { + Column { + SettingsToggleRow( + title = stringResource(Res.string.pre_release_badge), + checked = app.includePreReleases, + onCheckedChange = onTogglePreReleases, + ) + DividerThin() + SettingsToggleRow( + title = stringResource(Res.string.apps_ignore_updates), + checked = !app.updateCheckEnabled, + onCheckedChange = { onToggleUpdateCheck(!it) }, + ) + DividerThin() + SettingsActionRow( + title = stringResource(Res.string.apps_two_pane_pick_variant), + onClick = onPickVariant, + ) + DividerThin() + SettingsActionRow( + title = stringResource(Res.string.apps_two_pane_advanced_settings), + onClick = onOpenAdvancedSettings, + ) + } + } +} + +@Composable +private fun SettingsToggleRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + } +} + +@Composable +private fun SettingsActionRow( + title: String, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerLow, + onClick = onClick, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } +} + +@Composable +private fun DividerThin() { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + ) +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp, start = 4.dp), + ) +} + +@Composable +private fun EmptyDetailPane(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp), + ) { + Icon( + imageVector = Icons.Outlined.Apps, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(Res.string.apps_two_pane_empty_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.apps_two_pane_empty_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AppsSectionHeader.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AppsSectionHeader.kt index 346b0cce5..94545bf9d 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AppsSectionHeader.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/AppsSectionHeader.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -26,7 +27,9 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.vocabulary.Squiggle import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.apps_section_collapse import zed.rainxch.githubstore.core.presentation.res.apps_section_count_suffix @@ -34,15 +37,6 @@ import zed.rainxch.githubstore.core.presentation.res.apps_section_expand import zed.rainxch.githubstore.core.presentation.res.apps_section_state_collapsed import zed.rainxch.githubstore.core.presentation.res.apps_section_state_expanded -/** - * Section header shown above each grouped app list. Honours WCAG: the row - * carries `Role.Button` + `heading()` semantics, announces expanded / - * collapsed state via `stateDescription`, and includes the item count in - * the visible label so it's read as part of the heading. - * - * Pass [collapsible] = false to render a static heading (e.g. for the - * always-expanded "Updates available" group). - */ @Composable fun AppsSectionHeader( title: String, @@ -64,54 +58,63 @@ fun AppsSectionHeader( val rowSemantic = if (isExpanded) collapseLabel else expandLabel - Row( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = 16.dp, bottom = 4.dp) - .let { base -> - if (collapsible) { - base - .clickable(onClick = onToggle) - .semantics(mergeDescendants = true) { - role = Role.Button - heading() - contentDescription = "$title, $count, $rowSemantic" - stateDescription = if (isExpanded) expandedStateLabel else collapsedStateLabel - } - } else { - base.semantics(mergeDescendants = true) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 20.dp, bottom = 6.dp) + .let { base -> + if (collapsible) { + base + .clickable(onClick = onToggle) + .semantics(mergeDescendants = true) { + role = Role.Button heading() - contentDescription = "$title, $count" + contentDescription = "$title, $count, $rowSemantic" + stateDescription = if (isExpanded) expandedStateLabel else collapsedStateLabel } + } else { + base.semantics(mergeDescendants = true) { + heading() + contentDescription = "$title, $count" } - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + } + }, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.apps_section_count_suffix, count), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - if (collapsible) { - Icon( - imageVector = Icons.Default.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier - .size(20.dp) - .rotate(rotation), + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + ), + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(Res.string.apps_section_count_suffix, count), + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 13.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), ) + if (collapsible) { + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(22.dp) + .rotate(rotation), + ) + } } + Squiggle() } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/CompactAppRow.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/CompactAppRow.kt index ff1bbb5a9..5940f5724 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/CompactAppRow.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/CompactAppRow.kt @@ -3,7 +3,9 @@ package zed.rainxch.apps.presentation.components import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import zed.rainxch.core.presentation.theme.tokens.Radii import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -22,10 +24,11 @@ import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Update import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenu +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -70,17 +73,6 @@ import zed.rainxch.githubstore.core.presentation.res.discard_pending_install import zed.rainxch.githubstore.core.presentation.res.variant_picker_open import kotlin.time.ExperimentalTime -/** - * 64dp single-line row used in the "Up to date" section. Drops the verbose - * controls of [zed.rainxch.apps.presentation.AppItemCard] (filter / variant / - * pre-release / inline status text). Per-app configuration moves into the - * trailing overflow menu, which routes to the existing bottom sheet. - * - * Accessibility: the entire row carries a single merged semantic name that - * surfaces every "hidden" flag (filter active / variant pinned / pre-release - * on / variant stale / pending install / ready to install) so screen-reader - * users don't lose context that the dot cluster encodes visually. - */ @Composable fun CompactAppRow( appItem: AppItem, @@ -106,16 +98,21 @@ fun CompactAppRow( val rowSemanticName = buildCompactRowSemantics(app.appName, app.installedVersion, flags) Row( - modifier = - modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 64.dp) - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onRowClick) - .padding(horizontal = 12.dp, vertical = 8.dp) - .semantics(mergeDescendants = true) { - contentDescription = rowSemanticName - }, + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 68.dp) + .clip(Radii.row) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = Radii.row, + ) + .background(MaterialTheme.colorScheme.surface) + .clickable(onClick = onRowClick) + .padding(horizontal = 14.dp, vertical = 12.dp) + .semantics(mergeDescendants = true) { + contentDescription = rowSemanticName + }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -159,35 +156,20 @@ fun CompactAppRow( } if (app.pendingInstallFilePath != null) { - // One-tap path for a parked download — surface the install - // primary CTA even in compact mode because the file is on disk - // and finishing the install is the user's expected action. - Button( + + GhsButton( onClick = onInstallPendingClick, + label = stringResource(Res.string.install), + variant = GhsButtonVariant.Primary, + size = GhsButtonSize.Sm, enabled = !isBusy, - contentPadding = PaddingValues(horizontal = 12.dp), - modifier = Modifier.height(40.dp), - ) { - Icon( - imageVector = Icons.Default.Update, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - text = stringResource(Res.string.install), - style = MaterialTheme.typography.labelMedium, - ) - } + leadingIcon = Icons.Default.Update, + ) Spacer(Modifier.width(4.dp)) } else if (app.isPendingInstall) { - // Pending row whose file isn't (or is no longer) on disk. - // The app isn't installed; suppress the Open shortcut so we - // don't dead-end on a launch failure. Discard is reachable - // from the overflow. + } else if (!isBusy) { - // Subtle Open shortcut keeps the most-frequent action one tap - // away even though the row itself opens the repo on tap. + IconButton( onClick = onOpenClick, modifier = Modifier.size(40.dp), @@ -258,47 +240,47 @@ private fun CompactRowOverflow( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - DropdownMenu( + GhsDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, ) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.advanced_settings_open)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.advanced_settings_open), onClick = { expanded = false onAdvancedSettingsClick() }, ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.variant_picker_open)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.variant_picker_open), onClick = { expanded = false onPickVariantClick() }, ) - DropdownMenuItem( - text = { - val baseLabel = stringResource(Res.string.pre_release_badge) - Text(text = if (isPreReleaseEnabled) "$baseLabel ✓" else baseLabel) - }, - onClick = { - expanded = false - onTogglePreReleases(!isPreReleaseEnabled) - }, - ) - DropdownMenuItem( - text = { - val baseLabel = stringResource(Res.string.apps_ignore_updates) - Text(text = if (!isUpdateCheckEnabled) "$baseLabel ✓" else baseLabel) - }, - onClick = { - expanded = false - onToggleUpdateCheck(!isUpdateCheckEnabled) - }, - ) + run { + val baseLabel = stringResource(Res.string.pre_release_badge) + GhsDropdownMenuItem( + text = if (isPreReleaseEnabled) "$baseLabel ✓" else baseLabel, + onClick = { + expanded = false + onTogglePreReleases(!isPreReleaseEnabled) + }, + ) + } + run { + val baseLabel = stringResource(Res.string.apps_ignore_updates) + GhsDropdownMenuItem( + text = if (!isUpdateCheckEnabled) "$baseLabel ✓" else baseLabel, + onClick = { + expanded = false + onToggleUpdateCheck(!isUpdateCheckEnabled) + }, + ) + } if (hasSkippedReleaseTag) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.apps_skip_version_unskip)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.apps_skip_version_unskip), onClick = { expanded = false onUnskipVersionClick() @@ -306,13 +288,9 @@ private fun CompactRowOverflow( ) } if (isPending) { - DropdownMenuItem( - text = { - Text( - text = stringResource(Res.string.discard_pending_install), - color = MaterialTheme.colorScheme.error, - ) - }, + GhsDropdownMenuItem( + text = stringResource(Res.string.discard_pending_install), + contentColor = MaterialTheme.colorScheme.error, leadingIcon = { Icon( imageVector = Icons.Outlined.DeleteOutline, @@ -326,13 +304,9 @@ private fun CompactRowOverflow( }, ) } else { - DropdownMenuItem( - text = { - Text( - text = stringResource(Res.string.uninstall), - color = MaterialTheme.colorScheme.error, - ) - }, + GhsDropdownMenuItem( + text = stringResource(Res.string.uninstall), + contentColor = MaterialTheme.colorScheme.error, leadingIcon = { Icon( imageVector = Icons.Outlined.DeleteOutline, @@ -348,7 +322,6 @@ private fun CompactRowOverflow( } } } - // Suppress unused-parameter warning; isUpdateAvailable reserved for - // future Update CTA in compact mode if we ever want it. + @Suppress("UNUSED_EXPRESSION") isUpdateAvailable } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/ImportSummarySheet.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/ImportSummarySheet.kt index 025e90f56..1397fc5c8 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/ImportSummarySheet.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/ImportSummarySheet.kt @@ -20,13 +20,14 @@ import androidx.compose.material.icons.outlined.ExpandLess import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.RemoveCircleOutline import androidx.compose.material.icons.outlined.WarningAmber -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.ModalBottomSheet +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.overlays.GhsBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -80,10 +81,9 @@ fun ImportSummarySheet( return } - ModalBottomSheet( + GhsBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), ) { Column( modifier = Modifier @@ -160,19 +160,14 @@ fun ImportSummarySheet( ) } - Button( + GhsButton( onClick = onDismiss, + label = stringResource(Res.string.import_summary_close), + variant = GhsButtonVariant.Primary, modifier = Modifier .fillMaxWidth() .padding(top = 8.dp), - shape = RoundedCornerShape(16.dp), - contentPadding = PaddingValues(vertical = 12.dp), - ) { - Text( - text = stringResource(Res.string.import_summary_close), - fontWeight = FontWeight.SemiBold, - ) - } + ) Spacer(Modifier.height(8.dp)) } } @@ -185,10 +180,9 @@ private fun UnknownFormatSheet( sheetState: androidx.compose.material3.SheetState, onDismiss: () -> Unit, ) { - ModalBottomSheet( + GhsBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), ) { Column( modifier = Modifier @@ -227,17 +221,12 @@ private fun UnknownFormatSheet( ) } } - Button( + GhsButton( onClick = onDismiss, + label = stringResource(Res.string.import_summary_close), + variant = GhsButtonVariant.Primary, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - contentPadding = PaddingValues(vertical = 12.dp), - ) { - Text( - text = stringResource(Res.string.import_summary_close), - fontWeight = FontWeight.SemiBold, - ) - } + ) Spacer(Modifier.height(8.dp)) } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/InstalledAppIcon.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/InstalledAppIcon.kt index 497a72f7b..466291d4a 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/InstalledAppIcon.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/InstalledAppIcon.kt @@ -3,18 +3,6 @@ package zed.rainxch.apps.presentation.components import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -/** - * Renders the icon for a tracked app. - * - * Resolution order on Android: - * 1. The icon registered with the system PackageManager for [packageName]. - * 2. The icon embedded inside the parked APK at [apkFilePath] (used for - * pending-install rows whose package isn't on the system yet). - * 3. A generic fallback drawable. - * - * On JVM (desktop) the fallback is always used — there's no - * platform-resident icon registry to consult. - */ @Composable expect fun InstalledAppIcon( packageName: String, diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/KaoBanner.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/KaoBanner.kt index cc1e52a7f..34a99bf8b 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/KaoBanner.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/KaoBanner.kt @@ -17,7 +17,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -87,9 +89,12 @@ fun KaoBanner( modifier = Modifier.fillMaxWidth().padding(end = 8.dp), horizontalArrangement = Arrangement.End, ) { - TextButton(onClick = onLearnMore) { - Text(text = stringResource(Res.string.kao_banner_cta)) - } + GhsButton( + onClick = onLearnMore, + label = stringResource(Res.string.kao_banner_cta), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt index 3efdd4a66..21038d999 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt @@ -28,24 +28,22 @@ import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsTextField +import zed.rainxch.core.presentation.components.overlays.GhsBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.rememberModalBottomSheetState 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.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -67,11 +65,9 @@ fun LinkAppBottomSheet( state: AppsState, onAction: (AppsAction) -> Unit, ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - ModalBottomSheet( + GhsBottomSheet( onDismissRequest = { onAction(AppsAction.OnDismissLinkSheet) }, - sheetState = sheetState, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { AnimatedContent( targetState = state.linkStep, @@ -168,26 +164,13 @@ private fun PickAppStep( Spacer(Modifier.height(12.dp)) - TextField( + GhsTextField( value = searchQuery, onValueChange = onSearchChange, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)), - placeholder = { - Text(stringResource(Res.string.search_apps_hint)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - ) - }, + modifier = Modifier.fillMaxWidth(), + placeholder = stringResource(Res.string.search_apps_hint), + leadingIcon = Icons.Default.Search, singleLine = true, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), ) Spacer(Modifier.height(8.dp)) @@ -433,13 +416,12 @@ private fun SmartMatchStep( color = MaterialTheme.colorScheme.error, ) Spacer(Modifier.height(8.dp)) - FilledTonalButton( + GhsButton( onClick = onRetry, + label = stringResource(Res.string.retry), + variant = GhsButtonVariant.Tonal, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) { - Text(stringResource(Res.string.retry)) - } + ) } suggestions.isEmpty() -> { @@ -458,11 +440,7 @@ private fun SmartMatchStep( ) { items( items = suggestions, - // Lists may now mix GitHub + Forgejo hits for - // the same owner/repo slug across hosts — - // include sourceHost in the key so identical - // slugs on different forges render as distinct - // rows. + key = { "${it.sourceHost ?: "github"}|${it.owner}/${it.repo}" }, ) { suggestion -> SuggestionRow( @@ -485,14 +463,13 @@ private fun SmartMatchStep( Spacer(Modifier.height(12.dp)) - FilledTonalButton( + GhsButton( onClick = onEnterUrlManually, - modifier = Modifier.fillMaxWidth(), + label = stringResource(Res.string.link_smart_search_enter_manually), + variant = GhsButtonVariant.Tonal, enabled = !isValidating, - shape = RoundedCornerShape(12.dp), - ) { - Text(stringResource(Res.string.link_smart_search_enter_manually)) - } + modifier = Modifier.fillMaxWidth(), + ) } } @@ -530,7 +507,7 @@ private fun SuggestionRow( ) } Row(verticalAlignment = Alignment.CenterVertically) { - // Host badge — answers "where is this suggestion from". + HostBadge(suggestion.sourceHost) Spacer(Modifier.width(6.dp)) MatchSourceChip(suggestion.source) @@ -597,11 +574,7 @@ private fun MatchSourceChip(source: RepoMatchSource) { RepoMatchSource.FINGERPRINT -> stringResource(Res.string.match_source_fingerprint) RepoMatchSource.SEARCH -> stringResource(Res.string.match_source_search) RepoMatchSource.MANUAL -> stringResource(Res.string.match_source_manual) - // Reuses the "search" string for now — distinguishing the - // forge in the chip would need a new translated label per - // locale, which can come later. The underlying source-host - // is what actually drives URL building and the row's source - // chip on the Details / Apps screen. + RepoMatchSource.FORGEJO_SEARCH -> stringResource(Res.string.match_source_search) } Surface( @@ -655,7 +628,6 @@ private fun EnterUrlStep( Spacer(Modifier.height(16.dp)) - // Selected app info if (selectedApp != null) { Row( modifier = Modifier @@ -688,43 +660,31 @@ private fun EnterUrlStep( Spacer(Modifier.height(16.dp)) - OutlinedTextField( + GhsTextField( value = repoUrl, onValueChange = onUrlChanged, modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(Res.string.enter_repo_url)) }, - placeholder = { Text(stringResource(Res.string.repo_url_hint)) }, + label = stringResource(Res.string.enter_repo_url), + placeholder = stringResource(Res.string.repo_url_hint), singleLine = true, isError = validationError != null, - supportingText = validationError?.let { - { Text(it, color = MaterialTheme.colorScheme.error) } - }, - shape = RoundedCornerShape(12.dp), + supportingText = validationError, ) Spacer(Modifier.height(20.dp)) - FilledTonalButton( + GhsButton( onClick = onConfirm, - modifier = Modifier.fillMaxWidth(), - enabled = repoUrl.isNotBlank() && !isValidating, - shape = RoundedCornerShape(12.dp), - ) { - if (isValidating) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.validating_repo)) + label = if (isValidating) { + stringResource(Res.string.validating_repo) } else { - Text( - text = stringResource(Res.string.link_and_track), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - } - } + stringResource(Res.string.link_and_track) + }, + variant = GhsButtonVariant.Tonal, + enabled = repoUrl.isNotBlank() && !isValidating, + loading = isValidating, + modifier = Modifier.fillMaxWidth(), + ) if (isValidating && validationStatus != null) { Spacer(Modifier.height(8.dp)) @@ -790,60 +750,34 @@ private fun PickAssetStep( Spacer(Modifier.height(12.dp)) - // Asset filter — for monorepos that ship multiple apps from the - // same repo. Live-narrows the visible list and is persisted with - // the link, so the update checker only ever resolves matching APKs. - OutlinedTextField( + val filterSupporting = when { + filterError != null -> stringResource(Res.string.asset_filter_invalid) + visibleAssets.isEmpty() && filterValue.isNotBlank() -> + stringResource(Res.string.asset_filter_no_match) + filterValue.isNotBlank() -> + pluralStringResource( + Res.plurals.asset_filter_visible_count, + allAssets.size, + visibleAssets.size, + allAssets.size, + ) + else -> stringResource(Res.string.asset_filter_help) + } + GhsTextField( value = filterValue, onValueChange = onFilterChanged, modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(Res.string.asset_filter_label)) }, - placeholder = { Text(stringResource(Res.string.asset_filter_placeholder)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.FilterAlt, - contentDescription = null, - ) - }, + label = stringResource(Res.string.asset_filter_label), + placeholder = stringResource(Res.string.asset_filter_placeholder), + leadingIcon = Icons.Default.FilterAlt, singleLine = true, isError = filterError != null, - supportingText = { - Text( - text = - when { - filterError != null -> stringResource(Res.string.asset_filter_invalid) - visibleAssets.isEmpty() && filterValue.isNotBlank() -> - stringResource(Res.string.asset_filter_no_match) - filterValue.isNotBlank() -> - // Pass the total asset count as the plural - // quantity so Polish/Russian inflection picks - // the right form based on the *collection* - // size, and supply both counts as format args. - pluralStringResource( - Res.plurals.asset_filter_visible_count, - allAssets.size, - visibleAssets.size, - allAssets.size, - ) - else -> stringResource(Res.string.asset_filter_help) - }, - color = - if (filterError != null) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - }, + supportingText = filterSupporting, enabled = !isProcessing, - shape = RoundedCornerShape(12.dp), ) Spacer(Modifier.height(8.dp)) - // Fall-back-to-older-releases toggle. Only meaningful when a filter - // is set; in monorepos, the latest release is often for the wrong - // app, so the checker needs to walk back to find this app's APK. Row( modifier = Modifier .fillMaxWidth() @@ -938,12 +872,7 @@ private fun PickAssetStep( if (visibleAssets.isEmpty()) { item { - // Three distinct empty states: - // - No installable assets at all in the repo release - // (defensive: validateAndLinkRepo short-circuits - // this today, but guard in case flows change) - // - Filter regex is invalid (shown in error color) - // - Filter is valid but matched nothing + val (message, isError) = when { allAssets.isEmpty() -> stringResource(Res.string.asset_none_available) to false diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/StatusDotCluster.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/StatusDotCluster.kt index e252a4fdb..55646c13e 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/StatusDotCluster.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/StatusDotCluster.kt @@ -26,12 +26,6 @@ import zed.rainxch.githubstore.core.presentation.res.apps_compact_status_updates import zed.rainxch.githubstore.core.presentation.res.apps_compact_status_variant_pinned import zed.rainxch.githubstore.core.presentation.res.apps_compact_status_variant_stale -/** - * Per-app state flags that the compact row encodes visually. Each flag uses - * BOTH a unique [DotShape] AND a tinted color so that the cluster passes - * WCAG 1.4.1 (no information by color alone). Screen readers see the - * cluster's merged contentDescription via the parent row's semantics. - */ data class CompactStatusFlags( val filterActive: Boolean = false, val variantPinned: Boolean = false, @@ -42,11 +36,6 @@ data class CompactStatusFlags( val updatesIgnored: Boolean = false, ) -/** - * Computes the visible status flags for an [AppItem]. Memoised on its - * inputs so that the row's recomposition doesn't re-allocate the data - * class on every download-progress tick. - */ @Composable fun rememberCompactStatusFlags(appItem: AppItem): CompactStatusFlags { val app = appItem.installedApp @@ -72,12 +61,6 @@ fun rememberCompactStatusFlags(appItem: AppItem): CompactStatusFlags { } } -/** - * Renders the active flags as a compact row of 8dp shapes. Each flag has a - * dedicated shape (circle / square / triangle / diamond / ring / chevron) so - * shape-only viewers (deuteranopia, monochrome themes, low contrast) still - * tell the flags apart. - */ @Composable fun StatusDotCluster( flags: CompactStatusFlags, @@ -172,11 +155,6 @@ private fun DrawScope.drawShape(shape: DotShape, color: Color) { } } -/** - * Builds the merged accessible name for the row. The name template is - * read once per row recomposition; the dot cluster itself carries no - * semantics (decorative). - */ @Composable fun buildCompactRowSemantics( appName: String, diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/UpdatesBanner.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/UpdatesBanner.kt new file mode 100644 index 000000000..eb4cc5484 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/UpdatesBanner.kt @@ -0,0 +1,220 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + +package zed.rainxch.apps.presentation.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Update +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.apps.presentation.model.UpdateAllProgress +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.apps_updates_banner_hide +import zed.rainxch.githubstore.core.presentation.res.apps_updates_banner_show +import zed.rainxch.githubstore.core.presentation.res.apps_updates_banner_subtitle +import zed.rainxch.githubstore.core.presentation.res.apps_updates_banner_title_one +import zed.rainxch.githubstore.core.presentation.res.apps_updates_banner_title_other +import zed.rainxch.githubstore.core.presentation.res.cancel +import zed.rainxch.githubstore.core.presentation.res.currently_updating +import zed.rainxch.githubstore.core.presentation.res.update_all +import zed.rainxch.githubstore.core.presentation.res.updating_x_of_y + +@Composable +fun UpdatesBanner( + count: Int, + isExpanded: Boolean, + isUpdatingAll: Boolean, + updateAllProgress: UpdateAllProgress?, + updateAllEnabled: Boolean, + onUpdateAll: () -> Unit, + onCancelUpdateAll: () -> Unit, + onToggleExpanded: () -> Unit, + modifier: Modifier = Modifier, +) { + val rotation by animateFloatAsState( + targetValue = if (isExpanded) 0f else -90f, + animationSpec = tween(durationMillis = 180), + label = "updates-banner-chevron", + ) + val title = if (count == 1) { + stringResource(Res.string.apps_updates_banner_title_one) + } else { + stringResource(Res.string.apps_updates_banner_title_other, count) + } + + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.primaryContainer, + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(36.dp) + .padding(2.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), + modifier = Modifier.size(32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Update, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(18.dp), + ) + } + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + ), + color = MaterialTheme.colorScheme.onPrimaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(Res.string.apps_updates_banner_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f), + modifier = Modifier + .size(22.dp) + .rotate(rotation), + ) + } + + if (isUpdatingAll && updateAllProgress != null) { + Spacer(Modifier.height(14.dp)) + UpdateAllInlineProgress( + progress = updateAllProgress, + onCancel = onCancelUpdateAll, + ) + } else { + Spacer(Modifier.height(14.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + GhsButton( + onClick = onUpdateAll, + label = stringResource(Res.string.update_all), + variant = GhsButtonVariant.Primary, + enabled = updateAllEnabled, + leadingIcon = Icons.Default.Update, + modifier = Modifier.weight(1f), + ) + GhsButton( + onClick = onToggleExpanded, + label = if (isExpanded) { + stringResource(Res.string.apps_updates_banner_hide) + } else { + stringResource(Res.string.apps_updates_banner_show) + }, + variant = GhsButtonVariant.Outline, + modifier = Modifier.height(44.dp), + contentColorOverride = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + } +} + +@Composable +private fun UpdateAllInlineProgress( + progress: UpdateAllProgress, + onCancel: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + Res.string.updating_x_of_y, + progress.current, + progress.total, + ), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text( + text = stringResource( + Res.string.currently_updating, + progress.currentAppName, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + GhsButton( + onClick = onCancel, + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Outline, + size = GhsButtonSize.Sm, + modifier = Modifier.height(38.dp), + contentColorOverride = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Spacer(Modifier.height(10.dp)) + LinearWavyProgressIndicator( + progress = { progress.current.toFloat() / progress.total.coerceAtLeast(1) }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onPrimaryContainer, + trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.18f), + ) + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt index 11d334add..7f6c99075 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/VariantPickerDialog.kt @@ -28,7 +28,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,18 +43,6 @@ import zed.rainxch.apps.presentation.AppsState import zed.rainxch.core.domain.util.AssetVariant import zed.rainxch.githubstore.core.presentation.res.* -/** - * Dialog for picking the preferred APK variant when an app's release - * has multiple installable assets. Opened from: - * - The advanced settings sheet (explicit user action) - * - Tapping Update on an app whose `preferredVariantStale` is true - * (the picker takes over so the user resolves the ambiguity before - * the wrong APK gets downloaded) - * - * Each option corresponds to a stable variant tag derived from the - * filename. There's also a "Reset to auto" entry that clears the - * preference and lets the platform installer's auto-picker do its job. - */ @Composable fun VariantPickerDialog( state: AppsState, @@ -62,6 +52,7 @@ fun VariantPickerDialog( AlertDialog( onDismissRequest = { onAction(AppsAction.OnDismissVariantPicker) }, + shape = zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape.Dialog, title = { Column { Text( @@ -155,22 +146,22 @@ fun VariantPickerDialog( } }, confirmButton = { - // Cross-link: jump to the asset filter sheet for this app. - // The two are conceptually adjacent — filter decides which - // assets are *considered*, the variant picker decides which - // of the matching assets gets installed. Users debugging - // "wrong file installed" need both within reach. - TextButton( + + GhsButton( onClick = { onAction(AppsAction.OnDismissVariantPicker) onAction(AppsAction.OnOpenAdvancedSettings(app)) }, - ) { - Text(stringResource(Res.string.variant_picker_open_filter)) - } - TextButton(onClick = { onAction(AppsAction.OnDismissVariantPicker) }) { - Text(stringResource(Res.string.cancel)) - } + label = stringResource(Res.string.variant_picker_open_filter), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) + GhsButton( + onClick = { onAction(AppsAction.OnDismissVariantPicker) }, + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } @@ -227,7 +218,7 @@ private fun VariantOptionList( .heightIn(min = 0.dp, max = 280.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - // Reset-to-auto entry — placed at the top so it's always discoverable. + item { VariantRow( isSelected = current == null, @@ -242,10 +233,7 @@ private fun VariantOptionList( } items(state.variantPickerOptions, key = { it.id }) { asset -> - // The ViewModel guarantees every asset reaching this list has - // a non-null, non-empty extract — see openVariantPicker's - // pinnableAssets filter. Treat null as a defensive fallback - // and skip the row to keep the dialog tappable everywhere. + val variant = AssetVariant.extract(asset.name) if (variant.isNullOrEmpty()) return@items val isCurrent = variant.equals(current, ignoreCase = true) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt index 0ffc25305..73e5aff1f 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportAction.kt @@ -45,12 +45,5 @@ sealed interface ExternalImportAction { data object OnAddManually : ExternalImportAction - /** - * Cancels in-flight scan / auto-import work and drops to the review - * wizard with whatever candidates have already been resolved (or an - * empty Done state if nothing made it through yet). Exposed via the - * Skip button that surfaces after the progress screen has been up - * past the skip threshold (~5 s). - */ data object OnSkipLongScan : ExternalImportAction } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt index d55ed9272..f01b0b8c3 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportRoot.kt @@ -8,8 +8,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenu +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -75,9 +75,7 @@ fun ExternalImportRoot( } ExternalImportEvent.PlayConfetti -> confettiTrigger++ is ExternalImportEvent.ShowUndoSnackbar -> { - // Dismiss any prior snackbar so undo always wins the slot — the - // VM tracks one pending undo, and showing a stale message would - // let the user mis-target it. + snackbarHostState.currentSnackbarData?.dismiss() scope.launch { val undoLabel = getString(Res.string.external_import_undo_action) @@ -109,8 +107,10 @@ fun ExternalImportRoot( title = { Text( text = stringResource(Res.string.external_import_top_bar_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, ) }, navigationIcon = { @@ -131,12 +131,12 @@ fun ExternalImportRoot( contentDescription = stringResource(Res.string.external_import_overflow_more), ) } - DropdownMenu( + GhsDropdownMenu( expanded = menuOpen, onDismissRequest = { menuOpen = false }, ) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.external_import_overflow_skip_remaining)) }, + GhsDropdownMenuItem( + text = stringResource(Res.string.external_import_overflow_skip_remaining), onClick = { menuOpen = false viewModel.onAction(ExternalImportAction.OnSkipRemaining) @@ -263,8 +263,6 @@ fun ExternalImportRoot( } } - // Confetti is gated on PlayConfetti events: each event bumps the trigger - // and remounts the overlay so its LaunchedEffect re-runs the burst. if (state.phase == ImportPhase.Done && confettiTrigger > 0) { androidx.compose.runtime.key(confettiTrigger) { ConfettiOverlay(enabled = true) @@ -274,3 +272,5 @@ fun ExternalImportRoot( } } } + +const val EXTERNAL_IMPORT_OPEN_LINK_SHEET_KEY = "external_import_open_link_sheet" diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt index ebf6479bd..d388a0ba3 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportState.kt @@ -28,10 +28,7 @@ data class ExternalImportState( val invisiblePackageCountEstimate: Int = 0, val showCompletionToast: Boolean = false, val errorMessage: String? = null, - // Wall-clock at which the current Scanning / AutoImporting phase - // started. Drives the "Skip" affordance that the UI reveals after - // [SKIP_REVEAL_DELAY_MS] when the scan is taking too long. Null - // outside scan phases. + val scanStartedAtMs: Long? = null, val isSkipAvailable: Boolean = false, ) { diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index 548a5ec34..5b2409d57 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -28,7 +28,6 @@ import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository -import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalDecisionSnapshot import zed.rainxch.core.domain.system.InstallerKind @@ -54,24 +53,15 @@ class ExternalImportViewModel( private val externalImportRepository: ExternalImportRepository, private val appsRepository: AppsRepository, private val installedAppsRepository: InstalledAppsRepository, - private val telemetry: TelemetryRepository, private val logger: GitHubStoreLogger, private val tweaksRepository: zed.rainxch.core.domain.repository.TweaksRepository, ) : ViewModel() { private var candidatesByPackage: Map = emptyMap() - // Cached so OnAutoSummaryUndoAll can re-build cards for previously - // auto-linked packages without round-tripping resolveMatches() (which - // would issue another network call and could return different matches). + private var lastResolvedMatches: List = emptyList() - // Mirror of autoLinkedPackages: per-package pre-link snapshot of whether an - // installed_apps row already existed. Bulk undo consults this to avoid - // wiping rows that pre-existed (e.g., the user had previously linked the - // app through some other path before auto-link added an entry to it). + private var autoLinkedHadInstalledRow: Map = emptyMap() - // Per-package external_links snapshot captured BEFORE auto-link writes the - // MATCHED row. Bulk undo restores from these so the DAO actually rolls back - // to the pre-link state (typically PENDING_REVIEW). Snapshotting AFTER the - // link would just re-apply the linked state — a silent no-op. + private var autoLinkedPreSnapshots: Map = emptyMap() private var hasStarted = false private var scanJob: Job? = null @@ -103,7 +93,6 @@ class ExternalImportViewModel( ExternalImportAction.OnRequestPermission -> { _state.update { it.copy(phase = ImportPhase.RequestingPermission) } - viewModelScope.launch { runCatching { telemetry.importPermissionRequested() } } } is ExternalImportAction.OnPermissionGranted -> { @@ -134,10 +123,10 @@ class ExternalImportViewModel( is ExternalImportAction.OnToggleCardExpanded -> toggleCardExpanded(action.packageName) is ExternalImportAction.OnSearchOverrideChanged -> { - // Explicit submit only — we never auto-fire on keystrokes. + _state.update { if (it.activeSearchPackage != action.packageName) { - // Switched cards: drop stale results from the previous card. + it.copy( activeSearchPackage = action.packageName, searchQuery = action.query, @@ -185,8 +174,7 @@ class ExternalImportViewModel( } else { current.expandedPackages.toPersistentSet().add(packageName) } - // Clear cross-card search results when collapsing the active card so - // they don't bleed into the next card the user expands. + val keepSearch = current.activeSearchPackage == packageName && packageName in nextSet current.copy( expandedPackages = nextSet, @@ -204,9 +192,7 @@ class ExternalImportViewModel( private fun startScanIfIdle(force: Boolean = false) { if (!force && _state.value.phase != ImportPhase.Idle) return if (scanJob?.isActive == true) return - // Skip affordance: stays hidden while the scan is fast, fades in - // after SKIP_REVEAL_DELAY_MS so the user can bail out of a slow - // resolveMatches without backgrounding the app. + skipRevealJob?.cancel() skipRevealJob = viewModelScope.launch { kotlinx.coroutines.delay(SKIP_REVEAL_DELAY_MS) @@ -252,15 +238,11 @@ class ExternalImportViewModel( buildCard(candidate, match) }.toImmutableList() - // Cancel the skip-reveal timer — we've exited the - // long-running phase, so the Skip button shouldn't - // ambiently appear over the next screen. skipRevealJob?.cancel() skipRevealJob = null if (autoLinked.isNotEmpty()) { - // Stop on the summary screen so the user sees what auto-linked - // and can undo before we cascade into the review wizard. + val autoLinkedLabels = autoLinked.mapNotNull { pkg -> candidatesByPackage[pkg]?.appLabel } @@ -322,13 +304,6 @@ class ExternalImportViewModel( } } - /** - * Bail out of an in-flight scan / auto-import. Surfaces whatever - * candidates the resolver got far enough to expose, so the user can - * still review them manually instead of being trapped on a spinner. - * If nothing has been resolved yet, drops to an empty Done state — - * effectively "no candidates this scan, move on". - */ private fun skipLongScan() { val active = scanJob ?: return if (!active.isActive) return @@ -380,11 +355,7 @@ class ExternalImportViewModel( private fun submitSearchOverride(packageName: String) { val current = _state.value - // Search submit applies to whichever card was last typed in. If the - // active package and the submitted package mismatch, we still honour - // the submit using the active query — this only happens if the user - // taps the icon in a different card before the keystrokes registered, - // which the UI prevents via per-card query binding. + if (current.activeSearchPackage != packageName) return val query = current.searchQuery.trim() @@ -400,11 +371,6 @@ class ExternalImportViewModel( return } - // Fast-path: a host/owner/repo URL bypasses the search API and - // surfaces a single MANUAL suggestion that the user can tap to - // link. Accepts GitHub, Codeberg, known Forgejo / Gitea hosts, - // and anything the user has added under Tweaks → Network → - // Custom forges. Matches Obtainium's "paste a URL" mental model. searchJob?.cancel() _state.update { it.copy(isSearching = true, searchError = null) } searchJob = viewModelScope.launch { @@ -436,12 +402,9 @@ class ExternalImportViewModel( ), ) } - runCatching { telemetry.importSearchOverrideUsed() } return@launch } - // Not a URL — backend free-text search. - runCatching { telemetry.importSearchOverrideUsed() } val result = runCatching { externalImportRepository.searchRepos(query) } .getOrElse { e -> if (e is CancellationException) throw e @@ -450,7 +413,6 @@ class ExternalImportViewModel( result.fold( onSuccess = { suggestions -> if (suggestions.isEmpty()) { - runCatching { telemetry.importSearchOverrideNoResults() } } _state.update { if (it.activeSearchPackage != packageName) it @@ -482,9 +444,7 @@ class ExternalImportViewModel( private fun skipPackage(packageName: String, neverAsk: Boolean) { val card = _state.value.cards.firstOrNull { it.packageName == packageName } ?: return viewModelScope.launch { - // Distinguish "no prior row" (success → null) from "couldn't read" - // (failure). Treating the latter as null would let undoLast's - // fallback unlink wipe a row that should have been preserved. + val snapshotResult = runCatching { externalImportRepository.snapshotDecision(packageName) } @@ -500,9 +460,6 @@ class ExternalImportViewModel( val snapshot = snapshotResult.getOrNull() val hadInstalledRow = installedAppsRepository.getAppByPackage(packageName) != null - // Short-circuit on failure: don't remove the card, don't offer undo, - // don't fire telemetry — the DAO state is unchanged. Surface an error - // so the user knows the action didn't take effect. val ok = try { externalImportRepository.skipPackage(packageName, neverAsk = neverAsk) true @@ -521,12 +478,6 @@ class ExternalImportViewModel( return@launch } - runCatching { - telemetry.importSkipped( - countBucket = "1-2", - persisted = if (neverAsk) "forever" else "7day", - ) - } removeCardFromState(packageName) { it.copy(skipped = it.skipped + 1) } pendingUndo = PendingUndo( @@ -590,9 +541,6 @@ class ExternalImportViewModel( ) return@launch } - runCatching { - telemetry.importManuallyLinked(countBucket = "1-2", source = source) - } removeCardFromState(packageName) { it.copy(manuallyLinked = it.manuallyLinked + 1) } pendingUndo = PendingUndo( @@ -615,9 +563,7 @@ class ExternalImportViewModel( if (preselect != null) { pickSuggestion(packageName, preselect) } else { - // No preselection means there's nothing to confidently link to. The - // list-mode UI hides the link CTA in this case, but defensively - // surface the expand affordance instead of silently dropping. + toggleCardExpanded(packageName) } } @@ -627,9 +573,7 @@ class ExternalImportViewModel( viewModelScope.launch { try { - // Run rollback DAO ops without swallowing — any failure must - // abort and preserve `pendingUndo` so the user can retry from - // the snackbar. UI state is mutated only after every op succeeds. + if (undo.kind == PendingUndo.Kind.Link && !undo.hadInstalledAppRowBefore) { installedAppsRepository.deleteInstalledApp(undo.packageName) } @@ -640,7 +584,6 @@ class ExternalImportViewModel( externalImportRepository.unlink(undo.packageName) } - // All DAO ops succeeded — now mutate UI and consume the token. pendingUndo = null _state.update { current -> if (current.cards.any { it.packageName == undo.packageName }) { @@ -667,7 +610,7 @@ class ExternalImportViewModel( throw e } catch (e: Exception) { logger.error("Undo failed for ${undo.packageName}: ${e.message}") - // Preserve pendingUndo so the snackbar can offer Undo again. + _events.send( ExternalImportEvent.ShowError( getString(Res.string.external_import_undo_failed), @@ -680,8 +623,7 @@ class ExternalImportViewModel( private fun autoSummaryContinue() { val current = _state.value if (current.phase != ImportPhase.AutoImportSummary) return - // User accepted the auto-imports; the pre-link metadata is no - // longer needed and shouldn't leak into a subsequent wizard run. + autoLinkedHadInstalledRow = emptyMap() autoLinkedPreSnapshots = emptyMap() if (current.cards.isNotEmpty()) { @@ -706,20 +648,11 @@ class ExternalImportViewModel( return } - // Snapshot the pre-link maps locally so a concurrent reset can't race us. val hadInstalledMap = autoLinkedHadInstalledRow val preSnapshots = autoLinkedPreSnapshots viewModelScope.launch { - // Roll each auto-linked package back to its PRE-LINK external_links - // state using the snapshot captured BEFORE materializeAndMark wrote - // the MATCHED row. installed_apps is only deleted for packages whose - // row did NOT pre-exist before auto-link — same policy as undoLast. - // - // Fail-fast: any DAO failure aborts the bulk undo before we touch - // UI state or clear the pre-link maps. The user keeps seeing the - // AutoImportSummary screen and can retry. Already-rolled-back - // packages stay rolled back (idempotent on retry). + try { packages.forEach { pkg -> val preSnapshot = preSnapshots[pkg] @@ -730,7 +663,7 @@ class ExternalImportViewModel( if (preSnapshot != null) { externalImportRepository.restoreDecision(preSnapshot) } else { - // No pre-link row existed — drop the auto-link row entirely. + externalImportRepository.unlink(pkg) } } @@ -746,9 +679,6 @@ class ExternalImportViewModel( return@launch } - // All rollbacks succeeded — invalidate the single-row undo token, - // clear the per-package metadata so a subsequent wizard run can't - // see stale pre-link snapshots, and rebuild the wizard. pendingUndo = null autoLinkedHadInstalledRow = emptyMap() autoLinkedPreSnapshots = emptyMap() @@ -779,14 +709,7 @@ class ExternalImportViewModel( } private fun emitPermissionOutcome(granted: Boolean, sdkInt: Int?) { - viewModelScope.launch { - runCatching { - telemetry.importPermissionOutcome( - granted = granted, - sdkIntBucket = bucketSdkInt(sdkInt), - ) - } - } + } private fun bucketSdkInt(sdkInt: Int?): String = @@ -814,9 +737,7 @@ class ExternalImportViewModel( if (remaining.isEmpty()) return viewModelScope.launch { - // Track per-package outcome so a partial failure doesn't claim the - // entire wizard cleared and doesn't fire confetti / Done telemetry - // on packages whose DAO state is unchanged. + val successes = mutableSetOf() val failures = mutableListOf() remaining.forEach { card -> @@ -831,18 +752,6 @@ class ExternalImportViewModel( } } - if (successes.isNotEmpty()) { - runCatching { - telemetry.importSkipped( - countBucket = bucketCount(successes.size), - persisted = "7day", - ) - } - } - - // Skip-remaining is intentionally not undoable — bulk skip clears - // the wizard and triggers the completion screen, and a single - // snackbar isn't a sensible affordance for "undo seven things". pendingUndo = null val allSucceeded = failures.isEmpty() @@ -883,16 +792,6 @@ class ExternalImportViewModel( if (top.confidence < AUTO_LINK_THRESHOLD) return@forEach val candidate = candidatesByPackage[result.packageName] ?: return@forEach - // Capture pre-link state BEFORE materializeAndMark writes the - // MATCHED row. Bulk undo uses both: the pre-link snapshot to - // restore the DAO row to its original state, and the - // installed_apps presence flag to decide whether to delete the - // installed_apps row (only if auto-link created it). - // - // Snapshot failure must skip the auto-link entirely — without a - // reliable pre-link snapshot, undo would fall back to unlink and - // wipe a row that should have been preserved. Push the candidate - // through manual review instead. val snapshotResult = runCatching { externalImportRepository.snapshotDecision(result.packageName) } @@ -976,8 +875,7 @@ class ExternalImportViewModel( "external_links upsert failed for ${candidate.packageName}: " + "${linkResult.exceptionOrNull()?.message}", ) - // installed_apps row is already written; the audit trail is - // ahead but recoverable on the next scan via mergeCandidate. + } return true } @@ -995,9 +893,7 @@ class ExternalImportViewModel( packageName: String, tally: (ExternalImportState) -> ExternalImportState, ) { - // _state.update may invoke the lambda multiple times under contention; - // never assign captured vars from inside it. Read the post-update - // state to decide whether to fire the completion event. + _state.update { current -> val newCards = current.cards.filterNot { it.packageName == packageName }.toImmutableList() val tallied = tally(current).copy( @@ -1034,10 +930,7 @@ class ExternalImportViewModel( RepoMatchSource.SEARCH -> SuggestionSource.SEARCH RepoMatchSource.FINGERPRINT -> SuggestionSource.FINGERPRINT RepoMatchSource.MANUAL -> SuggestionSource.MANUAL - // Forgejo hits map to the same UI bucket as GitHub - // search hits — both are free-text matches against - // a remote registry; the differentiator is the - // sourceHost field on the suggestion itself. + RepoMatchSource.FORGEJO_SEARCH -> SuggestionSource.SEARCH }, stars = stars, @@ -1046,10 +939,7 @@ class ExternalImportViewModel( ) private suspend fun InstallerKind.toUiLabel(): String = - // Exhaustive: STORE_PLAY / STORE_AURORA / STORE_GALAXY / STORE_OEM_OTHER / - // SYSTEM are filtered out at the scanner today, but the wizard's enum - // contract is shared with the scanner — handle every value explicitly so - // a future scanner change can't silently mislabel a candidate. + when (this) { InstallerKind.STORE_OBTAINIUM -> getString(Res.string.external_import_installer_obtainium) InstallerKind.STORE_FDROID -> getString(Res.string.external_import_installer_fdroid) @@ -1082,10 +972,6 @@ class ExternalImportViewModel( private const val PRESELECT_MIN = 0.5 private const val PRESELECT_MAX = 0.85 - // Skip affordance reveal delay. Below this, scans complete - // quickly enough that an escape hatch would just be visual - // noise. Past it, the user assumes something is stuck and - // wants a way out. private const val SKIP_REVEAL_DELAY_MS = 5_000L } } @@ -1093,9 +979,7 @@ class ExternalImportViewModel( private fun parseGithubRepoUrl(input: String): Pair? { val trimmed = input.trim().removeSuffix("/") if (trimmed.isEmpty()) return null - // Accept both bare host references and full https URLs. Anything else - // (search keywords, partial slugs without owner) falls through to the - // backend search path. + val withoutScheme = trimmed .removePrefix("https://") .removePrefix("http://") diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/AutoImportSummaryScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/AutoImportSummaryScreen.kt index bbf632bb9..2dddb5b86 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/AutoImportSummaryScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/AutoImportSummaryScreen.kt @@ -12,10 +12,8 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -27,6 +25,8 @@ import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.external_import_auto_summary_body import zed.rainxch.githubstore.core.presentation.res.external_import_auto_summary_continue @@ -97,12 +97,16 @@ fun AutoImportSummaryScreen( } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton(onClick = onUndoAll) { - Text(stringResource(Res.string.external_import_auto_summary_undo_all)) - } - Button(onClick = onContinue) { - Text(stringResource(Res.string.external_import_auto_summary_continue)) - } + GhsButton( + onClick = onUndoAll, + label = stringResource(Res.string.external_import_auto_summary_undo_all), + variant = GhsButtonVariant.Outline, + ) + GhsButton( + onClick = onContinue, + label = stringResource(Res.string.external_import_auto_summary_continue), + variant = GhsButtonVariant.Primary, + ) } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt index b7db28be7..c70b15f9a 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CandidateCard.kt @@ -1,5 +1,6 @@ package zed.rainxch.apps.presentation.import.components +import androidx.compose.foundation.BorderStroke import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -16,13 +17,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.theme.tokens.Radii import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -72,12 +73,13 @@ fun CandidateCard( val reducedMotion = LocalReducedMotion.current Surface( - tonalElevation = 1.dp, - shape = RoundedCornerShape(20.dp), + shape = Radii.row, color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), modifier = modifier .fillMaxWidth() + .clip(Radii.row) .clickable( onClickLabel = if (expanded) collapseLabel else expandLabel, role = Role.Button, @@ -91,10 +93,6 @@ fun CandidateCard( PreselectedRow(suggestion = candidate.preselectedSuggestion) - // Collapsed footer: primary Link CTA (or hint) + expand affordance. - // The whole card is clickable to expand, but a dedicated control - // gives the disclosure a clear screen-reader role and a tap target - // that doesn't fight the underlying CTA buttons in expanded mode. if (!expanded) { CollapsedActions( canLink = candidate.preselectedSuggestion != null, @@ -137,19 +135,18 @@ fun CandidateCard( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - OutlinedButton( + GhsButton( onClick = onSkip, + label = stringResource(Res.string.external_import_card_action_skip), + variant = GhsButtonVariant.Outline, modifier = Modifier.weight(1f), - ) { - Text(stringResource(Res.string.external_import_card_action_skip)) - } - TextButton(onClick = onToggleExpanded) { - Text(stringResource(Res.string.external_import_card_action_less)) - Icon( - imageVector = Icons.Default.KeyboardArrowUp, - contentDescription = null, - ) - } + ) + GhsButton( + onClick = onToggleExpanded, + label = stringResource(Res.string.external_import_card_action_less), + variant = GhsButtonVariant.Text, + trailingIcon = Icons.Default.KeyboardArrowUp, + ) } } } @@ -169,23 +166,20 @@ private fun CollapsedActions( verticalAlignment = Alignment.CenterVertically, ) { if (canLink) { - Button( + GhsButton( onClick = onLink, + label = stringResource(Res.string.external_import_card_action_link), + variant = GhsButtonVariant.Primary, modifier = Modifier.weight(1f), - ) { - Text(stringResource(Res.string.external_import_card_action_link)) - } + ) } - TextButton( + GhsButton( onClick = onExpand, + label = stringResource(Res.string.external_import_card_action_more), + variant = GhsButtonVariant.Text, + trailingIcon = Icons.Default.KeyboardArrowDown, modifier = if (canLink) Modifier else Modifier.weight(1f), - ) { - Text(stringResource(Res.string.external_import_card_action_more)) - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = null, - ) - } + ) } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt index c9c19a521..a63d125e7 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/CompletionToast.kt @@ -8,11 +8,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -73,9 +74,11 @@ fun CompletionToast( ) } - Button(onClick = onExit) { - Text(stringResource(Res.string.external_import_completion_action_view_all)) - } + GhsButton( + onClick = onExit, + label = stringResource(Res.string.external_import_completion_action_view_all), + variant = GhsButtonVariant.Primary, + ) } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ConfettiOverlay.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ConfettiOverlay.kt index 952eaeb6d..b333ef66c 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ConfettiOverlay.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ConfettiOverlay.kt @@ -50,8 +50,7 @@ fun ConfettiOverlay( val particles = remember(palette) { val rng = Random(42) List(40) { index -> - // Use error sparingly — every 7th particle, and primary/secondary/tertiary - // for the rest with primaryContainer mixed in for variety. + val color = when { index % 7 == 6 -> palette[4] index % 5 == 0 -> palette[3] @@ -95,7 +94,7 @@ fun ConfettiOverlay( particles.forEach { p -> val x = p.xFraction * width val y = -40f + travel * p.fallSpeed * progress.value - // Stop drawing once a particle has cleared the bottom. + if (y > height + p.radiusPx) return@forEach val rotation = p.rotationOffset + p.rotationSpeed * progress.value diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt index 1697fa674..02e4da917 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/EmptyStateScreen.kt @@ -10,13 +10,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Search -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -62,12 +61,16 @@ fun EmptyStateScreen( color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, ) - Button(onClick = onExit) { - Text(stringResource(Res.string.external_import_empty_done)) - } - TextButton(onClick = onAddManually) { - Text(stringResource(Res.string.external_import_empty_add_manually)) - } + GhsButton( + onClick = onExit, + label = stringResource(Res.string.external_import_empty_done), + variant = GhsButtonVariant.Primary, + ) + GhsButton( + onClick = onAddManually, + label = stringResource(Res.string.external_import_empty_add_manually), + variant = GhsButtonVariant.Text, + ) } else { Icon( imageVector = Icons.Outlined.Search, @@ -89,16 +92,22 @@ fun EmptyStateScreen( textAlign = TextAlign.Center, ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton(onClick = onExit) { - Text(stringResource(Res.string.external_import_empty_ok)) - } - Button(onClick = onRequestPermission) { - Text(stringResource(Res.string.external_import_empty_grant_permission)) - } - } - TextButton(onClick = onAddManually) { - Text(stringResource(Res.string.external_import_empty_add_manually)) + GhsButton( + onClick = onExit, + label = stringResource(Res.string.external_import_empty_ok), + variant = GhsButtonVariant.Outline, + ) + GhsButton( + onClick = onRequestPermission, + label = stringResource(Res.string.external_import_empty_grant_permission), + variant = GhsButtonVariant.Primary, + ) } + GhsButton( + onClick = onAddManually, + label = stringResource(Res.string.external_import_empty_add_manually), + variant = GhsButtonVariant.Text, + ) } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt index cacfb5a1a..69727a6b0 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProgressScreen.kt @@ -12,7 +12,8 @@ import androidx.compose.animation.fadeOut import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -82,21 +83,16 @@ fun ImportProgressScreen( textAlign = TextAlign.Center, ) - // Skip affordance fades in once the scan crosses the - // long-running threshold (the VM flips `canSkip` after - // SKIP_REVEAL_DELAY_MS). Hidden during a fast scan so it - // doesn't add a flicker the user has no time to notice. AnimatedVisibility( visible = canSkip, enter = fadeIn(), exit = fadeOut(), ) { - TextButton(onClick = onSkip) { - Text( - text = stringResource(Res.string.external_import_progress_skip), - style = MaterialTheme.typography.labelLarge, - ) - } + GhsButton( + onClick = onSkip, + label = stringResource(Res.string.external_import_progress_skip), + variant = GhsButtonVariant.Text, + ) } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt index 4fe82b649..ec6162b72 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/ImportProposalBanner.kt @@ -16,7 +16,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -79,11 +81,12 @@ fun ImportProposalBanner( Spacer(Modifier.width(8.dp)) - TextButton(onClick = onReview) { - Text( - text = stringResource(Res.string.external_import_proposal_banner_review), - ) - } + GhsButton( + onClick = onReview, + label = stringResource(Res.string.external_import_proposal_banner_review), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) IconButton(onClick = onDismiss) { Icon( diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt index 05006b63e..d7835b268 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt @@ -9,10 +9,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Search -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope @@ -72,30 +72,27 @@ fun PermissionRationaleScreen( ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton( + GhsButton( onClick = { onAction(ExternalImportAction.OnPermissionDenied(sdkInt)) }, - ) { - Text(stringResource(Res.string.external_import_permission_not_now)) - } - Button(onClick = { - scope.launch { - onAction(ExternalImportAction.OnRequestPermission) - // QUERY_ALL_PACKAGES is install-time only on stock Android. - // Either we already have visibility (manifest grant honoured) - // → proceed Granted; or we don't → proceed Denied and let the - // scanner's heuristic-degraded path handle it. We never - // dispatch Granted optimistically — that would lie to - // telemetry and skip the degraded-path UX in EmptyStateScreen. - val action = if (requester.isGranted()) { - ExternalImportAction.OnPermissionGranted(sdkInt) - } else { - ExternalImportAction.OnPermissionDenied(sdkInt) + label = stringResource(Res.string.external_import_permission_not_now), + variant = GhsButtonVariant.Outline, + ) + GhsButton( + onClick = { + scope.launch { + onAction(ExternalImportAction.OnRequestPermission) + + val action = if (requester.isGranted()) { + ExternalImportAction.OnPermissionGranted(sdkInt) + } else { + ExternalImportAction.OnPermissionDenied(sdkInt) + } + onAction(action) } - onAction(action) - } - }) { - Text(stringResource(Res.string.external_import_permission_continue)) - } + }, + label = stringResource(Res.string.external_import_permission_continue), + variant = GhsButtonVariant.Primary, + ) } } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt index 85a68775a..3eef2f66d 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoCandidateRow.kt @@ -86,10 +86,7 @@ fun RepoCandidateRow( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - // Source chip: where this suggestion actually lives. - // Distinct tonal color so a Codeberg row visibly stands - // apart from a GitHub one — answers "why is this here - // and where is it from". + SuggestionHostChip(suggestion.sourceHost) } if (!suggestion.description.isNullOrBlank()) { diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt index a9dbd06ef..beb6958b8 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/RepoSearchOverride.kt @@ -14,9 +14,9 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import zed.rainxch.core.presentation.components.inputs.GhsTextField import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction @@ -45,22 +45,14 @@ fun RepoSearchOverride( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Box { - OutlinedTextField( + GhsTextField( value = query, onValueChange = onQueryChange, modifier = Modifier.fillMaxWidth(), singleLine = true, isError = !searchError.isNullOrBlank(), - supportingText = { - if (!searchError.isNullOrBlank()) { - Text(text = searchError) - } - }, - placeholder = { - Text( - text = stringResource(Res.string.external_import_search_placeholder_url), - ) - }, + supportingText = searchError?.takeIf { it.isNotBlank() }, + placeholder = stringResource(Res.string.external_import_search_placeholder_url), trailingIcon = { IconButton(onClick = onSubmit) { Icon( diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt index 3ceae3d12..3be23f352 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/WizardList.kt @@ -15,7 +15,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.LiveRegionMode @@ -91,22 +92,13 @@ fun WizardList( @Composable private fun AddManuallyFooter(onClick: () -> Unit) { - TextButton( + GhsButton( onClick = onClick, + label = stringResource(Res.string.external_import_list_add_manually), + variant = GhsButtonVariant.Text, + trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(Res.string.external_import_list_add_manually), - style = MaterialTheme.typography.bodyMedium, - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - modifier = Modifier - .padding(start = 8.dp) - .size(16.dp), - ) - } + ) } @Composable diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt index 94173904e..52357a540 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/model/RepoSuggestionUi.kt @@ -7,9 +7,7 @@ data class RepoSuggestionUi( val source: SuggestionSource, val stars: Int? = null, val description: String? = null, - // Non-null when the suggestion lives on a non-GitHub forge. - // Carries through from import paste / smart-match to the linker - // so the row is actually stored against the right host. + val sourceHost: String? = null, ) { val ownerSlashRepo: String get() = "$owner/$repo" diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/starred/StarredPickerRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/starred/StarredPickerRoot.kt index a4a851670..acbbbda68 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/starred/StarredPickerRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/starred/StarredPickerRoot.kt @@ -18,8 +18,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AssistChip -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip @@ -30,8 +28,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -43,6 +39,10 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.apps.presentation.starred.components.StarredCandidateRow +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsTextField import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.navigate_back @@ -90,7 +90,10 @@ private fun StarredPickerScreen( title = { Text( text = stringResource(Res.string.starred_picker_title), - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, ) }, navigationIcon = { @@ -165,18 +168,13 @@ private fun ContentBody( } Spacer(Modifier.height(12.dp)) - TextField( + GhsTextField( value = state.searchQuery, onValueChange = { onAction(StarredPickerAction.OnSearchChange(it)) }, modifier = Modifier.fillMaxWidth(), - placeholder = { Text(stringResource(Res.string.starred_picker_search_hint)) }, - leadingIcon = { Icon(imageVector = Icons.Filled.Search, contentDescription = null) }, + placeholder = stringResource(Res.string.starred_picker_search_hint), + leadingIcon = Icons.Filled.Search, singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = TextFieldDefaults.colors( - focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - ), ) Spacer(Modifier.height(8.dp)) @@ -273,13 +271,12 @@ private fun RateLimitedBanner(onResume: () -> Unit) { color = MaterialTheme.colorScheme.error, modifier = Modifier.weight(1f), ) - Button( + GhsButton( onClick = onResume, - colors = ButtonDefaults.buttonColors(), - shape = RoundedCornerShape(12.dp), - ) { - Text(stringResource(Res.string.starred_picker_resume)) - } + label = stringResource(Res.string.starred_picker_resume), + variant = GhsButtonVariant.Primary, + size = GhsButtonSize.Sm, + ) } } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/starred/StarredPickerViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/starred/StarredPickerViewModel.kt index ebee4e618..764ea38b3 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/starred/StarredPickerViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/starred/StarredPickerViewModel.kt @@ -13,12 +13,12 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import zed.rainxch.apps.domain.repository.AppsRepository import zed.rainxch.core.domain.model.RateLimitException -import zed.rainxch.core.domain.repository.AuthenticationState +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.StarredRepository class StarredPickerViewModel( - private val authenticationState: AuthenticationState, + private val userSessionRepository: UserSessionRepository, private val starredRepository: StarredRepository, private val installedAppsRepository: InstalledAppsRepository, private val appsRepository: AppsRepository, @@ -73,7 +73,7 @@ class StarredPickerViewModel( private fun bootstrap() { scanJob?.cancel() scanJob = viewModelScope.launch { - val isAuthenticated = authenticationState.isCurrentlyUserLoggedIn() + val isAuthenticated = userSessionRepository.isCurrentlyUserLoggedIn() _state.update { it.copy( phase = StarredPickerState.Phase.LoadingStars, @@ -88,7 +88,7 @@ class StarredPickerViewModel( } runCatching { starredRepository.syncStarredRepos(forceRefresh = false) } - .onFailure { /* fall through to local cache */ } + .onFailure { } val starred = starredRepository.getAllStarred().first() val tracked = installedAppsRepository.getAllInstalledApps().first() diff --git a/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.jvm.kt b/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.jvm.kt index 80f2be7e1..065a5a0fc 100644 --- a/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.jvm.kt +++ b/feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/ReducedMotionProvider.jvm.kt @@ -2,8 +2,5 @@ package zed.rainxch.apps.presentation.import.util import androidx.compose.runtime.Composable -// Desktop has no equivalent system "remove animations" toggle that the -// JVM Compose target can read portably. The wizard isn't shown on -// Desktop today (E2 territory), so this is a safe default. @Composable actual fun rememberSystemReducedMotion(): Boolean = false diff --git a/feature/auth/data/build.gradle.kts b/feature/auth/data/build.gradle.kts index a65fc7038..c48ad0870 100644 --- a/feature/auth/data/build.gradle.kts +++ b/feature/auth/data/build.gradle.kts @@ -17,15 +17,5 @@ kotlin { implementation(libs.bundles.koin.common) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/network/GitHubAuthApi.kt b/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/network/GitHubAuthApi.kt index 53cc9d289..d7b9194fd 100644 --- a/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/network/GitHubAuthApi.kt +++ b/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/network/GitHubAuthApi.kt @@ -62,14 +62,6 @@ object GitHubAuthApi { } } - /** - * Dedicated client for PAT validation — NO retries. Validation is a - * user-blocking synchronous step (sheet is spinning while we call - * this), so a 30s retry cascade defeats the UX for anyone on a - * degraded network. One shot, 10s timeout, done. Falls back to the - * "Unreachable" path on any failure, which the caller handles by - * saving optimistically. - */ private val validationHttp by lazy { HttpClient { install(ContentNegotiation) { json(json) } @@ -284,21 +276,6 @@ object GitHubAuthApi { } } - /** - * Validates a Personal Access Token by calling GitHub's `/user` - * endpoint with it. Three outcomes: - * - * [PatValidation.Valid] → 2xx response, token authenticates cleanly. - * [PatValidation.Rejected] → 401 or 403, token is bad or revoked. - * [PatValidation.Unreachable] → network/timeout, we couldn't ask. - * - * The `Unreachable` case is deliberately distinct from `Rejected`: - * many users paste a PAT precisely because their network can't - * reach GitHub reliably (China, corporate firewalls). Treating - * unreachable as a rejection would block the whole feature for - * exactly the people who need it most. Caller decides whether to - * save optimistically on `Unreachable`. - */ suspend fun validatePersonalAccessToken(token: String): PatValidation { return try { val res = validationHttp.get("https://api.github.com/user") { @@ -313,9 +290,7 @@ object GitHubAuthApi { PatValidation.Rejected(RejectedKind.BadCredentials) status == HttpStatusCode.Forbidden -> PatValidation.Rejected(RejectedKind.InsufficientScope) - // Non-auth 4xx (400, 422, etc.): treat as a definitive reject - // rather than "unreachable" — GitHub could answer, it just - // said no. Retrying won't help. + status.value in 400..499 -> PatValidation.Rejected(RejectedKind.Other(status.value)) else -> PatValidation.Unreachable("HTTP ${status.value}") diff --git a/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt b/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt index cf1f82ac6..46022ec12 100644 --- a/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt +++ b/feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt @@ -388,25 +388,13 @@ class AuthenticationRepositoryImpl( override suspend fun signInWithPat(token: String): Result = withContext(Dispatchers.IO) { val trimmed = token.trim() - // Format gatekeeping first: trip the obvious paste-errors - // (trailing whitespace, partial copy) before even making a - // network call. + if (!looksLikePat(trimmed)) { return@withContext Result.failure( IllegalArgumentException("Token format not recognized"), ) } - // Network validation via GitHub's /user endpoint. Three - // outcomes: Valid → proceed, Rejected → fail immediately - // (don't persist a known-bad token), Unreachable → proceed - // optimistically. The Unreachable case is important: the - // whole reason users use this flow is that their network - // can't reliably reach github.com. Blocking the save on - // unreachability would defeat the feature for China users. - // A bad-but-couldn't-validate token will still surface a - // 401 on the first authenticated API call, and the existing - // 401 handler will clear it cleanly. when (val validation = GitHubAuthApi.validatePersonalAccessToken(trimmed)) { is PatValidation.Valid -> { logger.debug("PAT network-validated against GitHub /user") @@ -446,17 +434,6 @@ class AuthenticationRepositoryImpl( } } - /** - * Accepts the two PAT shapes users can create from GitHub's UI: - * - classic: `ghp_` + ~36 chars - * - fine-grained: `github_pat_` + ~82 chars - * - * Deliberately rejects GitHub App / OAuth tokens (`ghs_`, `gho_`, - * `ghu_`, `ghr_`) — they can authenticate but have different - * expiry/refresh semantics than PATs and would need separate - * handling to be safe here. Length check is lenient on purpose so - * a future GitHub format bump doesn't silently lock us out. - */ private fun looksLikePat(token: String): Boolean { if (token.length < 20) return false if (token.any { it.isWhitespace() }) return false diff --git a/feature/auth/domain/build.gradle.kts b/feature/auth/domain/build.gradle.kts index b5a257f7a..4a38ddd7f 100644 --- a/feature/auth/domain/build.gradle.kts +++ b/feature/auth/domain/build.gradle.kts @@ -7,20 +7,9 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) - - implementation(projects.core.domain) - implementation(libs.kotlinx.coroutines.core) - } - } - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { + implementation(projects.core.domain) } } } diff --git a/feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt b/feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt index 97a0c77a1..e16b00423 100644 --- a/feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt +++ b/feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt @@ -16,30 +16,6 @@ interface AuthenticationRepository { path: AuthPath, ): PollOutcome - /** - * Saves a user-supplied Personal Access Token as the active auth - * credential. - * - * Validation flow: - * 1. Client-side format check (rejects obvious paste-errors). - * 2. Network-side check against GitHub's `/user` endpoint — if - * GitHub returns 401/403 we reject and do NOT persist. If GitHub - * is unreachable (timeout/DNS/block), we persist optimistically. - * A bad-but-unreachable token will surface a 401 on the first - * real authenticated call, same as any expired token. - * - * Use case: users on networks where the browser-side of device flow - * (reaching `github.com/login/device`) is unreliable — they generate - * a PAT on a device where GitHub works, paste it here, and skip the - * browser dance entirely. Unreachable-but-save-anyway is deliberate: - * the whole reason this feature exists is for users who can't reach - * GitHub reliably in the moment. - * - * @return [Result.success] on persist, [Result.failure] on client-side - * format error or GitHub-side 401/403 rejection. On [Result.failure] - * the caller should keep the input sheet open so the user can fix - * the token. - */ suspend fun signInWithPat(token: String): Result suspend fun registerWebAuth(): Result @@ -74,25 +50,13 @@ sealed interface DevicePollResult { data class Failed(val error: Throwable) : DevicePollResult } -/** - * Reason a Personal Access Token was rejected by GitHub. Lives in the - * domain layer so the VM can pattern-match and map to localized strings - * without any raw English leaking through from the data layer. - */ sealed interface RejectedKind { - /** GitHub returned 401 — token is invalid or has been revoked. */ + data object BadCredentials : RejectedKind - /** GitHub returned 403 — token lacks required permissions/scopes or is banned. */ data object InsufficientScope : RejectedKind - /** Any other non-2xx status that still represents a definitive reject. */ data class Other(val statusCode: Int) : RejectedKind } -/** - * Thrown by `signInWithPat` when GitHub definitively rejects the token - * (as opposed to "we couldn't reach GitHub to ask"). Carries a typed - * [kind] so the presentation layer can display a localized error. - */ class PatRejectedException(val kind: RejectedKind) : Exception("PAT rejected: $kind") diff --git a/feature/auth/presentation/build.gradle.kts b/feature/auth/presentation/build.gradle.kts index 5db6ce74b..12d920187 100644 --- a/feature/auth/presentation/build.gradle.kts +++ b/feature/auth/presentation/build.gradle.kts @@ -16,15 +16,5 @@ kotlin { implementation(libs.jetbrains.compose.components.resources) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt index 2ad5b4814..d8f150a74 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt @@ -27,7 +27,6 @@ sealed interface AuthenticationAction { data object OnResumed : AuthenticationAction - // PAT paste flow data object OpenPatSheet : AuthenticationAction data object DismissPatSheet : AuthenticationAction @@ -40,7 +39,6 @@ sealed interface AuthenticationAction { data object OpenPatSettingsPage : AuthenticationAction - // Web OAuth flow (default in 1.8.3). Device flow + PAT remain as fallbacks. data object StartWebAuth : AuthenticationAction data class ConsumeAuthHandoff( diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt index 528b497f9..6fd4d83e4 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt @@ -1,3 +1,8 @@ +@file:OptIn( + androidx.compose.material3.ExperimentalMaterial3Api::class, + androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class, +) + package zed.rainxch.auth.presentation import androidx.compose.animation.AnimatedContent @@ -13,13 +18,17 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -33,29 +42,22 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DoneAll -import androidx.compose.material.icons.filled.OpenWith +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.CardDefaults +import androidx.compose.material.icons.outlined.WarningAmber +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.SheetValue +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -66,13 +68,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -85,27 +86,26 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.auth.presentation.model.AuthLoginState import zed.rainxch.auth.presentation.model.GithubDeviceStartUi -import zed.rainxch.core.presentation.components.GithubStoreButton +import androidx.compose.ui.text.style.TextOverflow +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsPasswordVisibilityIcon +import zed.rainxch.core.presentation.components.inputs.GhsTextField +import zed.rainxch.core.presentation.components.inputs.passwordVisualTransformation +import zed.rainxch.core.presentation.components.overlays.GhsBottomSheet import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents +import zed.rainxch.core.presentation.utils.constrainedContentWidth import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.auth_use_device_code_instead -import zed.rainxch.githubstore.core.presentation.res.pat_cancel -import zed.rainxch.githubstore.core.presentation.res.pat_hide -import zed.rainxch.githubstore.core.presentation.res.pat_input_label -import zed.rainxch.githubstore.core.presentation.res.pat_input_placeholder -import zed.rainxch.githubstore.core.presentation.res.pat_open_settings -import zed.rainxch.githubstore.core.presentation.res.pat_sheet_description -import zed.rainxch.githubstore.core.presentation.res.pat_sheet_title -import zed.rainxch.githubstore.core.presentation.res.pat_show -import zed.rainxch.githubstore.core.presentation.res.pat_submit -import zed.rainxch.githubstore.core.presentation.res.pat_use_token_instead import zed.rainxch.githubstore.core.presentation.res.app_icon import zed.rainxch.githubstore.core.presentation.res.auth_check_status -import zed.rainxch.githubstore.core.presentation.res.auth_code_expires_in import zed.rainxch.githubstore.core.presentation.res.auth_error_with_message import zed.rainxch.githubstore.core.presentation.res.auth_polling_status import zed.rainxch.githubstore.core.presentation.res.auth_rate_limited +import zed.rainxch.githubstore.core.presentation.res.auth_hide_signin_options +import zed.rainxch.githubstore.core.presentation.res.auth_more_signin_options import zed.rainxch.githubstore.core.presentation.res.continue_as_guest import zed.rainxch.githubstore.core.presentation.res.copy_code import zed.rainxch.githubstore.core.presentation.res.enter_code_on_github @@ -113,6 +113,14 @@ import zed.rainxch.githubstore.core.presentation.res.ic_github import zed.rainxch.githubstore.core.presentation.res.more_requests import zed.rainxch.githubstore.core.presentation.res.more_requests_description import zed.rainxch.githubstore.core.presentation.res.open_github +import zed.rainxch.githubstore.core.presentation.res.pat_cancel +import zed.rainxch.githubstore.core.presentation.res.pat_input_label +import zed.rainxch.githubstore.core.presentation.res.pat_input_placeholder +import zed.rainxch.githubstore.core.presentation.res.pat_open_settings +import zed.rainxch.githubstore.core.presentation.res.pat_sheet_description +import zed.rainxch.githubstore.core.presentation.res.pat_sheet_title +import zed.rainxch.githubstore.core.presentation.res.pat_submit +import zed.rainxch.githubstore.core.presentation.res.pat_use_token_instead import zed.rainxch.githubstore.core.presentation.res.redirecting_message import zed.rainxch.githubstore.core.presentation.res.sign_in_with_github import zed.rainxch.githubstore.core.presentation.res.signed_in @@ -133,9 +141,7 @@ fun AuthenticationRoot( ObserveAsEvents(viewModel.events) { event -> when (event) { - AuthenticationEvents.OnNavigateToMain -> { - onNavigateToHome() - } + AuthenticationEvents.OnNavigateToMain -> onNavigateToHome() } } @@ -145,7 +151,6 @@ fun AuthenticationRoot( ) } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun AuthenticationScreen( state: AuthenticationState, @@ -155,41 +160,42 @@ fun AuthenticationScreen( modifier = Modifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.TopCenter, + ) { Column( - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 24.dp), + modifier = Modifier + .constrainedContentWidth() + .fillMaxHeight() + .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(Modifier.height(48.dp)) + Spacer(Modifier.height(56.dp)) val iconScale by animateFloatAsState( - targetValue = - when (state.loginState) { - is AuthLoginState.LoggedIn -> 0.9f - is AuthLoginState.Error -> 0.95f - else -> 1f - }, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow, - ), + targetValue = when (state.loginState) { + is AuthLoginState.LoggedIn -> 0.92f + is AuthLoginState.Error -> 0.96f + else -> 1f + }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), label = "icon_scale", ) Image( painter = painterResource(Res.drawable.app_icon), contentDescription = null, - modifier = - Modifier - .size(120.dp) - .graphicsLayer { - scaleX = iconScale - scaleY = iconScale - }.clip(RoundedCornerShape(28.dp)), + modifier = Modifier + .size(96.dp) + .graphicsLayer { + scaleX = iconScale + scaleY = iconScale + } + .clip(RoundedCornerShape(24.dp)), contentScale = ContentScale.Crop, ) @@ -198,50 +204,32 @@ fun AuthenticationScreen( AnimatedContent( targetState = state.loginState, transitionSpec = { - val enter = - fadeIn(tween(350)) + - slideInVertically( - animationSpec = - spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - initialOffsetY = { it / 5 }, - ) + val enter = fadeIn(tween(350)) + slideInVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialOffsetY = { it / 5 }, + ) val exit = fadeOut(tween(200)) enter togetherWith exit }, contentKey = { it::class }, - modifier = Modifier.fillMaxWidth().weight(1f), + modifier = Modifier + .fillMaxWidth() + .weight(1f), label = "auth_state", ) { authState -> when (authState) { - is AuthLoginState.LoggedOut -> { - StateLoggedOut(onAction = onAction) - } - - is AuthLoginState.DevicePrompt -> { - StateDevicePrompt( - state = state, - authState = authState, - onAction = onAction, - ) - } - - is AuthLoginState.Pending -> { - StatePending() - } - - is AuthLoginState.LoggedIn -> { - StateLoggedIn() - } - - is AuthLoginState.Error -> { - StateError( - authState = authState, - onAction = onAction, - ) - } + is AuthLoginState.LoggedOut -> StateLoggedOut(onAction = onAction) + is AuthLoginState.DevicePrompt -> StateDevicePrompt( + state = state, + authState = authState, + onAction = onAction, + ) + is AuthLoginState.Pending -> StatePending() + is AuthLoginState.LoggedIn -> StateLoggedIn() + is AuthLoginState.Error -> StateError(authState = authState, onAction = onAction) } } } @@ -254,118 +242,137 @@ fun AuthenticationScreen( onAction = onAction, ) } + } } } @Composable private fun StateLoggedOut(onAction: (AuthenticationAction) -> Unit) { + var showMoreOptions by remember { mutableStateOf(false) } + Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(Res.string.unlock_full_experience), - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.SemiBold, + ), color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) - Spacer(Modifier.height(24.dp)) - - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(32.dp), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Row( - modifier = Modifier.padding(20.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.Top, - ) { - Box( - modifier = - Modifier - .size(48.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.OpenWith, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } + Spacer(Modifier.height(20.dp)) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = stringResource(Res.string.more_requests), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - - Text( - text = stringResource(Res.string.more_requests_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } + BenefitsCard() Spacer(Modifier.weight(1f)) - GithubStoreButton( + PrimaryPillButton( text = stringResource(Res.string.sign_in_with_github), - onClick = { onAction(AuthenticationAction.StartWebAuth) }, - icon = { + leadingIcon = { Icon( painter = painterResource(Res.drawable.ic_github), contentDescription = null, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimary, ) }, - modifier = Modifier.fillMaxWidth(), + onClick = { onAction(AuthenticationAction.StartWebAuth) }, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(10.dp)) - TextButton(onClick = { onAction(AuthenticationAction.OpenPatSheet) }) { - Text( - text = stringResource(Res.string.pat_use_token_instead), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } + GhsButton( + onClick = { onAction(AuthenticationAction.SkipLogin) }, + label = stringResource(Res.string.continue_as_guest), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) - TextButton(onClick = { onAction(AuthenticationAction.StartLogin) }) { - Text( - text = stringResource(Res.string.auth_use_device_code_instead), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, - ) - } + GhsButton( + onClick = { showMoreOptions = !showMoreOptions }, + label = if (showMoreOptions) { + stringResource(Res.string.auth_hide_signin_options) + } else { + stringResource(Res.string.auth_more_signin_options) + }, + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + trailingIcon = Icons.Default.ExpandMore, + ) - TextButton(onClick = { onAction(AuthenticationAction.SkipLogin) }) { - Text( - text = stringResource(Res.string.continue_as_guest), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline, - ) + AnimatedVisibility(visible = showMoreOptions) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(4.dp)) + GhsButton( + onClick = { onAction(AuthenticationAction.OpenPatSheet) }, + label = stringResource(Res.string.pat_use_token_instead), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) + GhsButton( + onClick = { onAction(AuthenticationAction.StartLogin) }, + label = stringResource(Res.string.auth_use_device_code_instead), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) + } } Spacer(Modifier.height(16.dp)) } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun BenefitsCard() { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)), + ) { + Row( + modifier = Modifier.padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(Res.drawable.ic_github), + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResource(Res.string.more_requests), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.more_requests_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun StateDevicePrompt( state: AuthenticationState, @@ -378,26 +385,22 @@ private fun StateDevicePrompt( ) { Spacer(Modifier.weight(1f)) - ElevatedCard( + Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(32.dp), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)), ) { Column( - modifier = Modifier.padding(24.dp), + modifier = Modifier.padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(Res.string.enter_code_on_github), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - - Spacer(Modifier.height(16.dp)) - + Spacer(Modifier.height(14.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, @@ -405,123 +408,94 @@ private fun StateDevicePrompt( ) { Text( text = authState.start.userCode, - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.Bold, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ), color = MaterialTheme.colorScheme.onSurface, - letterSpacing = 2.sp, + letterSpacing = 3.sp, ) - Spacer(Modifier.width(12.dp)) - - IconButton( - shapes = IconButtonDefaults.shapes(), - onClick = { - onAction(AuthenticationAction.CopyCode(authState.start)) - }, - colors = - IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)) + .clickable { + onAction(AuthenticationAction.CopyCode(authState.start)) + }, + contentAlignment = Alignment.Center, ) { AnimatedContent( targetState = state.copied, transitionSpec = { - (scaleIn( - spring(dampingRatio = Spring.DampingRatioMediumBouncy), - ) + fadeIn()) togetherWith (scaleOut() + fadeOut()) + (scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn()) togetherWith + (scaleOut() + fadeOut()) }, label = "copy_icon", ) { isCopied -> Icon( - imageVector = - if (isCopied) { - Icons.Default.DoneAll - } else { - Icons.Default.ContentCopy - }, + imageVector = if (isCopied) Icons.Default.DoneAll else Icons.Default.ContentCopy, contentDescription = stringResource(Res.string.copy_code), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, ) } } } - state.info?.let { info -> - Spacer(Modifier.height(12.dp)) - + Spacer(Modifier.height(10.dp)) Text( text = info, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center, ) } - if (authState.remainingSeconds > 0) { - Spacer(Modifier.height(20.dp)) - - val progress = - authState.remainingSeconds.toFloat() / - authState.start.expiresInSec.toFloat() - + Spacer(Modifier.height(16.dp)) + val progress = authState.remainingSeconds.toFloat() / + authState.start.expiresInSec.toFloat() val animatedProgress by animateFloatAsState( targetValue = progress, animationSpec = tween(900), label = "countdown_progress", ) - val isUrgent = authState.remainingSeconds < 60 - val progressColor by animateColorAsState( - targetValue = - if (isUrgent) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, + targetValue = if (isUrgent) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, animationSpec = tween(500), label = "progress_color", ) - val timerColor by animateColorAsState( - targetValue = - if (isUrgent) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.outline - }, + targetValue = if (isUrgent) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, animationSpec = tween(500), label = "timer_color", ) - Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { LinearProgressIndicator( progress = { animatedProgress }, - modifier = - Modifier - .weight(1f) - .clip(RoundedCornerShape(4.dp)), + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(4.dp)), color = progressColor, - trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) - Spacer(Modifier.width(12.dp)) - val minutes = authState.remainingSeconds / 60 val seconds = authState.remainingSeconds % 60 - val formatted = - remember(minutes, seconds) { - "%02d:%02d".format(minutes, seconds) - } - + val formatted = remember(minutes, seconds) { + "%02d:%02d".format(minutes, seconds) + } Text( text = formatted, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Medium, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ), color = timerColor, ) } @@ -529,57 +503,50 @@ private fun StateDevicePrompt( } } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(18.dp)) - GithubStoreButton( + PrimaryPillButton( text = stringResource(Res.string.open_github), - onClick = { - onAction(AuthenticationAction.OpenGitHub(authState.start)) - }, - icon = { + leadingIcon = { Icon( painter = painterResource(Res.drawable.ic_github), contentDescription = null, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimary, ) }, - modifier = Modifier.fillMaxWidth(), + onClick = { onAction(AuthenticationAction.OpenGitHub(authState.start)) }, ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(10.dp)) - FilledTonalButton( - onClick = { onAction(AuthenticationAction.PollNow) }, - enabled = !state.isPolling, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), - ) { - if (state.isPolling) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) + OutlinedPillButton( + text = if (state.isPolling) { + stringResource(Res.string.auth_polling_status) } else { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - - Spacer(Modifier.width(8.dp)) - - Text( - text = - if (state.isPolling) { - stringResource(Res.string.auth_polling_status) - } else { - stringResource(Res.string.auth_check_status) - }, - style = MaterialTheme.typography.labelLarge, - ) - } + stringResource(Res.string.auth_check_status) + }, + leadingIcon = if (state.isPolling) { + { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } else { + { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + enabled = !state.isPolling, + onClick = { onAction(AuthenticationAction.PollNow) }, + ) if (state.pollIntervalSec > 0) { Spacer(Modifier.height(8.dp)) @@ -594,7 +561,6 @@ private fun StateDevicePrompt( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun StatePending() { Column( @@ -602,12 +568,8 @@ private fun StatePending() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - CircularWavyProgressIndicator( - modifier = Modifier.size(64.dp), - ) - - Spacer(Modifier.height(24.dp)) - + CircularWavyProgressIndicator(modifier = Modifier.size(56.dp)) + Spacer(Modifier.height(20.dp)) Text( text = stringResource(Res.string.waiting_for_authorization), style = MaterialTheme.typography.titleMedium, @@ -625,38 +587,36 @@ private fun StateLoggedIn() { verticalArrangement = Arrangement.Center, ) { var visible by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { visible = true } - AnimatedVisibility( visible = visible, - enter = - scaleIn( - spring(dampingRatio = Spring.DampingRatioMediumBouncy), - ) + fadeIn(), + enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(), ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - modifier = Modifier.size(72.dp), - tint = MaterialTheme.colorScheme.primary, - ) + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } } - - Spacer(Modifier.height(20.dp)) - + Spacer(Modifier.height(18.dp)) Text( text = stringResource(Res.string.signed_in), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground, ) - - Spacer(Modifier.height(8.dp)) - + Spacer(Modifier.height(6.dp)) Text( text = stringResource(Res.string.redirecting_message), - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -672,75 +632,118 @@ private fun StateError( horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.weight(1f)) - - ElevatedCard( + Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(32.dp), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.errorContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.4f)), ) { Column( - modifier = Modifier.padding(24.dp), + modifier = Modifier.padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( - imageVector = Icons.Default.Warning, + imageVector = Icons.Outlined.WarningAmber, contentDescription = null, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(34.dp), tint = MaterialTheme.colorScheme.onErrorContainer, ) - - Spacer(Modifier.height(16.dp)) - + Spacer(Modifier.height(12.dp)) Text( - text = - stringResource( - Res.string.auth_error_with_message, - authState.message, - ), - style = MaterialTheme.typography.titleMedium, + text = stringResource(Res.string.auth_error_with_message, authState.message), + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onErrorContainer, textAlign = TextAlign.Center, ) - authState.recoveryHint?.let { hint -> - Spacer(Modifier.height(8.dp)) - + Spacer(Modifier.height(6.dp)) Text( text = hint, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.85f), textAlign = TextAlign.Center, ) } } } - - Spacer(Modifier.height(24.dp)) - - GithubStoreButton( + Spacer(Modifier.height(20.dp)) + PrimaryPillButton( text = stringResource(Res.string.try_again), onClick = { onAction(AuthenticationAction.StartLogin) }, - modifier = Modifier.fillMaxWidth(), ) + Spacer(Modifier.height(6.dp)) + GhsButton( + onClick = { onAction(AuthenticationAction.SkipLogin) }, + label = stringResource(Res.string.continue_as_guest), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) + Spacer(Modifier.weight(2f)) + } +} - Spacer(Modifier.height(8.dp)) +@Composable +private fun PrimaryPillButton( + text: String, + onClick: () -> Unit, + leadingIcon: (@Composable () -> Unit)? = null, + enabled: Boolean = true, +) { + GhsButton( + onClick = onClick, + enabled = enabled, + variant = GhsButtonVariant.Primary, + size = GhsButtonSize.Lg, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + leadingIcon?.invoke() + Text( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} - TextButton(onClick = { onAction(AuthenticationAction.SkipLogin) }) { +@Composable +private fun OutlinedPillButton( + text: String, + onClick: () -> Unit, + leadingIcon: (@Composable () -> Unit)? = null, + enabled: Boolean = true, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)), + onClick = onClick, + enabled = enabled, + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + leadingIcon?.invoke() + if (leadingIcon != null) Spacer(Modifier.width(8.dp)) Text( - text = stringResource(Res.string.continue_as_guest), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline, + text = text, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, ) } - - Spacer(Modifier.weight(2f)) } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PatSignInSheet( input: String, @@ -750,80 +753,60 @@ private fun PatSignInSheet( ) { val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, - // Veto the visual Hidden transition while a submission is in - // flight, so a swipe-down/scrim-tap can't cosmetically dismiss - // the sheet before `onDismissRequest`'s guard runs. Pairs with - // the existing `!isSubmitting` check in `onDismissRequest` to - // fully gate dismissal during save. confirmValueChange = { newValue -> !(isSubmitting && newValue == SheetValue.Hidden) }, ) var isMasked by remember { mutableStateOf(true) } - ModalBottomSheet( + GhsBottomSheet( onDismissRequest = { if (!isSubmitting) onAction(AuthenticationAction.DismissPatSheet) }, sheetState = sheetState, ) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { Text( text = stringResource(Res.string.pat_sheet_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), color = MaterialTheme.colorScheme.onSurface, ) - Text( text = stringResource(Res.string.pat_sheet_description), - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - FilledTonalButton( + OutlinedPillButton( + text = stringResource(Res.string.pat_open_settings), onClick = { onAction(AuthenticationAction.OpenPatSettingsPage) }, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(Res.string.pat_open_settings), - style = MaterialTheme.typography.labelLarge, - ) - } + ) - OutlinedTextField( + GhsTextField( value = input, onValueChange = { onAction(AuthenticationAction.OnPatInputChanged(it)) }, - label = { Text(stringResource(Res.string.pat_input_label)) }, - placeholder = { Text(stringResource(Res.string.pat_input_placeholder)) }, + label = stringResource(Res.string.pat_input_label), + placeholder = stringResource(Res.string.pat_input_placeholder), singleLine = true, - visualTransformation = - if (isMasked) PasswordVisualTransformation() else VisualTransformation.None, - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Password, - autoCorrectEnabled = false, - capitalization = KeyboardCapitalization.None, - ), + visualTransformation = passwordVisualTransformation(!isMasked), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + capitalization = KeyboardCapitalization.None, + ), isError = error != null, enabled = !isSubmitting, trailingIcon = { - IconButton(onClick = { isMasked = !isMasked }) { - Icon( - imageVector = - if (isMasked) Icons.Default.Visibility else Icons.Default.VisibilityOff, - contentDescription = - stringResource( - if (isMasked) Res.string.pat_show else Res.string.pat_hide, - ), - ) - } + GhsPasswordVisibilityIcon( + visible = !isMasked, + onToggle = { isMasked = !isMasked }, + ) }, modifier = Modifier.fillMaxWidth(), ) @@ -831,41 +814,48 @@ private fun PatSignInSheet( if (error != null) { Text( text = error, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error, ) } - Spacer(Modifier.height(4.dp)) - Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - TextButton( + Surface( + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(50), + color = Color.Transparent, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)), onClick = { onAction(AuthenticationAction.DismissPatSheet) }, enabled = !isSubmitting, - modifier = Modifier.weight(1f), ) { - Text(stringResource(Res.string.pat_cancel)) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.pat_cancel), + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + } } - GithubStoreButton( - text = stringResource(Res.string.pat_submit), + GhsButton( onClick = { onAction(AuthenticationAction.SubmitPat) }, - modifier = Modifier.weight(1f), - icon = - if (isSubmitting) { - { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } - } else { - null - }, + label = stringResource(Res.string.pat_submit), + enabled = !isSubmitting && input.isNotBlank(), + loading = isSubmitting, + variant = GhsButtonVariant.Primary, + modifier = Modifier + .weight(1f) + .height(48.dp), ) } } @@ -877,14 +867,12 @@ private fun PatSignInSheet( private fun PreviewError() { GithubStoreTheme { AuthenticationScreen( - state = - AuthenticationState( - loginState = - AuthLoginState.Error( - message = "Network timeout", - recoveryHint = "Check your internet connection", - ), + state = AuthenticationState( + loginState = AuthLoginState.Error( + message = "Network timeout", + recoveryHint = "Check your internet connection", ), + ), onAction = {}, ) } @@ -895,10 +883,7 @@ private fun PreviewError() { private fun PreviewLoggedOut() { GithubStoreTheme { AuthenticationScreen( - state = - AuthenticationState( - loginState = AuthLoginState.LoggedOut, - ), + state = AuthenticationState(loginState = AuthLoginState.LoggedOut), onAction = {}, ) } @@ -909,20 +894,18 @@ private fun PreviewLoggedOut() { private fun PreviewDevicePrompt() { GithubStoreTheme { AuthenticationScreen( - state = - AuthenticationState( - loginState = - AuthLoginState.DevicePrompt( - GithubDeviceStartUi( - deviceCode = "", - userCode = "2102-UHHUF", - verificationUri = "", - expiresInSec = 900, - ), - remainingSeconds = 847, - ), - copied = true, + state = AuthenticationState( + loginState = AuthLoginState.DevicePrompt( + GithubDeviceStartUi( + deviceCode = "", + userCode = "2102-UHHUF", + verificationUri = "", + expiresInSec = 900, + ), + remainingSeconds = 847, ), + copied = true, + ), onAction = {}, ) } @@ -933,10 +916,7 @@ private fun PreviewDevicePrompt() { private fun PreviewLoggedIn() { GithubStoreTheme { AuthenticationScreen( - state = - AuthenticationState( - loginState = AuthLoginState.LoggedIn, - ), + state = AuthenticationState(loginState = AuthLoginState.LoggedIn), onAction = {}, ) } diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationState.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationState.kt index 18e8e0224..d624e1042 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationState.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationState.kt @@ -8,15 +8,12 @@ data class AuthenticationState( val info: String? = null, val isPolling: Boolean = false, val pollIntervalSec: Int = 0, - // PAT ("Personal Access Token") paste flow — alternate login path - // for users on networks where the browser can't reach github.com - // to complete the device-flow authorize step. + val isPatSheetVisible: Boolean = false, val patInput: String = "", val patError: String? = null, val isPatSubmitting: Boolean = false, - // Web OAuth flow is the default in 1.8.3. Device flow + PAT live behind - // this expandable for users who need them (corp firewalls, GFW etc). + val isAdvancedAuthVisible: Boolean = false, val isWebAuthInFlight: Boolean = false, ) diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt index adbaf062f..e65047d4b 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt @@ -48,14 +48,6 @@ class AuthenticationViewModel( private var pollingIntervalMs: Long = DEFAULT_POLL_INTERVAL_SEC * 1000L private var authPath: AuthPath = AuthPath.Backend - /** - * Wall-clock timestamp (`System.currentTimeMillis()`) when the most - * recent poll *started*. Used to dedupe user-triggered polls against - * the background polling loop so that rapid interactions (tapping - * "Check status" multiple times, reopening the app repeatedly) don't - * stack polls on top of each other and trigger GitHub `slow_down` - * responses — the root cause of the "Rate limited" cascade. - */ private var lastPollStartedAtMs: Long = 0L private val _state: MutableStateFlow = @@ -147,9 +139,7 @@ class AuthenticationViewModel( } AuthenticationAction.DismissPatSheet -> { - // Cancel any in-flight submission so it can't race past - // the dismissal and navigate/toast after the user has - // bailed on the sheet. + patSubmissionJob?.cancel() patSubmissionJob = null _state.update { @@ -164,8 +154,7 @@ class AuthenticationViewModel( is AuthenticationAction.OnPatInputChanged -> { _state.update { - // Clear error on edit so it doesn't linger after the - // user starts fixing the problem. + it.copy(patInput = action.input, patError = null) } } @@ -329,9 +318,7 @@ class AuthenticationViewModel( private fun consumeAuthHandoff(handoffId: String, state: String) { val expected = savedStateHandle.get(KEY_WEB_AUTH_STATE) - // Custom scheme is public — any app can fire githubstore://auth?... - // No pending session = not our flow. Drop silently to avoid knocking - // an unsuspecting user into an error screen. + if (expected == null) { logger.debug("Ignoring web-auth handoff with no pending session") return @@ -472,16 +459,8 @@ class AuthenticationViewModel( if (loginState !is AuthLoginState.DevicePrompt) return if (_state.value.isPolling) return - // If the background loop is alive it's already polling on a fixed - // schedule — ANY on-resume poll we fire here lands on top of it and - // triggers `slow_down`. (Previous implementation tried to time-window - // this; the window was too narrow and still raced.) With a healthy - // loop we trust it completely and do nothing on resume. if (pollingJob?.isActive == true) return - // Loop died between sessions (process death without restoreFromSavedState - // rehydrating, or a crash in the loop itself). Restart + immediate poll - // to get things moving again. logger.debug("Resume poll: background loop was dead, restarting") startPolling(loginState.start.deviceCode) pollOnce(loginState.start.deviceCode) @@ -492,9 +471,6 @@ class AuthenticationViewModel( if (loginState !is AuthLoginState.DevicePrompt) return val deviceCode = loginState.start.deviceCode - // Hard-block manual polls that land within MIN_MANUAL_POLL_SPACING_MS - // of the last poll. Prevents user tap-spam from burning the - // `slow_down` budget. val sinceLast = System.currentTimeMillis() - lastPollStartedAtMs if (sinceLast < MIN_MANUAL_POLL_SPACING_MS) { logger.debug("Manual poll suppressed — only ${sinceLast}ms since last poll") @@ -502,9 +478,7 @@ class AuthenticationViewModel( } logger.debug("Manual poll requested (pollingJobActive=${pollingJob?.isActive})") - // Restart the background loop so its next scheduled poll is a full - // interval AFTER this manual one, not stacked right on top. Preserve - // the adaptive interval — don't reset it on manual tap. + startPolling(deviceCode, resetInterval = false) pollOnce(deviceCode) } @@ -585,7 +559,7 @@ class AuthenticationViewModel( val intervalSec = (loginState as? AuthLoginState.DevicePrompt)?.start?.intervalSec ?: DEFAULT_POLL_INTERVAL_SEC - // Add 1s buffer above GitHub's minimum to avoid immediate slow_down + pollingIntervalMs = (intervalSec * 1000).toLong() + 1000L } pollingJob = @@ -598,10 +572,7 @@ class AuthenticationViewModel( } private fun pollOnce(deviceCode: String) { - // Set the timestamp SYNCHRONOUSLY here (not inside doPoll's suspend - // body) so any concurrent tryPollIfReady / forcePollNow / restore - // invocation sees the current poll reservation immediately and - // doesn't stack another poll on top. + lastPollStartedAtMs = System.currentTimeMillis() viewModelScope.launch { doPoll(deviceCode) @@ -619,17 +590,7 @@ class AuthenticationViewModel( } if (outcome.path != authPath) { - // Mid-session transitions are strictly one-way: only the - // Backend → Direct escalation that AuthenticationRepositoryImpl - // performs on infrastructure errors (timeouts, connect/socket - // failures, 5xx) is legitimate. Direct → Backend is not a path - // the repository ever returns; treat any such outcome as a - // defensive no-op rather than silently flipping back to a - // probably-broken Backend mid-flow. The infrastructure-failure - // gate itself lives at the repository (single source of - // truth — see `pollDeviceTokenOnce` and - // `Throwable.isAuthInfrastructureError()`); the VM only - // enforces direction. + val isLegalEscalation = authPath == AuthPath.Backend && outcome.path == AuthPath.Direct if (isLegalEscalation) { @@ -661,10 +622,7 @@ class AuthenticationViewModel( } is DevicePollResult.SlowDown -> { - // Cap the interval so one rough patch of rapid polls - // (e.g. several ON_RESUME stacks early in the session) - // can't strand the user waiting 30+ seconds to pick - // up a completed authorization. + val bumped = (pollingIntervalMs + 5000L).coerceAtMost(MAX_POLL_INTERVAL_MS) val clamped = bumped == MAX_POLL_INTERVAL_MS && pollingIntervalMs >= MAX_POLL_INTERVAL_MS pollingIntervalMs = bumped @@ -681,8 +639,7 @@ class AuthenticationViewModel( pollIntervalSec = (pollingIntervalMs / 1000).toInt(), ) } - // Don't restart — the existing polling loop reads pollingIntervalMs - // on each iteration via delay(), so it will pick up the new value. + } is DevicePollResult.Failed -> { @@ -708,8 +665,6 @@ class AuthenticationViewModel( } } - // region SavedStateHandle - private fun saveToSavedState( deviceCode: String, startUi: GithubDeviceStartUi, @@ -774,8 +729,6 @@ class AuthenticationViewModel( pollOnce(deviceCode) } - // endregion - private suspend fun categorizeError(t: Throwable): Pair { val msg = t.message ?: return getString(Res.string.error_unknown) to null val lowerMsg = msg.lowercase() @@ -869,19 +822,8 @@ class AuthenticationViewModel( private const val KEY_WEB_AUTH_STATE = "auth_web_state" private const val DEFAULT_POLL_INTERVAL_SEC = 5 - /** - * Minimum wall-clock gap between a user-initiated manual poll - * (tap "Check status") and the previous poll. Anything closer - * gets silently dropped to keep us out of `slow_down` territory. - */ private const val MIN_MANUAL_POLL_SPACING_MS = 2_000L - /** - * Ceiling on the adaptive `pollingIntervalMs`. Without this cap, - * a run of `slow_down` responses could push the interval up by - * 5s each time, leaving the user waiting 30+ seconds for the - * app to notice their completed authorization. - */ private const val MAX_POLL_INTERVAL_MS = 15_000L private const val PAT_SETTINGS_URL = "https://github.com/settings/tokens/new" diff --git a/feature/details/data/build.gradle.kts b/feature/details/data/build.gradle.kts index cb6ef330e..ea873112a 100644 --- a/feature/details/data/build.gradle.kts +++ b/feature/details/data/build.gradle.kts @@ -8,25 +8,15 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.details.domain) - implementation(libs.kotlinx.coroutines.core) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt index 231bfb9e0..2ca2eb126 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt @@ -91,25 +91,6 @@ class DetailsRepositoryImpl( private val readmeHelper = ReadmeLocalizationHelper(localizationManager) - /** - * Decides whether a backend failure should trigger the direct-to-GitHub - * fallback. **Side effect:** rethrows `CancellationException` to preserve - * structured concurrency — callers don't need a separate CE check before - * invoking this. - * - * Returns `true` for: - * - Any non-[BackendException] throwable (network errors, timeouts, - * parse failures — all treated as infra) - * - [BackendException] with status in 500..599 - * - * Returns `false` for: - * - [BackendException] with status in 400..499 — backend's answer is - * authoritative (cached 404, 401 auth failure, 429 rate limit, etc.) - * and GitHub-direct would return the same answer. **Note:** this - * includes 429 and 408 — if the backend is rate-limiting us or - * timing out on its own pipeline, retrying via GitHub direct - * doesn't help and only burns more quota. - */ private fun shouldFallbackToGithubOrRethrow(cause: Throwable): Boolean = sharedShouldFallback(cause) @@ -179,8 +160,6 @@ class DetailsRepositoryImpl( return cached } - // Try backend first. Phase 5.1: backend now lazy-caches unknown - // repos, so success rate is high even for non-curated repos. val backendResult = backendApiClient.getRepo(owner, name) backendResult.fold( onSuccess = { backendRepo -> @@ -191,9 +170,7 @@ class DetailsRepositoryImpl( }, onFailure = { e -> if (!shouldFallbackToGithubOrRethrow(e)) { - // Backend 4xx — GitHub would give the same answer. - // Serve stale if we have it, otherwise propagate the - // error so the VM can show the right state. + cacheManager.getStale(cacheKey)?.let { stale -> logger.debug("Backend 4xx for $owner/$name, serving stale cache") return stale @@ -204,7 +181,6 @@ class DetailsRepositoryImpl( }, ) - // Fallback to GitHub API (only reached on backend 5xx / network error) return try { val result = httpClient @@ -329,8 +305,6 @@ class DetailsRepositoryImpl( } } - // Backend-first. Phase 5.1 routes /v1/releases via the backend cache - // + ETag revalidation, China-reachable via Gcore/api-direct. val backendResult = backendApiClient.getReleases(owner, repo) backendResult.fold( onSuccess = { releases -> @@ -358,7 +332,6 @@ class DetailsRepositoryImpl( }, ) - // Fallback to GitHub API directly (only reached on backend 5xx / network error) return try { val releases = httpClient @@ -386,10 +359,7 @@ class DetailsRepositoryImpl( } catch (e: CancellationException) { throw e } catch (e: SerializationException) { - // Parse failure signals a DTO/API drift — surface loudly so it's - // findable in logs and crash reports. Still prefer returning a - // stale cache rather than throwing, so the UI can keep rendering - // the last known good data while we figure out the new shape. + logger.error("Failed to parse releases for $owner/$repo: ${e.message}", e) cacheManager.getStale>(cacheKey)?.let { stale -> logger.debug("Serving stale cache for releases $owner/$repo after parse failure") @@ -427,10 +397,7 @@ class DetailsRepositoryImpl( sourceHost: String?, ): Triple? { if (sourceHost != null) return getForgejoReadme(owner, repo, defaultBranch, sourceHost) - // v2 — bumped after markdown preprocessor overhaul (alerts, - // emoji, details, image-row). Forces re-fetch so users get a - // properly-processed readme instead of waiting for the stale - // v1 entry to expire. + val cacheKey = "details:readme:v4:$owner/$repo" cacheManager.get(cacheKey)?.let { cached -> @@ -438,10 +405,6 @@ class DetailsRepositoryImpl( return Triple(cached.content, cached.languageCode, cached.path) } - // Backend-first. Phase 5.2: /v1/readme proxies GitHub's contents API, - // which returns base64-encoded markdown — different shape from the - // raw.githubusercontent.com path below, but the post-processing - // pipeline is the same. val backendResult = backendApiClient.getReadme(owner, repo) backendResult.fold( onSuccess = { dto -> @@ -458,7 +421,7 @@ class DetailsRepositoryImpl( ) return processed } - // Decode/processing failed — fall through to the raw-URL path + logger.debug("Backend readme decode failed for $owner/$repo, falling back to raw URL") }, onFailure = { e -> @@ -467,17 +430,13 @@ class DetailsRepositoryImpl( logger.debug("Backend 4xx for readme $owner/$repo, serving stale cache") return Triple(stale.content, stale.languageCode, stale.path) } - // No stale — no readme exists or user can't access. Treat - // as "no readme" rather than propagating as an error; - // matches how fetchReadmeFromApi returned null. + return null } logger.debug("Backend infra error for readme $owner/$repo (${e.message}), falling back to raw URL") }, ) - // Fallback to raw.githubusercontent.com (only reached on backend - // infra error or on successful backend response that we couldn't decode) val result = fetchReadmeFromApi(owner, repo, defaultBranch) if (result != null) { @@ -500,10 +459,7 @@ class DetailsRepositoryImpl( repo: String, defaultBranch: String, ): Triple? { - // GitHub's contents API base64-encodes with embedded newlines; Mime - // variant tolerates all whitespace transparently so we don't have - // to pre-strip. Narrow catch: only IAE is decode-related, other - // throwables (OOM, etc.) propagate. + val rawContent = dto.content ?: return null val decoded = try { Base64.Mime.decode(rawContent).decodeToString() @@ -555,9 +511,7 @@ class DetailsRepositoryImpl( sourceHost: String?, ): RepoStats { if (sourceHost != null) return getForgejoRepoStats(owner, repo, sourceHost) - // v3 — backend now supplies license. Bumping the key forces re-fetch - // so post-upgrade users get a populated license instead of waiting - // 6h for the stale v2 entry (license=null) to expire. + val cacheKey = "details:stats:v3:$owner/$repo" cacheManager.get(cacheKey)?.let { cached -> @@ -565,9 +519,6 @@ class DetailsRepositoryImpl( return cached } - // Try backend first — provides stars/forks/openIssues/license/downloadCount. - // No more direct GitHub enrichment for license (was 1 quota hit per - // signed-in user per stats fetch); backend is now authoritative. val backendResult = backendApiClient.getRepo(owner, repo) backendResult.fold( onSuccess = { backendRepo -> @@ -594,7 +545,6 @@ class DetailsRepositoryImpl( }, ) - // Fallback to GitHub API return try { logger.debug("Backend miss for stats $owner/$repo, falling back to GitHub API") val info = @@ -633,8 +583,6 @@ class DetailsRepositoryImpl( return cached } - // Backend-first. Phase 5.3: /v1/user proxies GitHub's users API with - // aggressive edge caching (7-day TTL on Gcore). val backendResult = backendApiClient.getUser(username) backendResult.fold( onSuccess = { user -> @@ -654,7 +602,6 @@ class DetailsRepositoryImpl( }, ) - // Fallback to GitHub direct (only reached on backend 5xx / network error) return try { val user = httpClient @@ -693,14 +640,6 @@ class DetailsRepositoryImpl( twitterUsername = twitterUsername, ) - // ── Forgejo / Codeberg branch ───────────────────────────────────── - // - // No backend proxy, no GitHub fallback — all reads go straight to the - // forge instance. Cache keys are namespaced by host so the same - // `owner/repo` slug on github.com and codeberg.org never collide. Stats - // are derived from the repo response (stars / forks / openIssues / - // license fields exposed by Gitea/Forgejo API v1). - private suspend fun getForgejoRepository( owner: String, name: String, @@ -725,10 +664,7 @@ class DetailsRepositoryImpl( repo: String, sourceHost: String, ): List { - // v2 — release `body` is now pre-processed (CRLF → LF + relative - // URL rewrite to the Forgejo raw endpoint). Previously the raw - // CRLF bodies broke GFM table parsing in the markdown renderer - // (Gadgetbridge changelog tables rendered as literal pipes). + val cacheKey = "details:releases:forgejo:v2:$sourceHost:$owner/$repo" cacheManager.get>(cacheKey)?.takeIf { it.isNotEmpty() }?.let { return it } val client = forgejoClientRegistry.clientFor(sourceHost) @@ -757,11 +693,7 @@ class DetailsRepositoryImpl( owner: String, repo: String, ): String { - // Forgejo emits CRLF line endings. The intellij-markdown GFM - // table parser is line-sensitive — `\r` left in the separator - // row makes the table degrade to literal pipes. Normalize first, - // then run the same image / URL rewriting used on GitHub - // release bodies, but pointed at the Forgejo raw-branch URL. + val normalized = body.replace("\r\n", "\n") val baseUrl = "https://$sourceHost/$owner/$repo/raw/branch/HEAD/" return preprocessMarkdown(markdown = normalized, baseUrl = baseUrl) @@ -774,20 +706,13 @@ class DetailsRepositoryImpl( defaultBranch: String, sourceHost: String, ): Triple? { - // v2 — moved off the non-existent `/readme` endpoint (404s on every - // Forgejo / Gitea instance) onto `/contents/README.md`. + val cacheKey = "details:readme:forgejo:v2:$sourceHost:$owner/$repo" cacheManager.get(cacheKey)?.let { return Triple(it.content, it.languageCode, it.path) } val client = forgejoClientRegistry.clientFor(sourceHost) - // Forgejo / Gitea does NOT implement GitHub's `/repos/{o}/{r}/readme` - // convenience endpoint — verified live against codeberg.org. We hit - // the contents endpoint directly. Try `README.md` first (covers the - // overwhelming majority including Gadgetbridge), and on 404 fall - // back to listing the repo root and scanning for any - // `^README(\..+)?$` file. val dto = client.getContentsFile(owner, repo, "README.md", defaultBranch).getOrNull() ?: client.listContentsRoot(owner, repo, defaultBranch).getOrNull() ?.firstOrNull { entry -> @@ -803,8 +728,6 @@ class DetailsRepositoryImpl( return null } - // Forgejo's contents response is base64 (single continuous line, - // no MIME wrapping — Mime variant tolerates both forms transparently). val rawContent = dto.content ?: return null val decoded = try { Base64.Mime.decode(rawContent).decodeToString() @@ -813,8 +736,7 @@ class DetailsRepositoryImpl( return null } val path = dto.path?.takeIf { it.isNotBlank() } ?: "README.md" - // Relative image refs in a Forgejo README need the per-host raw URL - // base. Forgejo's raw path shape is `/{o}/{r}/raw/branch/{ref}/`. + val baseUrl = "https://$sourceHost/$owner/$repo/raw/branch/$defaultBranch/" val processed = preprocessMarkdown(markdown = decoded, baseUrl = baseUrl) val detected = readmeHelper.detectReadmeLanguage(processed) @@ -827,7 +749,7 @@ class DetailsRepositoryImpl( } private companion object { - // README, README.md, README.rst, README.adoc, Readme.txt, etc. + private val READMEFileNameRegex = Regex("""^README(\..+)?$""", RegexOption.IGNORE_CASE) } @@ -836,17 +758,13 @@ class DetailsRepositoryImpl( repo: String, sourceHost: String, ): RepoStats { - // v2 — added license sniffing (from /contents/LICENSE) + - // aggregated download count (from release assets), neither of - // which Forgejo exposes on the /repos endpoint itself. + val cacheKey = "details:stats:forgejo:v2:$sourceHost:$owner/$repo" cacheManager.get(cacheKey)?.let { return it } val client = forgejoClientRegistry.clientFor(sourceHost) return try { val info = client.getRepository(owner, repo).getOrThrow() - // Best-effort license + downloads enrichment. Both can fail - // silently — repo stats still render with the core counters - // pulled directly from the /repos payload. + val license = detectForgejoLicense(client, owner, repo, info.defaultBranch ?: "main") val downloads = sumForgejoReleaseDownloads(sourceHost, owner, repo) val result = RepoStats( @@ -864,14 +782,6 @@ class DetailsRepositoryImpl( } } - /** - * Forgejo / Gitea does NOT expose a `license` field on - * `/repos/{o}/{r}`. The dedicated `/license` endpoint is - * GitHub-specific (404 on Forgejo). Best-effort: fetch the LICENSE - * file from the repo root and regex-match the first ~200 chars - * against canonical license headers. Returns SPDX-style id when - * matched, falls back to a short label, returns `null` on miss. - */ @OptIn(ExperimentalEncodingApi::class) private suspend fun detectForgejoLicense( client: zed.rainxch.core.data.network.ForgejoApiClient, @@ -879,8 +789,7 @@ class DetailsRepositoryImpl( repo: String, ref: String, ): String? { - // Try common LICENSE filenames in priority order. Most repos use - // bare `LICENSE`; common alternates covered below. + val candidates = listOf("LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING") val dto = candidates.firstNotNullOfOrNull { name -> client.getContentsFile(owner, repo, name, ref).getOrNull() @@ -914,13 +823,6 @@ class DetailsRepositoryImpl( } } - /** - * Forgejo's `/repos/{o}/{r}` response carries no aggregate download - * count. Each release `asset.download_count` is per-asset, and the - * stable channel for total downloads is the sum across all release - * assets. Reuses the cached releases list when present so we don't - * double-fetch when stats + releases load in parallel from the VM. - */ private suspend fun sumForgejoReleaseDownloads( sourceHost: String, owner: String, diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt index a1ba44ce2..97d60986c 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/TranslationRepositoryImpl.kt @@ -20,12 +20,6 @@ import zed.rainxch.details.domain.repository.TranslationRepository import kotlin.time.Clock import kotlin.time.ExperimentalTime -/** - * Orchestrates translation: picks the user-configured [Translator] - * ([TranslationProvider]), drives chunking + caching in this layer - * (so each concrete translator only has to round-trip a single - * chunk), and stitches results back together. - */ class TranslationRepositoryImpl( private val localizationManager: LocalizationManager, private val clientProvider: TranslationClientProvider, @@ -39,8 +33,6 @@ class TranslationRepositoryImpl( isLenient = true } - // Google's provider has no per-install config — share a single - // instance for the lifetime of the repository. private val googleTranslator: GoogleTranslator = GoogleTranslator(httpClient = { httpClient }, json = json) @@ -62,9 +54,6 @@ class TranslationRepositoryImpl( } } - // Mask out machine-readable spans (code fences, HTML tags, - // markdown URLs, bare URLs) so the translator doesn't mangle - // them. See `protectFromTranslation` kdoc for the full list. val protection = protectFromTranslation(text) val translator = resolveTranslator() @@ -102,32 +91,14 @@ class TranslationRepositoryImpl( return result } - /** - * Strips machine-readable spans (code fences, HTML tags, markdown - * link/image URLs) that translators will otherwise mangle. Each span - * is replaced with an opaque marker the translator passes through - * verbatim, then restored after the joined translation comes back. - * - * Why bigger than just fenced code: real READMEs commonly stack - * `[![alt](badge.svg)](https://store/...)` badge rows and raw - * `` tags. Translators tokenize on `(` and `<`, then insert - * whitespace into URLs (observed on `https://appgallery.cloud. - * huawei.com/...`) or translate alt text into the href slot. Both - * corrupt the rendered markdown. - */ private fun protectFromTranslation(text: String): TranslationProtection { val spans = mutableListOf() var masked = text - // 1. Fenced code blocks first — these are the most disruptive - // spans and matching them before everything else means later - // passes won't accidentally re-mask their inner content. masked = Regex("```[\\s\\S]*?```", RegexOption.MULTILINE).replace(masked) { match -> replaceWithMarker(spans, match.value) } - // 2. HTML tags including their bodies (`...`, ``, - // `...`). Translators rewrite `href` and - // `src` attributes; preserve the whole element. + masked = Regex("<[^/!][a-zA-Z0-9]*[^>]*?/>").replace(masked) { match -> replaceWithMarker(spans, match.value) } @@ -140,24 +111,16 @@ class TranslationRepositoryImpl( masked = Regex("]*>", RegexOption.IGNORE_CASE).replace(masked) { match -> replaceWithMarker(spans, match.value) } - // 3. Markdown link / image URLs: the `](url)` tail. Keep the - // `[label]` part untouched so prose-style link text still - // translates (e.g. `[the docs]` → `[la documentación]`). + masked = Regex("\\]\\(([^)]+)\\)").replace(masked) { match -> val url = match.groupValues[1] "](" + replaceWithMarker(spans, url) + ")" } - // 4. Bare URLs in plain text. Without this, translators - // sometimes split long URLs across whitespace. + masked = Regex("https?://[^\\s<>\")]+").replace(masked) { match -> replaceWithMarker(spans, match.value) } - // 5. GFM alert callout markers: `[!NOTE]`, `[!TIP]`, - // `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`. The renderer - // pattern-matches these literally to pick the alert kind / - // icon / tint; translation rewrites `[!IMPORTANT]` into - // e.g. `[!Vajno]` (ru) and the renderer falls back to a - // plain blockquote, losing the formatting entirely. + masked = Regex( "\\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\]", RegexOption.IGNORE_CASE, @@ -171,9 +134,7 @@ class TranslationRepositoryImpl( private fun replaceWithMarker(spans: MutableList, value: String): String { val idx = spans.size spans += value - // Unicode math brackets + ALL_CAPS underscore token — empirically - // preserved verbatim by Google + Youdao translators across the 33 - // supported targets. + return "⟦TR_${idx}_END⟧" } @@ -181,9 +142,7 @@ class TranslationRepositoryImpl( if (spans.isEmpty()) return translated var result = translated spans.forEachIndexed { i, original -> - // Tolerate translator inserting whitespace around the marker. - // Falls back to leaving any unmatched marker in place rather - // than corrupting prose. + val pattern = Regex("⟦\\s*TR_\\s*${i}\\s*_END\\s*⟧") result = pattern.replaceFirst(result, Regex.escapeReplacement(original)) } @@ -197,12 +156,6 @@ class TranslationRepositoryImpl( override fun getDeviceLanguageCode(): String = localizationManager.getPrimaryLanguageCode() - /** - * Resolves the currently-selected translator from preferences. - * Called per request rather than held as a field so provider / - * credential changes take effect on the next translation without - * requiring the repository to be rebuilt. - */ private suspend fun resolveTranslator(): Translator { val provider = tweaksRepository.getTranslationProvider().first() return when (provider) { @@ -315,11 +268,8 @@ class TranslationRepositoryImpl( companion object { private const val MAX_CACHE_SIZE = 50 - private const val CACHE_TTL_MS = 30 * 60 * 1000L // 30 minutes - // Public Disroot-hosted LibreTranslate mirror — anonymous, no - // API key required. Used when the user picks LibreTranslate - // without configuring a self-hosted URL. Override in Tweaks → - // Translation when a mirror you trust more is available. + private const val CACHE_TTL_MS = 30 * 60 * 1000L + private const val LIBRE_TRANSLATE_DEFAULT_URL = "https://translate.disroot.org" } diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt index d01210038..9a403402e 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt @@ -68,11 +68,6 @@ class InstallationManagerImpl( val apkInfo = params.apkInfo val repo = params.repo - // Capture the user's variant pick as a fingerprint so the next - // update resolves to the same APK flavour. Returns null for - // single-asset releases or unparseable filenames — in that case - // the pin fields stay null and the resolver falls back to the - // platform auto-picker, same as before this fix. val fingerprint = AssetVariant.fingerprintFromPickedAsset( pickedAssetName = params.assetName, @@ -82,11 +77,6 @@ class InstallationManagerImpl( val pickedIndex = params.pickedAssetIndex?.takeIf { it >= 0 } val siblingCount = params.siblingAssetCount.takeIf { it > 0 } - // New apps inherit the global "include betas" preference - // so users who track betas across the board don't have to - // flip the per-app toggle for every install. Existing - // rows keep their own value; the global toggle is only - // consulted on creation. val defaultIncludePreReleases = runCatching { tweaksRepository.getIncludePreReleases().first() } .getOrDefault(false) @@ -104,11 +94,7 @@ class InstallationManagerImpl( installedVersion = params.releaseTag, installedAssetName = params.assetName, installedAssetUrl = params.assetUrl, - // Leave upstream `latest*` blank on first-install. The - // user may have picked an older release; pre-stamping - // these to the picked tag's metadata would poison the - // `checkForUpdates` versionCode-parity canary (#542). - // The next periodic check resolves them from the feed. + latestVersion = null, latestAssetName = null, latestAssetUrl = null, diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/DeeplTranslator.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/DeeplTranslator.kt index 586fa317d..fc36dca3d 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/DeeplTranslator.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/DeeplTranslator.kt @@ -72,9 +72,6 @@ internal class DeeplTranslator( ) } - // Surface `message` whenever it's present alongside a missing / - // empty translations array — DeepL sometimes returns - // `{"translations":[], "message":"..."}` instead of an HTTP error. val translations = root["translations"]?.jsonArray val errorMessage = root["message"]?.jsonPrimitive?.content if (translations.isNullOrEmpty()) { diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt index e2c5eeb37..2ad5ff35f 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/GoogleTranslator.kt @@ -9,25 +9,11 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive import zed.rainxch.details.domain.model.TranslationResult -/** - * Hits Google's undocumented `translate_a/single` endpoint. Works - * everywhere Google does, no credentials. May rate-limit or break - * without notice; Youdao is the escape hatch. - * - * Uses POST (form-encoded body) instead of GET: the repository chunks - * on `String.length`, but URL encoding a non-ASCII character — CJK, - * Arabic, etc. — expands ~3× for UTF-8 and ×3 again for percent - * encoding (roughly 9×). A 4500-char CJK chunk would produce a ~40 KB - * URL, well past most HTTP stacks' ~8 KB cap. POST bodies have no - * such limit. - */ internal class GoogleTranslator( private val httpClient: () -> HttpClient, private val json: Json, ) : Translator { - // POST body — bounded by server-side payload limits, not URL - // length. 4500 chars stays well within Google's accepted payload - // even for maximum-expansion CJK text. + override val maxChunkSize: Int = 4500 override suspend fun translate( diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/LibreTranslator.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/LibreTranslator.kt index 3e53f6236..9cc1275f0 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/LibreTranslator.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/LibreTranslator.kt @@ -48,9 +48,7 @@ internal class LibreTranslator( val body = response.bodyAsText() if (!response.status.isSuccess()) { - // Try to parse the JSON error envelope; fall through to raw - // body when the upstream returned HTML / plain text (proxy - // pages, WAF blocks, misconfigured self-host). + val message = runCatching { json.parseToJsonElement(body).jsonObject["error"]?.jsonPrimitive?.content }.getOrNull() diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/Translator.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/Translator.kt index cefd1b298..40b472f7f 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/Translator.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/Translator.kt @@ -2,12 +2,6 @@ package zed.rainxch.details.data.translation import zed.rainxch.details.domain.model.TranslationResult -/** - * Single-shot translator for a chunk of already-sized text. Chunking, - * caching and result-joining stay in the repository layer so each - * provider implementation only has to answer the question "translate - * this string and tell me what language it was in." - */ internal interface Translator { suspend fun translate( text: String, @@ -15,17 +9,7 @@ internal interface Translator { sourceLanguage: String, ): TranslationResult - /** - * Rough per-chunk upper bound for [text] length, in characters. - * Used by the repository's chunker to avoid tripping provider - * limits (Google's GET query length, Youdao's POST body length). - */ val maxChunkSize: Int } -/** - * Raised when the selected provider isn't configured (e.g. Youdao - * selected but `appKey` missing). UI surfaces this as "provider not - * configured" rather than a generic network error. - */ internal class TranslationProviderNotConfiguredException(message: String) : RuntimeException(message) diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/YoudaoTranslator.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/YoudaoTranslator.kt index a606b7786..b51a6fe4b 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/YoudaoTranslator.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/translation/YoudaoTranslator.kt @@ -15,19 +15,6 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import zed.rainxch.details.domain.model.TranslationResult -/** - * Hits Youdao Translation Open API v3 (`openapi.youdao.com/api`). - * Directly accessible from mainland China — the reason this provider - * exists (see issue #429). Requires user-supplied `appKey`/`appSecret` - * from Youdao's developer portal; missing credentials throw - * [TranslationProviderNotConfiguredException] up to the UI. - * - * Signing: v3 uses - * sign = sha256(appKey + input + salt + curtime + appSecret) - * where `input` is the query truncated to first-10 + length + last-10 - * for strings longer than 20 characters. - * See https://ai.youdao.com/DOCSIRMA/html/trans/api/wbfy/index.html. - */ @OptIn(ExperimentalTime::class, ExperimentalUuidApi::class) internal class YoudaoTranslator( private val httpClient: () -> HttpClient, @@ -35,8 +22,7 @@ internal class YoudaoTranslator( private val appKey: String, private val appSecret: String, ) : Translator { - // POST body — Youdao accepts up to 5000 chars per call. Leave a - // little room for URL-encoding inflation. + override val maxChunkSize: Int = 4500 override suspend fun translate( @@ -87,8 +73,6 @@ internal class YoudaoTranslator( ?.joinToString("\n") { it.jsonPrimitive.content } .orEmpty() - // `l` is "2" — e.g. "en2zh-CHS". First half is the - // auto-detected source language when `from=auto` was requested. val detected = root["l"] ?.jsonPrimitive @@ -104,8 +88,7 @@ internal class YoudaoTranslator( } private fun buildSignInput(q: String): String { - // Youdao's documented truncation rule for the signed `input`: - // if q.length > 20 use first 10 + q.length + last 10, else use q. + return if (q.length <= 20) { q } else { @@ -124,12 +107,6 @@ internal class YoudaoTranslator( } } - /** - * Translate Google-style BCP-47 codes (the rest of the app uses - * these) to Youdao's expected language codes. Anything we don't - * know passes through — if it's wrong Youdao will respond with - * errorCode 102 and the caller surfaces the error. - */ private fun mapLanguageCode(code: String): String = when (code.lowercase()) { "auto", "" -> "auto" diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/utils/preprocessMarkdown.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/utils/preprocessMarkdown.kt index d73a6befe..6ab6aec00 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/utils/preprocessMarkdown.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/utils/preprocessMarkdown.kt @@ -42,10 +42,6 @@ fun preprocessMarkdown( (lower.contains("/badge") && isSvgUrl(lower)) } - // SVGs are no longer skipped wholesale — Coil's SVG decoder now - // handles them (registered in the App composable). Only known badge - // / shields services stay skipped because they're noise even when - // rendered correctly (status badges = clutter on small screens). fun shouldSkipImage(url: String): Boolean = isBadgeUrl(url) fun resolveUrl(path: String): String { @@ -83,13 +79,6 @@ fun preprocessMarkdown( } } - // ======================================================================== - // Phase 0: Handle reference-style markdown definitions and usages - // ======================================================================== - // Reference definitions: [ref-name]: https://example.com/image.svg - // Reference usages: ![alt][ref-name] or [![img-ref]][link-ref] - - // 0a. Parse all reference definitions val refDefinitionRegex = Regex( """^\[([^\]]+)\]:\s*(\S+).*$""", @@ -102,14 +91,12 @@ fun preprocessMarkdown( referenceMap[refName] = url } - // 0b. Identify which references point to SVGs/badges val skipRefNames = referenceMap .filter { (_, url) -> shouldSkipImage(resolveUrl(url)) }.keys - // 0c. Remove reference-style image usages that point to SVGs: ![alt][svg-ref] if (skipRefNames.isNotEmpty()) { processed = processed.replace( @@ -125,7 +112,6 @@ fun preprocessMarkdown( } } - // 0d. Resolve remaining reference-style images to inline format: ![alt][ref] → ![alt](url) processed = processed.replace( Regex("""!\[([^\]]*)\]\[([^\]]+)\]"""), @@ -141,8 +127,6 @@ fun preprocessMarkdown( } } - // 0e. Handle nested badge-as-link patterns: [![badge-ref]][link-ref] - // After 0c strips the inner image, this can leave [**text**][link-ref] or [][link-ref] processed = processed.replace( Regex("""\[(\*\*[^*]*\*\*)\]\[([^\]]+)\]"""), @@ -156,14 +140,13 @@ fun preprocessMarkdown( boldText } } - // Clean empty bracket patterns left from stripped badge images: [][ref] + processed = processed.replace( Regex("""\[\s*\]\[([^\]]+)\]"""), "", ) - // 0f. Handle reference-style links: [text][ref] → [text](url) processed = processed.replace( Regex("""\[([^\]]+)\]\[([^\]]+)\]"""), @@ -171,7 +154,7 @@ fun preprocessMarkdown( val text = match.groupValues[1] val refName = match.groupValues[2].lowercase() val url = referenceMap[refName] - // Don't convert if text looks like it was already an image (starts with !) + if (url != null && !text.startsWith("!")) { "[$text](${resolveUrl(url)})" } else { @@ -179,7 +162,6 @@ fun preprocessMarkdown( } } - // 0g. Remove all reference definitions that were resolved processed = processed.replace( Regex("""^\[([^\]]+)\]:\s*\S+.*$""", RegexOption.MULTILINE), @@ -188,14 +170,6 @@ fun preprocessMarkdown( if (refName in referenceMap) "" else match.value } - // ======================================================================== - // Phase 0.5:
/ normalisation - // ======================================================================== - // Must run BEFORE Phase 1 — once `
` → `\n` and `
` → `\n\n` - // pass over a table cell, the row gets split and our context check - // (`linePrefix.contains('|')`) below would no longer see the pipe. - // Inline / table-cell details flatten to one line; standalone block - // details emit a fenced `ghs-details` block for ExpandableDetails. processed = processed.replace( Regex( @@ -212,9 +186,7 @@ fun preprocessMarkdown( val mustFlatten = isInline || isInTableCell if (mustFlatten) { - // Collapse any whitespace (incl. embedded HTML tags - // that would otherwise expand to newlines) to single - // spaces so the result stays on one source line. + val flatBody = body .replace(Regex("""""", RegexOption.IGNORE_CASE), " ") .replace(Regex("""\s+"""), " ") @@ -227,21 +199,13 @@ fun preprocessMarkdown( } } else { val encodedSummary = encodeDetailsSummary(summary) - // Body may contain its own ```fenced``` code blocks. Pick a - // fence delimiter at least one backtick longer than the - // longest run inside the body so nested fences don't - // terminate our wrapper early. + val longestRun = longestBacktickRun(body) val fence = "`".repeat(maxOf(4, longestRun + 1)) "\n\n${fence}ghs-details|$encodedSummary\n$body\n$fence\n\n" } } - // ======================================================================== - // Phase 1: HTML → Markdown conversions - // ======================================================================== - - // 1. Unwrap elements → keep only the fallback processed = processed.replace( Regex( @@ -251,14 +215,13 @@ fun preprocessMarkdown( ) { match -> match.groupValues[1] } - // Also strip orphaned tags (outside ) + processed = processed.replace( Regex("""]*?/?>""", RegexOption.IGNORE_CASE), "", ) - // 2. Unwrap tags that wrap tags — keep the for step 3 processed = processed.replace( Regex( @@ -269,7 +232,6 @@ fun preprocessMarkdown( match.groupValues[1] } - // 3. Convert tags → markdown images (handles multiline img tags) processed = processed.replace( Regex( @@ -298,7 +260,6 @@ fun preprocessMarkdown( } } - // 4. Normalize markdown image URLs (resolve relative, normalize GitHub blob) processed = processed.replace( Regex("""!\[([^\]]*)\]\(([^)]+)\)"""), @@ -314,7 +275,6 @@ fun preprocessMarkdown( } } - // 5. Handle
→ markdown headings for (level in 1..6) { val hashes = "#".repeat(level) processed = @@ -352,7 +311,6 @@ fun preprocessMarkdown( } } - // 7. Convert
and
tags processed = processed.replace( Regex("""""", RegexOption.IGNORE_CASE), @@ -364,8 +322,6 @@ fun preprocessMarkdown( "\n---\n", ) - // 8. Convert inline formatting tags - // / → **text** processed = processed.replace( Regex( @@ -375,7 +331,7 @@ fun preprocessMarkdown( ) { match -> "**${match.groupValues[2]}**" } - // / → *text* + processed = processed.replace( Regex( @@ -385,9 +341,7 @@ fun preprocessMarkdown( ) { match -> "*${match.groupValues[2]}*" } - //
 → ``` fence with language hint.
-    // Must run BEFORE the single-line  rule below — that one would
-    // otherwise grab the inner  and lose the language attribute.
+
     processed =
         processed.replace(
             Regex(
@@ -399,7 +353,7 @@ fun preprocessMarkdown(
             val code = match.groupValues[2]
             "\n```$lang\n$code\n```\n"
         }
-    //  → `text` (single-line only, not 
)
+
     processed =
         processed.replace(
             Regex(
@@ -409,7 +363,7 @@ fun preprocessMarkdown(
         ) { match ->
             "`${match.groupValues[1]}`"
         }
-    // 
X
→ markdown `> ` lines. + processed = processed.replace( Regex( @@ -420,7 +374,7 @@ fun preprocessMarkdown( val body = match.groupValues[1].trim() body.lineSequence().joinToString("\n") { "> $it" } } - // / / → ~~text~~ + processed = processed.replace( Regex( @@ -431,7 +385,6 @@ fun preprocessMarkdown( "~~${match.groupValues[2]}~~" } - // 9. Convert
text → [text](url) (non-image links) processed = processed.replace( Regex( @@ -449,7 +402,6 @@ fun preprocessMarkdown( } } - // 10. → `text` processed = processed.replace( Regex( @@ -460,8 +412,6 @@ fun preprocessMarkdown( "`${match.groupValues[1]}`" } - // 11. Strip remaining wrapper tags (keep content) - //
tags processed = processed.replace( Regex("""]*?>\s*""", RegexOption.IGNORE_CASE), @@ -472,7 +422,7 @@ fun preprocessMarkdown( Regex("""
\s*""", RegexOption.IGNORE_CASE), "\n\n", ) - //

/

+ processed = processed.replace( Regex("""]*?>""", RegexOption.IGNORE_CASE), @@ -483,8 +433,7 @@ fun preprocessMarkdown( Regex("""

""", RegexOption.IGNORE_CASE), "\n", ) - // Handle bare/stripped
without inner — keep - // contents as plain markdown. + processed = processed.replace( Regex("""]*?>""", RegexOption.IGNORE_CASE), @@ -499,11 +448,7 @@ fun preprocessMarkdown( ) { match -> "**${match.groupValues[1].trim()}**\n" } - // X / X → Unicode superscript/subscript chars - // where mappable (digits + operators + a few letters). Falls back - // to the literal char when no Unicode codepoint exists — markdown - // lib doesn't expose inline BaselineShift, so this is the best we - // can do without a custom text span. + processed = processed.replace( Regex("""]*>(.*?)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)), @@ -516,13 +461,13 @@ fun preprocessMarkdown( ) { match -> match.groupValues[1].map { SUBSCRIPTS[it] ?: it }.joinToString("") } - // — strip tags, keep content + processed = processed.replace( Regex("""]*?>""", RegexOption.IGNORE_CASE), "", ) - // Strip other common straggler HTML tags + processed = processed.replace( Regex( @@ -532,11 +477,8 @@ fun preprocessMarkdown( "\n", ) - // 12. Decode HTML entities. Named entity table covers the long tail - // of READMEs (©, ™, ‘curly quotes’, em/en-dash, ellipsis, - // French/Spanish quotation marks, currency symbols). HTML_ENTITIES.forEach { (entity, char) -> processed = processed.replace(entity, char) } - // Numeric HTML entities (decimal): &#NNN; → char. + processed = processed.replace(Regex("""&#(\d+);""")) { match -> val code = match.groupValues[1].toIntOrNull() @@ -546,7 +488,7 @@ fun preprocessMarkdown( match.value } } - // Numeric HTML entities (hex): &#xHHHH; → char. + processed = processed.replace(Regex("""&#x([0-9A-Fa-f]+);""")) { match -> val code = match.groupValues[1].toIntOrNull(16) @@ -557,7 +499,6 @@ fun preprocessMarkdown( } } - // 13. Clean up empty

tags and excess newlines processed = processed.replace( Regex("""]*?>\s*

""", RegexOption.IGNORE_CASE), @@ -569,21 +510,14 @@ fun preprocessMarkdown( "\n\n", ) - // 14. Clean up orphaned markdown link fragments processed = processed.replace( Regex("""^\]\([^)]+\)""", RegexOption.MULTILINE), "", ) - // 15. Replace GitHub emoji shortcodes (:rocket: → 🚀). Skips fenced - // code blocks; inline `:foo:` patterns inside `` `code` `` are - // rare enough not to warrant deeper tokenisation. processed = zed.rainxch.core.domain.util.EmojiShortcodes.render(processed) - // 16. Join consecutive image-only lines into a single paragraph so - // badge galleries (Play Store + GitHub buttons etc.) render - // in a row instead of stacking one per line. processed = joinAdjacentImageLines(processed) return processed.trim() @@ -604,8 +538,7 @@ private fun longestBacktickRun(text: String): Int { } private fun encodeDetailsSummary(text: String): String { - // URL-encode special chars to keep summary on one line inside the - // fence info string. Decoder mirror lives in the codeFence slot. + val safe = StringBuilder() text.forEach { c -> when (c) { @@ -628,7 +561,7 @@ private fun joinAdjacentImageLines(content: String): String { while (i < lines.size) { val line = lines[i] if (imageOnlyLine.matches(line)) { - // Greedily collect adjacent image-only lines. + val group = StringBuilder(line.trim()) var j = i + 1 while (j < lines.size && imageOnlyLine.matches(lines[j])) { @@ -647,25 +580,20 @@ private fun joinAdjacentImageLines(content: String): String { return out.toString() } -// ============================================================================ -// Lookup tables for HTML entity decoding + sub/sup Unicode mapping. -// ============================================================================ - private val HTML_ENTITIES: Map = mapOf( - // Core 5 (must come first; processor relies on them being decoded - // before any subsequent regex that operates on raw `<`/`>`). + "&" to "&", "<" to "<", ">" to ">", """ to "\"", "'" to "'", "'" to "'", - // Whitespace + " " to " ", " " to " ", " " to " ", " " to " ", - // Punctuation / typography + "…" to "…", "—" to "—", "–" to "–", @@ -681,7 +609,7 @@ private val HTML_ENTITIES: Map = mapOf( "·" to "·", "§" to "§", "¶" to "¶", - // Math / arrows + "×" to "×", "÷" to "÷", "±" to "±", @@ -700,11 +628,11 @@ private val HTML_ENTITIES: Map = mapOf( "↔" to "↔", "⇐" to "⇐", "⇒" to "⇒", - // Legal / brand + "©" to "©", "®" to "®", "™" to "™", - // Currency + "€" to "€", "£" to "£", "¥" to "¥", diff --git a/feature/details/domain/build.gradle.kts b/feature/details/domain/build.gradle.kts index 7ab581f42..0bac5a50c 100644 --- a/feature/details/domain/build.gradle.kts +++ b/feature/details/domain/build.gradle.kts @@ -11,15 +11,5 @@ kotlin { implementation(projects.core.domain) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ApkValidationResult.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ApkValidationResult.kt index 05022cb95..677d66380 100644 --- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ApkValidationResult.kt +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ApkValidationResult.kt @@ -3,15 +3,13 @@ package zed.rainxch.details.domain.model import zed.rainxch.core.domain.model.ApkPackageInfo sealed interface ApkValidationResult { - /** APK is valid and ready to install. */ + data class Valid( val apkInfo: ApkPackageInfo, ) : ApkValidationResult - /** Could not extract package information from the APK. */ data object ExtractionFailed : ApkValidationResult - /** Package name in the APK does not match the currently installed app. */ data class PackageMismatch( val apkPackageName: String, val installedPackageName: String, diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/FingerprintCheckResult.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/FingerprintCheckResult.kt index 6f4716208..4f0e2ac21 100644 --- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/FingerprintCheckResult.kt +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/FingerprintCheckResult.kt @@ -1,10 +1,9 @@ package zed.rainxch.details.domain.model sealed interface FingerprintCheckResult { - /** Fingerprint matches or no prior fingerprint is recorded. */ + data object Ok : FingerprintCheckResult - /** Signing key has changed compared to the previously installed version. */ data class Mismatch( val expectedFingerprint: String, val actualFingerprint: String, diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt index 01f540b7f..6a93aee5d 100644 --- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/SaveInstalledAppParams.kt @@ -14,11 +14,6 @@ data class SaveInstalledAppParams( val isFavourite: Boolean, val siblingAssetCount: Int, val pickedAssetIndex: Int?, - // Path to the parked APK on disk when this row is being saved as a - // pending install (e.g. system installer was launched but the user - // hasn't accepted yet). The apps row uses this to drive its one-tap - // Install retry button and to extract a real app icon while the - // package isn't yet on the system. `null` for genuine completed - // installs and for non-pending saves. + val pendingInstallFilePath: String? = null, ) diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/AttestationVerifier.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/AttestationVerifier.kt index 3fac96662..ef0606659 100644 --- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/AttestationVerifier.kt +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/AttestationVerifier.kt @@ -1,18 +1,7 @@ package zed.rainxch.details.domain.system -/** - * Verifies build attestations for downloaded assets using GitHub's - * supply-chain security API. - */ interface AttestationVerifier { - /** - * Computes the SHA-256 digest of [filePath] and checks whether - * the repository [owner]/[repoName] has a matching attestation. - * - * @return [VerificationResult.Verified] if a valid attestation exists, - * [VerificationResult.Unverified] if no matching attestation was found, - * [VerificationResult.Error] if the check could not be completed. - */ + suspend fun verify( owner: String, repoName: String, diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/InstallationManager.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/InstallationManager.kt index d1e1e2435..c47015a59 100644 --- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/InstallationManager.kt +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/system/InstallationManager.kt @@ -8,37 +8,17 @@ import zed.rainxch.details.domain.model.FingerprintCheckResult import zed.rainxch.details.domain.model.SaveInstalledAppParams import zed.rainxch.details.domain.model.UpdateInstalledAppParams -/** - * Encapsulates APK validation, fingerprint checking, and - * installed-app database persistence so the ViewModel stays thin. - */ interface InstallationManager { - /** - * Extracts [ApkPackageInfo] from [filePath] and validates it. - * On an update, verifies the package name matches [trackedPackageName]. - */ + suspend fun validateApk( filePath: String, isUpdate: Boolean, trackedPackageName: String?, ): ApkValidationResult - /** - * Checks whether the signing fingerprint of [apkInfo] matches - * the fingerprint previously recorded for the same package. - */ suspend fun checkSigningFingerprint(apkInfo: ApkPackageInfo): FingerprintCheckResult - /** - * Saves a freshly installed app to the database and optionally - * updates the favourite install status. - * - * @return the reloaded [InstalledApp], or `null` on failure. - */ suspend fun saveNewInstalledApp(params: SaveInstalledAppParams): InstalledApp? - /** - * Updates the version metadata of an already-tracked app. - */ suspend fun updateInstalledAppVersion(params: UpdateInstalledAppParams) } diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/util/VersionHelper.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/util/VersionHelper.kt index 22a346622..69214f646 100644 --- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/util/VersionHelper.kt +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/util/VersionHelper.kt @@ -3,42 +3,9 @@ package zed.rainxch.details.domain.util import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.util.VersionMath -/** - * Thin wrapper that defers all comparisons to - * [zed.rainxch.core.domain.util.VersionMath]. Kept as a separate type - * because the details feature also needs a release-list-aware - * downgrade check (see [isDowngradeVersion]) — that fallback is - * specific to the install flow on the details screen, not something - * the rest of the app needs. - */ object VersionHelper { fun normalizeVersion(version: String?): String = VersionMath.normalizeVersion(version) - /** - * Returns `true` when installing [candidate] over [current] would be - * a downgrade. - * - * Strategy: - * 1. Trust [VersionMath.compareVersions] when both inputs have a - * recognisable versioning scheme (SemVer / CalVer). The sign of - * the comparator is authoritative — list-position is too - * unreliable since GitHub's release ordering follows - * `published_at`, and maintainers can reorder by republishing - * or backdating a release. - * 2. Fall back to list-index ordering only when at least one input - * has no parseable scheme (commit-hash tags, ad-hoc strings). - * The release feed is newest-first, so a candidate that appears - * later in the list is older. - * 3. As a last resort, when neither lookup nor scheme detection - * yields an answer, fall through to [VersionMath.compareVersions] - * (which itself falls back to lexicographic comparison). - * - * Cross-references for the install flow caller behaviour: - * - `DetailsViewModel.install()` skips this check entirely when - * `normalizeVersion(candidate) == normalizeVersion(current)`. - * - The result gates [DowngradeWarning] so a `false` here proceeds - * straight to install. - */ fun isDowngradeVersion( candidate: String, current: String, @@ -74,11 +41,6 @@ object VersionHelper { return cmp < 0 } - /** - * Three-way comparison delegating to [VersionMath.compareVersions]. - * Kept on this surface so existing call sites don't have to learn - * the new helper's name. - */ fun compareSemanticVersions( a: String, b: String, diff --git a/feature/details/presentation/build.gradle.kts b/feature/details/presentation/build.gradle.kts index 193f55d39..6ea23707f 100644 --- a/feature/details/presentation/build.gradle.kts +++ b/feature/details/presentation/build.gradle.kts @@ -7,32 +7,22 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.datetime) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.details.domain) - implementation(projects.feature.profile.domain) implementation(libs.markdown.renderer) implementation(libs.markdown.renderer.coil3) implementation(libs.highlights) - implementation(libs.ktor.client.core) - implementation(libs.jetbrains.compose.components.resources) - implementation(libs.kotlinx.datetime) + implementation(libs.ktor.client.core) - implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.bundles.landscapist) - } - } - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { + implementation(libs.jetbrains.compose.components.resources) + implementation(libs.androidx.compose.ui.tooling.preview) } } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt index 06d3f81db..7f7f24836 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt @@ -113,74 +113,28 @@ sealed interface DetailsAction { data object DismissLanguagePicker : DetailsAction - // show release asset picker data class SelectDownloadAsset( val release: GithubAsset, ) : DetailsAction data object ToggleReleaseAssetsPicker : DetailsAction - /** - * Clears the user's preferred variant pin for the currently-tracked - * app. Falls back to the platform auto-picker on subsequent updates. - * Triggered by the "Unpin variant" affordance in the asset picker - * sheet. - */ data object UnpinPreferredVariant : DetailsAction - /** - * Flips the per-app `includePreReleases` flag. Exposed as the - * inline channel toggle on Details so users can opt in/out of - * beta updates without digging into the apps advanced settings - * sheet (GitHub-Store release UX #2). - */ data object ToggleIncludeBetas : DetailsAction - /** - * Switches the currently-tracked app from a pre-release to the - * latest stable release. Selects the stable release and - * initiates the install flow on it (GitHub-Store release UX #3). - */ data object SwitchToStable : DetailsAction - /** - * Opens the APK Inspect bottom sheet. The ViewModel resolves the - * APK source automatically — installed package wins over parked - * file when both exist (installed manifest is the authoritative - * source on what's actually on the device). - */ data object OnInspectApk : DetailsAction - /** Closes the APK Inspect bottom sheet. */ data object OnDismissApkInspect : DetailsAction - /** - * Acknowledges the inspect-button discoverability coachmark — fired - * either when the coachmark is tapped/dismissed or when the user - * opens the inspect sheet for the first time. Persists so the - * coachmark only ever shows once. - */ data object OnAcknowledgeApkInspectCoachmark : DetailsAction - /** - * Acknowledges the release-channel chip coachmark. Fired on - * tap/dismiss or when the user toggles the channel chip itself. - * Persists so the coachmark only ever shows once. - */ data object OnAcknowledgeChannelChipCoachmark : DetailsAction - /** - * Flip the "Show all platforms" picker setting (persisted globally). - * Carries the explicit target value so rapid back-and-forth toggles - * don't race against a stale read of the in-memory state. - */ data class OnToggleShowAllPlatforms(val enabled: Boolean) : DetailsAction - /** - * Download a non-current-platform asset for transfer to another - * device. Routes to the user's browser so the file lands in their - * normal Downloads folder. - */ data class OnDownloadForTransfer( val assetUrl: String, ) : DetailsAction diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt index ccf519d57..f5ce3aa7f 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt @@ -1,12 +1,17 @@ package zed.rainxch.details.presentation import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.model.Platform import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -29,8 +34,13 @@ import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material3.AlertDialog +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenu +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenuItem import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -42,7 +52,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -81,7 +90,11 @@ import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.ContentWidth +import zed.rainxch.core.presentation.components.FloatingPill import zed.rainxch.core.presentation.components.ScrollbarContainer +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import zed.rainxch.core.presentation.locals.LocalContentWidth import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled import zed.rainxch.core.presentation.theme.GithubStoreTheme @@ -90,7 +103,6 @@ import zed.rainxch.core.presentation.utils.arrowKeyScroll import zed.rainxch.core.presentation.utils.isPullToRefreshSupported import zed.rainxch.core.domain.model.RefreshError import zed.rainxch.details.presentation.components.ApkInspectSheet -import zed.rainxch.details.presentation.components.LanguagePicker import zed.rainxch.details.presentation.components.sections.about import zed.rainxch.details.presentation.components.sections.author import zed.rainxch.details.presentation.components.sections.header @@ -100,7 +112,6 @@ import zed.rainxch.details.presentation.components.sections.stats import zed.rainxch.details.presentation.components.sections.releaseChannel import zed.rainxch.details.presentation.components.sections.whatsNew import zed.rainxch.details.presentation.components.states.ErrorState -import zed.rainxch.details.presentation.model.TranslationTarget import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.add_to_favourites import zed.rainxch.githubstore.core.presentation.res.cancel @@ -145,6 +156,8 @@ fun DetailsRoot( onNavigateToDeveloperProfile: (username: String) -> Unit, onOpenRepositoryInApp: (repoId: Long) -> Unit, onNavigateToSearchByPlatform: (DiscoveryPlatform) -> Unit, + onNavigateToAbout: (repoId: Long, owner: String, repo: String, sourceHost: String?) -> Unit, + onNavigateToWhatsNew: (repoId: Long, owner: String, repo: String, sourceHost: String?) -> Unit, viewModel: DetailsViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -226,6 +239,26 @@ fun DetailsRoot( state = state, snackbarHostState = snackbarHostState, onAction = onAction, + onReadMoreAbout = state.repository?.let { repo -> + { + onNavigateToAbout( + repo.id, + repo.owner.login, + repo.name, + repo.sourceHost, + ) + } + }, + onReadMoreWhatsNew = state.repository?.let { repo -> + { + onNavigateToWhatsNew( + repo.id, + repo.owner.login, + repo.name, + repo.sourceHost, + ) + } + }, ) state.downgradeWarning?.let { warning -> @@ -233,9 +266,13 @@ fun DetailsRoot( onDismissRequest = { viewModel.onAction(DetailsAction.OnDismissDowngradeWarning) }, + shape = WonkySquircleShape.Dialog, title = { Text( text = stringResource(Res.string.downgrade_requires_uninstall), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), ) }, text = { @@ -249,41 +286,41 @@ fun DetailsRoot( ) }, confirmButton = { - TextButton( + GhsButton( onClick = { viewModel.onAction(DetailsAction.OnDismissDowngradeWarning) viewModel.onAction(DetailsAction.UninstallApp) }, - ) { - Text( - text = stringResource(Res.string.uninstall_first), - color = MaterialTheme.colorScheme.error, - ) - } + label = stringResource(Res.string.uninstall_first), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, dismissButton = { - TextButton( + GhsButton( onClick = { viewModel.onAction(DetailsAction.OnDismissDowngradeWarning) }, - ) { - Text( - text = stringResource(Res.string.cancel), - ) - } + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } - // Signing key changed warning dialog state.signingKeyWarning?.let { warning -> AlertDialog( onDismissRequest = { viewModel.onAction(DetailsAction.OnDismissSigningKeyWarning) }, + shape = WonkySquircleShape.Dialog, title = { Text( text = stringResource(Res.string.signing_key_changed_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), ) }, text = { @@ -297,41 +334,41 @@ fun DetailsRoot( ) }, confirmButton = { - TextButton( + GhsButton( onClick = { viewModel.onAction(DetailsAction.OnOverrideSigningKeyWarning) }, - ) { - Text( - text = stringResource(Res.string.install_anyway), - color = MaterialTheme.colorScheme.error, - ) - } + label = stringResource(Res.string.install_anyway), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, dismissButton = { - TextButton( + GhsButton( onClick = { viewModel.onAction(DetailsAction.OnDismissSigningKeyWarning) }, - ) { - Text( - text = stringResource(Res.string.cancel), - ) - } + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } - // Uninstall confirmation dialog if (state.showUninstallConfirmation) { val appName = state.installedApp?.appName ?: "" AlertDialog( onDismissRequest = { viewModel.onAction(DetailsAction.OnDismissUninstallConfirmation) }, + shape = WonkySquircleShape.Dialog, title = { Text( text = stringResource(Res.string.confirm_uninstall_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), ) }, text = { @@ -340,25 +377,24 @@ fun DetailsRoot( ) }, confirmButton = { - TextButton( + GhsButton( onClick = { viewModel.onAction(DetailsAction.OnConfirmUninstall) }, - ) { - Text( - text = stringResource(Res.string.uninstall), - color = MaterialTheme.colorScheme.error, - ) - } + label = stringResource(Res.string.uninstall), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, dismissButton = { - TextButton( + GhsButton( onClick = { viewModel.onAction(DetailsAction.OnDismissUninstallConfirmation) }, - ) { - Text(text = stringResource(Res.string.cancel)) - } + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } @@ -369,8 +405,14 @@ fun DetailsRoot( onDismissRequest = { viewModel.onAction(DetailsAction.OnDismissUnlinkConfirmation) }, + shape = WonkySquircleShape.Dialog, title = { - Text(text = stringResource(Res.string.details_unlink_external_app_dialog_title)) + Text( + text = stringResource(Res.string.details_unlink_external_app_dialog_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + ) }, text = { Text( @@ -378,25 +420,24 @@ fun DetailsRoot( ) }, confirmButton = { - TextButton( + GhsButton( onClick = { viewModel.onAction(DetailsAction.OnConfirmUnlinkExternalApp) }, - ) { - Text( - text = stringResource(Res.string.details_unlink_external_app_dialog_confirm), - color = MaterialTheme.colorScheme.error, - ) - } + label = stringResource(Res.string.details_unlink_external_app_dialog_confirm), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, dismissButton = { - TextButton( + GhsButton( onClick = { viewModel.onAction(DetailsAction.OnDismissUnlinkConfirmation) }, - ) { - Text(text = stringResource(Res.string.cancel)) - } + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } @@ -406,29 +447,37 @@ fun DetailsRoot( onDismissRequest = { viewModel.onAction(DetailsAction.DismissExternalInstallerPrompt) }, + shape = WonkySquircleShape.Dialog, title = { - Text(text = stringResource(Res.string.install_permission_unavailable)) + Text( + text = stringResource(Res.string.install_permission_unavailable), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + ) }, text = { Text(text = stringResource(Res.string.install_permission_blocked_message)) }, confirmButton = { - TextButton( + GhsButton( onClick = { viewModel.onAction(DetailsAction.OpenWithExternalInstaller) }, - ) { - Text(text = stringResource(Res.string.open_with_external_installer)) - } + label = stringResource(Res.string.open_with_external_installer), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, dismissButton = { - TextButton( + GhsButton( onClick = { viewModel.onAction(DetailsAction.DismissExternalInstallerPrompt) }, - ) { - Text(text = stringResource(Res.string.dismiss)) - } + label = stringResource(Res.string.dismiss), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } @@ -448,6 +497,8 @@ fun DetailsScreen( state: DetailsState, onAction: (DetailsAction) -> Unit, snackbarHostState: SnackbarHostState, + onReadMoreAbout: (() -> Unit)? = null, + onReadMoreWhatsNew: (() -> Unit)? = null, ) { Scaffold( topBar = { @@ -464,36 +515,6 @@ fun DetailsScreen( containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> - LanguagePicker( - isVisible = state.isLanguagePickerVisible, - selectedLanguageCode = - when (state.languagePickerTarget) { - TranslationTarget.About -> state.aboutTranslation.targetLanguageCode - TranslationTarget.WhatsNew -> state.whatsNewTranslation.targetLanguageCode - null -> null - }, - deviceLanguageCode = state.deviceLanguageCode, - onLanguageSelected = { language -> - when (state.languagePickerTarget) { - TranslationTarget.About -> { - onAction(DetailsAction.TranslateAbout(language.code)) - } - - TranslationTarget.WhatsNew -> { - onAction( - DetailsAction.TranslateWhatsNew( - language.code, - ), - ) - } - - null -> {} - } - onAction(DetailsAction.DismissLanguagePicker) - }, - onDismiss = { onAction(DetailsAction.DismissLanguagePicker) }, - ) - if (state.isLoading) { Box( modifier = Modifier.fillMaxSize(), @@ -513,7 +534,7 @@ fun DetailsScreen( val density = LocalDensity.current var containerHeightDp by remember { mutableStateOf(0.dp) } - val collapsedSectionHeight = containerHeightDp * 0.7f + val collapsedSectionHeight = containerHeightDp * 0.4f val listState = rememberLazyListState() val isScrollbarEnabled = LocalScrollbarEnabled.current val contentWidthDp = when (LocalContentWidth.current) { @@ -523,13 +544,6 @@ fun DetailsScreen( } val pullEnabled = remember { isPullToRefreshSupported() } - // Gutter scroll forwarding is a desktop-only UX polish - // (mouse-wheel-in-side-margins → scrolls content column). - // On Android the outer scrollable fought the inner - // LazyColumn's own scroll handler — both pointing at the - // same `listState` doubled-up gesture handling and the - // touch scroll froze. Issue tracked under content-width - // PR follow-up. Keep enabled = isDesktop. val isDesktop = remember { getPlatform() != Platform.ANDROID } Box( modifier = Modifier @@ -537,23 +551,12 @@ fun DetailsScreen( .scrollable( state = listState, orientation = Orientation.Vertical, - // LazyColumn's internal scrollable uses - // reverseDirection=true (vertical, default - // layout direction). Matching that here makes - // the wheel-direction sign convention agree - // — otherwise wheel-up at the top jumps to - // the bottom and vice versa, because parent - // and child interpret the same pointer delta - // with opposite signs. + reverseDirection = true, enabled = isDesktop, ) .onSizeChanged { size -> - // Layout-phase write; cheaper than BoxWithConstraints - // which subcomposes during the measure pass. Setting - // a state var here recomposes only the consumers that - // read it (the about/whatsNew sections), not the - // entire Scaffold subtree. + val newHeight = with(density) { size.height.toDp() } if (newHeight != containerHeightDp) containerHeightDp = newHeight }, @@ -622,16 +625,7 @@ fun DetailsScreen( collapsedHeight = collapsedSectionHeight, measuredHeightPx = state.whatsNewMeasuredHeightPx, onMeasured = { onAction(DetailsAction.OnWhatsNewMeasured(it)) }, - translationState = state.whatsNewTranslation, - onTranslateClick = { - onAction(DetailsAction.TranslateWhatsNew(state.deviceLanguageCode)) - }, - onLanguagePickerClick = { - onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WhatsNew)) - }, - onToggleTranslation = { - onAction(DetailsAction.ToggleWhatsNewTranslation) - }, + onReadMore = onReadMoreWhatsNew, ) } @@ -644,16 +638,7 @@ fun DetailsScreen( collapsedHeight = collapsedSectionHeight, measuredHeightPx = state.aboutMeasuredHeightPx, onMeasured = { onAction(DetailsAction.OnAboutMeasured(it)) }, - translationState = state.aboutTranslation, - onTranslateClick = { - onAction(DetailsAction.TranslateAbout(state.deviceLanguageCode)) - }, - onLanguagePickerClick = { - onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.About)) - }, - onToggleTranslation = { - onAction(DetailsAction.ToggleAboutTranslation) - }, + onReadMore = onReadMoreAbout, ) } } else { @@ -666,16 +651,7 @@ fun DetailsScreen( collapsedHeight = collapsedSectionHeight, measuredHeightPx = state.aboutMeasuredHeightPx, onMeasured = { onAction(DetailsAction.OnAboutMeasured(it)) }, - translationState = state.aboutTranslation, - onTranslateClick = { - onAction(DetailsAction.TranslateAbout(state.deviceLanguageCode)) - }, - onLanguagePickerClick = { - onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.About)) - }, - onToggleTranslation = { - onAction(DetailsAction.ToggleAboutTranslation) - }, + onReadMore = onReadMoreAbout, ) } @@ -687,16 +663,7 @@ fun DetailsScreen( collapsedHeight = collapsedSectionHeight, measuredHeightPx = state.whatsNewMeasuredHeightPx, onMeasured = { onAction(DetailsAction.OnWhatsNewMeasured(it)) }, - translationState = state.whatsNewTranslation, - onTranslateClick = { - onAction(DetailsAction.TranslateWhatsNew(state.deviceLanguageCode)) - }, - onLanguagePickerClick = { - onAction(DetailsAction.ShowLanguagePicker(TranslationTarget.WhatsNew)) - }, - onToggleTranslation = { - onAction(DetailsAction.ToggleWhatsNewTranslation) - }, + onReadMore = onReadMoreWhatsNew, ) } } @@ -751,156 +718,61 @@ private fun DetailsTopbar( state: DetailsState, onAction: (DetailsAction) -> Unit, ) { - TopAppBar( - title = { }, - navigationIcon = { - IconButton( - shapes = IconButtonDefaults.shapes(), - onClick = { - onAction(DetailsAction.OnNavigateBackClick) - }, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.navigate_back), - modifier = Modifier.size(24.dp), - ) - } - }, - actions = { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + FloatingPill( + onClick = { onAction(DetailsAction.OnNavigateBackClick) }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.navigate_back), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp), + ) + } + if (state.repository != null) { Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surface) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(50), + ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (state.repository != null) { - IconButton( - onClick = { - onAction( - DetailsAction.OnMessage( - messageText = - if (state.isStarred) { - Res.string.unstar_from_github - } else { - Res.string.star_from_github - }, - ), - ) - }, - shapes = IconButtonDefaults.shapes(), - colors = - IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon( - imageVector = - if (state.isStarred) { - Icons.Default.Star - } else { - Icons.Default.StarBorder - }, - contentDescription = - stringResource( - resource = - if (state.isStarred) { - Res.string.repository_starred - } else { - Res.string.repository_not_starred - }, - ), - ) - } - - IconButton( - onClick = { - onAction(DetailsAction.OnToggleFavorite) - }, - shapes = IconButtonDefaults.shapes(), - colors = - IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon( - imageVector = - if (state.isFavourite) { - Icons.Default.Favorite - } else { - Icons.Default.FavoriteBorder - }, - contentDescription = - stringResource( - resource = - if (state.isFavourite) { - Res.string.remove_from_favourites - } else { - Res.string.add_to_favourites - }, - ), - ) - } - - IconButton( - onClick = { - onAction(DetailsAction.OnShareClick) - }, - shapes = IconButtonDefaults.shapes(), - colors = - IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = stringResource(Res.string.share_repository), - ) - } - } - - state.repository?.htmlUrl?.let { - IconButton( - shapes = IconButtonDefaults.shapes(), - onClick = { - onAction(DetailsAction.OpenRepoInBrowser) - }, - colors = - IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon( - imageVector = Icons.Default.OpenInBrowser, - contentDescription = stringResource(Res.string.open_repository), - modifier = Modifier.size(24.dp), - ) - } - } - - if (state.repository != null) { - DetailsOverflowMenu(state = state, onAction = onAction) + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable { onAction(DetailsAction.OpenRepoInBrowser) } + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = stringResource(Res.string.open_repository), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp), + ) } + Box( + modifier = Modifier + .size(1.dp, 20.dp) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), + ) + DetailsOverflowMenu(state = state, onAction = onAction) } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - ), - modifier = - Modifier - .shadow( - elevation = 6.dp, - ambientColor = MaterialTheme.colorScheme.surfaceTint, - spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), - ).background( - Brush.linearGradient( - 0f to MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), - 0.5f to MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f), - 1f to MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), - ), - ).background(MaterialTheme.colorScheme.surfaceContainerHighest), - ) + } + } } + @OptIn( ExperimentalTime::class, ExperimentalMaterial3Api::class, @@ -931,39 +803,98 @@ private fun DetailsOverflowMenu( val refreshDisabled = cooldownActive || state.isRefreshing Box { - IconButton( - shapes = IconButtonDefaults.shapes(), - onClick = { menuOpen = true }, - colors = - IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface, - ), + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable { menuOpen = true } + .padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Default.MoreVert, contentDescription = stringResource(Res.string.details_refresh_more_options), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp), ) } - DropdownMenu( + GhsDropdownMenu( expanded = menuOpen, onDismissRequest = { menuOpen = false }, ) { - DropdownMenuItem( - enabled = !refreshDisabled, - text = { - Text( - text = - if (cooldownActive) { - stringResource(Res.string.details_refresh_cooldown, cooldownSeconds) + GhsDropdownMenuItem( + text = stringResource( + if (state.isStarred) Res.string.repository_starred + else Res.string.repository_not_starred, + ), + leadingIcon = { + Icon( + imageVector = if (state.isStarred) Icons.Default.Star else Icons.Default.StarBorder, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + onClick = { + menuOpen = false + onAction( + DetailsAction.OnMessage( + messageText = if (state.isStarred) { + Res.string.unstar_from_github } else { - stringResource(Res.string.details_refresh) + Res.string.star_from_github }, + ), + ) + }, + ) + GhsDropdownMenuItem( + text = stringResource( + if (state.isFavourite) Res.string.remove_from_favourites + else Res.string.add_to_favourites, + ), + leadingIcon = { + Icon( + imageVector = if (state.isFavourite) { + Icons.Default.Favorite + } else { + Icons.Default.FavoriteBorder + }, + contentDescription = null, + modifier = Modifier.size(18.dp), ) }, + onClick = { + menuOpen = false + onAction(DetailsAction.OnToggleFavorite) + }, + ) + state.repository?.htmlUrl?.let { + GhsDropdownMenuItem( + text = stringResource(Res.string.share_repository), + leadingIcon = { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + onClick = { + menuOpen = false + onAction(DetailsAction.OnShareClick) + }, + ) + } + GhsDropdownMenuItem( + enabled = !refreshDisabled, + text = if (cooldownActive) { + stringResource(Res.string.details_refresh_cooldown, cooldownSeconds) + } else { + stringResource(Res.string.details_refresh) + }, leadingIcon = { Icon( imageVector = Icons.Default.Refresh, contentDescription = null, + modifier = Modifier.size(18.dp), ) }, onClick = { @@ -972,14 +903,13 @@ private fun DetailsOverflowMenu( }, ) if (state.installedApp?.installSource == InstallSource.MANUAL) { - DropdownMenuItem( - text = { - Text(text = stringResource(Res.string.details_unlink_external_app_menu)) - }, + GhsDropdownMenuItem( + text = stringResource(Res.string.details_unlink_external_app_menu), leadingIcon = { Icon( imageVector = Icons.Default.LinkOff, contentDescription = null, + modifier = Modifier.size(18.dp), ) }, onClick = { diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt index aa6142c84..c960522c1 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt @@ -7,8 +7,6 @@ import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.GithubUserProfile import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.SystemArchitecture -import zed.rainxch.core.domain.model.isEffectivelyPreRelease -import zed.rainxch.core.domain.util.VersionMath import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.model.RepoStats import zed.rainxch.details.presentation.model.AttestationStatus @@ -27,10 +25,8 @@ data class DetailsState( val errorMessage: String? = null, val userProfile: GithubUserProfile? = null, val repository: GithubRepoSummary? = null, - // state for assets val primaryAsset: GithubAsset? = null, val installableAssets: List = emptyList(), - // state for releases val selectedRelease: GithubRelease? = null, val allReleases: List = emptyList(), val releasesLoadFailed: Boolean = false, @@ -57,23 +53,12 @@ data class DetailsState( val isAppManagerAvailable: Boolean = false, val isAppManagerEnabled: Boolean = false, val installedApp: InstalledApp? = null, - /** - * All apps tracked for this repository. For single-app repos this - * contains at most one element (same as [installedApp]). For - * monorepos it may contain multiple entries with different package - * names. [installedApp] is the "primary" — the one whose asset - * filter matches the currently selected asset, or the first. - */ val installedApps: List = emptyList(), val isFavourite: Boolean = false, val isStarred: Boolean = false, val isTrackingApp: Boolean = false, val isAboutExpanded: Boolean = false, val isWhatsNewExpanded: Boolean = false, - // Measured intrinsic heights of the rendered markdown blocks, hoisted - // out of the composable so LazyColumn item disposal/recompose doesn't - // re-trigger the measure → clip → reflow loop that snapped scroll - // position to the section start. val aboutMeasuredHeightPx: Float? = null, val whatsNewMeasuredHeightPx: Float? = null, val aboutTranslation: TranslationState = TranslationState(), @@ -89,125 +74,19 @@ data class DetailsState( val showUninstallConfirmation: Boolean = false, val showUnlinkConfirmation: Boolean = false, val attestationStatus: AttestationStatus = AttestationStatus.UNCHECKED, - /** - * Days since the most recent stable release when the project is - * actively shipping pre-releases on top of it. `null` means - * either healthy (recent stable) or no applicable signal - * (project has no stable releases at all). Set by the ViewModel - * from `latestStable.publishedAt` vs `Clock.now()` when releases - * load. See release UX #6. - */ val stalledStableSinceDays: Int? = null, - /** - * Concatenated release notes for every release newer than the - * user's `installedApp.installedVersion`, most-recent-first. - * Populated when the user is tracking the app and at least one - * newer release exists. Null when there's no installed version - * or no newer releases. See release UX #4. - */ val mergedChangelog: String? = null, - /** - * Release tag for the head of [mergedChangelog] (the version the - * user would jump from). Used to title the merged section as - * "What's changed since v1.2.3". - */ val mergedChangelogBaseTag: String? = null, - /** - * Whether [latestStableRelease] has at least one asset that the - * platform installer can handle. Computed by the ViewModel - * whenever `allReleases` changes — we can't compute it here - * because the installer's per-platform asset-extension policy - * lives outside the data model. Gates [canSwitchToStable] so - * the rollback chip never advertises an action that would - * silently no-op for releases that ship only source tarballs. - */ val latestStableHasInstallableAsset: Boolean = false, - /** APK inspection result currently driving the inspect bottom sheet. */ val apkInspection: ApkInspection? = null, - /** Whether the inspect bottom sheet is on screen. */ val isApkInspectSheetVisible: Boolean = false, - /** Loading state for the inspect sheet — set while the inspector runs. */ val isApkInspectLoading: Boolean = false, - /** - * One-shot flag from DataStore — `false` until the user has seen - * the discoverability coachmark for the inspect button. Drives the - * pulse + tooltip animation in the install button row. - */ val isApkInspectCoachmarkPending: Boolean = false, - /** - * One-shot flag — `false` until the user has seen the - * release-channel coachmark. Drives the pulse + tooltip on the - * `ChannelChip` so users discover the per-app channel toggle. - */ val isChannelChipCoachmarkPending: Boolean = false, - /** - * Mirrors `TweaksRepository.getShowAllPlatforms()`. When true the - * release-assets picker lists installers for every OS (grouped by - * section); the install button still operates on the current - * platform's primary asset. - */ val showAllPlatforms: Boolean = false, -) { - val filteredReleases: List - get() = - when (selectedReleaseCategory) { - ReleaseCategory.STABLE -> allReleases.filter { !it.isEffectivelyPreRelease() } - ReleaseCategory.PRE_RELEASE -> allReleases.filter { it.isEffectivelyPreRelease() } - ReleaseCategory.ALL -> allReleases - } - /** - * Most recent non-pre-release release, or `null` when the - * project has no stable releases in the current window. Drives - * the "Switch to stable vX.Y.Z" rollback action. - */ - val latestStableRelease: GithubRelease? - get() = - allReleases - .filter { !it.isEffectivelyPreRelease() } - .maxByOrNull { it.publishedAt } - - /** - * True when the install button should expose a "switch to - * stable" rollback affordance: the user is tracking this app, - * is currently on a release that's effectively a pre-release, - * a distinct stable release exists, AND that stable release has - * at least one installable asset on the current platform. The - * handler (`DetailsAction.SwitchToStable`) selects the stable - * release and invokes the normal install path. - */ - val canSwitchToStable: Boolean - get() { - val app = installedApp ?: return false - val stable = latestStableRelease ?: return false - if (!latestStableHasInstallableAsset) return false - val installedIsPreRelease = - allReleases.firstOrNull { VersionMath.isSameVersion(it.tagName, app.installedVersion) } - ?.isEffectivelyPreRelease() == true - if (!installedIsPreRelease) return false - // Don't offer the button if the stable release IS the - // one the user has already (same version, ignoring tag-prefix - // drift like "v" vs "" or "release-" vs ""). - return !VersionMath.isSameVersion(stable.tagName, app.installedVersion) - } - - /** - * True when the currently-tracked app has a *parked* install file - * that matches the user's current selection (release tag + asset - * name). The install button can short-circuit the download phase - * and dispatch the dialog/install flow on the parked file directly. - * - * This is the data-layer match — the VM also re-checks the file - * exists on disk before actually using it (in [parkedFilePathIfMatches]). - */ - val isPendingInstallReady: Boolean - get() { - val app = installedApp ?: return false - val parkedVersion = app.pendingInstallVersion ?: return false - val parkedAsset = app.pendingInstallAssetName ?: return false - if (app.pendingInstallFilePath.isNullOrBlank()) return false - val tag = selectedRelease?.tagName ?: return false - val assetName = primaryAsset?.name ?: return false - return parkedVersion == tag && parkedAsset == assetName - } -} + val filteredReleases: List = emptyList(), + val latestStableRelease: GithubRelease? = null, + val canSwitchToStable: Boolean = false, + val isPendingInstallReady: Boolean = false, +) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index fc5d39ee6..f4ea3080b 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first @@ -41,7 +42,6 @@ import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository -import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.ApkInspector import zed.rainxch.core.domain.system.DownloadOrchestrator @@ -51,6 +51,7 @@ import zed.rainxch.core.domain.system.InstallOutcome import zed.rainxch.core.domain.system.InstallPolicy import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.model.InstallerType +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.util.AssetVariant @@ -101,14 +102,12 @@ import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock.System import kotlin.time.ExperimentalTime +import kotlin.time.Instant class DetailsViewModel( private val repositoryId: Long, private val ownerParam: String, private val repoParam: String, - // Non-null for Forgejo / Codeberg / custom-forge repos. Routes all - // DetailsRepository calls through the Forgejo API client instead of - // the GitHub-backed default path. private val sourceHostParam: String?, private val detailsRepository: DetailsRepository, private val downloader: Downloader, @@ -129,12 +128,10 @@ class DetailsViewModel( private val installationManager: InstallationManager, private val attestationVerifier: AttestationVerifier, private val downloadOrchestrator: DownloadOrchestrator, - private val telemetryRepository: TelemetryRepository, private val externalImportRepository: ExternalImportRepository, private val apkInspector: ApkInspector, - private val authenticationState: zed.rainxch.core.domain.repository.AuthenticationState, private val systemInstallSerializer: zed.rainxch.core.domain.system.SystemInstallSerializer, - private val profileRepository: zed.rainxch.profile.domain.repository.ProfileRepository, + private val userSessionRepository: UserSessionRepository ) : ViewModel() { private var hasLoadedInitialData = false private var currentDownloadJob: Job? = null @@ -142,8 +139,8 @@ class DetailsViewModel( private var aboutTranslationJob: Job? = null private var whatsNewTranslationJob: Job? = null - private val _state = MutableStateFlow(DetailsState()) - val state = + private val _state = MutableStateFlow(RawDetailsState()) + val state: StateFlow = _state .onStart { if (!hasLoadedInitialData) { @@ -155,7 +152,9 @@ class DetailsViewModel( hasLoadedInitialData = true } - }.stateIn( + } + .map { it.toView() } + .stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), DetailsState(), @@ -173,7 +172,6 @@ class DetailsViewModel( viewModelScope.launch { try { installer.uninstall(installedApp.packageName) - _state.value.repository?.id?.let { telemetryRepository.recordUninstalled(it) } } catch (e: Exception) { logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") _events.send( @@ -192,14 +190,11 @@ class DetailsViewModel( logger.debug("Unlinking externally-imported app: $packageName") viewModelScope.launch { try { - // installed_apps + external_links must move together so the - // next scan re-proposes a match instead of treating the row - // as a healthy tracked app on a stale link. + installedAppsRepository.executeInTransaction { externalImportRepository.unlink(packageName) installedAppsRepository.deleteInstalledApp(packageName) } - runCatching { telemetryRepository.importUnlinkedFromDetails() } _events.send( DetailsEvent.OnMessage( getString(Res.string.details_unlink_external_app_success), @@ -488,9 +483,7 @@ class DetailsViewModel( } DetailsAction.ToggleIncludeBetas -> { - // Tapping the chip is the strongest possible signal that - // the user has discovered the toggle. No need to keep - // pulsing it after that. + acknowledgeChannelChipCoachmark() toggleIncludeBetas() } @@ -530,11 +523,7 @@ class DetailsViewModel( } is DetailsAction.OnDownloadForTransfer -> { - // Cross-platform assets land here when the user picks an - // installer for a different OS. Browser handles the - // actual save-to-Downloads — keeps install plumbing - // unchanged and matches existing "open external link" - // flows on Details. + helper.openUrl(action.assetUrl) { err -> logger.warn("Open transfer download failed: $err") } @@ -542,14 +531,6 @@ class DetailsViewModel( } } - /** - * Resolves the right APK source and runs [ApkInspector]. Installed - * package wins over a parked file when both exist — a successful - * install means the manifest on the system is the authoritative - * description of what's actually running, even if the bytes that - * produced it are still parked. Falls back to the parked file path - * for pre-install inspections. - */ private fun openApkInspectSheet() { val installed = _state.value.installedApp val parkedPath = installed?.pendingInstallFilePath @@ -585,9 +566,7 @@ class DetailsViewModel( apkInspection = inspection, ) } - // Opening the sheet implicitly satisfies the discoverability - // coachmark — no need to keep nudging the user about a - // feature they've now used. + acknowledgeApkInspectCoachmark() } } @@ -604,12 +583,7 @@ class DetailsViewModel( } private fun acknowledgeChannelChipCoachmark() { - // Persist unconditionally — `state.update` with the same value is a - // no-op, and `setChannelChipCoachmarkShown(true)` is idempotent. - // Skipping persistence when in-memory `pending` is already false - // (e.g., toggle + dismiss race) leaves the DataStore flag at false - // if the very first persist failed, so the coachmark would - // re-appear on next launch. + _state.update { it.copy(isChannelChipCoachmarkPending = false) } viewModelScope.launch { runCatching { tweaksRepository.setChannelChipCoachmarkShown(true) } @@ -619,12 +593,6 @@ class DetailsViewModel( } } - /** - * Derived signals surfaced in the Details UX for pre-release - * handling (release UX #4 and #6). Computed once per release-list - * load and re-used across the two call sites that update state - * with a fresh `allReleases`. - */ private data class ReleaseInsights( val stalledStableSinceDays: Int?, val mergedChangelog: String?, @@ -632,16 +600,12 @@ class DetailsViewModel( val latestStableHasInstallableAsset: Boolean, ) - @OptIn(kotlin.time.ExperimentalTime::class) + @OptIn(ExperimentalTime::class) private fun computeReleaseInsights( allReleases: List, installedApp: InstalledApp?, ): ReleaseInsights { - // Merged "What's changed since v…": concatenate release notes - // for every release strictly newer than the installed tag, - // most-recent-first. Mirrors what app stores do when the user - // skips versions between updates — they deserve to see every - // intermediate changelog, not just the head one. + val (merged, mergedBase) = if (installedApp != null && allReleases.size > 1) { val installedTag = installedApp.installedVersion @@ -669,18 +633,13 @@ class DetailsViewModel( .filter { !it.isEffectivelyPreRelease() } .maxByOrNull { it.publishedAt } - // Stalled-project warning: the project has at least one stable - // release, has shipped pre-releases on top of it, and the last - // stable is older than [STALLED_STABLE_THRESHOLD_DAYS]. That's - // the "beta spiral with no stabilisation" signal that warrants - // a heads-up before the user opts into betas. val stalledDays: Int? = run { val stable = latestStable ?: return@run null val preReleasesAfter = allReleases.any { release -> release.isEffectivelyPreRelease() && - VersionMath.isVersionNewer(release.tagName, stable.tagName) + VersionMath.isVersionNewer(release.tagName, stable.tagName) } if (!preReleasesAfter) return@run null val days = daysSinceIso(stable.publishedAt) ?: return@run null @@ -698,26 +657,19 @@ class DetailsViewModel( ) } - @OptIn(kotlin.time.ExperimentalTime::class) + @OptIn(ExperimentalTime::class) private fun daysSinceIso(isoTimestamp: String?): Int? { if (isoTimestamp.isNullOrBlank()) return null return try { - val published = kotlin.time.Instant.parse(isoTimestamp) + val published = Instant.parse(isoTimestamp) val now = System.now() val diffMs = now.toEpochMilliseconds() - published.toEpochMilliseconds() if (diffMs < 0) null else (diffMs / MILLIS_PER_DAY).toInt() - } catch (e: Exception) { + } catch (_: Exception) { null } } - /** - * Flips the per-app `includePreReleases` flag via - * [InstalledAppsRepository.setIncludePreReleases]. Kicks off a - * fresh `checkForUpdates` so the new channel takes effect - * immediately on-screen instead of waiting for the next - * periodic cycle. - */ private fun toggleIncludeBetas() { val app = _state.value.installedApp ?: return val newValue = !app.includePreReleases @@ -727,9 +679,7 @@ class DetailsViewModel( packageName = app.packageName, enabled = newValue, ) - // Re-validate against the new channel immediately so - // the user sees the result of the toggle in the next - // frame (the DB observer will also refresh state). + installedAppsRepository.checkForUpdates(app.packageName) } catch (e: CancellationException) { throw e @@ -739,22 +689,9 @@ class DetailsViewModel( } } - /** - * Switches the currently-tracked app to the latest stable - * release: selects it as the picked release, and triggers the - * normal install flow. Reuses the existing `InstallPrimary` - * path so downgrade warnings, signing-key checks, and asset - * picking all kick in exactly as they do for a manual version - * selection. - */ private fun switchToStable() { - val stable = _state.value.latestStableRelease ?: return - // Defence in depth: the chip should already be hidden when the - // stable release ships nothing the platform installer can - // handle, but a stale state could still drive us here. Resolve - // the primary asset up front and bail before the dispatch chain - // would otherwise reach `install()` with `primaryAsset = null` - // and silently no-op. + val stable = _state.value.latestStableRelease() ?: return + val (_, primary) = recomputeAssetsForRelease(stable, _state.value.installedApp) if (primary == null) { logger.warn( @@ -766,20 +703,6 @@ class DetailsViewModel( onAction(DetailsAction.InstallPrimary) } - /** - * Persists the multi-layer fingerprint of [picked] when: - * - the app is already tracked (otherwise there's no row to update — - * the link flow will derive the fingerprint at install time) - * - the picked asset has a non-null fingerprint (single-asset releases - * and unparseable filenames return null) - * - the new fingerprint differs from what's currently stored, OR the - * stale flag is set (re-picking the same variant after a stale event - * must clear the flag) - * - * Emits a one-time "remembered" toast when the app had no fingerprint - * before this pick — that's the user's first time pinning, and the - * implicit behaviour deserves to be made explicit. - */ private fun persistPreferredVariantOnPick(picked: GithubAsset) { val installedApp = _state.value.installedApp ?: return val installable = _state.value.installableAssets @@ -804,19 +727,15 @@ class DetailsViewModel( } val isSameFingerprint = sameVariant && - serializedTokens == currentTokens && - fingerprint.glob == currentGlob && - pickedIndex == installedApp.pickedAssetIndex && - newSiblingCount == installedApp.pickedAssetSiblingCount - - // Treat the app as "previously unpinned" only when *all* identity - // layers are blank — otherwise we'd nag every time the user - // re-picked the same variant after the resolver populated the - // legacy tail field. + serializedTokens == currentTokens && + fingerprint.glob == currentGlob && + pickedIndex == installedApp.pickedAssetIndex && + newSiblingCount == installedApp.pickedAssetSiblingCount + val isFirstPin = currentVariant.isNullOrBlank() && - currentTokens.isNullOrBlank() && - currentGlob.isNullOrBlank() + currentTokens.isNullOrBlank() && + currentGlob.isNullOrBlank() val shouldSave = !isSameFingerprint || installedApp.preferredVariantStale if (!shouldSave) return @@ -848,7 +767,7 @@ class DetailsViewModel( } catch (e: Exception) { logger.error( "Failed to persist preferred variant for " + - "${installedApp.packageName}: ${e.message}", + "${installedApp.packageName}: ${e.message}", ) } } @@ -867,27 +786,16 @@ class DetailsViewModel( } catch (e: Exception) { logger.error( "Failed to clear preferred variant for " + - "${installedApp.packageName}: ${e.message}", + "${installedApp.packageName}: ${e.message}", ) } } } - /** - * One-shot eligibility check for the APK Inspect coachmark. The - * coachmark may *only* fire if the user opened this Details screen - * with the app already genuinely installed — never as a side effect - * of an install completing during the current session. Otherwise - * the pulse would render at the exact moment the system install - * prompt is up, which is the user's peak-attention frame. - */ - // Reactively flips `isCurrentUserOwner` whenever either the signed-in - // user changes (login/logout/switch-account) or the loaded repository - // owner login arrives. Avoids touching every repo-assignment site. private fun observeCurrentUserForBadge() { viewModelScope.launch { combine( - profileRepository.getUser(), + userSessionRepository.getUser(), _state .map { it.repository?.owner?.login } .distinctUntilChanged(), @@ -906,13 +814,7 @@ class DetailsViewModel( runCatching { tweaksRepository.getApkInspectCoachmarkShown().first() } .getOrDefault(true) if (alreadyShown) return@launch - // Wait for `loadInitial` to settle. The first non-loading - // emission carries the authoritative `installedApp` for the - // app the user is viewing. If it's null (or pending) at - // that frame, this screen instance is not eligible — we - // never enable the coachmark for the rest of the session, - // even if an install completes here. The user will see it - // on their next visit instead. + val firstStable = _state.first { !it.isLoading } val installedAtOpen = firstStable.installedApp?.isReallyInstalled() == true @@ -935,10 +837,7 @@ class DetailsViewModel( runCatching { tweaksRepository.getChannelChipCoachmarkShown().first() } .getOrDefault(true) if (alreadyShown) return@launch - // ChannelChip only renders when the app is tracked - // (`installedApp != null` in `ReleaseChannel.releaseChannel`). - // No point pulsing a non-existent chip — defer to a future - // visit where the user actually has the app installed. + val firstStable = _state.first { !it.isLoading } if (firstStable.installedApp == null) return@launch _state.update { it.copy(isChannelChipCoachmarkPending = true) } @@ -959,11 +858,7 @@ class DetailsViewModel( defaultBranch = repo.defaultBranch, sourceHost = sourceHostParam, ) - // Prefer a release that matches the user's previous category. - // Only fall back to the generic "first stable, else first" rule - // when no release exists in that category — in which case reset - // the category too so the UI doesn't end up with a category - // selected but no matching release. + val byPrevCategory = when (prevCategory) { ReleaseCategory.STABLE -> releases.firstOrNull { !it.isEffectivelyPreRelease() } ReleaseCategory.PRE_RELEASE -> releases.firstOrNull { it.isEffectivelyPreRelease() } @@ -972,11 +867,7 @@ class DetailsViewModel( val selected = byPrevCategory ?: releases.firstOrNull { !it.isEffectivelyPreRelease() } ?: releases.firstOrNull() - // When the previous category yields nothing, derive the - // category from the actually-selected release so the - // filter matches what's on screen — otherwise a - // pre-release-only project leaves the user with category - // STABLE and an empty filtered list. + val resolvedCategory = when { byPrevCategory != null -> prevCategory selected?.isEffectivelyPreRelease() == true -> ReleaseCategory.PRE_RELEASE @@ -1008,12 +899,7 @@ class DetailsViewModel( it.copy(isRetryingReleases = false, releasesLoadFailed = true) } } catch (t: Throwable) { - // The detailed cause ("HTTP 403", network error, parse - // failure) only matters for telemetry — for the user, - // "the release list isn't available right now, try - // again in a bit" is the entire signal. Surface the - // friendly message via snackbar; keep the raw cause in - // logs so support / bug reports can still trace it. + logger.warn("Retry failed to load releases: ${t.message}") viewModelScope.launch { _events.send( @@ -1040,20 +926,18 @@ class DetailsViewModel( installer.isAssetInstallable(asset.name) }.orEmpty() - val trackedApp = installedAppOverride - val variantMatch = - AssetVariant.resolvePreferredAsset( - assets = installable, - pinnedVariant = trackedApp?.preferredAssetVariant, - pinnedTokens = AssetVariant.deserializeTokens(trackedApp?.preferredAssetTokens), - pinnedGlob = trackedApp?.assetGlobPattern, - ) + val variantMatch = AssetVariant.resolvePreferredAsset( + assets = installable, + pinnedVariant = installedAppOverride?.preferredAssetVariant, + pinnedTokens = AssetVariant.deserializeTokens(installedAppOverride?.preferredAssetTokens), + pinnedGlob = installedAppOverride?.assetGlobPattern, + ) val samePositionMatch = if (variantMatch == null) { AssetVariant.resolveBySamePosition( assets = installable, - originalIndex = trackedApp?.pickedAssetIndex, - siblingCountAtPickTime = trackedApp?.pickedAssetSiblingCount, + originalIndex = installedAppOverride?.pickedAssetIndex, + siblingCountAtPickTime = installedAppOverride?.pickedAssetSiblingCount, ) } else { null @@ -1062,22 +946,6 @@ class DetailsViewModel( return installable to primary } - /** - * Pick the "primary" installed app for a repo when multiple - * variants are tracked (e.g. generic + Play-flavored APKs of the - * same project). Earlier code picked `apps.firstOrNull()`, which - * for OSS-DocumentScanner-style multi-flavor repos could pick the - * outdated variant and surface a misleading "Update" CTA even - * when the variant the user is actually running is current. - * Issue #638. - * - * Preference order: - * 1. Variant whose `assetFilterRegex` matches the current - * primaryAsset name — strongest signal that this row owns - * the selected release asset. - * 2. Variant that is up-to-date (`isUpdateAvailable == false`). - * 3. First app — preserves prior behavior for single-variant repos. - */ private fun pickPrimaryInstalledApp( apps: List, primaryAssetName: String?, @@ -1101,21 +969,12 @@ class DetailsViewModel( .getAppsByRepoIdAsFlow(repoId) .distinctUntilChanged() .collect { apps -> - // See [pickPrimaryInstalledApp]. Picks the variant - // that already matches the latest release first, - // so multi-variant projects (e.g. an app shipped - // as both a generic + Play-store-flavored APK) - // don't surface a false "Update" CTA when only one - // of the variants is current. Issue #638. + val primary = pickPrimaryInstalledApp( apps = apps, primaryAssetName = _state.value.primaryAsset?.name, ) - // Recompute merged changelog + stalled signals - // against the new installed version — if the - // user just updated externally, the installed - // tag flips and what they've "missed" changes. val insights = computeReleaseInsights(_state.value.allReleases, primary) _state.update { it.copy( @@ -1395,9 +1254,6 @@ class DetailsViewModel( private fun openApp() { val installedApp = _state.value.installedApp ?: return val launched = installer.openApp(installedApp.packageName) - if (launched && platform == Platform.ANDROID) { - _state.value.repository?.id?.let { telemetryRepository.recordAppOpenedAfterInstall(it) } - } if (!launched) { viewModelScope.launch { _events.send( @@ -1484,12 +1340,6 @@ class DetailsViewModel( val newFavoriteState = favouritesRepository.isFavoriteSync(repo.id) _state.value = _state.value.copy(isFavourite = newFavoriteState) - if (newFavoriteState) { - telemetryRepository.recordFavorited(repo.id) - } else { - telemetryRepository.recordUnfavorited(repo.id) - } - _events.send( element = DetailsEvent.OnMessage( @@ -1553,7 +1403,6 @@ class DetailsViewModel( viewModelScope.launch { try { installer.uninstall(installedApp.packageName) - _state.value.repository?.id?.let { telemetryRepository.recordUninstalled(it) } } catch (e: Exception) { logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") _events.send( @@ -1617,9 +1466,7 @@ class DetailsViewModel( viewModelScope.launch { try { val ext = warning.pendingAssetName.substringAfterLast('.', "").lowercase() - // Same Android-only serialization rationale as the primary - // install path (`installRelease`): non-Android installers - // never receive the broadcast that releases the gate. + val gatePackageName = if (platform == Platform.ANDROID) warning.pendingApkInfo.packageName else null if (gatePackageName != null) { @@ -1675,29 +1522,6 @@ class DetailsViewModel( } } - /** - * Entry point for "download + install" from the install button. - * - * Hands the actual download off to [downloadOrchestrator] (so it - * survives this screen being destroyed) and then observes the - * orchestrator's state to mirror progress into [DetailsState] and - * to dispatch the install dialog flow when bytes are on disk. - * - * Install policy is decided by installer type: - * - **Shizuku**: [InstallPolicy.AlwaysInstall] — orchestrator - * runs the install in its own scope. The user gets a silent - * install whether they stay on this screen or not. The - * PackageEventReceiver picks up `PACKAGE_REPLACED` and the - * installed-apps DB syncs without further work from the VM. - * - **Regular installer**: [InstallPolicy.InstallWhileForeground] - * — orchestrator parks the file at `AwaitingInstall` and the - * foreground VM (this one) runs the existing dialog flow - * (validation → fingerprint check → installer.install → DB - * save). If the screen leaves before bytes are done, the - * VM's `onCleared` calls [DownloadOrchestrator.downgradeToDeferred], - * the orchestrator notifies the user, and the apps row picks - * up the deferred install. - */ private fun installAsset( downloadUrl: String, assetName: String, @@ -1705,11 +1529,7 @@ class DetailsViewModel( releaseTag: String, isUpdate: Boolean = false, ) { - // Cancel the existing observation job (if any) — but not the - // orchestrator entry itself. A user re-tapping install for a - // different asset should preempt the *previous* observer, not - // the in-flight download (the orchestrator dedupes by - // package name). + currentDownloadJob?.cancel() val packageKey = orchestratorKey() val asset = _state.value.primaryAsset @@ -1720,16 +1540,6 @@ class DetailsViewModel( } currentAssetName = assetName - // ──────────────────────────────────────────────────────── - // SHORT-CIRCUIT: parked file matches what the user picked - // ──────────────────────────────────────────────────────── - // If the user already deferred a download for this exact - // (releaseTag, assetName) pair (e.g. they navigated away - // from Details mid-download, the file got parked, and now - // they're back), skip the orchestrator entirely and - // dispatch the existing install dialog flow on the parked - // file directly. Saves the network round-trip and the - // disk space of a duplicate download. val parkedFilePath = parkedFilePathIfMatches(releaseTag, assetName) if (parkedFilePath != null) { logger.debug("Reusing parked file for $releaseTag / $assetName") @@ -1791,7 +1601,7 @@ class DetailsViewModel( tweaksRepository.getInstallerType().first() } catch (e: kotlinx.coroutines.CancellationException) { throw e - } catch (e: Exception) { + } catch (_: Exception) { InstallerType.DEFAULT } val policy = @@ -1854,19 +1664,6 @@ class DetailsViewModel( } } - /** - * Returns the path of a parked install file iff the currently-tracked - * app has one AND it represents *this exact* (releaseTag, assetName) - * pair AND the file still exists on disk. - * - * Used as the short-circuit gate in [installAsset] to skip the - * orchestrator round-trip when the bytes are already on disk. - * Returns `null` (= "do a fresh download") in any of these cases: - * - app not tracked - * - no parked file - * - parked file represents a different version or asset - * - parked file no longer exists (manually deleted, etc.) - */ private fun parkedFilePathIfMatches( releaseTag: String, assetName: String, @@ -1877,10 +1674,7 @@ class DetailsViewModel( val parkedAsset = installedApp.pendingInstallAssetName ?: return null if (parkedVersion != releaseTag) return null if (parkedAsset != assetName) return null - // Verify the file still exists. If a user manually cleared - // their downloads dir between parking and re-opening Details, - // the column points at a stale path and we'd hand the - // installer a missing file. + return try { val file = File(parkedPath) if (file.exists() && file.length() > 0) parkedPath else null @@ -1890,18 +1684,6 @@ class DetailsViewModel( } } - /** - * Stable orchestrator key for the currently-displayed app. - * - * Tracked apps key by `packageName` so the apps list and the - * details screen point at the same orchestrator entry. Untracked - * apps (fresh installs) key by `owner/repo` synthetic — real - * package names never contain `/`, so there's no collision risk. - * - * After a fresh install completes, the InstalledApp row is created - * with the real package name; subsequent updates use the real - * key. The synthetic key is one-shot and short-lived. - */ private fun orchestratorKey(): String { val packageName = _state.value.installedApp?.packageName if (packageName != null) return packageName @@ -1910,19 +1692,6 @@ class DetailsViewModel( return "$owner/$name" } - /** - * Subscribes to the orchestrator's entry for [packageKey] and - * mirrors its state into [DetailsState]. When the entry reaches - * [OrchestratorStage.AwaitingInstall] *and* the install policy is - * [InstallPolicy.InstallWhileForeground] (i.e. not the Shizuku - * silent path), kicks off the existing install dialog flow on - * the file path. - * - * Suspends until the entry reaches a terminal state (`Completed`, - * `Cancelled`, `Failed`, or removed from the map). Cancellation - * of the *observer* doesn't cancel the orchestrator — that's the - * whole point. - */ private suspend fun observeOrchestratorEntry( packageKey: String, downloadUrl: String, @@ -1932,12 +1701,9 @@ class DetailsViewModel( isUpdate: Boolean, ) { var installFired = false - var telemetryStartFired = false downloadOrchestrator.observe(packageKey).collect { entry -> if (entry == null) { - // Orchestrator dropped the entry (cancelled or - // dismissed elsewhere). Tear down our local UI state - // and exit the observer. + if (_state.value.downloadStage != DownloadStage.IDLE) { _state.value = _state.value.copy( @@ -1949,11 +1715,6 @@ class DetailsViewModel( return@collect } - // Mirror progress into local state for the UI. Update - // bytes too — the live byte counter is what users see - // when content-length is small enough that the percent - // doesn't tick smoothly. The orchestrator emits both on - // every chunk so the UI gets a continuous update. _state.value = _state.value.copy( downloadProgressPercent = entry.progressPercent, @@ -1963,7 +1724,7 @@ class DetailsViewModel( when (entry.stage) { OrchestratorStage.Queued -> { - // Nothing UI-visible — same as DOWNLOADING placeholder + _state.value = _state.value.copy(downloadStage = DownloadStage.DOWNLOADING) } @@ -1972,27 +1733,11 @@ class DetailsViewModel( } OrchestratorStage.Installing -> { - // Either the orchestrator's bare-install path - // (Shizuku) or our own install fired below. Either - // way, surface the INSTALLING stage. _state.value = _state.value.copy(downloadStage = DownloadStage.INSTALLING) - - if (!telemetryStartFired) { - telemetryStartFired = true - _state.value.repository?.id?.let { id -> - telemetryRepository.recordReleaseDownloaded(id) - telemetryRepository.recordInstallStarted(id) - } - } } OrchestratorStage.AwaitingInstall -> { - // Bytes are on disk. For the foreground path - // (regular installer), this is our cue to run - // the existing dialog/validation/install flow. - // For the Shizuku path the orchestrator already - // moved past Installing → Completed before we - // ever see AwaitingInstall (it doesn't park). + if (installFired) return@collect installFired = true val filePath = entry.filePath ?: return@collect @@ -2004,17 +1749,7 @@ class DetailsViewModel( tag = releaseTag, result = LogResult.Downloaded, ) - if (!telemetryStartFired) { - telemetryStartFired = true - _state.value.repository?.id?.let { id -> - telemetryRepository.recordReleaseDownloaded(id) - telemetryRepository.recordInstallStarted(id) - } - } - // Run the existing install dialog flow on the - // downloaded file. This is the unchanged - // validation + fingerprint + installer + DB save - // path that the VM has always owned. + try { installAsset( isUpdate = isUpdate, @@ -2024,10 +1759,7 @@ class DetailsViewModel( sizeBytes = sizeBytes, releaseTag = releaseTag, ) - _state.value.repository?.id?.let { telemetryRepository.recordInstallSucceeded(it) } - // Successful install — release the entry - // from the orchestrator so the apps row - // doesn't keep showing "ready to install". + downloadOrchestrator.dismiss(packageKey) } catch (e: kotlinx.coroutines.CancellationException) { throw e @@ -2044,9 +1776,6 @@ class DetailsViewModel( tag = releaseTag, result = Error(t.message), ) - _state.value.repository?.id?.let { - telemetryRepository.recordInstallFailed(it, t.message) - } } } @@ -2066,11 +1795,6 @@ class DetailsViewModel( else -> LogResult.Installed }, ) - if (isCompleted) { - _state.value.repository?.id?.let { - telemetryRepository.recordInstallSucceeded(it) - } - } if (platform == Platform.ANDROID) { val filePath = entry.filePath @@ -2095,7 +1819,7 @@ class DetailsViewModel( } else { logger.warn( "Orchestrator install settled (outcome=$resolvedOutcome) " + - "but APK validation failed: $validation", + "but APK validation failed: $validation", ) } }.onFailure { t -> @@ -2104,7 +1828,7 @@ class DetailsViewModel( } else { logger.warn( "Orchestrator install settled (outcome=$resolvedOutcome) " + - "but filePath is null; DB not updated", + "but filePath is null; DB not updated", ) } } @@ -2145,7 +1869,6 @@ class DetailsViewModel( result = Error(entry.errorMessage), ) _state.value.repository?.id?.let { - telemetryRepository.recordInstallFailed(it, entry.errorMessage) } downloadOrchestrator.dismiss(packageKey) return@collect @@ -2178,20 +1901,18 @@ class DetailsViewModel( when (validationResult) { is ApkValidationResult.ExtractionFailed -> { - // Don't block installation — proceed without - // validation (same as the Shizuku path). - // PackageEventReceiver will sync the DB post-install. + logger.warn( "Could not extract APK info for $assetName, " + - "proceeding with unvalidated install", + "proceeding with unvalidated install", ) } is ApkValidationResult.PackageMismatch -> { logger.error( "Package name mismatch on update: " + - "APK=${validationResult.apkPackageName}, " + - "installed=${validationResult.installedPackageName}", + "APK=${validationResult.apkPackageName}, " + + "installed=${validationResult.installedPackageName}", ) _state.value = _state.value.copy( @@ -2247,9 +1968,6 @@ class DetailsViewModel( } } - // Serialize Android system installer dialogs only — desktop - // installers don't fire the broadcast that releases the gate, so - // gating there would block the next install for the full timeout. val gatePackageName = if (platform == Platform.ANDROID) validatedApkInfo?.packageName else null if (gatePackageName != null) { @@ -2265,7 +1983,6 @@ class DetailsViewModel( throw e } - // Launch attestation check asynchronously (non-blocking) launchAttestationCheck(filePath) if (platform == Platform.ANDROID && validatedApkInfo != null) { @@ -2333,8 +2050,7 @@ class DetailsViewModel( ) { val repo = _state.value.repository ?: return val isPending = installOutcome != InstallOutcome.COMPLETED - // Only carry the parked path through when the row is actually - // pending — a completed install must not store a stale pointer. + val pendingPath = parkedFilePath?.takeIf { isPending } if (isUpdate) { @@ -2347,9 +2063,7 @@ class DetailsViewModel( isPendingInstall = isPending, ), ) - // For pending updates, also park the file path on the row - // so the apps list can resume the install in one tap if - // the user dismissed the system prompt. + if (pendingPath != null) { runCatching { installedAppsRepository.setPendingInstallFilePath( @@ -2363,10 +2077,7 @@ class DetailsViewModel( } } } else { - // Snapshot the installable list as the user saw it at install - // time — this is the reference the variant fingerprint is - // relative to (pinning "the same kind of APK" means the same - // choice among these specific siblings). + val installable = _state.value.installableAssets val pickedIndex = installable .indexOfFirst { it.name == assetName } @@ -2391,14 +2102,6 @@ class DetailsViewModel( } } - /** - * "Download only" entry point — used by the action that - * downloads an asset without auto-installing it (e.g. for users - * who want to side-load via a different installer). Routes - * through the orchestrator with [InstallPolicy.DeferUntilUserAction] - * so the file is parked at AwaitingInstall and the user can pick - * it up from the apps row whenever they're ready. - */ private fun downloadAsset( downloadUrl: String, assetName: String, @@ -2408,7 +2111,7 @@ class DetailsViewModel( currentDownloadJob?.cancel() val packageKey = orchestratorKey() val repository = _state.value.repository ?: return - // Use the exact asset the user tapped, not the auto-picked primary. + val asset = _state.value.selectedRelease?.assets ?.find { it.downloadUrl == downloadUrl } ?: _state.value.primaryAsset @@ -2464,7 +2167,7 @@ class DetailsViewModel( assetName = assetName, size = sizeBytes, tag = releaseTag, - result = LogResult.Error(t.message), + result = Error(t.message), ) } } @@ -2512,20 +2215,9 @@ class DetailsViewModel( override fun onCleared() { super.onCleared() - // Cancel the orchestrator OBSERVER (not the orchestrator - // entry itself). The download keeps running in the - // application-scoped orchestrator scope. + currentDownloadJob?.cancel() - // Tell the orchestrator that the foreground watcher is gone: - // any in-flight download with policy InstallWhileForeground - // should switch to DeferUntilUserAction so the file gets - // parked + the user gets a notification when bytes are done. - // Race-safe — the orchestrator handles "already past park - // time" by retroactively notifying. - // - // NonCancellable so the call runs to completion even though - // viewModelScope is being torn down around us. val packageKey = orchestratorKey() viewModelScope.launch(NonCancellable) { try { @@ -2551,10 +2243,7 @@ class DetailsViewModel( val repo = when { - // Forgejo / Codeberg path — repoId is a synthetic - // foreign id (see RepoIdCodec), so the GitHub - // `/repositories/{id}` endpoint can't resolve it. - // Owner+name must be supplied by the caller. + sourceHostParam != null -> { if (ownerParam.isBlank() || repoParam.isBlank()) { error("Foreign-source Details opened without owner/repo for host=$sourceHostParam") @@ -2565,20 +2254,18 @@ class DetailsViewModel( sourceHost = sourceHostParam, ) } + ownerParam.isNotEmpty() && repoParam.isNotEmpty() -> detailsRepository.getRepositoryByOwnerAndName( owner = ownerParam, name = repoParam, sourceHost = null, ) + else -> detailsRepository.getRepositoryById(repositoryId) } launch { seenReposRepository.markAsSeen(repo) } - // Launch both checks in parallel before awaiting either — - // previously `isFavoriteDeferred.await()` ran before - // `isStarredDeferred` was even started, serialising two - // independent reads on the Details screen cold path. val isFavoriteDeferred = async { try { @@ -2669,10 +2356,7 @@ class DetailsViewModel( val userProfileDeferred = async { - // GitHub user profile lookup is GitHub-only — skip - // for Forgejo / Codeberg repos to avoid hitting the - // wrong API + leaking a 404 toast. Author card just - // hides when null. + if (sourceHostParam != null) return@async null try { detailsRepository.getUserProfile(owner) @@ -2690,7 +2374,6 @@ class DetailsViewModel( try { val dbApps = installedAppsRepository.getAppsByRepoId(repo.id) - // Reconcile pending-install status for each tracked app dbApps.map { dbApp -> if (dbApp.isPendingInstall && packageMonitor.isPackageInstalled(dbApp.packageName) @@ -2728,12 +2411,7 @@ class DetailsViewModel( ) if (rateLimited.get()) { - // Any deferred tripping the rate-limit flag leaves the UI - // in an incomplete state. Flag the releases section as - // failed so it renders its FAILED card with a Retry - // affordance instead of the misleading EMPTY card ("no - // releases published yet") — the default would be EMPTY - // because allReleases stays at its initial empty list. + _state.value = _state.value.copy( isLoading = false, errorMessage = null, @@ -2746,7 +2424,10 @@ class DetailsViewModel( allReleases.firstOrNull { !it.isEffectivelyPreRelease() } ?: allReleases.firstOrNull() - val (installable, primary) = recomputeAssetsForRelease(selectedRelease, installedApp) + val (installable, primary) = recomputeAssetsForRelease( + selectedRelease, + installedApp + ) val isObtainiumAvailable = installer.isObtainiumInstalled() val isAppManagerAvailable = installer.isAppManagerInstalled() @@ -2786,8 +2467,6 @@ class DetailsViewModel( insights.latestStableHasInstallableAsset, ) - telemetryRepository.recordRepoViewed(repo.id) - observeInstalledApp(repo.id) maybeAutoTranslate( @@ -2797,7 +2476,7 @@ class DetailsViewModel( } catch (e: RateLimitException) { logger.error("Rate limited: ${e.message}") val seconds = e.rateLimitInfo.timeUntilReset().inWholeSeconds - val signedIn = authenticationState.isCurrentlyUserLoggedIn() + val signedIn = userSessionRepository.isCurrentlyUserLoggedIn() val base = if (seconds > 0L) { getString(Res.string.rate_limit_exceeded_retry_in, seconds.toInt()) } else { @@ -2849,9 +2528,7 @@ class DetailsViewModel( _state.update { it.copy(isRefreshing = true) } viewModelScope.launch { try { - // Refresh is GitHub-backend-only. For Forgejo repos there's - // no backend mediator; we just re-fetch via the Forgejo APIs - // directly using the same load path. + val refreshed = if (sourceHostParam != null) { detailsRepository.getRepositoryByOwnerAndName( owner = owner, @@ -2983,9 +2660,7 @@ class DetailsViewModel( tweaksRepository.getAutoTranslateEnabled().first() }.getOrDefault(false) if (!enabled) return@launch - // Treat blank explicit target as "unset" so fallback chain can - // run — `explicit = ""` would otherwise short-circuit the - // `?:` operator and disable auto-translate. + val explicit = runCatching { tweaksRepository.getAutoTranslateTargetLang().first() }.getOrNull()?.takeIf { it.isNotBlank() } @@ -3008,16 +2683,10 @@ class DetailsViewModel( getCurrentState = { _state.value.aboutTranslation }, ) } - // Source-language guard mirrors the README branch — if the - // release-notes source language already matches the target, - // skip the translation round-trip. The release model carries - // no language hint, so we fall back to `readmeLanguage` as the - // best available signal for repositories that consistently - // author release notes in the repo's primary language. - val releaseSourceLang = currentReadmeLang + if (!releaseDescription.isNullOrBlank() && _state.value.whatsNewTranslation.translatedText == null && - releaseSourceLang?.equals(target, ignoreCase = true) != true + currentReadmeLang?.equals(target, ignoreCase = true) != true ) { whatsNewTranslationJob?.cancel() whatsNewTranslationJob = translateContent( diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/RawDetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/RawDetailsState.kt new file mode 100644 index 000000000..727ebbdfb --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/RawDetailsState.kt @@ -0,0 +1,87 @@ +package zed.rainxch.details.presentation + +import zed.rainxch.core.domain.model.ApkInspection +import zed.rainxch.core.domain.model.GithubAsset +import zed.rainxch.core.domain.model.GithubRelease +import zed.rainxch.core.domain.model.GithubRepoSummary +import zed.rainxch.core.domain.model.GithubUserProfile +import zed.rainxch.core.domain.model.InstalledApp +import zed.rainxch.core.domain.model.SystemArchitecture +import zed.rainxch.details.domain.model.ReleaseCategory +import zed.rainxch.details.domain.model.RepoStats +import zed.rainxch.details.presentation.model.AttestationStatus +import zed.rainxch.details.presentation.model.DowngradeWarning +import zed.rainxch.details.presentation.model.DownloadStage +import zed.rainxch.details.presentation.model.InstallLogItem +import zed.rainxch.details.presentation.model.SigningKeyWarning +import zed.rainxch.details.presentation.model.TranslationState +import zed.rainxch.details.presentation.model.TranslationTarget + +internal data class RawDetailsState( + val isLoading: Boolean = true, + val isCurrentUserOwner: Boolean = false, + val isRefreshing: Boolean = false, + val refreshCooldownUntilEpochMs: Long? = null, + val errorMessage: String? = null, + val userProfile: GithubUserProfile? = null, + val repository: GithubRepoSummary? = null, + val primaryAsset: GithubAsset? = null, + val installableAssets: List = emptyList(), + val selectedRelease: GithubRelease? = null, + val allReleases: List = emptyList(), + val releasesLoadFailed: Boolean = false, + val isRetryingReleases: Boolean = false, + val isReleaseSelectorVisible: Boolean = false, + val selectedReleaseCategory: ReleaseCategory = ReleaseCategory.STABLE, + val isVersionPickerVisible: Boolean = false, + val stats: RepoStats? = null, + val readmeMarkdown: String? = null, + val readmeLanguage: String? = null, + val installLogs: List = emptyList(), + val isDownloading: Boolean = false, + val downloadProgressPercent: Int? = null, + val downloadedBytes: Long = 0L, + val totalBytes: Long? = null, + val isInstalling: Boolean = false, + val downloadError: String? = null, + val installError: String? = null, + val downloadStage: DownloadStage = DownloadStage.IDLE, + val systemArchitecture: SystemArchitecture = SystemArchitecture.UNKNOWN, + val isObtainiumAvailable: Boolean = false, + val isObtainiumEnabled: Boolean = false, + val isInstallDropdownExpanded: Boolean = false, + val isAppManagerAvailable: Boolean = false, + val isAppManagerEnabled: Boolean = false, + val installedApp: InstalledApp? = null, + val installedApps: List = emptyList(), + val isFavourite: Boolean = false, + val isStarred: Boolean = false, + val isTrackingApp: Boolean = false, + val isAboutExpanded: Boolean = false, + val isWhatsNewExpanded: Boolean = false, + val aboutMeasuredHeightPx: Float? = null, + val whatsNewMeasuredHeightPx: Float? = null, + val aboutTranslation: TranslationState = TranslationState(), + val whatsNewTranslation: TranslationState = TranslationState(), + val isLanguagePickerVisible: Boolean = false, + val languagePickerTarget: TranslationTarget? = null, + val deviceLanguageCode: String = "en", + val isComingFromUpdate: Boolean = false, + val downgradeWarning: DowngradeWarning? = null, + val signingKeyWarning: SigningKeyWarning? = null, + val showExternalInstallerPrompt: Boolean = false, + val pendingInstallFilePath: String? = null, + val showUninstallConfirmation: Boolean = false, + val showUnlinkConfirmation: Boolean = false, + val attestationStatus: AttestationStatus = AttestationStatus.UNCHECKED, + val stalledStableSinceDays: Int? = null, + val mergedChangelog: String? = null, + val mergedChangelogBaseTag: String? = null, + val latestStableHasInstallableAsset: Boolean = false, + val apkInspection: ApkInspection? = null, + val isApkInspectSheetVisible: Boolean = false, + val isApkInspectLoading: Boolean = false, + val isApkInspectCoachmarkPending: Boolean = false, + val isChannelChipCoachmarkPending: Boolean = false, + val showAllPlatforms: Boolean = false, +) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/RawDetailsStateMapper.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/RawDetailsStateMapper.kt new file mode 100644 index 000000000..53fcf778d --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/RawDetailsStateMapper.kt @@ -0,0 +1,118 @@ +package zed.rainxch.details.presentation + +import zed.rainxch.core.domain.model.GithubRelease +import zed.rainxch.core.domain.model.isEffectivelyPreRelease +import zed.rainxch.core.domain.util.VersionMath +import zed.rainxch.details.domain.model.ReleaseCategory + +internal fun RawDetailsState.toView(): DetailsState { + val filteredReleases = when (selectedReleaseCategory) { + ReleaseCategory.STABLE -> allReleases.filter { !it.isEffectivelyPreRelease() } + ReleaseCategory.PRE_RELEASE -> allReleases.filter { it.isEffectivelyPreRelease() } + ReleaseCategory.ALL -> allReleases + } + val latestStableRelease = allReleases + .filter { !it.isEffectivelyPreRelease() } + .maxByOrNull { it.publishedAt } + val canSwitchToStable = computeCanSwitchToStable(latestStableRelease) + val isPendingInstallReady = computeIsPendingInstallReady() + + return DetailsState( + isLoading = isLoading, + isCurrentUserOwner = isCurrentUserOwner, + isRefreshing = isRefreshing, + refreshCooldownUntilEpochMs = refreshCooldownUntilEpochMs, + errorMessage = errorMessage, + userProfile = userProfile, + repository = repository, + primaryAsset = primaryAsset, + installableAssets = installableAssets, + selectedRelease = selectedRelease, + allReleases = allReleases, + releasesLoadFailed = releasesLoadFailed, + isRetryingReleases = isRetryingReleases, + isReleaseSelectorVisible = isReleaseSelectorVisible, + selectedReleaseCategory = selectedReleaseCategory, + isVersionPickerVisible = isVersionPickerVisible, + stats = stats, + readmeMarkdown = readmeMarkdown, + readmeLanguage = readmeLanguage, + installLogs = installLogs, + isDownloading = isDownloading, + downloadProgressPercent = downloadProgressPercent, + downloadedBytes = downloadedBytes, + totalBytes = totalBytes, + isInstalling = isInstalling, + downloadError = downloadError, + installError = installError, + downloadStage = downloadStage, + systemArchitecture = systemArchitecture, + isObtainiumAvailable = isObtainiumAvailable, + isObtainiumEnabled = isObtainiumEnabled, + isInstallDropdownExpanded = isInstallDropdownExpanded, + isAppManagerAvailable = isAppManagerAvailable, + isAppManagerEnabled = isAppManagerEnabled, + installedApp = installedApp, + installedApps = installedApps, + isFavourite = isFavourite, + isStarred = isStarred, + isTrackingApp = isTrackingApp, + isAboutExpanded = isAboutExpanded, + isWhatsNewExpanded = isWhatsNewExpanded, + aboutMeasuredHeightPx = aboutMeasuredHeightPx, + whatsNewMeasuredHeightPx = whatsNewMeasuredHeightPx, + aboutTranslation = aboutTranslation, + whatsNewTranslation = whatsNewTranslation, + isLanguagePickerVisible = isLanguagePickerVisible, + languagePickerTarget = languagePickerTarget, + deviceLanguageCode = deviceLanguageCode, + isComingFromUpdate = isComingFromUpdate, + downgradeWarning = downgradeWarning, + signingKeyWarning = signingKeyWarning, + showExternalInstallerPrompt = showExternalInstallerPrompt, + pendingInstallFilePath = pendingInstallFilePath, + showUninstallConfirmation = showUninstallConfirmation, + showUnlinkConfirmation = showUnlinkConfirmation, + attestationStatus = attestationStatus, + stalledStableSinceDays = stalledStableSinceDays, + mergedChangelog = mergedChangelog, + mergedChangelogBaseTag = mergedChangelogBaseTag, + latestStableHasInstallableAsset = latestStableHasInstallableAsset, + apkInspection = apkInspection, + isApkInspectSheetVisible = isApkInspectSheetVisible, + isApkInspectLoading = isApkInspectLoading, + isApkInspectCoachmarkPending = isApkInspectCoachmarkPending, + isChannelChipCoachmarkPending = isChannelChipCoachmarkPending, + showAllPlatforms = showAllPlatforms, + filteredReleases = filteredReleases, + latestStableRelease = latestStableRelease, + canSwitchToStable = canSwitchToStable, + isPendingInstallReady = isPendingInstallReady, + ) +} + +internal fun RawDetailsState.latestStableRelease(): GithubRelease? = + allReleases + .filter { !it.isEffectivelyPreRelease() } + .maxByOrNull { it.publishedAt } + +private fun RawDetailsState.computeCanSwitchToStable(latestStable: GithubRelease?): Boolean { + val app = installedApp ?: return false + val stable = latestStable ?: return false + if (!latestStableHasInstallableAsset) return false + val installedIsPreRelease = allReleases + .firstOrNull { VersionMath.isSameVersion(it.tagName, app.installedVersion) } + ?.isEffectivelyPreRelease() == true + if (!installedIsPreRelease) return false + return !VersionMath.isSameVersion(stable.tagName, app.installedVersion) +} + +private fun RawDetailsState.computeIsPendingInstallReady(): Boolean { + val app = installedApp ?: return false + val parkedVersion = app.pendingInstallVersion ?: return false + val parkedAsset = app.pendingInstallAssetName ?: return false + if (app.pendingInstallFilePath.isNullOrBlank()) return false + val tag = selectedRelease?.tagName ?: return false + val assetName = primaryAsset?.name ?: return false + return parkedVersion == tag && parkedAsset == assetName +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/about/AboutRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/about/AboutRoot.kt new file mode 100644 index 000000000..a2c71f66c --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/about/AboutRoot.kt @@ -0,0 +1,228 @@ +package zed.rainxch.details.presentation.about + +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.PaddingValues +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.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.mikepenz.markdown.compose.Markdown +import io.ktor.client.HttpClient +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named +import zed.rainxch.core.presentation.components.buttons.IconButton +import zed.rainxch.core.presentation.vocabulary.Squiggle +import zed.rainxch.details.presentation.components.LanguagePicker +import zed.rainxch.details.presentation.components.TranslationCard +import zed.rainxch.details.presentation.markdown.githubStoreMarkdownComponents +import zed.rainxch.details.presentation.utils.MarkdownImageTransformer +import zed.rainxch.details.presentation.utils.rememberMarkdownColors +import zed.rainxch.details.presentation.utils.rememberMarkdownTypography +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.cd_back +import zed.rainxch.githubstore.core.presentation.res.details_about_screen_title + +@Composable +fun AboutRoot( + repositoryId: Long, + owner: String, + repo: String, + sourceHost: String?, + onNavigateBack: () -> Unit, + viewModel: DetailsAboutViewModel = koinViewModel { + parametersOf(repositoryId, owner, repo, sourceHost) + }, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + AboutScreen( + state = state, + onBack = onNavigateBack, + onRetry = viewModel::retry, + onTranslate = viewModel::translate, + onToggleTranslation = viewModel::toggleTranslation, + onPickLanguage = viewModel::showLanguagePicker, + onDismissLanguagePicker = viewModel::dismissLanguagePicker, + onClearTranslation = viewModel::clearTranslation, + ) +} + +@Composable +private fun AboutScreen( + state: DetailsAboutState, + onBack: () -> Unit, + onRetry: () -> Unit, + onTranslate: (String) -> Unit, + onToggleTranslation: () -> Unit, + onPickLanguage: () -> Unit, + onDismissLanguagePicker: () -> Unit, + onClearTranslation: () -> Unit, +) { + val isDark = androidx.compose.foundation.isSystemInDarkTheme() + val probeClient = koinInject(qualifier = named("test")) + val imageTransformer = remember(probeClient) { MarkdownImageTransformer(probeClient) } + val colors = rememberMarkdownColors() + val typography = rememberMarkdownTypography() + val components = remember(isDark, imageTransformer) { + githubStoreMarkdownComponents(imageTransformer, isDark) + } + + val displayedMarkdown = if ( + state.translation.isShowingTranslation && state.translation.translatedText != null + ) { + state.translation.translatedText + } else { + state.readmeMarkdown + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .systemBarsPadding(), + ) { + AboutTopBar(title = state.repoName, onBack = onBack) + when { + state.isLoading -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + + state.errorMessage != null -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.size(8.dp)) + Text( + text = "Retry", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(8.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } + + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item(key = "header") { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stringResource(Res.string.details_about_screen_title), + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 26.sp, + ), + color = MaterialTheme.colorScheme.onBackground, + ) + Squiggle() + state.readmeLanguage?.let { lang -> + Spacer(Modifier.height(4.dp)) + Text( + text = lang, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + item(key = "translation_card") { + Spacer(Modifier.height(4.dp)) + TranslationCard( + state = state.translation, + deviceLanguageCode = state.deviceLanguageCode, + onPickLanguage = onPickLanguage, + onTranslate = onTranslate, + onToggle = onToggleTranslation, + onCancel = onClearTranslation, + ) + } + item(key = "markdown") { + Spacer(Modifier.height(4.dp)) + Markdown( + content = displayedMarkdown, + colors = colors, + typography = typography, + imageTransformer = imageTransformer, + components = components, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + + LanguagePicker( + isVisible = state.isLanguagePickerVisible, + selectedLanguageCode = state.translation.targetLanguageCode ?: state.deviceLanguageCode, + deviceLanguageCode = state.deviceLanguageCode, + onLanguageSelected = { lang -> + onDismissLanguagePicker() + onTranslate(lang.code) + }, + onDismiss = onDismissLanguagePicker, + ) +} + +@Composable +private fun AboutTopBar(title: String, onBack: () -> Unit) { + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.cd_back), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp), + ) + } +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/about/DetailsAboutState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/about/DetailsAboutState.kt new file mode 100644 index 000000000..1d8fba7cb --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/about/DetailsAboutState.kt @@ -0,0 +1,14 @@ +package zed.rainxch.details.presentation.about + +import zed.rainxch.details.presentation.model.TranslationState + +data class DetailsAboutState( + val isLoading: Boolean = true, + val repoName: String = "", + val readmeMarkdown: String = "", + val readmeLanguage: String? = null, + val errorMessage: String? = null, + val deviceLanguageCode: String = "en", + val translation: TranslationState = TranslationState(), + val isLanguagePickerVisible: Boolean = false, +) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/about/DetailsAboutViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/about/DetailsAboutViewModel.kt new file mode 100644 index 000000000..69d734b0a --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/about/DetailsAboutViewModel.kt @@ -0,0 +1,132 @@ +package zed.rainxch.details.presentation.about + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import zed.rainxch.details.domain.repository.DetailsRepository +import zed.rainxch.details.domain.repository.TranslationRepository +import zed.rainxch.details.presentation.model.SupportedLanguages +import zed.rainxch.details.presentation.model.TranslationState + +class DetailsAboutViewModel( + private val repositoryId: Long, + private val owner: String, + private val repo: String, + private val sourceHost: String?, + private val detailsRepository: DetailsRepository, + private val translationRepository: TranslationRepository, +) : ViewModel() { + + private val _state = MutableStateFlow( + DetailsAboutState(deviceLanguageCode = translationRepository.getDeviceLanguageCode()), + ) + val state = _state.asStateFlow() + + init { + load() + } + + fun retry() { + load() + } + + fun translate(targetLanguageCode: String) { + val markdown = _state.value.readmeMarkdown + if (markdown.isBlank()) return + val displayName = SupportedLanguages.all.firstOrNull { it.code == targetLanguageCode }?.displayName + _state.update { + it.copy( + translation = it.translation.copy( + isTranslating = true, + targetLanguageCode = targetLanguageCode, + targetLanguageDisplayName = displayName, + error = null, + ), + ) + } + viewModelScope.launch { + runCatching { + translationRepository.translate(markdown, targetLanguageCode) + }.onSuccess { result -> + _state.update { + it.copy( + translation = it.translation.copy( + isTranslating = false, + translatedText = result.translatedText, + isShowingTranslation = true, + detectedSourceLanguage = result.detectedSourceLanguage, + error = null, + ), + ) + } + }.onFailure { e -> + _state.update { + it.copy( + translation = it.translation.copy( + isTranslating = false, + error = e.message ?: "Translation failed", + ), + ) + } + } + } + } + + fun toggleTranslation() { + _state.update { + it.copy( + translation = it.translation.copy( + isShowingTranslation = !it.translation.isShowingTranslation, + ), + ) + } + } + + fun clearTranslation() { + _state.update { + it.copy(translation = TranslationState()) + } + } + + fun showLanguagePicker() { + _state.update { it.copy(isLanguagePickerVisible = true) } + } + + fun dismissLanguagePicker() { + _state.update { it.copy(isLanguagePickerVisible = false) } + } + + private fun load() { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, errorMessage = null) } + runCatching { + val resolved = if (owner.isNotBlank() && repo.isNotBlank()) { + detailsRepository.getRepositoryByOwnerAndName(owner, repo, sourceHost) + } else { + detailsRepository.getRepositoryById(repositoryId) + } + val readme = detailsRepository.getReadme( + owner = resolved.owner.login, + repo = resolved.name, + defaultBranch = resolved.defaultBranch, + sourceHost = sourceHost, + ) + resolved to readme + }.onSuccess { (resolved, readme) -> + _state.update { + it.copy( + isLoading = false, + repoName = resolved.name, + readmeMarkdown = readme?.first.orEmpty(), + readmeLanguage = readme?.second, + ) + } + }.onFailure { e -> + _state.update { it.copy(isLoading = false, errorMessage = e.message ?: "Failed to load") } + } + } + } +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ApkInspectSheet.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ApkInspectSheet.kt index 80bd0e7b6..b1f43addf 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ApkInspectSheet.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ApkInspectSheet.kt @@ -1,6 +1,10 @@ package zed.rainxch.details.presentation.components +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,11 +32,15 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet +import zed.rainxch.core.presentation.components.overlays.GhsBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.AnnotatedString import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -49,9 +57,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import androidx.compose.ui.draw.clip import zed.rainxch.core.domain.model.ApkInspection import zed.rainxch.core.domain.model.ApkPermission import zed.rainxch.core.domain.model.ProtectionLevel +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.apk_inspect_compatibility import zed.rainxch.githubstore.core.presentation.res.apk_inspect_components @@ -89,12 +99,9 @@ fun ApkInspectSheet( isLoading: Boolean, onDismiss: () -> Unit, ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - ModalBottomSheet( + GhsBottomSheet( onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { when { isLoading -> LoadingState() @@ -136,7 +143,7 @@ private fun EmptyState() { private fun InspectionContent(inspection: ApkInspection) { LazyColumn( modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { item { Header(inspection) } @@ -160,17 +167,20 @@ private fun Header(inspection: ApkInspection) { ) Text( text = inspection.appLabel, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.SemiBold, + ), color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.copyableOnLongPress(inspection.appLabel), ) Text( text = inspection.packageName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, + modifier = Modifier.copyableOnLongPress(inspection.packageName), ) val sourceLabel = when (inspection.source) { ApkInspection.Source.FILE -> stringResource(Res.string.apk_inspect_source_file) @@ -293,17 +303,6 @@ private fun PermissionsSection(permissions: List) { return@InspectSection } - // Per-group expand/collapse state lives in the sheet itself — - // not worth a VM round-trip. NORMAL / UNKNOWN buckets start - // collapsed because they're the long, low-signal lists; the - // spicy DANGEROUS / PRIVILEGED / SIGNATURE groups are open. - // - // Keyed on the permissions reference so opening the sheet for a - // different APK rebuilds the map with that APK's defaults - // (otherwise a previous app's "expanded NORMAL" choice would - // leak forward — fine right now because the sheet is - // recreated per visibility toggle, but cheap insurance against - // a future change that keeps the sheet alive across inspections). val expanded = remember(permissions) { mutableStateMapOf().apply { @@ -378,10 +377,11 @@ private fun PermissionGroupHeader( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = label.uppercase(), - style = MaterialTheme.typography.labelMedium, + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), color = color, - fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f, fill = false), ) Surface( @@ -435,9 +435,7 @@ private fun PermissionRow(permission: ApkPermission) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Demoted to a footnote — small, lower-opacity, monospace. - // The technical name is useful for search and bug reports - // but it shouldn't compete with the human-readable label. + Text( text = permission.name, style = MaterialTheme.typography.labelSmall, @@ -445,6 +443,7 @@ private fun PermissionRow(permission: ApkPermission) { fontFamily = FontFamily.Monospace, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.copyableOnLongPress(permission.name), ) } Surface( @@ -534,21 +533,42 @@ private fun InspectSection( ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(32.dp) + .clip(Radii.chip) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + Spacer(Modifier.width(10.dp)) Text( text = title, - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, ) } - content() + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + content() + } + } } } @@ -567,12 +587,17 @@ private fun InspectRow(label: String, value: String, monospace: Boolean = false) modifier = Modifier.width(120.dp), ) } + val valueModifier = if (monospace) { + Modifier.weight(1f).copyableOnLongPress(value) + } else { + Modifier.weight(1f) + } Text( text = value, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default, - modifier = Modifier.weight(1f), + modifier = valueModifier, ) } } @@ -580,8 +605,9 @@ private fun InspectRow(label: String, value: String, monospace: Boolean = false) @Composable private fun DangerNote(text: String) { Surface( - shape = RoundedCornerShape(12.dp), + shape = Radii.chip, color = MaterialTheme.colorScheme.errorContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.35f)), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -620,6 +646,21 @@ private fun protectionStyle(level: ProtectionLevel): Pair { } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Modifier.copyableOnLongPress(value: String): Modifier { + @Suppress("DEPRECATION") + val clipboard = LocalClipboardManager.current + val haptic = LocalHapticFeedback.current + return this.combinedClickable( + onClick = {}, + onLongClick = { + clipboard.setText(AnnotatedString(value)) + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + ) +} + private fun formatBytes(bytes: Long): String = when { bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/AppHeader.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/AppHeader.kt index ae2c367e1..015d7c995 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/AppHeader.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/AppHeader.kt @@ -1,8 +1,13 @@ package zed.rainxch.details.presentation.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,54 +18,83 @@ 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.filled.Update +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.outlined.AccountTree +import androidx.compose.material.icons.outlined.Star import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.skydoves.landscapist.coil3.CoilImage +import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.GithubUserProfile import zed.rainxch.core.domain.model.InstalledApp -import zed.rainxch.core.domain.util.VersionMath +import zed.rainxch.core.presentation.color.avatarColorFor import zed.rainxch.core.presentation.components.ForkBadge +import zed.rainxch.core.presentation.components.GitHubStoreImage import zed.rainxch.core.presentation.components.OfficialBadge import zed.rainxch.core.presentation.components.PlatformChip -import zed.rainxch.core.presentation.utils.formatReleasedAt +import zed.rainxch.core.presentation.theme.shapes.CornerRadii +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape +import zed.rainxch.core.presentation.utils.formatCount +import zed.rainxch.details.domain.model.RepoStats import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.githubstore.core.presentation.res.Res -import zed.rainxch.githubstore.core.presentation.res.by_author import zed.rainxch.githubstore.core.presentation.res.installed -import zed.rainxch.githubstore.core.presentation.res.installed_version import zed.rainxch.githubstore.core.presentation.res.no_description import zed.rainxch.githubstore.core.presentation.res.pending_install import zed.rainxch.githubstore.core.presentation.res.update_available +private val HeaderShape = WonkySquircleShape( + topStart = CornerRadii(30.dp, 24.dp), + topEnd = CornerRadii(24.dp, 30.dp), + bottomEnd = CornerRadii(28.dp, 22.dp), + bottomStart = CornerRadii(22.dp, 28.dp), +) + +private fun Color.normalizedForStripe(isDark: Boolean): Color { + val r = (red * 255f).toInt().coerceIn(0, 255) + val g = (green * 255f).toInt().coerceIn(0, 255) + val b = (blue * 255f).toInt().coerceIn(0, 255) + val lum = (r + g + b) / 3 + val target = if (isDark) 170 else 140 + val minLum = if (isDark) 90 else 70 + if (lum >= minLum) return this + val factor = target.toFloat() / lum.coerceAtLeast(1).toFloat() + return Color( + red = (r * factor).toInt().coerceIn(0, 255), + green = (g * factor).toInt().coerceIn(0, 255), + blue = (b * factor).toInt().coerceIn(0, 255), + alpha = 255, + ) +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) @Composable fun AppHeader( @@ -68,326 +102,344 @@ fun AppHeader( repository: GithubRepoSummary, release: GithubRelease?, installedApp: InstalledApp?, + stats: RepoStats?, modifier: Modifier = Modifier, downloadStage: DownloadStage = DownloadStage.IDLE, downloadProgress: Int? = null, isCurrentUserOwner: Boolean = false, onPlatformClick: ((DiscoveryPlatform) -> Unit)? = null, + onOwnerClick: () -> Unit = {}, ) { + val isDark = isSystemInDarkTheme() + val surface = MaterialTheme.colorScheme.surface + val avatarUrl = author?.avatarUrl ?: repository.owner.avatarUrl + val rawAccent = avatarColorFor(avatarUrl, MaterialTheme.colorScheme.primary) + val normalizedAccent = remember(rawAccent, isDark) { rawAccent.normalizedForStripe(isDark) } + val tintFraction = if (isDark) 0.10f else 0.06f + val animatedAccent by animateColorAsState( + targetValue = normalizedAccent, + animationSpec = tween(durationMillis = 1800, easing = LinearOutSlowInEasing), + label = "details-hero-accent", + ) + val animatedSurface by animateColorAsState( + targetValue = lerp(surface, normalizedAccent, tintFraction), + animationSpec = tween(durationMillis = 1800, easing = LinearOutSlowInEasing), + label = "details-hero-surface", + ) + val stripeBase = if (isDark) animatedAccent.copy(alpha = 0.18f) else animatedAccent.copy(alpha = 0.12f) + val stripeLineThick = if (isDark) animatedAccent.copy(alpha = 0.45f) else animatedAccent.copy(alpha = 0.55f) + val stripeLineThin = if (isDark) animatedAccent.copy(alpha = 0.22f) else animatedAccent.copy(alpha = 0.30f) + val avatarBg = if (isDark) animatedAccent.copy(alpha = 0.20f) else animatedAccent.copy(alpha = 0.14f) + val borderColor = MaterialTheme.colorScheme.outline + val animatedProgress by animateFloatAsState( targetValue = (downloadProgress ?: 0) / 100f, animationSpec = tween(durationMillis = 500), - label = "avatar_progress_animation", + label = "avatar-progress", ) - val supportedPlatforms by remember(release?.assets) { - derivedStateOf { - derivePlatformsFromAssets(release) + val supportedPlatforms = remember(release?.assets) { + val names = release?.assets?.map { it.name.lowercase() }.orEmpty() + buildList { + if (names.any { it.endsWith(".apk") }) add(DiscoveryPlatform.Android) + if (names.any { it.endsWith(".exe") || it.endsWith(".msi") }) add(DiscoveryPlatform.Windows) + if (names.any { it.endsWith(".dmg") || it.endsWith(".pkg") }) add(DiscoveryPlatform.Macos) + if (names.any { + it.endsWith(".appimage") || + it.endsWith(".deb") || + it.endsWith(".rpm") || + it.endsWith(".pkg.tar.zst") + } + ) add(DiscoveryPlatform.Linux) } } - Column( - modifier = modifier.fillMaxWidth(), + Box( + modifier = modifier + .fillMaxWidth() + .clip(HeaderShape) + .background(animatedSurface) + .border(1.5.dp, borderColor, HeaderShape), ) { - Row( - verticalAlignment = Alignment.Top, - ) { + Column(modifier = Modifier.fillMaxWidth()) { Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(100.dp), - ) { - CoilImage( - // Fall back to `repository.owner.avatarUrl` when no - // user profile was fetched — covers the Forgejo / - // Codeberg path where we skip the GitHub-only - // `/users/{login}` lookup, as well as transient - // profile fetch failures on the GitHub path. - imageModel = { author?.avatarUrl ?: repository.owner.avatarUrl }, - modifier = - Modifier - .size(100.dp) - .clip(CircleShape) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, - shape = CircleShape, - ), - loading = { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularWavyProgressIndicator() + modifier = Modifier + .fillMaxWidth() + .height(76.dp) + .clipToBounds() + .drawBehind { + drawRect(color = stripeBase) + val thick = 9.dp.toPx() + val thin = 2.5.dp.toPx() + val gapAfterThick = 10.dp.toPx() + val gapBetweenThin = 6.dp.toPx() + val cycle = thick + gapAfterThick + thin + gapBetweenThin + thin + gapAfterThick + var x = -size.height + while (x < size.width + size.height) { + drawLine( + color = stripeLineThick, + start = Offset(x, size.height), + end = Offset(x + size.height, 0f), + strokeWidth = thick, + cap = StrokeCap.Round, + ) + var xt = x + thick + gapAfterThick + drawLine( + color = stripeLineThin, + start = Offset(xt, size.height), + end = Offset(xt + size.height, 0f), + strokeWidth = thin, + cap = StrokeCap.Round, + ) + xt += thin + gapBetweenThin + drawLine( + color = stripeLineThin, + start = Offset(xt, size.height), + end = Offset(xt + size.height, 0f), + strokeWidth = thin, + cap = StrokeCap.Round, + ) + x += cycle } }, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 130.dp, end = 20.dp, top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = author?.login ?: repository.owner.login, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 13.sp, + ), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f, fill = false) + .clickable(onClick = onOwnerClick), ) - - if (downloadStage != DownloadStage.IDLE) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(100.dp), - ) { - when (downloadStage) { - DownloadStage.DOWNLOADING -> { - CircularProgressIndicator( - progress = { 1f }, - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - strokeWidth = 4.dp, - ) - - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.primary, - strokeWidth = 4.dp, - strokeCap = StrokeCap.Round, - ) - } - - DownloadStage.VERIFYING, DownloadStage.INSTALLING -> { - CircularProgressIndicator( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.primary, - strokeWidth = 4.dp, - strokeCap = StrokeCap.Round, - ) - } - } - } - } + if (isCurrentUserOwner) OfficialBadge() } - - Spacer(Modifier.width(16.dp)) - - Column( - modifier = Modifier.weight(1f), + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 130.dp, end = 20.dp, top = 2.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.foundation.text.BasicText( + text = repository.name, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Black, + fontSize = 30.sp, + letterSpacing = (-0.4).sp, + color = MaterialTheme.colorScheme.onSurface, + ), + maxLines = 1, + softWrap = false, + autoSize = androidx.compose.foundation.text.TextAutoSize.StepBased( + minFontSize = 18.sp, + maxFontSize = 30.sp, + stepSize = 1.sp, + ), + modifier = Modifier.weight(1f, fill = false), + ) + if (repository.isFork) ForkBadge() + } + Spacer(Modifier.height(10.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), ) { + val statColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f) + val statStyle = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + ) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { + Icon( + imageVector = Icons.Outlined.Star, + contentDescription = null, + tint = statColor, + modifier = Modifier.size(15.dp), + ) Text( - text = repository.name, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), + text = formatCount(stats?.stars ?: repository.stargazersCount), + style = statStyle, + color = statColor, ) - - if (repository.isFork) { - ForkBadge() - } } - author?.login?.let { author -> + val forksValue = stats?.forks ?: repository.forksCount + if (forksValue > 0) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Text( - text = stringResource(Res.string.by_author, author), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), + Icon( + imageVector = Icons.Outlined.AccountTree, + contentDescription = null, + tint = statColor, + modifier = Modifier.size(15.dp), ) - - if (isCurrentUserOwner) { - OfficialBadge() - } - } - } - - Spacer(Modifier.height(8.dp)) - - if (installedApp != null) { - when { - installedApp.isPendingInstall -> { - PendingInstallBadge() - } - - else -> { - InstallStatusBadge( - isUpdateAvailable = installedApp.isUpdateAvailable, - ) - } - } - } - - Spacer(Modifier.height(8.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - release?.tagName?.let { Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary, + text = formatCount(forksValue), + style = statStyle, + color = statColor, ) } - - if (installedApp != null && - !VersionMath.isExactSameVersion(installedApp.installedVersion, release?.tagName) + } + val downloadsValue = stats?.totalDownloads ?: repository.downloadCount + if (downloadsValue > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = null, + tint = statColor, + modifier = Modifier.size(15.dp), + ) Text( - text = - stringResource( - Res.string.installed_version, - installedApp.installedVersion, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = formatCount(downloadsValue), + style = statStyle, + color = statColor, ) } } - - release?.publishedAt?.let { publishedAt -> - Spacer(Modifier.height(4.dp)) - + val licenseValue = stats?.license + if (!licenseValue.isNullOrBlank()) { Text( - text = formatReleasedAt(publishedAt), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, + text = licenseValue, + style = statStyle, + color = statColor, ) } } - } - - if (supportedPlatforms.isNotEmpty()) { Spacer(Modifier.height(12.dp)) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - supportedPlatforms.forEach { platform -> - PlatformChip( - platform = platform, - onClick = onPlatformClick?.let { handler -> { handler(platform) } }, - ) + Text( + text = repository.description ?: stringResource(Res.string.no_description), + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp), + ) + if (installedApp != null) { + Spacer(Modifier.height(12.dp)) + Box(modifier = Modifier.padding(horizontal = 20.dp)) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .border( + width = 1.dp, + color = when { + installedApp.isPendingInstall -> + MaterialTheme.colorScheme.secondary + installedApp.isUpdateAvailable -> + MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.primary + }, + shape = RoundedCornerShape(50), + ) + .padding(horizontal = 12.dp, vertical = 5.dp), + ) { + Text( + text = stringResource( + when { + installedApp.isPendingInstall -> Res.string.pending_install + installedApp.isUpdateAvailable -> Res.string.update_available + else -> Res.string.installed + }, + ), + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ), + color = when { + installedApp.isPendingInstall -> + MaterialTheme.colorScheme.secondary + installedApp.isUpdateAvailable -> + MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.primary + }, + ) + } } } - } - - Spacer(Modifier.height(16.dp)) - - Text( - text = repository.description ?: stringResource(Res.string.no_description), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -private fun derivePlatformsFromAssets(release: GithubRelease?): List { - if (release == null) return emptyList() - val names = release.assets.map { it.name.lowercase() } - return buildList { - if (names.any { it.endsWith(".apk") }) add(DiscoveryPlatform.Android) - if (names.any { it.endsWith(".exe") || it.endsWith(".msi") }) add(DiscoveryPlatform.Windows) - if (names.any { it.endsWith(".dmg") || it.endsWith(".pkg") }) add(DiscoveryPlatform.Macos) - if (names.any { - it.endsWith(".appimage") || - it.endsWith(".deb") || - it.endsWith(".rpm") || - it.endsWith(".pkg.tar.zst") + if (supportedPlatforms.isNotEmpty()) { + Spacer(Modifier.height(14.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 20.dp), + ) { + supportedPlatforms.forEach { platform -> + PlatformChip( + platform = platform, + onClick = onPlatformClick?.let { handler -> { handler(platform) } }, + ) + } + } } - ) { - add( - DiscoveryPlatform.Linux, - ) + Spacer(Modifier.height(20.dp)) } - } -} - -@Composable -fun InstallStatusBadge( - isUpdateAvailable: Boolean, - modifier: Modifier = Modifier, -) { - val backgroundColor = - if (isUpdateAvailable) { - MaterialTheme.colorScheme.tertiaryContainer - } else { - MaterialTheme.colorScheme.primaryContainer - } - - val textColor = - if (isUpdateAvailable) { - MaterialTheme.colorScheme.onTertiaryContainer - } else { - MaterialTheme.colorScheme.onPrimaryContainer - } - - val icon = - if (isUpdateAvailable) { - Icons.Default.Update - } else { - Icons.Default.CheckCircle - } - - val text = - if (isUpdateAvailable) { - stringResource(Res.string.update_available) - } else { - stringResource(Res.string.installed) - } - - Surface( - modifier = modifier, - shape = RoundedCornerShape(12.dp), - color = backgroundColor, - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + Box( + modifier = Modifier + .align(Alignment.TopStart) + .offset(x = 20.dp, y = 30.dp) + .size(100.dp), + contentAlignment = Alignment.Center, ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = textColor, - ) - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - color = textColor, - fontWeight = FontWeight.SemiBold, - ) - } - } -} - -@Composable -fun PendingInstallBadge(modifier: Modifier = Modifier) { - Surface( - modifier = modifier, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = Icons.Default.Schedule, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) - Text( - text = stringResource(Res.string.pending_install), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - fontWeight = FontWeight.SemiBold, - ) + Box( + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background(avatarBg) + .border(2.5.dp, animatedAccent, CircleShape), + contentAlignment = Alignment.Center, + ) { + GitHubStoreImage( + imageModel = { avatarUrl }, + modifier = Modifier.size(92.dp).clip(CircleShape), + extractDominantFor = avatarUrl, + ) + } + if (downloadStage != DownloadStage.IDLE) { + when (downloadStage) { + DownloadStage.DOWNLOADING -> { + CircularProgressIndicator( + progress = { 1f }, + modifier = Modifier.fillMaxSize(), + color = animatedAccent.copy(alpha = 0.2f), + strokeWidth = 4.dp, + ) + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxSize(), + color = animatedAccent, + strokeWidth = 4.dp, + strokeCap = StrokeCap.Round, + ) + } + DownloadStage.VERIFYING, DownloadStage.INSTALLING -> { + CircularProgressIndicator( + modifier = Modifier.fillMaxSize(), + color = animatedAccent, + strokeWidth = 4.dp, + strokeCap = StrokeCap.Round, + ) + } + } + } } } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/InspectApkButton.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/InspectApkButton.kt index 1e99e1f87..ccb77359e 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/InspectApkButton.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/InspectApkButton.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon @@ -29,7 +30,9 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -37,8 +40,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import org.jetbrains.compose.resources.stringResource @@ -48,15 +53,6 @@ import zed.rainxch.githubstore.core.presentation.res.apk_inspect_coachmark_body import zed.rainxch.githubstore.core.presentation.res.apk_inspect_coachmark_dismiss import zed.rainxch.githubstore.core.presentation.res.apk_inspect_coachmark_title -/** - * Discoverable entry point for the APK Inspect sheet. - * - * Renders a 52dp circular icon button next to the install button. When - * [showCoachmark] is true, the button does a slow pulse + tilt and a - * tooltip-style coachmark renders above the icon, anchored with an - * arrow. The coachmark is one-shot per user — tapping it (or the - * button) dismisses and persists via [onCoachmarkDismiss]. - */ @Composable fun InspectApkButton( showCoachmark: Boolean, @@ -122,7 +118,7 @@ private fun rememberTilt(active: Boolean) = private fun Coachmark(onDismiss: () -> Unit) { Popup( alignment = Alignment.TopEnd, - // Render above the button. Negative Y offset moves the popup up. + offset = androidx.compose.ui.unit.IntOffset(x = 0, y = -260), properties = PopupProperties( focusable = false, @@ -133,14 +129,14 @@ private fun Coachmark(onDismiss: () -> Unit) { ) { Column(horizontalAlignment = Alignment.End) { Surface( - shape = RoundedCornerShape(16.dp), + shape = WonkySquircleShape.Toast, color = MaterialTheme.colorScheme.primary, shadowElevation = 6.dp, modifier = Modifier.width(260.dp), ) { Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -154,9 +150,11 @@ private fun Coachmark(onDismiss: () -> Unit) { ) Text( text = stringResource(Res.string.apk_inspect_coachmark_title), - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ), color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold, ) } Text( @@ -165,20 +163,20 @@ private fun Coachmark(onDismiss: () -> Unit) { color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.9f), ) Row( - modifier = Modifier.padding(top = 4.dp).fillMaxWidthOnly(), + modifier = Modifier.padding(top = 2.dp).fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - TextButton(onClick = onDismiss) { - Text( - text = stringResource(Res.string.apk_inspect_coachmark_dismiss), - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.SemiBold, - ) - } + GhsButton( + onClick = onDismiss, + label = stringResource(Res.string.apk_inspect_coachmark_dismiss), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + contentColorOverride = MaterialTheme.colorScheme.onPrimary, + ) } } } - // Triangle arrow pointing down at the icon button. + Box( modifier = Modifier .padding(end = 24.dp) @@ -189,8 +187,6 @@ private fun Coachmark(onDismiss: () -> Unit) { } } -private fun Modifier.fillMaxWidthOnly(): Modifier = this.fillMaxWidth() - private fun Modifier.arrowDown(color: androidx.compose.ui.graphics.Color): Modifier = this.fillMaxSize().background( color = color, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt index 01e067779..28eca32b2 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LanguagePicker.kt @@ -24,8 +24,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField +import zed.rainxch.core.presentation.components.inputs.GhsTextField +import zed.rainxch.core.presentation.components.overlays.GhsBottomSheet +import zed.rainxch.core.presentation.vocabulary.Squiggle import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -54,7 +55,6 @@ fun LanguagePicker( ) { if (!isVisible) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) var searchQuery by remember { mutableStateOf("") } val deviceLanguage = remember(deviceLanguageCode) { @@ -74,39 +74,29 @@ fun LanguagePicker( } } - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .navigationBarsPadding(), - ) { + GhsBottomSheet(onDismissRequest = onDismiss) { + Column(modifier = Modifier.fillMaxWidth()) { Text( text = stringResource(Res.string.translate_to), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = 6.dp), ) + Squiggle() + Spacer(Modifier.height(8.dp)) - OutlinedTextField( + GhsTextField( value = searchQuery, onValueChange = { searchQuery = it }, - placeholder = { Text(stringResource(Res.string.search_language)) }, - leadingIcon = { - Icon(Icons.Default.Search, contentDescription = null) - }, + placeholder = stringResource(Res.string.search_language), + leadingIcon = Icons.Default.Search, singleLine = true, - shape = RoundedCornerShape(12.dp), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) - // Device language shortcut — only shown when not searching if (searchQuery.isBlank() && deviceLanguage != null) { Row( modifier = diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LinkedRepoBanner.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LinkedRepoBanner.kt index f086887b8..6b0dbb89e 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LinkedRepoBanner.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/LinkedRepoBanner.kt @@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.RoundedCornerShape +import zed.rainxch.core.presentation.theme.tokens.Radii import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.outlined.Link @@ -16,13 +18,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.details_linked_repo_banner_body import zed.rainxch.githubstore.core.presentation.res.details_linked_repo_banner_title @@ -37,8 +41,9 @@ fun LinkedRepoBanner( ) { Surface( modifier = modifier, - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { Row( modifier = Modifier.padding(start = 12.dp, top = 8.dp, end = 4.dp, bottom = 8.dp), @@ -72,15 +77,13 @@ fun LinkedRepoBanner( Spacer(Modifier.width(8.dp)) - TextButton(onClick = onUnlink) { - Icon( - imageVector = Icons.Default.LinkOff, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text(text = stringResource(Res.string.details_unlink_external_app_dialog_confirm)) - } + GhsButton( + onClick = onUnlink, + label = stringResource(Res.string.details_unlink_external_app_dialog_confirm), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + leadingIcon = Icons.Default.LinkOff, + ) } } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt index 962c57d34..d7ea951ab 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleaseAssetsPicker.kt @@ -1,14 +1,16 @@ package zed.rainxch.details.presentation.components +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -21,21 +23,20 @@ import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.outlined.Devices import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.contentColorFor -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.sp import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -57,6 +58,13 @@ import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubUser import zed.rainxch.core.domain.util.AssetVariant +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.overlays.GhsBottomSheet +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.vocabulary.Squiggle import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.githubstore.core.presentation.res.Res @@ -76,10 +84,7 @@ fun ReleaseAssetsPicker( showAllPlatforms: Boolean = false, crossPlatformAssets: List = emptyList(), ) { - // Decouple from `showAllPlatforms`: the toggle lives INSIDE the sheet, - // so disabling the open-card whenever the current branch is empty - // would lock the user out of flipping the setting back. Picker stays - // openable whenever either source has anything to show. + val isPickerEnabled by remember(assetsList, crossPlatformAssets) { derivedStateOf { assetsList.isNotEmpty() || crossPlatformAssets.isNotEmpty() @@ -110,38 +115,47 @@ fun ReleaseAssetsPicker( ) { Text( text = stringResource(Res.string.assets_title), - style = MaterialTheme.typography.labelLargeEmphasized, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ), color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(horizontal = 4.dp), ) - OutlinedCard( - onClick = { onAction(DetailsAction.ToggleReleaseAssetsPicker) }, - enabled = isPickerEnabled, - modifier = Modifier.fillMaxWidth(), + Row( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = Radii.row, + ) + .background(MaterialTheme.colorScheme.surface) + .clickable(enabled = isPickerEnabled) { + onAction(DetailsAction.ToggleReleaseAssetsPicker) + } + .padding(horizontal = 14.dp, vertical = 10.dp) + .heightIn(min = 36.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - .heightIn(min = 36.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = selectedAsset?.name ?: stringResource(Res.string.no_assets_selected), - style = MaterialTheme.typography.titleSmall, + Text( + text = selectedAsset?.name ?: stringResource(Res.string.no_assets_selected), + style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.SemiBold, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Icons.Default.UnfoldMore, - contentDescription = stringResource(Res.string.select_version), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + ), + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.Default.UnfoldMore, + contentDescription = stringResource(Res.string.select_version), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) } } } @@ -164,7 +178,6 @@ private fun ReleaseAssetsItemsPicker( ) { if (!showPicker) return - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) var showInfoDialog by rememberSaveable { mutableStateOf(false) } ReleaseAssetsAboutDialog( @@ -172,36 +185,29 @@ private fun ReleaseAssetsItemsPicker( onDismiss = { showInfoDialog = false }, ) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - modifier = modifier, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .navigationBarsPadding(), - ) { + GhsBottomSheet(onDismissRequest = onDismiss, modifier = modifier) { + Column(modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(Res.string.assets_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = - Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .weight(1f), - ) + Column(modifier = Modifier.weight(1f).padding(vertical = 6.dp)) { + Text( + text = stringResource(Res.string.assets_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Squiggle() + } IconButton(onClick = { showInfoDialog = true }) { - Icon(imageVector = Icons.Outlined.Info, contentDescription = stringResource(Res.string.icon_content_description_info)) + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = stringResource(Res.string.icon_content_description_info), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - // "Pinned to: … [Unpin]" hint, only when the user actually - // has a pin. Surfaces both the current pin and a one-tap - // unpin affordance — the only place in the app where a pin - // can be removed without picking a different one. if (!pinnedVariant.isNullOrBlank()) { Row( modifier = @@ -216,53 +222,48 @@ private fun ReleaseAssetsItemsPicker( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) - androidx.compose.material3.TextButton(onClick = onUnpin) { - Text(stringResource(Res.string.variant_picker_unpin)) - } + GhsButton( + onClick = onUnpin, + label = stringResource(Res.string.variant_picker_unpin), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } } - // Cross-platform toggle. Persisted globally — flipping here - // changes every Details screen's picker behaviour for this - // user. Off = current-OS assets only; On = grouped sections - // for Android / Windows / macOS / Linux. - Surface( - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Row( - modifier = - Modifier - .clickable(onClick = { onToggleShowAllPlatforms(!showAllPlatforms) }) - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Devices, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.size(12.dp)) - Text( - text = stringResource(Res.string.show_all_platforms_label), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - androidx.compose.material3.Switch( - checked = showAllPlatforms, - onCheckedChange = onToggleShowAllPlatforms, + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clip(Radii.row) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = Radii.row, ) - } + .background(MaterialTheme.colorScheme.surface) + .clickable(onClick = { onToggleShowAllPlatforms(!showAllPlatforms) }) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Devices, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.size(12.dp)) + Text( + text = stringResource(Res.string.show_all_platforms_label), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + androidx.compose.material3.Switch( + checked = showAllPlatforms, + onCheckedChange = onToggleShowAllPlatforms, + ) } - // Hoisted out of the LazyListScope below: `LazyColumn { … }` - // body is not a @Composable context, so `remember` calls have - // to live in the enclosing Column instead. val groups = remember(crossPlatformAssets) { crossPlatformAssets .groupBy { @@ -279,16 +280,9 @@ private fun ReleaseAssetsItemsPicker( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - // Grouped path only when the toggle is on AND the release - // has platform-classifiable assets. If toggle is on but - // `assetPlatformOf` rejected every asset (e.g. release - // ships only .zip bundles / extensionless binaries), we - // fall through to the OFF-mode `assetsList` render so - // the user still sees the current-platform installables - // instead of an empty sheet. + if (showAllPlatforms && groups.isNotEmpty()) { - // Order: current-platform section first (it's the - // primary install target), then the others. + val sectionOrder = listOf( zed.rainxch.core.domain.model.DiscoveryPlatform.Android to Res.string.platform_section_android, @@ -358,30 +352,37 @@ private fun ReleaseAssetsAboutDialog( onDismiss: () -> Unit, modifier: Modifier = Modifier, properties: DialogProperties = DialogProperties(), - containerColor: Color = AlertDialogDefaults.containerColor, - shape: Shape = AlertDialogDefaults.shape, ) { if (!showDialog) return BasicAlertDialog(onDismissRequest = onDismiss, modifier = modifier, properties = properties) { - Surface( - color = containerColor, - contentColor = contentColorFor(containerColor), - shape = shape, + Box( + modifier = Modifier + .clip(WonkySquircleShape.Dialog) + .background(MaterialTheme.colorScheme.surface) + .border( + width = 1.5.dp, + color = MaterialTheme.colorScheme.outline, + shape = WonkySquircleShape.Dialog, + ) + .padding(24.dp), ) { - Column( - modifier = Modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + Column { Text( text = stringResource(Res.string.multiple_assets_info_dialog_title), - style = MaterialTheme.typography.headlineSmall, - color = AlertDialogDefaults.titleContentColor, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + ), + color = MaterialTheme.colorScheme.onSurface, ) + Spacer(Modifier.size(6.dp)) + Squiggle() + Spacer(Modifier.size(12.dp)) Text( text = stringResource(Res.string.multiple_assets_info_dialog_text), style = MaterialTheme.typography.bodyMedium, - color = AlertDialogDefaults.textContentColor, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleasesStatusCard.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleasesStatusCard.kt index edbe6a7ed..196b85fe7 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleasesStatusCard.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/ReleasesStatusCard.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.tokens.Radii import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.githubstore.core.presentation.res.Res @@ -42,7 +43,7 @@ fun ReleasesStatusCard( onRetry: () -> Unit, modifier: Modifier = Modifier, ) { - OutlinedCard(modifier = modifier.fillMaxWidth()) { + OutlinedCard(modifier = modifier.fillMaxWidth(), shape = Radii.row) { Column( modifier = Modifier diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt index e411a70e2..040b6a1a0 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt @@ -1,51 +1,56 @@ package zed.rainxch.details.presentation.components import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Update import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.Warning -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubAsset -import zed.rainxch.core.domain.model.GithubUser import zed.rainxch.core.domain.util.VersionMath import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.details.presentation.DetailsState @@ -72,7 +77,10 @@ import zed.rainxch.githubstore.core.presentation.res.updating import zed.rainxch.githubstore.core.presentation.res.verified_build import zed.rainxch.githubstore.core.presentation.res.verifying -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private val ButtonHeight = 56.dp +private val PillCorner = 28.dp +private val InnerCorner = 8.dp + @Composable fun SmartInstallButton( isDownloading: Boolean, @@ -88,502 +96,440 @@ fun SmartInstallButton( val isUpdateAvailable = installedApp?.isUpdateAvailable == true && !installedApp.isPendingInstall - // VersionMath.isExactSameVersion gates the CTA: it requires both - // sides to be present and compares post-prefix-strip strings literally, - // so build-metadata variants (`1.0.0+build.1` vs `1.0.0+build.2`) are - // correctly treated as different versions even though semver-style - // ordering would consider them equal. Reuse the trimmed selected tag - // downstream so the "Install version X" label can never render empty. - val normInstalled = - installedApp?.installedVersion?.trim()?.takeIf { it.isNotBlank() } - val normSelected = - state.selectedRelease?.tagName?.trim()?.takeIf { it.isNotBlank() } - - // Some maintainers tag releases with path-style strings such as - // `com.akylas.documentscanner/android/github/1.21.0/152`. Using the raw - // tag in "Install version X" wraps the CTA across two lines and looks - // broken. `VersionMath.normalizeVersion` already extracts the - // dotted-digit core for these cases (`1.21.0`), so reuse it for the - // *display* tag while leaving the actual install pipeline on the raw - // tag (which has to match GitHub exactly). - val displaySelected = - normSelected?.let { tag -> - VersionMath.normalizeVersion(tag).takeIf { it.isNotBlank() } ?: tag - } - + val normInstalled = installedApp?.installedVersion?.trim()?.takeIf { it.isNotBlank() } + val normSelected = state.selectedRelease?.tagName?.trim()?.takeIf { it.isNotBlank() } + val displaySelected = normSelected?.let { tag -> + VersionMath.normalizeVersion(tag).takeIf { it.isNotBlank() } ?: tag + } val isSameVersionInstalled = isInstalled && normInstalled != null && normSelected != null && VersionMath.isExactSameVersion(normInstalled, normSelected) - val enabled = - remember(primaryAsset, isDownloading, isInstalling) { - primaryAsset != null && !isDownloading && !isInstalling - } - + val enabled = remember(primaryAsset, isDownloading, isInstalling) { + primaryAsset != null && !isDownloading && !isInstalling + } val isActiveDownload = state.isDownloading || state.downloadStage != DownloadStage.IDLE - // When same version is installed, show Open button if (isSameVersionInstalled && !isActiveDownload) { Column(modifier = modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - // Uninstall button - ElevatedCard( - onClick = { onAction(DetailsAction.OnRequestUninstall) }, - modifier = - Modifier - .weight(1f) - .height(52.dp), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - shape = - RoundedCornerShape( - topStart = 24.dp, - bottomStart = 24.dp, - topEnd = 6.dp, - bottomEnd = 6.dp, - ), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - text = stringResource(Res.string.uninstall), - color = MaterialTheme.colorScheme.onErrorContainer, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, - ) - } - } - } - - // Open button - ElevatedCard( - modifier = - Modifier - .weight(1f) - .height(52.dp), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - shape = - RoundedCornerShape( - topStart = 6.dp, - bottomStart = 6.dp, - topEnd = 24.dp, - bottomEnd = 24.dp, - ), - onClick = { - onAction(DetailsAction.OpenApp) - }, - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimary, - ) - Text( - text = stringResource(Res.string.open_app), - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, - ) - } - } - } - } - + InstalledSplitRow( + onUninstall = { onAction(DetailsAction.OnRequestUninstall) }, + onOpenApp = { onAction(DetailsAction.OpenApp) }, + ) AttestationBadge(attestationStatus = state.attestationStatus) } return } - // Regular install/update button for all other cases - val buttonColor = - when { - !enabled && !isActiveDownload -> MaterialTheme.colorScheme.surfaceContainer - isUpdateAvailable -> MaterialTheme.colorScheme.tertiary - isInstalled -> MaterialTheme.colorScheme.secondary - else -> MaterialTheme.colorScheme.primary - } + val accent = when { + !enabled && !isActiveDownload -> MaterialTheme.colorScheme.surfaceContainerHighest + isUpdateAvailable -> MaterialTheme.colorScheme.tertiary + isInstalled -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.primary + } + val onAccent = when { + !enabled && !isActiveDownload -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f) + isUpdateAvailable -> MaterialTheme.colorScheme.onTertiary + isInstalled -> MaterialTheme.colorScheme.onSecondary + else -> MaterialTheme.colorScheme.onPrimary + } - val buttonText = - when { - !enabled && primaryAsset == null -> { - stringResource(Res.string.not_available) - } + val buttonText = when { + !enabled && primaryAsset == null -> stringResource(Res.string.not_available) + state.isPendingInstallReady -> stringResource(Res.string.install_ready) + isUpdateAvailable -> stringResource( + Res.string.update_to_version, + installedApp.latestVersion.toString(), + ) + isInstalled && + normInstalled != null && + normSelected != null && + !VersionMath.isExactSameVersion(normInstalled, normSelected) -> { + stringResource(Res.string.install_version, displaySelected ?: normSelected) + } + normSelected != null && + state.allReleases.firstOrNull()?.tagName?.let { latestTag -> + !VersionMath.isExactSameVersion(latestTag, normSelected) + } == true -> { + stringResource(Res.string.install_version, displaySelected ?: normSelected) + } + else -> stringResource(Res.string.install_latest) + } - // Highest priority: a previously-deferred download is - // already on disk and matches the current selection. - // Tell the user they can install in one tap, no - // re-download. The actual short-circuit lives in - // DetailsViewModel.installAsset. - state.isPendingInstallReady -> { - stringResource(Res.string.install_ready) - } + val hasTrailing = isActiveDownload || state.isObtainiumEnabled - isUpdateAvailable -> { - stringResource( - Res.string.update_to_version, - installedApp.latestVersion.toString(), + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val primaryShape = if (hasTrailing) { + RoundedCornerShape( + topStart = PillCorner, + bottomStart = PillCorner, + topEnd = InnerCorner, + bottomEnd = InnerCorner, ) + } else { + RoundedCornerShape(PillCorner) } + PrimaryAction( + modifier = Modifier.weight(1f), + shape = primaryShape, + accent = accent, + onAccent = onAccent, + enabled = enabled, + isActiveDownload = isActiveDownload, + isUpdateAvailable = isUpdateAvailable, + isInstalled = isInstalled, + buttonText = buttonText, + primaryAsset = primaryAsset, + state = state, + progress = progress, + onClick = { + if (!state.isDownloading && state.downloadStage == DownloadStage.IDLE) { + if (isUpdateAvailable) { + onAction(DetailsAction.UpdateApp) + } else { + onAction(DetailsAction.InstallPrimary) + } + } + }, + ) - isInstalled && - normInstalled != null && - normSelected != null && - !VersionMath.isExactSameVersion(normInstalled, normSelected) -> { - stringResource(Res.string.install_version, displaySelected ?: normSelected) + if (isActiveDownload) { + TrailingActionPill( + container = MaterialTheme.colorScheme.errorContainer, + content = MaterialTheme.colorScheme.onErrorContainer, + icon = Icons.Default.Close, + contentDescription = stringResource(Res.string.cancel_download), + onClick = { onAction(DetailsAction.CancelCurrentDownload) }, + ) + } else if (state.isObtainiumEnabled) { + TrailingActionPill( + container = if (enabled) accent else MaterialTheme.colorScheme.surfaceContainerHighest, + content = onAccent, + icon = Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(Res.string.show_install_options), + onClick = { onAction(DetailsAction.OnToggleInstallDropdown) }, + ) } + } - // Not installed yet, but the user reached for the release picker - // and chose something other than the newest available release — - // the CTA should reflect *which* version will install instead of - // misleading them with "Install latest". The latest tag comes - // from the head of `allReleases` (GitHub returns newest-first - // by `published_at`); fall through to "Install latest" only when - // we genuinely can't tell, or the user is already on the head. - normSelected != null && - state.allReleases.firstOrNull()?.tagName?.let { latestTag -> - !VersionMath.isExactSameVersion(latestTag, normSelected) - } == true -> { - stringResource(Res.string.install_version, displaySelected ?: normSelected) - } + AttestationBadge(attestationStatus = state.attestationStatus) + } +} - else -> { - stringResource(Res.string.install_latest) - } +@Composable +private fun PrimaryAction( + modifier: Modifier, + shape: RoundedCornerShape, + accent: Color, + onAccent: Color, + enabled: Boolean, + isActiveDownload: Boolean, + isUpdateAvailable: Boolean, + isInstalled: Boolean, + buttonText: String, + primaryAsset: GithubAsset?, + state: DetailsState, + progress: Int?, + onClick: () -> Unit, +) { + BoxWithConstraints(modifier = modifier) { + val totalWidthPx = constraints.maxWidth.toFloat().coerceAtLeast(1f) + val pct = progress?.coerceIn(0, 100) ?: 0 + val targetFraction = if (isActiveDownload && state.downloadStage == DownloadStage.DOWNLOADING) { + pct / 100f + } else if (isActiveDownload) { + 1f + } else { + 0f } - - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - ElevatedCard( - modifier = - Modifier - .weight(1f) - .height(52.dp) - .background( - color = buttonColor, - shape = CircleShape, - ).clickable( - enabled = enabled, - onClick = { - if (!state.isDownloading && state.downloadStage == DownloadStage.IDLE) { - if (isUpdateAvailable) { - onAction(DetailsAction.UpdateApp) - } else { - onAction(DetailsAction.InstallPrimary) - } - } - }, - ), - colors = - CardDefaults.elevatedCardColors( - containerColor = buttonColor, - ), - shape = - if (state.isObtainiumEnabled || isActiveDownload) { - RoundedCornerShape( - topStart = 24.dp, - bottomStart = 24.dp, - topEnd = 6.dp, - bottomEnd = 6.dp, - ) - } else { - CircleShape - }, + val animatedFraction by animateFloatAsState( + targetValue = targetFraction, + animationSpec = tween(durationMillis = 350), + label = "install-progress", + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(ButtonHeight) + .clip(shape) + .background(accent.copy(alpha = if (isActiveDownload) 0.35f else 1f)) + .clickable(enabled = enabled, onClick = onClick), ) { + if (isActiveDownload) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(with(androidx.compose.ui.platform.LocalDensity.current) { + (animatedFraction * totalWidthPx).toDp() + }) + .background(accent), + ) + } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { if (isActiveDownload) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - when (state.downloadStage) { - DownloadStage.DOWNLOADING -> { - Text( - text = - if (isUpdateAvailable) { - stringResource(Res.string.updating) - } else { - stringResource( - Res.string.downloading, - ) - }, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold, - ) - - val progressText = - if (state.totalBytes != null && state.totalBytes > 0) { - "${formatFileSize(state.downloadedBytes)} / ${ - formatFileSize( - state.totalBytes, - ) - }" - } else { - "${progress ?: 0}%" - } - Text( - text = progressText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f), - ) - } - - DownloadStage.VERIFYING -> { - Text( - text = stringResource(Res.string.verifying), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold, - ) - } - - DownloadStage.INSTALLING -> { - Text( - text = - if (isUpdateAvailable) { - stringResource(Res.string.updating) - } else { - stringResource( - Res.string.installing, - ) - }, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold, - ) - } - - DownloadStage.IDLE -> {} - } - } + DownloadingLabel( + state = state, + progress = progress, + contentColor = onAccent, + isUpdateAvailable = isUpdateAvailable, + ) } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (isUpdateAvailable) { - Icon( - imageVector = Icons.Default.Update, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onTertiary, - ) - } else if (isInstalled) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSecondary, - ) - } - - Text( - text = buttonText, - color = - if (enabled) { - when { - isUpdateAvailable -> MaterialTheme.colorScheme.onTertiary - isInstalled -> MaterialTheme.colorScheme.onSecondary - else -> MaterialTheme.colorScheme.onPrimary - } - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - }, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - if (primaryAsset != null) { - val assetArch = extractArchitectureFromName(primaryAsset.name) - val systemArch = state.systemArchitecture - val sizeText = formatFileSize(primaryAsset.size) - val archLabel = assetArch ?: systemArch.name.lowercase() - val subtitle = "$archLabel \u2022 $sizeText" - - Spacer(modifier = Modifier.height(2.dp)) - - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = subtitle, - color = - if (enabled) { - when { - isUpdateAvailable -> { - MaterialTheme.colorScheme.onTertiary.copy( - alpha = 0.8f, - ) - } - - isInstalled -> { - MaterialTheme.colorScheme.onSecondary.copy( - alpha = 0.8f, - ) - } - - else -> { - MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) - } - } - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) - }, - style = MaterialTheme.typography.bodySmall, - ) - - if (assetArch != null && - isExactArchitectureMatch( - assetName = primaryAsset.name.lowercase(), - systemArch = systemArch, - ) - ) { - Spacer(modifier = Modifier.width(4.dp)) - - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = stringResource(Res.string.architecture_compatible), - tint = - if (enabled) { - when { - isUpdateAvailable -> { - MaterialTheme.colorScheme.onTertiary.copy( - alpha = 0.8f, - ) - } - - isInstalled -> { - MaterialTheme.colorScheme.onSecondary.copy( - alpha = 0.8f, - ) - } + IdleLabel( + text = buttonText, + enabled = enabled, + contentColor = onAccent, + isUpdateAvailable = isUpdateAvailable, + isInstalled = isInstalled, + primaryAsset = primaryAsset, + state = state, + ) + } + } + } + } +} - else -> { - MaterialTheme.colorScheme.onPrimary.copy( - alpha = 0.8f, - ) - } - } - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) - }, - modifier = Modifier.size(14.dp), - ) - } - } - } - } +@Composable +private fun IdleLabel( + text: String, + enabled: Boolean, + contentColor: Color, + isUpdateAvailable: Boolean, + isInstalled: Boolean, + primaryAsset: GithubAsset?, + state: DetailsState, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val leadingIcon = when { + isUpdateAvailable -> Icons.Default.Update + isInstalled -> Icons.Default.CheckCircle + enabled -> Icons.Default.Download + else -> null + } + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = contentColor, + ) + } + Text( + text = text, + color = contentColor, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 15.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (primaryAsset != null) { + val assetArch = extractArchitectureFromName(primaryAsset.name) + val systemArch = state.systemArchitecture + val sizeText = formatFileSize(primaryAsset.size) + val archLabel = assetArch ?: systemArch.name.lowercase() + val subtitle = "$archLabel · $sizeText" + Spacer(modifier = Modifier.height(2.dp)) + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = subtitle, + color = contentColor.copy(alpha = 0.78f), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), + ) + if (assetArch != null && isExactArchitectureMatch( + assetName = primaryAsset.name.lowercase(), + systemArch = systemArch, + ) + ) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = stringResource(Res.string.architecture_compatible), + tint = contentColor.copy(alpha = 0.78f), + modifier = Modifier.size(12.dp), + ) } } } + } +} + +@Composable +private fun DownloadingLabel( + state: DetailsState, + progress: Int?, + contentColor: Color, + isUpdateAvailable: Boolean, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val label = when (state.downloadStage) { + DownloadStage.DOWNLOADING -> if (isUpdateAvailable) { + stringResource(Res.string.updating) + } else { + stringResource(Res.string.downloading) + } + DownloadStage.VERIFYING -> stringResource(Res.string.verifying) + DownloadStage.INSTALLING -> if (isUpdateAvailable) { + stringResource(Res.string.updating) + } else { + stringResource(Res.string.installing) + } + DownloadStage.IDLE -> "" + } + Text( + text = label, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 15.sp), + color = contentColor, + fontWeight = FontWeight.SemiBold, + ) + if (state.downloadStage == DownloadStage.DOWNLOADING) { + Spacer(Modifier.height(2.dp)) + val progressText = if (state.totalBytes != null && state.totalBytes > 0) { + "${formatFileSize(state.downloadedBytes)} / ${formatFileSize(state.totalBytes)}" + } else { + "${progress ?: 0}%" + } + Text( + text = progressText, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), + color = contentColor.copy(alpha = 0.78f), + ) + } + } +} - if (isActiveDownload) { - IconButton( - onClick = { - onAction(DetailsAction.CancelCurrentDownload) - }, - colors = - IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - modifier = Modifier.size(52.dp), - shape = - RoundedCornerShape( - topStart = 6.dp, - bottomStart = 6.dp, - topEnd = 24.dp, - bottomEnd = 24.dp, - ), +@Composable +private fun TrailingActionPill( + container: Color, + content: Color, + icon: androidx.compose.ui.graphics.vector.ImageVector, + contentDescription: String, + onClick: () -> Unit, +) { + val shape = RoundedCornerShape( + topStart = InnerCorner, + bottomStart = InnerCorner, + topEnd = PillCorner, + bottomEnd = PillCorner, + ) + Box( + modifier = Modifier + .size(width = ButtonHeight, height = ButtonHeight) + .clip(shape) + .background(container) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = content, + modifier = Modifier.size(22.dp), + ) + } +} + +@Composable +private fun InstalledSplitRow( + onUninstall: () -> Unit, + onOpenApp: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val leftShape = RoundedCornerShape( + topStart = PillCorner, + bottomStart = PillCorner, + topEnd = InnerCorner, + bottomEnd = InnerCorner, + ) + val rightShape = RoundedCornerShape( + topStart = InnerCorner, + bottomStart = InnerCorner, + topEnd = PillCorner, + bottomEnd = PillCorner, + ) + Box( + modifier = Modifier + .weight(1f) + .height(ButtonHeight) + .clip(leftShape) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.error.copy(alpha = 0.55f), + shape = leftShape, + ) + .clickable(onClick = onUninstall), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(Res.string.cancel_download), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onErrorContainer, + imageVector = Icons.Outlined.DeleteOutline, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = stringResource(Res.string.uninstall), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 15.sp), ) } - } else if (state.isObtainiumEnabled) { - IconButton( - onClick = { - onAction(DetailsAction.OnToggleInstallDropdown) - }, - colors = - IconButtonDefaults.iconButtonColors( - containerColor = - if (enabled) { - buttonColor - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - ), - modifier = Modifier.size(52.dp), - shape = - RoundedCornerShape( - topStart = 6.dp, - bottomStart = 6.dp, - topEnd = 24.dp, - bottomEnd = 24.dp, - ), + } + Box( + modifier = Modifier + .weight(1f) + .height(ButtonHeight) + .clip(rightShape) + .background(MaterialTheme.colorScheme.primary) + .clickable(onClick = onOpenApp), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = stringResource(Res.string.show_install_options), - modifier = Modifier.size(24.dp), - tint = - if (enabled) { - when { - isUpdateAvailable -> MaterialTheme.colorScheme.onTertiary - isInstalled -> MaterialTheme.colorScheme.onSecondary - else -> MaterialTheme.colorScheme.onPrimary - } - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - }, + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + Text( + text = stringResource(Res.string.open_app), + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 15.sp), ) } } @@ -593,70 +539,81 @@ fun SmartInstallButton( @Composable private fun AttestationBadge(attestationStatus: AttestationStatus) { AnimatedVisibility( - visible = - attestationStatus == AttestationStatus.VERIFIED || - attestationStatus == AttestationStatus.CHECKING || - attestationStatus == AttestationStatus.UNABLE_TO_VERIFY, + visible = attestationStatus == AttestationStatus.VERIFIED || + attestationStatus == AttestationStatus.CHECKING || + attestationStatus == AttestationStatus.UNABLE_TO_VERIFY, enter = fadeIn(), exit = fadeOut(), ) { - Row( - modifier = Modifier.padding(top = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + val (container, content, icon, label) = when (attestationStatus) { + AttestationStatus.VERIFIED -> AttestationVisual( + container = MaterialTheme.colorScheme.tertiaryContainer, + content = MaterialTheme.colorScheme.onTertiaryContainer, + icon = Icons.Filled.VerifiedUser, + label = stringResource(Res.string.verified_build), + ) + AttestationStatus.UNABLE_TO_VERIFY -> AttestationVisual( + container = MaterialTheme.colorScheme.surfaceContainerHigh, + content = MaterialTheme.colorScheme.onSurfaceVariant, + icon = Icons.Outlined.Warning, + label = stringResource(Res.string.unable_to_verify_attestation), + ) + AttestationStatus.CHECKING -> AttestationVisual( + container = MaterialTheme.colorScheme.surfaceContainerHigh, + content = MaterialTheme.colorScheme.onSurfaceVariant, + icon = null, + label = stringResource(Res.string.checking_attestation), + ) + else -> AttestationVisual( + container = Color.Transparent, + content = Color.Transparent, + icon = null, + label = "", + ) + } + Surface( + modifier = Modifier.padding(top = 10.dp), + shape = RoundedCornerShape(50), + color = container, + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), ) { - when (attestationStatus) { - AttestationStatus.CHECKING -> { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (attestationStatus == AttestationStatus.CHECKING) { CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(13.dp), + strokeWidth = 1.6.dp, + color = content, ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(Res.string.checking_attestation), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - AttestationStatus.VERIFIED -> { + } else if (icon != null) { Icon( - imageVector = Icons.Filled.VerifiedUser, + imageVector = icon, contentDescription = null, modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.tertiary, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(Res.string.verified_build), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.tertiary, - fontWeight = FontWeight.SemiBold, + tint = content, ) } - - AttestationStatus.UNABLE_TO_VERIFY -> { - Icon( - imageVector = Icons.Outlined.Warning, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(Res.string.unable_to_verify_attestation), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - else -> {} + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), + color = content, + fontWeight = FontWeight.SemiBold, + ) } } } } +private data class AttestationVisual( + val container: Color, + val content: Color, + val icon: androidx.compose.ui.graphics.vector.ImageVector?, + val label: String, +) + private fun formatFileSize(bytes: Long): String = when { bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0) @@ -664,35 +621,3 @@ private fun formatFileSize(bytes: Long): String = bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) else -> "$bytes B" } - -@Preview -@Composable -fun SmartInstallButtonDownloadingPreview() { - SmartInstallButton( - isDownloading = true, - isInstalling = false, - progress = 45, - primaryAsset = - GithubAsset( - id = 1L, - name = "app-arm64-v8a.apk", - contentType = "application/vnd.android.package-archive", - size = 50_000_000L, - downloadUrl = "https://example.com/app.apk", - uploader = - GithubUser( - id = 1L, - login = "developer", - avatarUrl = "", - htmlUrl = "", - ), - ), - onAction = {}, - state = - DetailsState( - isDownloading = true, - downloadStage = DownloadStage.DOWNLOADING, - downloadProgressPercent = 45, - ), - ) -} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/StatItem.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/StatItem.kt index 86435c416..1d1616ab6 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/StatItem.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/StatItem.kt @@ -1,15 +1,20 @@ package zed.rainxch.details.presentation.components +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.utils.formatCount @Composable fun StatItem( @@ -26,31 +31,32 @@ fun StatItem( stat: Long, modifier: Modifier = Modifier, ) { - OutlinedCard( - modifier = modifier, - colors = - CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), + Column( + modifier = modifier + .clip(Radii.row) + .border(width = 1.dp, color = MaterialTheme.colorScheme.outline, shape = Radii.row) + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 14.dp, vertical = 10.dp), ) { - Column( - modifier = Modifier.padding(12.dp), - ) { - Text( - text = label, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.outline, - maxLines = 1, - softWrap = false, - ) - - Text( - text = formatCount(stat), - style = MaterialTheme.typography.titleLarge, + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + softWrap = false, + ) + Text( + text = formatCount(stat), + style = MaterialTheme.typography.titleLarge.copy( fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onBackground, - ) - } + fontSize = 22.sp, + letterSpacing = (-0.3).sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) } } @@ -60,39 +66,33 @@ fun TextStatItem( value: String, modifier: Modifier = Modifier, ) { - OutlinedCard( - modifier = modifier, - colors = - CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), + Column( + modifier = modifier + .clip(Radii.row) + .border(width = 1.dp, color = MaterialTheme.colorScheme.outline, shape = Radii.row) + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 14.dp, vertical = 10.dp), ) { - Column( - modifier = Modifier.padding(12.dp), - ) { - Text( - text = label, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.outline, - maxLines = 1, - softWrap = false, - ) - - Text( - text = value, - style = MaterialTheme.typography.titleLarge, + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + softWrap = false, + ) + Text( + text = value, + style = MaterialTheme.typography.titleLarge.copy( fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - softWrap = false, - ) - } + fontSize = 18.sp, + letterSpacing = (-0.3).sp, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + softWrap = false, + ) } } - -private fun formatCount(count: Long): String = - when { - count >= 1_000_000 -> String.format("%.1fM", count / 1_000_000.0) - count >= 1_000 -> String.format("%.1fK", count / 1_000.0) - else -> count.toString() - } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationCard.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationCard.kt new file mode 100644 index 000000000..cb9b3032f --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationCard.kt @@ -0,0 +1,306 @@ +package zed.rainxch.details.presentation.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Translate +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.details.presentation.model.SupportedLanguages +import zed.rainxch.details.presentation.model.TranslationState +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.translation_card_cancel +import zed.rainxch.githubstore.core.presentation.res.translation_card_change_language +import zed.rainxch.githubstore.core.presentation.res.translation_card_detected_source +import zed.rainxch.githubstore.core.presentation.res.translation_card_retry +import zed.rainxch.githubstore.core.presentation.res.translation_card_show_original +import zed.rainxch.githubstore.core.presentation.res.translation_card_show_translation +import zed.rainxch.githubstore.core.presentation.res.translation_card_subtitle_idle +import zed.rainxch.githubstore.core.presentation.res.translation_card_subtitle_original +import zed.rainxch.githubstore.core.presentation.res.translation_card_subtitle_translated +import zed.rainxch.githubstore.core.presentation.res.translation_card_subtitle_translating +import zed.rainxch.githubstore.core.presentation.res.translation_card_target_label +import zed.rainxch.githubstore.core.presentation.res.translation_card_title +import zed.rainxch.githubstore.core.presentation.res.translation_card_translate_to + +@Composable +fun TranslationCard( + state: TranslationState, + deviceLanguageCode: String, + onPickLanguage: () -> Unit, + onTranslate: (String) -> Unit, + onToggle: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + val effectiveTargetCode = state.targetLanguageCode ?: deviceLanguageCode + val effectiveTargetName = state.targetLanguageDisplayName + ?: SupportedLanguages.all.firstOrNull { it.code == effectiveTargetCode }?.displayName + ?: effectiveTargetCode + + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Header(state = state, displayName = effectiveTargetName) + + Spacer(Modifier.height(14.dp)) + + TargetLanguageRow( + displayName = effectiveTargetName, + onPick = onPickLanguage, + ) + + Spacer(Modifier.height(12.dp)) + + ActionRow( + state = state, + effectiveTargetCode = effectiveTargetCode, + effectiveTargetName = effectiveTargetName, + onTranslate = onTranslate, + onToggle = onToggle, + onCancel = onCancel, + ) + + if (state.error != null) { + Spacer(Modifier.height(10.dp)) + ErrorRow( + message = state.error, + onRetry = { onTranslate(effectiveTargetCode) }, + ) + } + + if (state.translatedText != null && state.detectedSourceLanguage != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource( + Res.string.translation_card_detected_source, + SupportedLanguages.all.firstOrNull { it.code == state.detectedSourceLanguage }?.displayName + ?: state.detectedSourceLanguage, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun Header(state: TranslationState, displayName: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Translate, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.translation_card_title), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 17.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + val subtitle = when { + state.isTranslating -> stringResource(Res.string.translation_card_subtitle_translating) + state.translatedText != null && state.isShowingTranslation -> + stringResource(Res.string.translation_card_subtitle_translated, displayName) + state.translatedText != null && !state.isShowingTranslation -> + stringResource(Res.string.translation_card_subtitle_original) + else -> stringResource(Res.string.translation_card_subtitle_idle) + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun TargetLanguageRow( + displayName: String, + onPick: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(Res.string.translation_card_target_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onPick), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Language, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + text = displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = stringResource(Res.string.translation_card_change_language), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Composable +private fun ActionRow( + state: TranslationState, + effectiveTargetCode: String, + effectiveTargetName: String, + onTranslate: (String) -> Unit, + onToggle: () -> Unit, + onCancel: () -> Unit, +) { + when { + state.isTranslating -> { + GhsButton( + onClick = onCancel, + label = stringResource(Res.string.translation_card_cancel), + variant = GhsButtonVariant.Outline, + loading = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + state.translatedText != null -> { + GhsButton( + onClick = onToggle, + label = if (state.isShowingTranslation) { + stringResource(Res.string.translation_card_show_original) + } else { + stringResource(Res.string.translation_card_show_translation) + }, + variant = GhsButtonVariant.Outline, + modifier = Modifier.fillMaxWidth(), + ) + } + + else -> { + GhsButton( + onClick = { onTranslate(effectiveTargetCode) }, + label = stringResource( + Res.string.translation_card_translate_to, + effectiveTargetName, + ), + variant = GhsButtonVariant.Primary, + leadingIcon = Icons.Outlined.Translate, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun ErrorRow( + message: String, + onRetry: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Box( + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.error) + .clickable(onClick = onRetry) + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.translation_card_retry), + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onError, + ) + } + } + } +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationControls.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationControls.kt deleted file mode 100644 index a10384310..000000000 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/TranslationControls.kt +++ /dev/null @@ -1,288 +0,0 @@ -package zed.rainxch.details.presentation.components - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.GTranslate -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.details.presentation.model.TranslationState -import zed.rainxch.githubstore.core.presentation.res.* - -@Composable -fun TranslationControls( - translationState: TranslationState, - onTranslateClick: () -> Unit, - onLanguagePickerClick: () -> Unit, - onToggleTranslation: () -> Unit, - modifier: Modifier = Modifier, -) { - AnimatedContent( - targetState = translationState.controlState, - modifier = modifier, - transitionSpec = { - (fadeIn() + slideInHorizontally { it / 3 }) togetherWith - (fadeOut() + slideOutHorizontally { -it / 3 }) - }, - label = "translation_controls", - ) { state -> - when (state) { - TranslationControlState.IDLE -> { - IdleControls( - onTranslateClick = onTranslateClick, - onLanguagePickerClick = onLanguagePickerClick, - ) - } - - TranslationControlState.TRANSLATING -> { - TranslatingIndicator() - } - - TranslationControlState.SHOWING_TRANSLATION -> { - TranslatedControls( - displayName = translationState.targetLanguageDisplayName, - isShowingTranslation = true, - onToggle = onToggleTranslation, - onLanguagePickerClick = onLanguagePickerClick, - ) - } - - TranslationControlState.SHOWING_ORIGINAL -> { - TranslatedControls( - displayName = translationState.targetLanguageDisplayName, - isShowingTranslation = false, - onToggle = onToggleTranslation, - onLanguagePickerClick = onLanguagePickerClick, - ) - } - - TranslationControlState.ERROR -> { - ErrorControls( - onRetry = onTranslateClick, - onLanguagePickerClick = onLanguagePickerClick, - ) - } - } - } -} - -@Composable -private fun IdleControls( - onTranslateClick: () -> Unit, - onLanguagePickerClick: () -> Unit, -) { - Row( - modifier = - Modifier - .clip(RoundedCornerShape(20.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .clickable(onClick = onTranslateClick) - .padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.GTranslate, - contentDescription = stringResource(Res.string.translate), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(5.dp)) - Text( - text = stringResource(Res.string.translate), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - LanguageDropdownButton(onLanguagePickerClick) - } -} - -@Composable -private fun TranslatingIndicator() { - Row( - modifier = - Modifier - .clip(RoundedCornerShape(20.dp)) - .background(MaterialTheme.colorScheme.primaryContainer) - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - Text( - text = stringResource(Res.string.translating), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } -} - -@Composable -private fun TranslatedControls( - displayName: String?, - isShowingTranslation: Boolean, - onToggle: () -> Unit, - onLanguagePickerClick: () -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = - Modifier - .clip(RoundedCornerShape(20.dp)) - .background( - if (isShowingTranslation) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - }, - ).clickable(onClick = onToggle) - .padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - AnimatedVisibility( - visible = isShowingTranslation, - enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut(), - ) { - Row { - Icon( - imageVector = Icons.Default.GTranslate, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(14.dp), - ) - Spacer(Modifier.width(4.dp)) - } - } - - Text( - text = - if (isShowingTranslation) { - stringResource(Res.string.show_original) - } else { - displayName ?: stringResource(Res.string.translate) - }, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium, - color = - if (isShowingTranslation) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - - LanguageDropdownButton( - onClick = onLanguagePickerClick, - tint = - if (isShowingTranslation) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } -} - -@Composable -private fun ErrorControls( - onRetry: () -> Unit, - onLanguagePickerClick: () -> Unit, -) { - Row( - modifier = - Modifier - .clip(RoundedCornerShape(20.dp)) - .background(MaterialTheme.colorScheme.errorContainer) - .clickable(onClick = onRetry) - .padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = stringResource(Res.string.translation_error_retry), - tint = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.size(14.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - text = stringResource(Res.string.translation_error_retry), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - LanguageDropdownButton( - onClick = onLanguagePickerClick, - tint = MaterialTheme.colorScheme.onErrorContainer, - ) - } -} - -@Composable -private fun LanguageDropdownButton( - onClick: () -> Unit, - tint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant, -) { - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = stringResource(Res.string.change_language), - tint = tint, - modifier = - Modifier - .size(24.dp) - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onClick) - .padding(2.dp), - ) -} - -private enum class TranslationControlState { - IDLE, - TRANSLATING, - SHOWING_TRANSLATION, - SHOWING_ORIGINAL, - ERROR, -} - -private val TranslationState.controlState: TranslationControlState - get() = - when { - isTranslating -> TranslationControlState.TRANSLATING - error != null && translatedText == null -> TranslationControlState.ERROR - isShowingTranslation && translatedText != null -> TranslationControlState.SHOWING_TRANSLATION - !isShowingTranslation && translatedText != null -> TranslationControlState.SHOWING_ORIGINAL - else -> TranslationControlState.IDLE - } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt index 68b675c95..b89aae25c 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt @@ -1,5 +1,7 @@ package zed.rainxch.details.presentation.components +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -8,7 +10,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -20,28 +21,29 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.isEffectivelyPreRelease import zed.rainxch.core.domain.model.preReleaseLabel +import zed.rainxch.core.presentation.components.overlays.GhsBottomSheet +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.vocabulary.Squiggle import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.latest_badge @@ -51,7 +53,7 @@ import zed.rainxch.githubstore.core.presentation.res.pre_release_badge import zed.rainxch.githubstore.core.presentation.res.select_version import zed.rainxch.githubstore.core.presentation.res.versions_title -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun VersionPicker( selectedRelease: GithubRelease?, @@ -70,104 +72,102 @@ fun VersionPicker( ) { Text( text = stringResource(Res.string.versions_title), - style = MaterialTheme.typography.labelLargeEmphasized, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ), color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(horizontal = 4.dp), ) - OutlinedCard( - onClick = { onAction(DetailsAction.ToggleVersionPicker) }, - enabled = isPickerEnabled, - modifier = Modifier.fillMaxWidth(), + Row( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = Radii.row, + ) + .background(MaterialTheme.colorScheme.surface) + .clickable(enabled = isPickerEnabled) { + onAction(DetailsAction.ToggleVersionPicker) + } + .padding(horizontal = 14.dp, vertical = 10.dp) + .heightIn(min = 36.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - .heightIn(min = 36.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = - selectedRelease?.tagName - ?: stringResource(Res.string.no_version_selected), - style = MaterialTheme.typography.titleSmall, + Column(modifier = Modifier.weight(1f)) { + Text( + text = selectedRelease?.tagName + ?: stringResource(Res.string.no_version_selected), + style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.SemiBold, - overflow = TextOverflow.Clip, - maxLines = 1, - ) - selectedRelease?.name?.let { name -> - if (name != selectedRelease.tagName) { - Text( - text = name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + ), + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Clip, + maxLines = 1, + ) + selectedRelease?.name?.let { name -> + if (name != selectedRelease.tagName) { + Text( + text = name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } - Icon( - imageVector = Icons.Default.UnfoldMore, - contentDescription = stringResource(Res.string.select_version), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) } + Icon( + imageVector = Icons.Default.UnfoldMore, + contentDescription = stringResource(Res.string.select_version), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) } } if (isPickerVisible) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) - - ModalBottomSheet( - onDismissRequest = { onAction(DetailsAction.ToggleVersionPicker) }, - sheetState = sheetState, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .navigationBarsPadding(), - ) { + GhsBottomSheet(onDismissRequest = { onAction(DetailsAction.ToggleVersionPicker) }) { + Text( + text = stringResource(Res.string.versions_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(vertical = 6.dp), + ) + Squiggle() + Spacer(Modifier.size(8.dp)) + if (filteredReleases.isEmpty()) { Text( - text = stringResource(Res.string.versions_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(Res.string.not_available), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp), ) - - HorizontalDivider() - - if (filteredReleases.isEmpty()) { - Text( - text = stringResource(Res.string.not_available), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - ) - } else { - val latestReleaseId by remember(filteredReleases) { - derivedStateOf { filteredReleases.firstOrNull()?.id } - } - - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(vertical = 8.dp), - ) { - items( - items = filteredReleases, - key = { it.id }, - ) { release -> - VersionListItem( - release = release, - isSelected = release.id == selectedRelease?.id, - isLatest = release.id == latestReleaseId, - onClick = { onAction(DetailsAction.SelectRelease(release)) }, - ) - } + } else { + val latestReleaseId by remember(filteredReleases) { + derivedStateOf { filteredReleases.firstOrNull()?.id } + } + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(items = filteredReleases, key = { it.id }) { release -> + VersionListItem( + release = release, + isSelected = release.id == selectedRelease?.id, + isLatest = release.id == latestReleaseId, + onClick = { onAction(DetailsAction.SelectRelease(release)) }, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + thickness = 0.5.dp, + ) } } } @@ -183,13 +183,13 @@ private fun VersionListItem( onClick: () -> Unit, ) { Row( - modifier = - Modifier - .fillMaxWidth() - .clickable( - onClickLabel = stringResource(Res.string.select_version), - onClick = onClick, - ).padding(horizontal = 16.dp, vertical = 12.dp), + modifier = Modifier + .fillMaxWidth() + .clickable( + onClickLabel = stringResource(Res.string.select_version), + onClick = onClick, + ) + .padding(horizontal = 4.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -200,50 +200,52 @@ private fun VersionListItem( ) { Text( text = release.tagName, - style = MaterialTheme.typography.titleSmall, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = - if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold, + ), + color = if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, ) if (isLatest) { - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.primaryContainer, - ) { - Text( - text = stringResource(Res.string.latest_badge), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } + Text( + text = stringResource(Res.string.latest_badge), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + letterSpacing = 0.6.sp, + ), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(50), + ) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) } if (release.isEffectivelyPreRelease()) { - // Prefer the specific marker ("Beta", "RC", "Alpha"…) - // over the generic "Pre-release" pill — a stronger - // signal for users deciding whether to install. Falls - // back to the generic badge only when the API flag - // marks a release as pre-release but no recognised - // marker is in the tag or name. val specificLabel = release.preReleaseLabel() - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.tertiaryContainer, - ) { - Text( - text = specificLabel ?: stringResource(Res.string.pre_release_badge), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) - } + Text( + text = specificLabel ?: stringResource(Res.string.pre_release_badge), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + letterSpacing = 0.6.sp, + ), + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.tertiary, + shape = RoundedCornerShape(50), + ) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) } } - release.name?.let { name -> if (name != release.tagName) { Text( @@ -255,14 +257,14 @@ private fun VersionListItem( ) } } - Text( text = release.publishedAt.take(10), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - if (isSelected) { Spacer(Modifier.width(8.dp)) Icon( diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionTypePicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionTypePicker.kt index 0808e385f..0500289ba 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionTypePicker.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionTypePicker.kt @@ -4,13 +4,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.chips.FilterChip import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.githubstore.core.presentation.res.Res @@ -31,18 +30,15 @@ fun VersionTypePicker( ) { items(ReleaseCategory.entries) { category -> FilterChip( - selected = category == selectedCategory, + label = stringResource( + when (category) { + ReleaseCategory.STABLE -> Res.string.category_stable + ReleaseCategory.PRE_RELEASE -> Res.string.category_pre_release + ReleaseCategory.ALL -> Res.string.category_all + }, + ), + active = category == selectedCategory, onClick = { onAction(DetailsAction.SelectReleaseCategory(category)) }, - label = { - Text( - text = - when (category) { - ReleaseCategory.STABLE -> stringResource(Res.string.category_stable) - ReleaseCategory.PRE_RELEASE -> stringResource(Res.string.category_pre_release) - ReleaseCategory.ALL -> stringResource(Res.string.category_all) - }, - ) - }, ) } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt index 81b4dd565..f1932191b 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/About.kt @@ -8,7 +8,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.Icon +import androidx.compose.ui.draw.clip import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -34,9 +41,12 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.vocabulary.Squiggle import com.mikepenz.markdown.compose.Markdown import com.mikepenz.markdown.model.ImageTransformer import com.mikepenz.markdown.model.rememberMarkdownState @@ -47,9 +57,7 @@ import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.intellij.markdown.parser.MarkdownParser import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.util.applyThemeAwareImages -import zed.rainxch.details.presentation.components.TranslationControls import zed.rainxch.details.presentation.markdown.githubStoreMarkdownComponents -import zed.rainxch.details.presentation.model.TranslationState import zed.rainxch.details.presentation.utils.MarkdownImageTransformer import zed.rainxch.details.presentation.utils.rememberMarkdownColors import zed.rainxch.details.presentation.utils.rememberMarkdownTypography @@ -65,23 +73,16 @@ fun LazyListScope.about( collapsedHeight: Dp, measuredHeightPx: Float?, onMeasured: (Float) -> Unit, - translationState: TranslationState, - onTranslateClick: () -> Unit, - onLanguagePickerClick: () -> Unit, - onToggleTranslation: () -> Unit, + onReadMore: (() -> Unit)? = null, ) { item { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(20.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -89,11 +90,12 @@ fun LazyListScope.about( ) { Text( text = stringResource(Res.string.about_this_app), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + ), color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold, ) - readmeLanguage?.let { Text( text = it, @@ -102,23 +104,13 @@ fun LazyListScope.about( ) } } - - TranslationControls( - translationState = translationState, - onTranslateClick = onTranslateClick, - onLanguagePickerClick = onLanguagePickerClick, - onToggleTranslation = onToggleTranslation, - ) + Squiggle() } + Spacer(Modifier.height(8.dp)) } item(key = "about_markdown") { - val raw = - if (translationState.isShowingTranslation && translationState.translatedText != null) { - translationState.translatedText - } else { - readmeMarkdown - } + val raw = readmeMarkdown val isDark = androidx.compose.foundation.isSystemInDarkTheme() val probeClient = org.koin.compose.koinInject( qualifier = org.koin.core.qualifier.named("test"), @@ -131,15 +123,13 @@ fun LazyListScope.about( rawMarkdown = raw, isDark = isDark, isExpanded = isExpanded, - onToggleExpanded = onToggleExpanded, + onToggleExpanded = onReadMore ?: onToggleExpanded, imageTransformer = imageTransformer, collapsedHeight = collapsedHeight, measuredHeightPx = measuredHeightPx, onMeasured = onMeasured, fadeColor = MaterialTheme.colorScheme.background, - modifier = - Modifier - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) } } @@ -161,23 +151,10 @@ fun ExpandableMarkdownContent( val colors = rememberMarkdownColors() val typography = rememberMarkdownTypography() - // Pre-process markdown off the main thread. The theme-aware image rewrite - // is regex-heavy; running it inside `remember { ... }` happened on the - // composition thread (typically Main) and contributed to the visible - // freeze on first render and theme toggle. We launch it on Default and - // gate rendering on completion via `fullChunks` being non-null. - // - // We split the full body into ~4 000-char chunks (kept whole around - // code fences). Chunk 0 doubles as the collapsed preview; subsequent - // chunks stream in one frame at a time once the user taps Expand. - // Crucially, chunk 0 stays mounted across collapse → expand, so the - // transition is a height grow rather than a content swap — no flicker. var fullChunks by remember(rawMarkdown, isDark) { mutableStateOf?>(null) } LaunchedEffect(rawMarkdown, isDark) { val processed = withContext(Dispatchers.Default) { - // Theme-aware image substitution first, then split adjacent - // image-link rows into their own paragraphs so badge stacks - // render as block-level images (no inline-overlap). + val themed = applyThemeAwareImages(rawMarkdown, isDark) zed.rainxch.core.domain.util.separateAdjacentImageLinks(themed) } @@ -187,9 +164,6 @@ fun ExpandableMarkdownContent( fullChunks = chunks } - // Parser + flavour are heavy to construct and identical across recompositions; - // hoist them once so they survive content / theme / scroll churn. The Markdown - // lib parses lazily on Dispatchers.Default once handed a fresh MarkdownState. val flavour = remember { GFMFlavourDescriptor() } val parser = remember(flavour) { MarkdownParser(flavour) } val components = remember(isDark, imageTransformer) { @@ -246,56 +220,61 @@ fun ExpandableMarkdownContent( if (!isExpanded && needsExpansion) { Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(80.dp) - .background( - Brush.verticalGradient( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(140.dp) + .background( + Brush.verticalGradient( + colorStops = arrayOf( 0f to fadeColor.copy(alpha = 0f), + 0.35f to fadeColor.copy(alpha = 0.10f), + 0.6f to fadeColor.copy(alpha = 0.35f), + 0.8f to fadeColor.copy(alpha = 0.7f), 1f to fadeColor, ), ), + ), ) } } if (needsExpansion) { - TextButton( - onClick = onToggleExpanded, - modifier = Modifier.align(Alignment.CenterHorizontally), + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.onSurface) + .clickable(onClick = onToggleExpanded) + .padding(horizontal = 22.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = - if (isExpanded) { - stringResource(Res.string.show_less) - } else { - stringResource(Res.string.read_more) - }, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, + text = if (isExpanded) { + stringResource(Res.string.show_less) + } else { + stringResource(Res.string.read_more) + }, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.surface, ) + if (!isExpanded) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.surface, + modifier = Modifier.size(18.dp), + ) + } } } } } -/** - * Renders chunked markdown progressively. Chunk 0 is always mounted - * (used as the collapsed-state preview, clipped by the parent's - * `Modifier.height(collapsedHeight)` modifier). When `isExpanded` flips - * true, subsequent chunks stream in one frame at a time without - * unmounting / remounting chunk 0 — that stable identity is what kills - * the previous expand-time flicker. - * - * Each chunk is its own `Markdown(...)` composable with its own - * `MarkdownState` (parser runs on `Dispatchers.Default` per the - * mikepenz lib). The "one per frame" cadence keeps composition cost - * predictable instead of dropping the entire body's compose pass on - * Main in a single 4-second hit (observed Davey on a kubernetes-sized - * README before this change). - */ @Composable internal fun ProgressiveMarkdown( isExpanded: Boolean, @@ -325,15 +304,11 @@ internal fun ProgressiveMarkdown( return } - // Counter starts at 1 so chunk 0 (the natural preview) renders - // immediately for the collapsed view. Once the user expands, the - // remaining chunks stream in one frame at a time. var renderedCount by remember(rawKey) { mutableStateOf(1) } LaunchedEffect(rawKey, isExpanded, chunks.size) { if (!isExpanded) return@LaunchedEffect while (renderedCount < chunks.size) { - // Yield to the frame so the previously-added chunk has a chance - // to compose + layout + draw before the next chunk arrives. + kotlinx.coroutines.yield() renderedCount++ } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt index 2bf876977..3a00da51d 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt @@ -13,8 +13,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Update -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenu +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -57,12 +57,20 @@ fun LazyListScope.header( release = state.selectedRelease, repository = state.repository, installedApp = state.installedApp, + stats = state.stats, downloadStage = state.downloadStage, downloadProgress = state.downloadProgressPercent, isCurrentUserOwner = state.isCurrentUserOwner, onPlatformClick = { platform -> onAction(DetailsAction.OnPlatformChipClick(platform)) }, + onOwnerClick = { + onAction( + DetailsAction.OpenDeveloperProfile( + state.repository.owner.login, + ), + ) + }, ) } } @@ -80,14 +88,6 @@ fun LazyListScope.header( } } - // Status card replaces the pickers + install button in three cases: - // 1. releases fetch failed — show error + Retry - // 2. retry in flight — show spinner - // 3. repo truly has no releases — show "no releases published" empty state - // Initial page load (isLoading) is intentionally excluded — the top-level - // loading spinner covers it, no need to double up. Same for repository - // not loaded yet: the release-specific states only make sense once we - // know the repo exists (matches the VM's retryReleases() guard). val releasesStatus: ReleasesStatus? = when { state.repository == null -> null @@ -106,7 +106,7 @@ fun LazyListScope.header( ) } } else { - // versions type list + if (state.allReleases.isNotEmpty()) { item { VersionTypePicker( @@ -117,7 +117,6 @@ fun LazyListScope.header( } } - // version and installable release if (state.allReleases.isNotEmpty() || state.installableAssets.isNotEmpty()) { item { Row( @@ -125,9 +124,7 @@ fun LazyListScope.header( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Memoize the platform-classifiable subset of release - // assets so each Header recompose (scroll, animation, - // unrelated state change) doesn't re-run the filter. + val crossPlatformAssets = androidx.compose.runtime.remember(state.selectedRelease) { state.selectedRelease @@ -138,10 +135,7 @@ fun LazyListScope.header( } .orEmpty() } - // Refresh pinned label from the currently matched asset - // — stored value may carry a stale qualifier-counter - // prefix (e.g. `beta.24-arm64-v8a`) from when the pin - // was first set. Issue #612. + val pinnedVariantLabel = state.installedApp?.preferredAssetVariant?.let { stored -> state.primaryAsset?.name?.let { name -> @@ -171,16 +165,9 @@ fun LazyListScope.header( } item { - // Inspect button only surfaces once the package is genuinely - // installed on device (`isReallyInstalled()` filters out - // pending-install rows whose `installedApp` is non-null but - // the system hasn't confirmed the install). This avoids - // popping the icon in at the exact frame the system install - // prompt appears, which is the user's peak-attention moment. + val canInspectApk = state.installedApp?.isReallyInstalled() == true - // Even when visible, the coachmark animation only fires - // during a calm moment — never while a download or install - // is mid-flight, never with the inspect sheet already open. + val coachmarkActive = state.isApkInspectCoachmarkPending && canInspectApk && @@ -216,96 +203,48 @@ fun LazyListScope.header( } } - DropdownMenu( + GhsDropdownMenu( expanded = state.isInstallDropdownExpanded, onDismissRequest = { onAction(DetailsAction.OnToggleInstallDropdown) }, offset = DpOffset(x = 0.dp, y = 20.dp), ) { - DropdownMenuItem( - text = { - Column { - Text( - text = stringResource(Res.string.open_in_obtainium), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.obtainium_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - onClick = { - onAction(DetailsAction.OpenInObtainium) - }, + GhsDropdownMenuItem( + text = stringResource(Res.string.open_in_obtainium), + subtitle = stringResource(Res.string.obtainium_description), leadingIcon = { Icon( imageVector = Icons.Default.Update, contentDescription = null, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(20.dp), ) }, + onClick = { onAction(DetailsAction.OpenInObtainium) }, ) - - Spacer(Modifier.height(8.dp)) - - DropdownMenuItem( - text = { - Column { - Text( - text = stringResource(Res.string.inspect_with_appmanager), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.appmanager_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - onClick = { - onAction(DetailsAction.OpenInAppManager) - }, + GhsDropdownMenuItem( + text = stringResource(Res.string.inspect_with_appmanager), + subtitle = stringResource(Res.string.appmanager_description), leadingIcon = { Icon( imageVector = Icons.Default.Security, contentDescription = null, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(20.dp), ) }, + onClick = { onAction(DetailsAction.OpenInAppManager) }, ) - - Spacer(Modifier.height(8.dp)) - - DropdownMenuItem( - text = { - Column { - Text( - text = stringResource(Res.string.open_with_external_installer), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.external_installer_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - onClick = { - onAction(DetailsAction.InstallWithExternalApp) - }, + GhsDropdownMenuItem( + text = stringResource(Res.string.open_with_external_installer), + subtitle = stringResource(Res.string.external_installer_description), leadingIcon = { Icon( imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(20.dp), ) }, + onClick = { onAction(DetailsAction.InstallWithExternalApp) }, ) } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Logs.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Logs.kt index 1acc74ae6..abd4ffa54 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Logs.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Logs.kt @@ -1,16 +1,22 @@ package zed.rainxch.details.presentation.components.sections +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.theme.geistMono +import zed.rainxch.core.presentation.vocabulary.Squiggle import zed.rainxch.details.presentation.DetailsState import zed.rainxch.details.presentation.model.LogResult import zed.rainxch.details.presentation.utils.asText @@ -18,22 +24,25 @@ import zed.rainxch.githubstore.core.presentation.res.* fun LazyListScope.logs(state: DetailsState) { item { - HorizontalDivider() - - Text( - text = stringResource(Res.string.install_logs), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(vertical = 8.dp), - fontWeight = FontWeight.Bold, - ) + Spacer(Modifier.height(20.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResource(Res.string.install_logs), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + ), + color = MaterialTheme.colorScheme.onBackground, + ) + Squiggle() + } } - // `timeIso` is second-precision, so the same APK installed twice in - // one second would collide on a (timeIso + assetName) composite key. - // Including the list index makes the key unconditionally unique while - // still cheaper than no-key (kept stable across recompositions of the - // same list shape). itemsIndexed( items = state.installLogs, key = { index, log -> "$index|${log.timeIso}|${log.assetName}" }, @@ -41,16 +50,15 @@ fun LazyListScope.logs(state: DetailsState) { ) { _, log -> Text( text = "> ${log.result.asText()}: ${log.assetName}", - style = - MaterialTheme.typography.labelSmall.copy( - fontStyle = FontStyle.Italic, - ), - color = - if (log.result is LogResult.Error) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.outline - }, + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = geistMono, + fontSize = 11.sp, + ), + color = if (log.result is LogResult.Error) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.outline + }, ) } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Owner.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Owner.kt index 382e66a7e..38f07b176 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Owner.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Owner.kt @@ -1,5 +1,7 @@ package zed.rainxch.details.presentation.components.sections +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -7,6 +9,7 @@ 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 @@ -14,154 +17,189 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material3.CardDefaults +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.skydoves.landscapist.coil3.CoilImage import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubUserProfile +import zed.rainxch.core.presentation.theme.shapes.CornerRadii +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape +import zed.rainxch.core.presentation.vocabulary.Squiggle import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.githubstore.core.presentation.res.* +private val DeveloperCardShape = WonkySquircleShape( + topStart = CornerRadii(26.dp, 20.dp), + topEnd = CornerRadii(20.dp, 26.dp), + bottomEnd = CornerRadii(26.dp, 20.dp), + bottomStart = CornerRadii(20.dp, 26.dp), +) + @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.author( author: GithubUserProfile?, onAction: (DetailsAction) -> Unit, ) { item { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(20.dp)) - Text( - text = stringResource(Res.string.author), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(bottom = 12.dp), - fontWeight = FontWeight.Bold, - ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResource(Res.string.details_developer_section), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + ), + color = MaterialTheme.colorScheme.onBackground, + ) + Squiggle() + } - OutlinedCard( - onClick = { - author?.login?.let { author -> - onAction( - DetailsAction.OpenDeveloperProfile( - author, - ), - ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(DeveloperCardShape) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = DeveloperCardShape, + ) + .background(MaterialTheme.colorScheme.surface) + .clickable(enabled = author?.login != null) { + author?.login?.let { login -> + onAction(DetailsAction.OpenDeveloperProfile(login)) + } } - }, - colors = - CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - shape = RoundedCornerShape(32.dp), + .padding(horizontal = 14.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier.padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, + CoilImage( + imageModel = { author?.avatarUrl }, + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .border(2.dp, MaterialTheme.colorScheme.primary, CircleShape), + loading = { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularWavyProgressIndicator() } + }, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), ) { - CoilImage( - imageModel = { author?.avatarUrl }, - modifier = - Modifier - .size(80.dp) - .clip(CircleShape), - loading = { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularWavyProgressIndicator() - } - }, - ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - author?.login?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, + val displayName = author?.name?.takeIf { it.isNotBlank() } ?: author?.login + val handle = author?.login?.takeIf { it != author.name } + displayName?.let { + androidx.compose.foundation.text.BasicText( + text = it, + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Black, + fontSize = 22.sp, + letterSpacing = (-0.3).sp, color = MaterialTheme.colorScheme.onSurface, - ) - } - - author?.bio?.let { bio -> - Text( - text = bio, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline, - maxLines = 2, - softWrap = false, - overflow = TextOverflow.Ellipsis, - ) - } - - Spacer(Modifier.height(4.dp)) - - author?.htmlUrl?.let { - Row( - modifier = - Modifier.clickable { - onAction(DetailsAction.OpenAuthorInBrowser) - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - painter = painterResource(Res.drawable.ic_github), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - - Text( - text = stringResource(Res.string.profile), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - } + ), + maxLines = 1, + softWrap = false, + autoSize = androidx.compose.foundation.text.TextAutoSize.StepBased( + minFontSize = 15.sp, + maxFontSize = 22.sp, + stepSize = 1.sp, + ), + ) } - - author?.login?.let { author -> - IconButton( - onClick = { - onAction(DetailsAction.OpenDeveloperProfile(author)) + handle?.let { login -> + Text( + text = "@$login", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + author?.bio?.let { bio -> + Text( + text = bio, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + softWrap = true, + overflow = TextOverflow.Ellipsis, + ) + } + if (!author?.htmlUrl.isNullOrBlank()) { + Spacer(Modifier.height(2.dp)) + Row( + modifier = Modifier.clickable { + onAction(DetailsAction.OpenAuthorInBrowser) }, - colors = - IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurface, - ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = stringResource(Res.string.open_developer_profile), - modifier = Modifier.size(24.dp), + painter = painterResource(Res.drawable.ic_github), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(Res.string.profile), + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.primary, ) } } } + if (author?.login != null) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.onSurface) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringResource(Res.string.details_view_developer_profile), + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.surface, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.surface, + ) + } + } } } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReleaseChannel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReleaseChannel.kt index 0a116801c..e1b47def3 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReleaseChannel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReleaseChannel.kt @@ -30,7 +30,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -43,6 +45,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.details.presentation.DetailsState import zed.rainxch.githubstore.core.presentation.res.Res @@ -57,20 +61,6 @@ import zed.rainxch.githubstore.core.presentation.res.stalled_project_warning_day import zed.rainxch.githubstore.core.presentation.res.stalled_project_warning_description import zed.rainxch.githubstore.core.presentation.res.stalled_project_warning_months -/** - * Release-channel UX bundle for the Details screen - * (GitHub-Store release UX #2, #3, #4, #6): - * - Inline chip to toggle per-app pre-release channel. - * - "Switch to stable vX.Y.Z" chip when user is on a pre-release - * and a stable is available. - * - Stalled-project warning card when the latest stable is old - * but pre-releases are still flowing. - * - Merged "What's changed since v…" card that concatenates - * release notes across every version the user has skipped. - * - * All four are additive — nothing renders when the app isn't - * tracked or when the corresponding signal is absent. - */ fun LazyListScope.releaseChannel( state: DetailsState, onAction: (DetailsAction) -> Unit, @@ -107,9 +97,7 @@ fun LazyListScope.releaseChannel( ChannelChip( label = channelLabel, icon = Icons.Default.Science, - // Visually signal the "hot" channel when the user - // has opted into betas; keep it muted when they're - // on the default stable-only track. + tint = if (includeBetas) { MaterialTheme.colorScheme.tertiary @@ -117,12 +105,7 @@ fun LazyListScope.releaseChannel( MaterialTheme.colorScheme.onSurfaceVariant }, onClick = { onAction(DetailsAction.ToggleIncludeBetas) }, - // Mirror the visible label so screen readers hear - // the current channel ("Include betas" / "Stable - // only") instead of the previous static - // "Toggle beta releases for this app" string, - // which gave no indication of which side the - // toggle is currently on. + contentDescriptionText = channelLabel, ) if (state.isChannelChipCoachmarkPending) { @@ -166,15 +149,15 @@ fun LazyListScope.releaseChannel( Card( colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f), contentColor = MaterialTheme.colorScheme.onErrorContainer, ), - shape = RoundedCornerShape(12.dp), + shape = Radii.row, modifier = Modifier.fillMaxWidth(), ) { Row( verticalAlignment = Alignment.Top, - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), ) { Icon( imageVector = Icons.Default.WarningAmber, @@ -208,10 +191,10 @@ fun LazyListScope.releaseChannel( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, contentColor = MaterialTheme.colorScheme.onSurface, ), - shape = RoundedCornerShape(12.dp), + shape = Radii.row, modifier = Modifier.fillMaxWidth(), ) { - Column(modifier = Modifier.padding(12.dp)) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.Default.Bolt, @@ -311,14 +294,14 @@ private fun ChannelChipCoachmark(onDismiss: () -> Unit) { onDismissRequest = onDismiss, ) { Surface( - shape = RoundedCornerShape(16.dp), + shape = WonkySquircleShape.Toast, color = MaterialTheme.colorScheme.primary, shadowElevation = 6.dp, modifier = Modifier.width(280.dp), ) { Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -346,13 +329,13 @@ private fun ChannelChipCoachmark(onDismiss: () -> Unit) { modifier = Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.End, ) { - TextButton(onClick = onDismiss) { - Text( - text = stringResource(Res.string.channel_chip_coachmark_dismiss), - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.SemiBold, - ) - } + GhsButton( + onClick = onDismiss, + label = stringResource(Res.string.channel_chip_coachmark_dismiss), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + contentColorOverride = MaterialTheme.colorScheme.onPrimary, + ) } } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReportIssue.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReportIssue.kt index 05ce2de9b..deeea7768 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReportIssue.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReportIssue.kt @@ -1,69 +1,67 @@ package zed.rainxch.details.presentation.components.sections +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.githubstore.core.presentation.res.Res -import zed.rainxch.githubstore.core.presentation.res.open_github_link import zed.rainxch.githubstore.core.presentation.res.open_in_browser import zed.rainxch.githubstore.core.presentation.res.report_issue fun LazyListScope.reportIssue(repoUrl: String) { item { val uriHandler = LocalUriHandler.current - - OutlinedCard( - onClick = { - uriHandler.openUri("${repoUrl.trimEnd('/')}/issues") - }, - colors = - CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - shape = RoundedCornerShape(32.dp), + Row( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .border(width = 1.dp, color = MaterialTheme.colorScheme.outline, shape = Radii.row) + .background(MaterialTheme.colorScheme.surface) + .clickable { uriHandler.openUri("${repoUrl.trimEnd('/')}/issues") } + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier.padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.BugReport, - contentDescription = stringResource(Res.string.report_issue), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(32.dp), - ) - - Text( - text = stringResource(Res.string.report_issue), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = stringResource(Res.string.open_in_browser), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } + Icon( + imageVector = Icons.Default.BugReport, + contentDescription = stringResource(Res.string.report_issue), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(Res.string.report_issue), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(Res.string.open_in_browser), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) } } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Stats.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Stats.kt index fe8422245..34afb1553 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Stats.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Stats.kt @@ -1,14 +1,23 @@ package zed.rainxch.details.presentation.components.sections 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.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.vocabulary.Squiggle import zed.rainxch.details.domain.model.RepoStats import zed.rainxch.details.presentation.components.StatItem import zed.rainxch.details.presentation.components.TextStatItem @@ -18,24 +27,39 @@ fun LazyListScope.stats( repoStats: RepoStats, ) { item { - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(20.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResource(Res.string.details_stats_section), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + ), + color = MaterialTheme.colorScheme.onBackground, + ) + Squiggle() + } Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { StatItem( label = stringResource(Res.string.forks), stat = repoStats.forks, modifier = Modifier.weight(1.5f), ) - StatItem( label = stringResource(Res.string.stars), stat = repoStats.stars, modifier = Modifier.weight(2f), ) - StatItem( label = stringResource(Res.string.issues), stat = repoStats.openIssues, @@ -43,18 +67,17 @@ fun LazyListScope.stats( ) } - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(10.dp)) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { StatItem( label = stringResource(Res.string.downloads), stat = repoStats.totalDownloads, modifier = Modifier.weight(1f), ) - TextStatItem( label = stringResource(Res.string.license), value = repoStats.license ?: stringResource(Res.string.license_none), diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt index ffc4646a3..108269b8e 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt @@ -8,7 +8,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.ui.draw.clip +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.Icon import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -33,10 +39,14 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Brush import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.vocabulary.Squiggle import kotlinx.coroutines.withContext import org.intellij.markdown.parser.MarkdownParser import com.mikepenz.markdown.model.rememberMarkdownState @@ -47,8 +57,6 @@ import com.mikepenz.markdown.compose.Markdown import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubRelease -import zed.rainxch.details.presentation.components.TranslationControls -import zed.rainxch.details.presentation.model.TranslationState import zed.rainxch.details.presentation.utils.MarkdownImageTransformer import zed.rainxch.details.presentation.utils.rememberMarkdownColors import zed.rainxch.details.presentation.utils.rememberMarkdownTypography @@ -61,72 +69,58 @@ fun LazyListScope.whatsNew( collapsedHeight: Dp, measuredHeightPx: Float?, onMeasured: (Float) -> Unit, - translationState: TranslationState, - onTranslateClick: () -> Unit, - onLanguagePickerClick: () -> Unit, - onToggleTranslation: () -> Unit, + onReadMore: (() -> Unit)? = null, ) { item { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(Modifier.height(20.dp)) - Spacer(Modifier.height(16.dp)) - - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( text = stringResource(Res.string.whats_new), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + ), color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold, - ) - - TranslationControls( - translationState = translationState, - onTranslateClick = onTranslateClick, - onLanguagePickerClick = onLanguagePickerClick, - onToggleTranslation = onToggleTranslation, ) + Squiggle() } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(10.dp)) - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ), + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = Radii.row, + ) + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - release.tagName, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - ) - - Text( - release.publishedAt.take(10), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + Text( + text = release.tagName, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = release.publishedAt.take(10), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + softWrap = false, + ) } } @@ -134,11 +128,10 @@ fun LazyListScope.whatsNew( Spacer(Modifier.height(12.dp)) ExpandableMarkdownContent( - translationState = translationState, release = release, collapsedHeight = collapsedHeight, isExpanded = isExpanded, - onToggleExpanded = onToggleExpanded, + onToggleExpanded = onReadMore ?: onToggleExpanded, measuredHeightPx = measuredHeightPx, onMeasured = onMeasured, ) @@ -147,7 +140,6 @@ fun LazyListScope.whatsNew( @Composable private fun ExpandableMarkdownContent( - translationState: TranslationState, release: GithubRelease, collapsedHeight: Dp, isExpanded: Boolean, @@ -155,15 +147,9 @@ private fun ExpandableMarkdownContent( measuredHeightPx: Float?, onMeasured: (Float) -> Unit, ) { - val raw = - if (translationState.isShowingTranslation && translationState.translatedText != null) { - translationState.translatedText - } else { - release.description ?: stringResource(Res.string.no_release_notes) - } + val raw = release.description ?: stringResource(Res.string.no_release_notes) val isDark = androidx.compose.foundation.isSystemInDarkTheme() - // Off-main pre-processing — see About.kt for the rationale. var fullChunks by remember(raw, isDark) { mutableStateOf?>(null) } LaunchedEffect(raw, isDark) { val processed = withContext(Dispatchers.Default) { @@ -191,7 +177,7 @@ private fun ExpandableMarkdownContent( val components = remember(isDark, imageTransformer) { githubStoreMarkdownComponents(imageTransformer, isDark) } - val cardColor = MaterialTheme.colorScheme.surfaceContainerLow + val cardColor = MaterialTheme.colorScheme.background val collapsedHeightPx = with(density) { collapsedHeight.toPx() } val effectiveHeight = measuredHeightPx ?: 0f @@ -239,36 +225,56 @@ private fun ExpandableMarkdownContent( if (!isExpanded && needsExpansion) { Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(80.dp) - .background( - Brush.verticalGradient( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(140.dp) + .background( + Brush.verticalGradient( + colorStops = arrayOf( 0f to cardColor.copy(alpha = 0f), + 0.35f to cardColor.copy(alpha = 0.10f), + 0.6f to cardColor.copy(alpha = 0.35f), + 0.8f to cardColor.copy(alpha = 0.7f), 1f to cardColor, ), ), + ), ) } } if (needsExpansion) { - TextButton( - onClick = onToggleExpanded, - modifier = Modifier.align(Alignment.CenterHorizontally), + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.onSurface) + .clickable(onClick = onToggleExpanded) + .padding(horizontal = 22.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = - if (isExpanded) { - stringResource(Res.string.show_less) - } else { - stringResource(Res.string.read_more) - }, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, + text = if (isExpanded) { + stringResource(Res.string.show_less) + } else { + stringResource(Res.string.read_more) + }, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.surface, ) + if (!isExpanded) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.surface, + modifier = Modifier.size(18.dp), + ) + } } } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/states/ErrorState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/states/ErrorState.kt index bc7746e8a..394a3ce97 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/states/ErrorState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/states/ErrorState.kt @@ -4,7 +4,6 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -20,12 +19,9 @@ import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material.icons.outlined.HourglassEmpty import androidx.compose.material.icons.outlined.SearchOff import androidx.compose.material.icons.outlined.SentimentDissatisfied -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,6 +34,8 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.ExpressiveCard +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.error_state_go_back @@ -109,28 +107,18 @@ fun ErrorState( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - TextButton( + GhsButton( onClick = { onAction(DetailsAction.OnNavigateBackClick) }, + label = stringResource(Res.string.error_state_go_back), + variant = GhsButtonVariant.Text, modifier = Modifier.weight(1f), - shape = RoundedCornerShape(16.dp), - ) { - Text( - text = stringResource(Res.string.error_state_go_back), - fontWeight = FontWeight.SemiBold, - ) - } - Button( + ) + GhsButton( onClick = { onAction(DetailsAction.Retry) }, + label = stringResource(Res.string.retry), + variant = GhsButtonVariant.Primary, modifier = Modifier.weight(1f), - shape = RoundedCornerShape(16.dp), - contentPadding = PaddingValues(vertical = 12.dp), - colors = ButtonDefaults.buttonColors(), - ) { - Text( - text = stringResource(Res.string.retry), - fontWeight = FontWeight.SemiBold, - ) - } + ) } } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/AlertBlockQuote.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/AlertBlockQuote.kt index e33f2da63..8610697ea 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/AlertBlockQuote.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/AlertBlockQuote.kt @@ -43,7 +43,7 @@ enum class GithubAlertKind(val token: String) { ; companion object { - // [!KIND] on its own line, allowing trailing spaces. + private val PATTERN = Regex("""^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)]\s*$""") fun parse(blockquoteText: String): Match? { diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/ExpandableDetails.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/ExpandableDetails.kt index c474569e1..51b2771f0 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/ExpandableDetails.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/ExpandableDetails.kt @@ -129,12 +129,7 @@ private fun decodeDetailsSummary(encoded: String): String { } private fun extractFenceBody(model: MarkdownComponentModel): String { - // Slice the original source between the first and last - // CODE_FENCE_CONTENT child. Iterating child-by-child and - // re-inserting newlines double-counts (prepend on content + EOL - // token), producing a blank line between every body line — which - // breaks GFM tables, lists, and any block that needs contiguous - // source lines. + val content = model.content val contentNodes = model.node.children.filter { it.type == MarkdownTokenTypes.CODE_FENCE_CONTENT } if (contentNodes.isEmpty()) return "" diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/GithubStoreMarkdownComponents.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/GithubStoreMarkdownComponents.kt index 5925f31f3..f1e01c61b 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/GithubStoreMarkdownComponents.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/GithubStoreMarkdownComponents.kt @@ -14,9 +14,6 @@ import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.ast.ASTNode -// Plain (non-@Composable) factory so callers can wrap in `remember(isDark)` -// — the wrapped MarkdownComponents holds lambdas that get invoked inside the -// Markdown render scope, but constructing the wrapper has no composition cost. fun githubStoreMarkdownComponents( imageTransformer: ImageTransformer, isDark: Boolean, @@ -47,19 +44,6 @@ fun githubStoreMarkdownComponents( image = { model -> LinkAwareMarkdownImage(model.content, model.node, imageTransformer) }, ) -/** - * Drop-in replacement for the lib's default `MarkdownImage` that also - * supports the `[![alt](image-url)](href)` pattern — i.e. the image is a - * clickable hyperlink. README badges (Maven Central status, build status, - * sponsor buttons, "Get it on Play Store" / "Get it on F-Droid" tiles) - * all use that pattern. - * - * The lib's stock renderer pulls the image's own `LINK_DESTINATION` (the - * src) but ignores the outer INLINE_LINK that wraps it. We walk up via - * `ASTNode.parent`; if an ancestor is an INLINE_LINK, we grab its - * `LINK_DESTINATION` and apply `Modifier.clickable { openUri }` around - * the rendered image. - */ @Composable private fun LinkAwareMarkdownImage( content: String, @@ -72,13 +56,6 @@ private fun LinkAwareMarkdownImage( val outerHref = findEnclosingLinkDestination(node, content) val imageData = imageTransformer.transform(imageSrc) ?: return - // Block-level images: GitHub-style sizing. Cap width to the - // content column, let height flow naturally from the intrinsic - // aspect ratio. No height cap — a tall screenshot renders at its - // full proportional height and the user scrolls past it, matching - // what github.com / `.markdown-body img { max-width: 100% }` does. - // Inline rendering still uses the lib's `Placeholder` slot via - // `MarkdownImageTransformer`. val blockModifier = androidx.compose.ui.Modifier.fillMaxWidth() if (outerHref != null) { @@ -118,14 +95,11 @@ private fun findChildRecursive(node: ASTNode, type: IElementType): ASTNode? { private fun findEnclosingLinkDestination(imageNode: ASTNode, content: String): String? { var cursor: ASTNode? = imageNode.parent - // Walk at most a few levels — INLINE_LINK is usually one or two - // parents up. Bail before drifting into the surrounding paragraph. + var depth = 0 while (cursor != null && depth < 4) { if (cursor.type == MarkdownElementTypes.INLINE_LINK) { - // The INLINE_LINK's direct LINK_DESTINATION child is the href. - // Use the non-recursive child lookup so we don't accidentally - // pick up the image's own (inner) LINK_DESTINATION. + val destNode = cursor.children.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION } ?: return null diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/SyntaxHighlightedCode.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/SyntaxHighlightedCode.kt index 7fbd3baf9..86f8b1cae 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/SyntaxHighlightedCode.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/markdown/SyntaxHighlightedCode.kt @@ -44,18 +44,12 @@ fun SyntaxHighlightedCode( extractFenceContent(model.node, model.content) } - // Highlight tokenization is CPU-heavy on big code blocks. Run it on - // Default and render the plain code immediately so the markdown pass - // never blocks waiting for highlighting — Main-thread ANR observed - // previously with multiple large fences on a single README. var highlighted by remember(code, language, isDark) { mutableStateOf(AnnotatedString(code)) } LaunchedEffect(code, language, isDark) { if (code.isEmpty() || language == SyntaxLanguage.DEFAULT) return@LaunchedEffect - // Skip giant code blocks (e.g. embedded JSON dumps, generated YAML). - // The Highlights tokenizer is super-linear in input size and the - // payoff for plain-eye reading shrinks fast past a few thousand chars. + if (code.length > MAX_HIGHLIGHTABLE_CHARS) return@LaunchedEffect val result = withContext(Dispatchers.Default) { buildHighlighted(code, language, isDark) @@ -81,9 +75,7 @@ fun SyntaxHighlightedCode( } private fun extractFenceContent(node: ASTNode, content: String): Pair { - // Code fence node children: - // ``` opener, lang token (FENCE_LANG), eol, CODE_FENCE_CONTENT lines, - // ``` closer. Walk children to extract language hint + raw code body. + var language: SyntaxLanguage = SyntaxLanguage.DEFAULT val body = StringBuilder() var sawContent = false diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownImageTransformer.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownImageTransformer.kt index c595fe116..3698b2cb4 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownImageTransformer.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownImageTransformer.kt @@ -32,24 +32,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.cd_image -/** - * Skips rendering of images whose advertised `Content-Length` exceeds - * [MAX_IMAGE_BYTES]. A HEAD probe runs once per URL on `Dispatchers.IO` - * via the injected [probeClient] (re-used: the same client used for - * proxy testing in `core.data.di.SharedModule` under the `test` - * qualifier — short request timeouts, no extra interceptors). Results - * are cached process-wide so a README that references the same big - * image twice still probes once. - * - * Decoded-bitmap dimension is also capped at [MAX_BITMAP_DIMENSION_PX] - * so a 4K source image (Content-Length under the byte threshold) is - * still rescaled before reaching the GPU. - * - * Servers that omit `Content-Length` (chunked transfer, mis-configured - * CDNs) are treated as "size unknown" and rendered normally — the byte - * cap is best-effort, not a guarantee. - */ class MarkdownImageTransformer( private val probeClient: HttpClient, ) : ImageTransformer { @@ -96,10 +82,6 @@ class MarkdownImageTransformer( probeResult = result } - // Skip rendering entirely when the advertised payload would have - // burned bandwidth + decode time. Returning `null` makes the - // markdown renderer omit the image element; alt text (if any) - // remains visible in the surrounding paragraph. if (probeResult is ProbeResult.Skipped) return null val context = LocalPlatformContext.current @@ -119,18 +101,12 @@ class MarkdownImageTransformer( val isBadgeLike = looksLikeBadge(normalizedLink) val inlineModifier = if (isBadgeLike) { - // SVG / shields.io / GitHub Actions badge / Codecov tile — - // narrow vector content. Cap tight so several badges tile - // on one line without overlap. + Modifier .heightIn(max = BADGE_MAX_HEIGHT_DP.dp) .widthIn(max = BADGE_MAX_WIDTH_DP.dp) } else { - // Raster image (PNG / JPG / WEBP / GIF) — typically a - // banner, hero, or screenshot. Give it room while still - // bounded so a stray oversized PNG can't take over the - // page. Block-level rendering of these goes through - // `LinkAwareMarkdownImage` and ignores this modifier. + Modifier .heightIn(max = RASTER_MAX_HEIGHT_DP.dp) .widthIn(max = RASTER_MAX_WIDTH_DP.dp) @@ -139,27 +115,23 @@ class MarkdownImageTransformer( return ImageData( painter = painter, modifier = inlineModifier, - contentDescription = "Image", + contentDescription = stringResource(Res.string.cd_image), contentScale = ContentScale.Fit, ) } private fun looksLikeBadge(url: String): Boolean { val lower = url.lowercase() - // 1. Explicit `.svg` (with or without query string). Covers most - // repos' own action badges + custom shields. + val pathOnly = lower.substringBefore('?').substringBefore('#') if (pathOnly.endsWith(".svg")) return true - // 2. Known badge providers — many serve SVG without an - // extension in the URL (`https://img.shields.io/badge/...`). + val host = lower .removePrefix("https://") .removePrefix("http://") .substringBefore('/') return host in BADGE_HOSTS || - // 3. Path-based hints — GitHub Actions workflow badges - // (`/actions/workflows/.../badge.svg`), Open Source - // Insights, Bestpractices.dev tiles. + "/badge" in pathOnly || "/badges/" in pathOnly || "/workflows/" in pathOnly && pathOnly.endsWith("/badge") || @@ -169,31 +141,6 @@ class MarkdownImageTransformer( @Composable override fun intrinsicSize(painter: Painter): Size = painter.intrinsicSize - /** - * Override the lib's per-paragraph placeholder size so badge rows - * (the typical `[![…](svg)](url) [![…](svg)](url)` stack in a - * README header) don't all inherit the FIRST image's placeholder - * dimensions — which is what produces the observed overlap when the - * first image is an SVG with no intrinsic size (`Size(0, 180)` - * defaults from the lib) and every following badge takes a 180sp - * tall slot in the same line. - * - * Strategy: when the painter has not yet reported an intrinsic - * size, allocate a badge-sized slot (32sp tall, 120sp wide) instead - * of the lib's 180×180 default. That tiles cleanly horizontally on - * a single line. Once Coil decodes and the painter reports a real - * `intrinsicImageSize`, we scale into the container width with the - * height capped at [RASTER_MAX_HEIGHT_DP] (the larger of the two - * per-kind ceilings, so a single shared placeholder per paragraph - * still fits a raster image without vertical overflow); when that - * cap trims height we scale width proportionally so aspect ratio - * and placeholder slot stay consistent. Badge images remain bounded - * at 40 dp by their own modifier inside this slot. Container width of `0f` (first - * composition before the layout pass) is treated the same as - * unspecified — otherwise the placeholder collapses to zero size, - * the image is invisible on first frame, and a second - * recomposition is forced once layout reports a real width. - */ override fun placeholderConfig( density: androidx.compose.ui.unit.Density, containerSize: Size, @@ -205,12 +152,7 @@ class MarkdownImageTransformer( intrinsicImageSize.height <= 0f -> BADGE_DEFAULT_WIDTH_SP to BADGE_DEFAULT_HEIGHT_SP else -> with(density) { - // Treat a zero container width the same as unspecified. - // Compose can hand us `containerSize.width == 0f` on the - // first composition before the surrounding paragraph - // has been measured; without this fallback the - // placeholder collapses to zero size and the image - // disappears for one frame. + val containerWidthPx = when { containerSize.isUnspecified -> intrinsicImageSize.width containerSize.width <= 0f -> intrinsicImageSize.width @@ -220,24 +162,10 @@ class MarkdownImageTransformer( val ratio = intrinsicImageSize.height / intrinsicImageSize.width.coerceAtLeast(1f) val rawHeight = initialWidth * ratio - // Cap at the LARGER of the two per-kind modifier - // ceilings (raster). The mikepenz lib uses a single - // shared `Placeholder` per paragraph, so if a paragraph - // contains a genuinely inline raster image (e.g. - // `Some text ![screenshot](img.png) more text`) and we - // capped here at `BADGE_MAX_HEIGHT_DP` (40 dp), the - // placeholder slot would be 40 dp while the raster's - // own modifier still allows 320 dp — the image would - // overflow the text flow vertically. Using the raster - // cap reserves enough slot for either kind; badge - // images are still independently bounded at 40 dp by - // their own modifier inside this slot. + val maxHeightPx = RASTER_MAX_HEIGHT_DP.dp.toPx() val targetHeight = rawHeight.coerceAtMost(maxHeightPx) - // Preserve aspect ratio: when height was clamped, scale - // the width down by the same factor so the placeholder - // box matches the actual rendered image shape — avoids - // excess horizontal whitespace and badge re-wrapping. + val targetWidth = if (rawHeight > maxHeightPx && ratio > 0f) { targetHeight / ratio } else { @@ -264,9 +192,7 @@ class MarkdownImageTransformer( else -> ProbeResult.Allowed } }.getOrElse { - // Probe failed (timeout, CORS, network off). Allow render; - // Coil itself will surface the error if the real request - // fails. We don't penalise unreachable servers here. + ProbeResult.Allowed } probeCache[url] = result @@ -285,38 +211,19 @@ class MarkdownImageTransformer( "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0.0.0 Mobile Safari/537.36 GitHubStore/1.8" - // Hard cap on the decoded bitmap dimension (see class kdoc). const val MAX_BITMAP_DIMENSION_PX = 2048 - // Hard cap on the advertised payload size. Above this we skip the - // image entirely. 5 MB covers ~98% of repo READMEs in the wild; - // anything above is almost always a generated mega-screenshot or - // an uncompressed source export that would be unreadable inline - // anyway. const val MAX_IMAGE_BYTES = 5L * 1024 * 1024 - // Default inline slot for badges (most common SVG inline case). - // 32sp tall fits a Shields.io / GitHub Actions badge with room - // to breathe; 120sp wide is the typical "Get it on …" width. private const val BADGE_DEFAULT_WIDTH_SP = 120f private const val BADGE_DEFAULT_HEIGHT_SP = 32f - // SVG / vector badge bounds — designed for narrow vector - // content. Single source of truth: placeholder cap and - // ImageData.modifier height cap derive from the same dp - // constant so the placeholder slot and the rendered Image - // remain in lock-step at every density. private const val BADGE_MAX_HEIGHT_DP = 40 private const val BADGE_MAX_WIDTH_DP = 220 - // Raster image bounds — banners, screenshots, hero shots. Looser - // height + width so they're actually legible inline (when they - // appear inline at all — most are top-level via LinkAwareMarkdownImage). private const val RASTER_MAX_HEIGHT_DP = 320 private const val RASTER_MAX_WIDTH_DP = 480 - // Hosts that overwhelmingly serve SVG badges, often without a - // `.svg` URL suffix. Matched case-insensitively. private val BADGE_HOSTS = setOf( "img.shields.io", "shields.io", @@ -333,9 +240,6 @@ class MarkdownImageTransformer( ) .build() - // Process-wide probe cache. README rerenders, theme toggles, and - // recompositions all share the same map so the HEAD round-trip - // only happens once per URL per app session. private val probeCache = mutableMapOf() private val probeMutex = Mutex() } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt index 2d6f69024..68dd6555a 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/MarkdownTruncate.kt @@ -1,19 +1,9 @@ package zed.rainxch.details.presentation.utils -/** - * Returns a markdown substring suitable for rendering as a collapsed - * "preview" without the cost of composing the full tree. Truncates at a - * sensible boundary (double newline, then single newline, then `maxChars`) - * so headings and code fences aren't sliced mid-block. - * - * Always callable from any thread — no Compose APIs touched. - */ fun truncateMarkdownPreview(content: String, maxChars: Int): String { if (content.length <= maxChars) return content val window = content.substring(0, maxChars) - // Prefer cutting at paragraph break (blank line) inside the last 25% - // of the window so the preview ends on a natural section boundary. val searchFrom = (maxChars * 0.75).toInt().coerceAtLeast(0) val paragraphBreak = window.lastIndexOf("\n\n", maxChars).takeIf { it >= searchFrom } if (paragraphBreak != null && paragraphBreak > 0) { @@ -26,21 +16,6 @@ fun truncateMarkdownPreview(content: String, maxChars: Int): String { return window.trimEnd() + "…" } -/** - * Splits a markdown document into roughly equal chunks at top-level block - * boundaries (double newlines), each ≤ `targetChunkChars`. Each chunk is a - * self-contained snippet the markdown parser can handle in isolation; we - * render them as separate `Markdown(...)` composables so the expensive - * AST-to-Compose pass happens once per chunk rather than once for the - * entire document. - * - * Code fences are kept whole (we never slice between ```...```), even if - * that makes one chunk larger than `targetChunkChars`. Without that - * guard the parser would emit half-open fence nodes and the renderer - * would print raw backticks. - * - * Always callable from any thread — no Compose APIs touched. - */ fun splitMarkdownIntoChunks(content: String, targetChunkChars: Int): List { if (content.length <= targetChunkChars) return listOf(content) val chunks = mutableListOf() @@ -55,9 +30,7 @@ fun splitMarkdownIntoChunks(content: String, targetChunkChars: Int): List = emptyList(), + val errorMessage: String? = null, + val deviceLanguageCode: String = "en", + val translation: TranslationState = TranslationState(), + val isLanguagePickerVisible: Boolean = false, +) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/whatsnew/DetailsWhatsNewViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/whatsnew/DetailsWhatsNewViewModel.kt new file mode 100644 index 000000000..385cbd2b1 --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/whatsnew/DetailsWhatsNewViewModel.kt @@ -0,0 +1,130 @@ +package zed.rainxch.details.presentation.whatsnew + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import zed.rainxch.details.domain.repository.DetailsRepository +import zed.rainxch.details.domain.repository.TranslationRepository +import zed.rainxch.details.presentation.model.SupportedLanguages +import zed.rainxch.details.presentation.model.TranslationState + +class DetailsWhatsNewViewModel( + private val repositoryId: Long, + private val owner: String, + private val repo: String, + private val sourceHost: String?, + private val detailsRepository: DetailsRepository, + private val translationRepository: TranslationRepository, +) : ViewModel() { + + private val _state = MutableStateFlow( + DetailsWhatsNewState(deviceLanguageCode = translationRepository.getDeviceLanguageCode()), + ) + val state = _state.asStateFlow() + + init { + load() + } + + fun retry() { + load() + } + + fun translate(targetLanguageCode: String) { + val releases = _state.value.releases + val body = releases.firstOrNull()?.description.orEmpty() + if (body.isBlank()) return + val displayName = SupportedLanguages.all.firstOrNull { it.code == targetLanguageCode }?.displayName + _state.update { + it.copy( + translation = it.translation.copy( + isTranslating = true, + targetLanguageCode = targetLanguageCode, + targetLanguageDisplayName = displayName, + error = null, + ), + ) + } + viewModelScope.launch { + runCatching { + translationRepository.translate(body, targetLanguageCode) + }.onSuccess { result -> + _state.update { + it.copy( + translation = it.translation.copy( + isTranslating = false, + translatedText = result.translatedText, + isShowingTranslation = true, + detectedSourceLanguage = result.detectedSourceLanguage, + error = null, + ), + ) + } + }.onFailure { e -> + _state.update { + it.copy( + translation = it.translation.copy( + isTranslating = false, + error = e.message ?: "Translation failed", + ), + ) + } + } + } + } + + fun toggleTranslation() { + _state.update { + it.copy( + translation = it.translation.copy( + isShowingTranslation = !it.translation.isShowingTranslation, + ), + ) + } + } + + fun clearTranslation() { + _state.update { it.copy(translation = TranslationState()) } + } + + fun showLanguagePicker() { + _state.update { it.copy(isLanguagePickerVisible = true) } + } + + fun dismissLanguagePicker() { + _state.update { it.copy(isLanguagePickerVisible = false) } + } + + private fun load() { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, errorMessage = null) } + runCatching { + val resolved = if (owner.isNotBlank() && repo.isNotBlank()) { + detailsRepository.getRepositoryByOwnerAndName(owner, repo, sourceHost) + } else { + detailsRepository.getRepositoryById(repositoryId) + } + val releases = detailsRepository.getAllReleases( + owner = resolved.owner.login, + repo = resolved.name, + defaultBranch = resolved.defaultBranch, + sourceHost = sourceHost, + ) + resolved to releases + }.onSuccess { (resolved, releases) -> + _state.update { + it.copy( + isLoading = false, + repoName = resolved.name, + releases = releases, + ) + } + }.onFailure { e -> + _state.update { it.copy(isLoading = false, errorMessage = e.message ?: "Failed to load") } + } + } + } +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/whatsnew/WhatsNewRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/whatsnew/WhatsNewRoot.kt new file mode 100644 index 000000000..9a829edad --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/whatsnew/WhatsNewRoot.kt @@ -0,0 +1,240 @@ +package zed.rainxch.details.presentation.whatsnew + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.mikepenz.markdown.compose.Markdown +import io.ktor.client.HttpClient +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.named +import zed.rainxch.core.presentation.components.buttons.IconButton +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.vocabulary.Squiggle +import zed.rainxch.details.presentation.components.LanguagePicker +import zed.rainxch.details.presentation.components.TranslationCard +import zed.rainxch.details.presentation.markdown.githubStoreMarkdownComponents +import zed.rainxch.details.presentation.utils.MarkdownImageTransformer +import zed.rainxch.details.presentation.utils.rememberMarkdownColors +import zed.rainxch.details.presentation.utils.rememberMarkdownTypography +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.cd_back +import zed.rainxch.githubstore.core.presentation.res.details_whats_new_screen_title +import zed.rainxch.githubstore.core.presentation.res.no_release_notes + +@Composable +fun WhatsNewRoot( + repositoryId: Long, + owner: String, + repo: String, + sourceHost: String?, + onNavigateBack: () -> Unit, + viewModel: DetailsWhatsNewViewModel = koinViewModel { + parametersOf(repositoryId, owner, repo, sourceHost) + }, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + WhatsNewScreen( + state = state, + onBack = onNavigateBack, + onTranslate = viewModel::translate, + onToggleTranslation = viewModel::toggleTranslation, + onPickLanguage = viewModel::showLanguagePicker, + onDismissLanguagePicker = viewModel::dismissLanguagePicker, + onClearTranslation = viewModel::clearTranslation, + ) +} + +@Composable +private fun WhatsNewScreen( + state: DetailsWhatsNewState, + onBack: () -> Unit, + onTranslate: (String) -> Unit, + onToggleTranslation: () -> Unit, + onPickLanguage: () -> Unit, + onDismissLanguagePicker: () -> Unit, + onClearTranslation: () -> Unit, +) { + val isDark = androidx.compose.foundation.isSystemInDarkTheme() + val probeClient = koinInject(qualifier = named("test")) + val imageTransformer = remember(probeClient) { MarkdownImageTransformer(probeClient) } + val colors = rememberMarkdownColors() + val typography = rememberMarkdownTypography() + val components = remember(isDark, imageTransformer) { + githubStoreMarkdownComponents(imageTransformer, isDark) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .systemBarsPadding(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.cd_back), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + text = state.repoName, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp), + ) + } + when { + state.isLoading -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + + state.errorMessage != null -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + item(key = "header") { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stringResource(Res.string.details_whats_new_screen_title), + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 26.sp, + ), + color = MaterialTheme.colorScheme.onBackground, + ) + Squiggle() + } + } + + item(key = "translation_card") { + TranslationCard( + state = state.translation, + deviceLanguageCode = state.deviceLanguageCode, + onPickLanguage = onPickLanguage, + onTranslate = onTranslate, + onToggle = onToggleTranslation, + onCancel = onClearTranslation, + ) + } + + itemsIndexed(items = state.releases, key = { _, item -> item.id }) { index, release -> + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = Radii.row, + ) + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = release.tagName, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = release.publishedAt.take(10), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + softWrap = false, + ) + } + Spacer(Modifier.height(6.dp)) + val isLatest = index == 0 + val translated = state.translation.translatedText + ?.takeIf { isLatest && state.translation.isShowingTranslation } + val body = translated + ?: release.description?.takeIf { it.isNotBlank() } + ?: stringResource(Res.string.no_release_notes) + Markdown( + content = body, + colors = colors, + typography = typography, + imageTransformer = imageTransformer, + components = components, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + + LanguagePicker( + isVisible = state.isLanguagePickerVisible, + selectedLanguageCode = state.translation.targetLanguageCode ?: state.deviceLanguageCode, + deviceLanguageCode = state.deviceLanguageCode, + onLanguageSelected = { lang -> + onDismissLanguagePicker() + onTranslate(lang.code) + }, + onDismiss = onDismissLanguagePicker, + ) +} diff --git a/feature/dev-profile/data/build.gradle.kts b/feature/dev-profile/data/build.gradle.kts index c12ec34eb..2daab4121 100644 --- a/feature/dev-profile/data/build.gradle.kts +++ b/feature/dev-profile/data/build.gradle.kts @@ -8,25 +8,15 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.devProfile.domain) - implementation(libs.kotlinx.coroutines.core) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/ContributionsResponse.kt b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/ContributionsResponse.kt new file mode 100644 index 000000000..d7a085e5d --- /dev/null +++ b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/ContributionsResponse.kt @@ -0,0 +1,16 @@ +package zed.rainxch.devprofile.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ContributionsResponse( + val total: Map = emptyMap(), + val contributions: List = emptyList(), +) + +@Serializable +data class ContributionDayResponse( + val date: String, + val count: Int, + val level: Int, +) diff --git a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/GitHubRepoResponse.kt b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/GitHubRepoResponse.kt index ff02a3513..cfc598ec6 100644 --- a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/GitHubRepoResponse.kt +++ b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/GitHubRepoResponse.kt @@ -15,7 +15,6 @@ data class GitHubRepoResponse( @SerialName("open_issues_count") val openIssuesCount: Int, val language: String? = null, @SerialName("updated_at") val updatedAt: String, - @SerialName("pushed_at") val pushedAt: String? = null, @SerialName("has_downloads") val hasDownloads: Boolean = false, val archived: Boolean = false, val fork: Boolean = false, diff --git a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/GitHubUserResponse.kt b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/GitHubUserResponse.kt index c545e459c..918f06187 100644 --- a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/GitHubUserResponse.kt +++ b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/dto/GitHubUserResponse.kt @@ -21,4 +21,5 @@ data class GitHubUserResponse( @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String, @SerialName("html_url") val htmlUrl: String, + val type: String? = null, ) diff --git a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GitHubRepoToDomain.kt b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GitHubRepoToDomain.kt index 1b6529377..f840b25b8 100644 --- a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GitHubRepoToDomain.kt +++ b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GitHubRepoToDomain.kt @@ -9,6 +9,7 @@ fun GitHubRepoResponse.toDomain( isInstalled: Boolean = false, isFavorite: Boolean = false, latestVersion: String? = null, + latestReleaseAt: String? = null, ) = DeveloperRepository( id = id, name = name, @@ -24,6 +25,6 @@ fun GitHubRepoResponse.toDomain( isInstalled = isInstalled, isFavorite = isFavorite, latestVersion = latestVersion, + latestReleaseAt = latestReleaseAt, updatedAt = updatedAt, - pushedAt = pushedAt, ) diff --git a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GitHubUserToDomain.kt b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GitHubUserToDomain.kt index c83a07f77..0c984501c 100644 --- a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GitHubUserToDomain.kt +++ b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GitHubUserToDomain.kt @@ -21,4 +21,5 @@ fun GitHubUserResponse.toDomain() = createdAt = createdAt, updatedAt = updatedAt, htmlUrl = htmlUrl, + userType = type, ) diff --git a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GithubRepoNetworkModelToGitHubRepoResponse.kt b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GithubRepoNetworkModelToGitHubRepoResponse.kt index cd0ff9bd2..d9a8bd8b6 100644 --- a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GithubRepoNetworkModelToGitHubRepoResponse.kt +++ b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/mappers/GithubRepoNetworkModelToGitHubRepoResponse.kt @@ -15,7 +15,6 @@ fun GithubRepoNetworkModel.toGitHubRepoResponse(): GitHubRepoResponse = openIssuesCount = openIssuesCount, language = language, updatedAt = updatedAt, - pushedAt = pushedAt, hasDownloads = hasDownloads, archived = archived, fork = fork, diff --git a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt index 743b8488e..7e2b95317 100644 --- a/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt +++ b/feature/dev-profile/data/src/commonMain/kotlin/zed/rainxch/devprofile/data/repository/DeveloperProfileRepositoryImpl.kt @@ -24,11 +24,16 @@ import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.FavouritesRepository +import io.ktor.client.request.headers +import io.ktor.client.request.url +import zed.rainxch.devprofile.data.dto.ContributionsResponse import zed.rainxch.devprofile.data.dto.GitHubRepoResponse import zed.rainxch.devprofile.data.dto.GitHubUserResponse import zed.rainxch.devprofile.data.mappers.toDeveloperProfile import zed.rainxch.devprofile.data.mappers.toDomain import zed.rainxch.devprofile.data.mappers.toGitHubRepoResponse +import zed.rainxch.devprofile.domain.model.ContributionCalendar +import zed.rainxch.devprofile.domain.model.ContributionDay import zed.rainxch.devprofile.domain.model.DeveloperProfile import zed.rainxch.devprofile.domain.model.DeveloperRepository import zed.rainxch.devprofile.domain.repository.DeveloperProfileRepository @@ -75,6 +80,33 @@ class DeveloperProfileRepositoryImpl( } } + override suspend fun getContributionCalendar(username: String): Result { + return withContext(Dispatchers.IO) { + try { + val response = httpClient.get { + url("https://github-contributions-api.jogruber.de/v4/$username?y=last") + headers { remove("X-GitHub-Token") } + } + if (!response.status.isSuccess()) { + return@withContext Result.failure( + Exception("Failed to fetch contributions: ${response.status.description}"), + ) + } + val body: ContributionsResponse = response.body() + val days = body.contributions.map { + ContributionDay(date = it.date, count = it.count, level = it.level) + } + val total = body.total["lastYear"] ?: body.total.values.firstOrNull() ?: 0 + Result.success(ContributionCalendar(totalLastYear = total, days = days)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn("Failed to fetch contributions for $username: ${e.message}") + Result.failure(e) + } + } + } + override suspend fun getDeveloperRepositories(username: String): Result> { return withContext(Dispatchers.IO) { try { @@ -170,34 +202,43 @@ class DeveloperProfileRepositoryImpl( val installedApps = installedAppsDao.getAppsByRepoId(repo.id) val isFavorite = favoriteIds.contains(repo.id) - val (hasReleases, hasInstallableAssets, latestVersion) = - checkReleaseInfo( - owner = repo.fullName.split("/")[0], - repoName = repo.name, - ) + val info = checkReleaseInfo( + owner = repo.fullName.split("/")[0], + repoName = repo.name, + ) return repo.toDomain( - hasReleases = hasReleases, - hasInstallableAssets = hasInstallableAssets, - // Treat a repo as installed if any tracked app has - // completed install; a parked-download row left by a - // failed install would otherwise leak "Installed" into - // the UI (see `InstalledApp.isReallyInstalled`). + hasReleases = info.hasReleases, + hasInstallableAssets = info.hasInstallable, isInstalled = installedApps.any { !it.isPendingInstall }, isFavorite = isFavorite, - latestVersion = latestVersion, + latestVersion = info.latestVersion, + latestReleaseAt = info.publishedAt, ) } + private data class ReleaseInfo( + val hasReleases: Boolean, + val hasInstallable: Boolean, + val latestVersion: String?, + val publishedAt: String?, + ) { + companion object { + val EMPTY = ReleaseInfo(false, false, null, null) + } + } + private suspend fun checkReleaseInfo( owner: String, repoName: String, - ): Triple { + ): ReleaseInfo { val backendResult = backendApiClient.getReleases(owner, repoName, perPage = 10) backendResult.fold( onSuccess = { releases -> val stableRelease = releases.firstOrNull { it.draft != true && it.prerelease != true } - if (stableRelease == null) return Triple(releases.isNotEmpty(), false, null) + if (stableRelease == null) { + return ReleaseInfo(releases.isNotEmpty(), false, null, null) + } val hasInstallable = stableRelease.assets.any { asset -> val name = asset.name.lowercase() when (platform) { @@ -208,10 +249,15 @@ class DeveloperProfileRepositoryImpl( name.endsWith(".rpm") || name.endsWith(".pkg.tar.zst") } } - return Triple(true, hasInstallable, stableRelease.tagName) + return ReleaseInfo( + hasReleases = true, + hasInstallable = hasInstallable, + latestVersion = stableRelease.tagName, + publishedAt = stableRelease.publishedAt, + ) }, onFailure = { error -> - if (!shouldFallbackToGithubOrRethrow(error)) return Triple(false, false, null) + if (!shouldFallbackToGithubOrRethrow(error)) return ReleaseInfo.EMPTY }, ) @@ -222,7 +268,7 @@ class DeveloperProfileRepositoryImpl( } if (!response.status.isSuccess()) { - return Triple(false, false, null) + return ReleaseInfo.EMPTY } val releases: List = response.body() @@ -233,36 +279,26 @@ class DeveloperProfileRepositoryImpl( } if (stableRelease == null) { - return Triple(releases.isNotEmpty(), false, null) + return ReleaseInfo(releases.isNotEmpty(), false, null, null) } val hasInstallableAssets = stableRelease.assets.any { asset -> val name = asset.name.lowercase() when (platform) { - Platform.ANDROID -> { - name.endsWith(".apk") - } - - Platform.WINDOWS -> { - name.endsWith(".msi") || name.endsWith(".exe") - } - - Platform.MACOS -> { - name.endsWith(".dmg") || name.endsWith(".pkg") - } - - Platform.LINUX -> { - name.endsWith(".appimage") || name.endsWith(".deb") || - name.endsWith(".rpm") || name.endsWith(".pkg.tar.zst") - } + Platform.ANDROID -> name.endsWith(".apk") + Platform.WINDOWS -> name.endsWith(".msi") || name.endsWith(".exe") + Platform.MACOS -> name.endsWith(".dmg") || name.endsWith(".pkg") + Platform.LINUX -> name.endsWith(".appimage") || name.endsWith(".deb") || + name.endsWith(".rpm") || name.endsWith(".pkg.tar.zst") } } - Triple( - true, - hasInstallableAssets, - if (hasInstallableAssets) stableRelease.tagName else null, + ReleaseInfo( + hasReleases = true, + hasInstallable = hasInstallableAssets, + latestVersion = stableRelease.tagName, + publishedAt = stableRelease.publishedAt, ) } catch (e: RateLimitException) { throw e @@ -270,7 +306,7 @@ class DeveloperProfileRepositoryImpl( throw e } catch (e: Exception) { logger.warn("Failed to check releases for $owner/$repoName : ${e.message}") - Triple(false, false, null) + ReleaseInfo.EMPTY } } diff --git a/feature/dev-profile/domain/build.gradle.kts b/feature/dev-profile/domain/build.gradle.kts index 7ab581f42..0bac5a50c 100644 --- a/feature/dev-profile/domain/build.gradle.kts +++ b/feature/dev-profile/domain/build.gradle.kts @@ -11,15 +11,5 @@ kotlin { implementation(projects.core.domain) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/ContributionCalendar.kt b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/ContributionCalendar.kt new file mode 100644 index 000000000..12ecb0e43 --- /dev/null +++ b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/ContributionCalendar.kt @@ -0,0 +1,12 @@ +package zed.rainxch.devprofile.domain.model + +data class ContributionDay( + val date: String, + val count: Int, + val level: Int, +) + +data class ContributionCalendar( + val totalLastYear: Int, + val days: List, +) diff --git a/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/DeveloperProfile.kt b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/DeveloperProfile.kt index 4809c71f2..35e9bb522 100644 --- a/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/DeveloperProfile.kt +++ b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/DeveloperProfile.kt @@ -17,4 +17,7 @@ data class DeveloperProfile( val createdAt: String, val updatedAt: String, val htmlUrl: String, -) + val userType: String? = null, +) { + val isOrganization: Boolean get() = userType.equals("Organization", ignoreCase = true) +} diff --git a/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/DeveloperRepository.kt b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/DeveloperRepository.kt index 0dbad7cdb..5cdb9ad7c 100644 --- a/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/DeveloperRepository.kt +++ b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/DeveloperRepository.kt @@ -15,6 +15,6 @@ data class DeveloperRepository( val isInstalled: Boolean = false, val isFavorite: Boolean = false, val latestVersion: String? = null, + val latestReleaseAt: String? = null, val updatedAt: String, - val pushedAt: String?, ) diff --git a/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/RepoFilterType.kt b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/RepoFilterType.kt index ae0232d34..cbc2bc1e5 100644 --- a/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/RepoFilterType.kt +++ b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/model/RepoFilterType.kt @@ -2,6 +2,7 @@ package zed.rainxch.devprofile.domain.model enum class RepoFilterType { WITH_RELEASES, + WITH_INSTALLABLE, INSTALLED, FAVORITES, } diff --git a/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/repository/DeveloperProfileRepository.kt b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/repository/DeveloperProfileRepository.kt index fac1769d2..d1483b06c 100644 --- a/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/repository/DeveloperProfileRepository.kt +++ b/feature/dev-profile/domain/src/commonMain/kotlin/zed/rainxch/devprofile/domain/repository/DeveloperProfileRepository.kt @@ -1,5 +1,6 @@ package zed.rainxch.devprofile.domain.repository +import zed.rainxch.devprofile.domain.model.ContributionCalendar import zed.rainxch.devprofile.domain.model.DeveloperProfile import zed.rainxch.devprofile.domain.model.DeveloperRepository @@ -7,4 +8,6 @@ interface DeveloperProfileRepository { suspend fun getDeveloperProfile(username: String): Result suspend fun getDeveloperRepositories(username: String): Result> + + suspend fun getContributionCalendar(username: String): Result } diff --git a/feature/dev-profile/presentation/build.gradle.kts b/feature/dev-profile/presentation/build.gradle.kts index 8eb6e5587..94e1650b1 100644 --- a/feature/dev-profile/presentation/build.gradle.kts +++ b/feature/dev-profile/presentation/build.gradle.kts @@ -7,26 +7,18 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.collections.immutable) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.devProfile.domain) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.jetbrains.compose.components.resources) - implementation(libs.bundles.landscapist) - implementation(libs.kotlinx.collections.immutable) - } - } + implementation(libs.coil3.compose) + implementation(libs.coil3.svg) - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.jetbrains.compose.components.resources) } } } diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileAction.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileAction.kt index 4b1a8a4b8..f0af894c7 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileAction.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileAction.kt @@ -34,4 +34,8 @@ sealed interface DeveloperProfileAction { data class OnOpenLink( val url: String, ) : DeveloperProfileAction + + data class OnNavigateToUser( + val username: String, + ) : DeveloperProfileAction } diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt index 97c7bac8b..72ea9399c 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileRoot.kt @@ -29,10 +29,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler @@ -47,16 +50,18 @@ import zed.rainxch.core.presentation.components.ScrollbarContainer import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled import zed.rainxch.core.presentation.utils.arrowKeyScroll import zed.rainxch.devprofile.domain.model.RepoFilterType +import zed.rainxch.devprofile.presentation.components.ContributionCalendarCard import zed.rainxch.devprofile.presentation.components.DeveloperRepoItem import zed.rainxch.devprofile.presentation.components.FilterSortControls +import zed.rainxch.devprofile.presentation.components.IdentityRailCard import zed.rainxch.devprofile.presentation.components.ProfileInfoCard -import zed.rainxch.devprofile.presentation.components.StatsRow import zed.rainxch.githubstore.core.presentation.res.* @Composable fun DeveloperProfileRoot( onNavigateBack: () -> Unit, onNavigateToDetails: (repoId: Long) -> Unit, + onNavigateToUser: (username: String) -> Unit, viewModel: DeveloperProfileViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -80,6 +85,13 @@ fun DeveloperProfileRoot( if (allowed) uriHandler.openUri(url) } + is DeveloperProfileAction.OnNavigateToUser -> { + val username = action.username.trim().removePrefix("@") + if (username.isNotBlank() && username != state.username) { + onNavigateToUser(username) + } + } + else -> { viewModel.onAction(action) } @@ -145,8 +157,20 @@ fun DeveloperProfileScreen( ) } + if (!state.profile.isOrganization) { + item { + ContributionCalendarCard( + contributions = state.contributions, + isLoading = state.isLoadingContributions, + ) + } + } + item { - StatsRow(profile = state.profile) + IdentityRailCard( + profile = state.profile, + onAction = onAction, + ) } item { @@ -211,15 +235,14 @@ fun DeveloperProfileScreen( .align(Alignment.BottomCenter) .padding(16.dp), action = { - TextButton( + GhsButton( onClick = { onAction(DeveloperProfileAction.OnRetry) }, - ) { - Text( - text = stringResource(Res.string.retry), - ) - } + label = stringResource(Res.string.retry), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, dismissAction = { IconButton( @@ -251,6 +274,7 @@ private fun EmptyReposContent( val message = when (filter) { RepoFilterType.WITH_RELEASES -> stringResource(Res.string.no_repos_with_releases) + RepoFilterType.WITH_INSTALLABLE -> stringResource(Res.string.no_repos_with_installable) RepoFilterType.INSTALLED -> stringResource(Res.string.no_installed_repos) RepoFilterType.FAVORITES -> stringResource(Res.string.no_favorite_repos) } diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileState.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileState.kt index bb01bf261..f1e753931 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileState.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileState.kt @@ -2,6 +2,7 @@ package zed.rainxch.devprofile.presentation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import zed.rainxch.devprofile.domain.model.ContributionCalendar import zed.rainxch.devprofile.domain.model.DeveloperProfile import zed.rainxch.devprofile.domain.model.DeveloperRepository import zed.rainxch.devprofile.domain.model.RepoFilterType @@ -18,4 +19,6 @@ data class DeveloperProfileState( val currentFilter: RepoFilterType = RepoFilterType.WITH_RELEASES, val currentSort: RepoSortType = RepoSortType.UPDATED, val searchQuery: String = "", + val contributions: ContributionCalendar? = null, + val isLoadingContributions: Boolean = false, ) diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileViewModel.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileViewModel.kt index 557cb5b7a..b3fbe453c 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileViewModel.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/DeveloperProfileViewModel.kt @@ -64,8 +64,12 @@ class DeveloperProfileViewModel( profile = profile, isLoading = false, isLoadingRepos = true, + isLoadingContributions = !profile.isOrganization, ) } + if (!profile.isOrganization) { + loadContributions() + } }.onFailure { error -> _state.update { it.copy( @@ -119,6 +123,23 @@ class DeveloperProfileViewModel( } } + private fun loadContributions() { + viewModelScope.launch { + repository.getContributionCalendar(username) + .onSuccess { cal -> + _state.update { + it.copy( + contributions = cal, + isLoadingContributions = false, + ) + } + } + .onFailure { + _state.update { it.copy(isLoadingContributions = false) } + } + } + } + private fun applyFiltersAndSort() { viewModelScope.launch(Dispatchers.Default) { val currentState = _state.value @@ -137,9 +158,11 @@ class DeveloperProfileViewModel( filtered = when (currentState.currentFilter) { RepoFilterType.WITH_RELEASES -> { - filtered - .filter { it.hasInstallableAssets } - .toImmutableList() + filtered.filter { it.hasReleases }.toImmutableList() + } + + RepoFilterType.WITH_INSTALLABLE -> { + filtered.filter { it.hasInstallableAssets }.toImmutableList() } RepoFilterType.INSTALLED -> { @@ -179,6 +202,7 @@ class DeveloperProfileViewModel( DeveloperProfileAction.OnNavigateBackClick, is DeveloperProfileAction.OnRepositoryClick, is DeveloperProfileAction.OnOpenLink, + is DeveloperProfileAction.OnNavigateToUser, -> { } diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ContributionCalendar.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ContributionCalendar.kt new file mode 100644 index 000000000..493047246 --- /dev/null +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ContributionCalendar.kt @@ -0,0 +1,209 @@ +package zed.rainxch.devprofile.presentation.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.devprofile.domain.model.ContributionCalendar +import zed.rainxch.devprofile.domain.model.ContributionDay + +private val CELL_SIZE = 11.dp +private val CELL_GAP = 3.dp + +@Composable +fun ContributionCalendarCard( + contributions: ContributionCalendar?, + isLoading: Boolean, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Contribution activity", + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + contributions?.let { + Text( + text = "${it.totalLastYear} this year", + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(12.dp)) + when { + isLoading && contributions == null -> { + Text( + text = "Loading…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + contributions == null -> { + Text( + text = "Couldn't load contributions.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + else -> CalendarGrid(days = contributions.days) + } + } + } +} + +@Composable +private fun CalendarGrid(days: List) { + if (days.isEmpty()) return + val cs = MaterialTheme.colorScheme + val baseTint = remember(cs.surfaceContainerHigh) { cs.surfaceContainerHigh } + val primary = cs.primary + val palette = remember(primary, baseTint) { + listOf( + baseTint, + primary.copy(alpha = 0.25f).compositeOver(baseTint), + primary.copy(alpha = 0.5f).compositeOver(baseTint), + primary.copy(alpha = 0.75f).compositeOver(baseTint), + primary, + ) + } + val scrollState = rememberScrollState() + + val firstDayOfWeek = remember(days) { + val first = days.firstOrNull()?.date ?: return@remember 0 + dateToDayOfWeek(first) + } + val totalCells = firstDayOfWeek + days.size + val weeks = (totalCells + 6) / 7 + + Box( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState), + ) { + Canvas( + modifier = Modifier.size( + width = (CELL_SIZE + CELL_GAP) * weeks, + height = (CELL_SIZE + CELL_GAP) * 7, + ), + ) { + val cellPx = CELL_SIZE.toPx() + val gapPx = CELL_GAP.toPx() + val stride = cellPx + gapPx + val radius = CornerRadius(cellPx * 0.18f, cellPx * 0.18f) + + for (i in days.indices) { + val cellIndex = firstDayOfWeek + i + val week = cellIndex / 7 + val dow = cellIndex % 7 + val day = days[i] + val color = palette[day.level.coerceIn(0, 4)] + drawRoundRect( + color = color, + topLeft = Offset(week * stride, dow * stride), + size = Size(cellPx, cellPx), + cornerRadius = radius, + ) + } + } + } + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Less", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + palette.forEach { c -> + Box( + modifier = Modifier + .size(CELL_SIZE) + .clip(RoundedCornerShape(2.dp)) + .background(c), + ) + } + Text( + text = "More", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun dateToDayOfWeek(iso: String): Int { + val parts = iso.split('-') + if (parts.size != 3) return 0 + val y = parts[0].toIntOrNull() ?: return 0 + val m = parts[1].toIntOrNull() ?: return 0 + val d = parts[2].toIntOrNull() ?: return 0 + return zellerDayOfWeek(y, m, d) +} + +private fun zellerDayOfWeek(year: Int, month: Int, day: Int): Int { + var y = year + var m = month + if (m < 3) { + m += 12 + y -= 1 + } + val k = y % 100 + val j = y / 100 + val h = (day + (13 * (m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j) % 7 + return ((h + 6) % 7) +} + +private fun Color.compositeOver(background: Color): Color { + val a = alpha + val invA = 1f - a + return Color( + red = red * a + background.red * invA, + green = green * a + background.green * invA, + blue = blue * a + background.blue * invA, + alpha = 1f, + ) +} diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/DeveloperRepoItem.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/DeveloperRepoItem.kt index c4ca18360..29c8d90b1 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/DeveloperRepoItem.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/DeveloperRepoItem.kt @@ -2,10 +2,13 @@ package zed.rainxch.devprofile.presentation.components +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -14,33 +17,33 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.CallSplit +import androidx.compose.material.icons.automirrored.outlined.CallSplit import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.FavoriteBorder -import androidx.compose.material.icons.outlined.Warning -import androidx.compose.material3.Badge -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledIconToggleButton +import androidx.compose.material.icons.outlined.StarOutline +import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.toShape 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.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import zed.rainxch.core.presentation.components.ExpressiveCard +import zed.rainxch.core.presentation.components.chips.StatChip import zed.rainxch.core.presentation.theme.GithubStoreTheme +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.core.presentation.utils.formatCount import zed.rainxch.devprofile.domain.model.DeveloperRepository import zed.rainxch.githubstore.core.presentation.res.* @@ -48,7 +51,7 @@ import kotlin.time.Clock import kotlin.time.ExperimentalTime import kotlin.time.Instant -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalLayoutApi::class) @Composable fun DeveloperRepoItem( repository: DeveloperRepository, @@ -56,16 +59,17 @@ fun DeveloperRepoItem( onToggleFavorite: () -> Unit, modifier: Modifier = Modifier, ) { - ExpressiveCard( - onClick = onItemClick, + Surface( modifier = modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f), + ), + onClick = onItemClick, ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { + Column(modifier = Modifier.padding(14.dp)) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -74,238 +78,217 @@ fun DeveloperRepoItem( Text( text = repository.name, maxLines = 1, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface, ) - + val releaseDate = repository.latestReleaseAt + val (label, dateString) = if (releaseDate != null) { + Res.string.released_on_date to releaseDate + } else { + Res.string.updated_on_date to repository.updatedAt + } Text( - text = - stringResource( - resource = Res.string.updated_on_date, - formatRelativeDate(repository.updatedAt), - ).replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleMedium, + text = stringResource(label, formatRelativeDate(dateString)) + .replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.labelMedium, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) } - - Spacer(modifier = Modifier.width(8.dp)) - - FilledIconToggleButton( - checked = repository.isFavorite, - onCheckedChange = { onToggleFavorite() }, - modifier = Modifier.size(40.dp), - shape = MaterialShapes.Cookie6Sided.toShape(), - ) { - Icon( - imageVector = - if (repository.isFavorite) { - Icons.Filled.Favorite - } else { - Icons.Outlined.FavoriteBorder - }, - contentDescription = - if (repository.isFavorite) { - stringResource(Res.string.remove_from_favourites) - } else { - stringResource(Res.string.add_to_favourites) - }, - modifier = Modifier.size(20.dp), - ) - } + Spacer(Modifier.width(8.dp)) + FavoriteToggle( + isFavorite = repository.isFavorite, + onClick = onToggleFavorite, + ) } - repository.description?.let { description -> - Spacer(modifier = Modifier.height(8.dp)) - + repository.description?.takeIf { it.isNotBlank() }?.let { description -> + Spacer(Modifier.height(8.dp)) Text( text = description, maxLines = 2, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + overflow = TextOverflow.Ellipsis, ) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(Modifier.height(10.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - RepoStat( - icon = Icons.Default.Star, - value = formatCount(repository.stargazersCount), - contentDescription = $$"$${repository.stargazersCount} $${stringResource(Res.string.stars)}", - ) - - RepoStat( - icon = Icons.AutoMirrored.Filled.CallSplit, - value = formatCount(repository.forksCount), - contentDescription = "${repository.forksCount} ${stringResource(Res.string.forks)}", - ) - + if (repository.stargazersCount > 0) { + StatChip( + label = formatCount(repository.stargazersCount), + leading = { + Icon( + imageVector = Icons.Outlined.StarOutline, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } + if (repository.forksCount > 0) { + StatChip( + label = formatCount(repository.forksCount), + leading = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.CallSplit, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } if (repository.openIssuesCount > 0) { - RepoStat( - icon = Icons.Outlined.Warning, - value = formatCount(repository.openIssuesCount), - contentDescription = "${repository.openIssuesCount} ${stringResource(Res.string.issues)}", + StatChip( + label = formatCount(repository.openIssuesCount), + leading = { + Icon( + imageVector = Icons.Outlined.WarningAmber, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, ) } - - repository.language?.let { language -> - SuggestionChip( - onClick = {}, - label = { - Text( - text = language, - style = MaterialTheme.typography.labelSmall, + repository.language?.takeIf { it.isNotBlank() }?.let { language -> + StatChip( + label = language, + leading = { + Icon( + imageVector = Icons.Outlined.Code, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, - modifier = Modifier.height(32.dp), ) } } - val repoBadges = - buildList { + val showBadges = repository.hasReleases || + repository.hasInstallableAssets || + repository.isInstalled + if (showBadges) { + Spacer(Modifier.height(10.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { if (repository.hasInstallableAssets) { - add( - RepoBadge( - text = - repository.latestVersion - ?: stringResource(Res.string.has_release), - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), + TonalBadge( + text = repository.latestVersion + ?: stringResource(Res.string.has_release), + container = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + content = MaterialTheme.colorScheme.primary, + ) + } else if (repository.hasReleases) { + TonalBadge( + text = repository.latestVersion + ?: stringResource(Res.string.has_release), + container = MaterialTheme.colorScheme.secondaryContainer, + content = MaterialTheme.colorScheme.onSecondaryContainer, ) } if (repository.isInstalled) { - add( - RepoBadge( - text = stringResource(Res.string.installed), - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - ), + TonalBadge( + text = stringResource(Res.string.installed), + container = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.18f), + content = MaterialTheme.colorScheme.tertiary, ) } } - - if (repoBadges.isNotEmpty()) { - Spacer(modifier = Modifier.height(12.dp)) - - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - repoBadges.forEach { badge -> - Badge( - containerColor = badge.containerColor, - ) { - Text( - text = badge.text, - style = MaterialTheme.typography.labelSmall, - color = badge.contentColor, - ) - } - } - } } } } } @Composable -private fun RepoStat( - icon: ImageVector, - value: String, - contentDescription: String, - modifier: Modifier = Modifier, +private fun FavoriteToggle( + isFavorite: Boolean, + onClick: () -> Unit, ) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, + val container = if (isFavorite) { + MaterialTheme.colorScheme.error.copy(alpha = 0.18f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val tint = if (isFavorite) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(container) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, ) { Icon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = stringResource( + if (isFavorite) Res.string.remove_from_favourites + else Res.string.add_to_favourites, + ), + modifier = Modifier.size(18.dp), + tint = tint, ) + } +} +@Composable +private fun TonalBadge(text: String, container: Color, content: Color) { + Surface( + shape = RoundedCornerShape(50), + color = container, + ) { Text( - text = value, - maxLines = 1, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = text, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = content, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), ) } } -private data class RepoBadge( - val text: String, - val containerColor: Color, - val contentColor: Color, -) - @Composable private fun formatRelativeDate(dateString: String): String { - val instant = - try { - Instant.parse(dateString) - } catch (_: IllegalArgumentException) { - return dateString - } + val instant = try { + Instant.parse(dateString) + } catch (_: IllegalArgumentException) { + return dateString + } val now = Clock.System.now() val duration = now - instant - return when { - duration.inWholeDays > 365 -> { - stringResource( - Res.string.time_years_ago, - (duration.inWholeDays / 365).toInt(), - ) - } - - duration.inWholeDays > 30 -> { - stringResource( - Res.string.time_months_ago, - (duration.inWholeDays / 30).toInt(), - ) - } - - duration.inWholeDays > 0 -> { - stringResource( - Res.string.time_days_ago, - duration.inWholeDays.toInt(), - ) - } - - duration.inWholeHours > 0 -> { - stringResource( - Res.string.time_hours_ago, - duration.inWholeHours.toInt(), - ) - } - - duration.inWholeMinutes > 0 -> { - stringResource( - Res.string.time_minutes_ago, - duration.inWholeMinutes.toInt(), - ) - } - - else -> { - stringResource(Res.string.just_now) - } + duration.inWholeDays > 365 -> + stringResource(Res.string.time_years_ago, (duration.inWholeDays / 365).toInt()) + duration.inWholeDays > 30 -> + stringResource(Res.string.time_months_ago, (duration.inWholeDays / 30).toInt()) + duration.inWholeDays > 0 -> + stringResource(Res.string.time_days_ago, duration.inWholeDays.toInt()) + duration.inWholeHours > 0 -> + stringResource(Res.string.time_hours_ago, duration.inWholeHours.toInt()) + duration.inWholeMinutes > 0 -> + stringResource(Res.string.time_minutes_ago, duration.inWholeMinutes.toInt()) + else -> stringResource(Res.string.just_now) } } @@ -314,25 +297,23 @@ private fun formatRelativeDate(dateString: String): String { private fun PreviewDeveloperRepoItem() { GithubStoreTheme { DeveloperRepoItem( - repository = - DeveloperRepository( - id = 1, - name = "awesome-kotlin-app", - fullName = "developer/awesome-kotlin-app", - description = "An amazing Kotlin Multiplatform application that demonstrates modern Android development", - htmlUrl = "", - stargazersCount = 2340, - forksCount = 456, - openIssuesCount = 23, - language = "Kotlin", - hasReleases = true, - hasInstallableAssets = true, - isInstalled = true, - isFavorite = false, - latestVersion = "v1.5.2", - updatedAt = Clock.System.now().toString(), - pushedAt = null, - ), + repository = DeveloperRepository( + id = 1, + name = "awesome-kotlin-app", + fullName = "developer/awesome-kotlin-app", + description = "An amazing Kotlin Multiplatform application that demonstrates modern Android development", + htmlUrl = "", + stargazersCount = 2340, + forksCount = 456, + openIssuesCount = 23, + language = "Kotlin", + hasReleases = true, + hasInstallableAssets = true, + isInstalled = true, + isFavorite = false, + latestVersion = "v1.5.2", + updatedAt = Clock.System.now().toString(), + ), onItemClick = {}, onToggleFavorite = {}, ) diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt index bcdbce52b..fe25f23cd 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt @@ -1,33 +1,27 @@ package zed.rainxch.devprofile.presentation.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer 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.shape.RoundedCornerShape +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SecondaryScrollableTabRow -import androidx.compose.material3.Tab import androidx.compose.material3.Text -import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -35,14 +29,32 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.inputs.GhsTextField +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenu +import zed.rainxch.core.presentation.components.overlays.GhsDropdownMenuItem +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.devprofile.domain.model.RepoFilterType import zed.rainxch.devprofile.domain.model.RepoSortType import zed.rainxch.devprofile.presentation.DeveloperProfileAction -import zed.rainxch.githubstore.core.presentation.res.* +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.clear_search +import zed.rainxch.githubstore.core.presentation.res.filter_favorites +import zed.rainxch.githubstore.core.presentation.res.filter_installed +import zed.rainxch.githubstore.core.presentation.res.filter_with_installable +import zed.rainxch.githubstore.core.presentation.res.filter_with_releases +import zed.rainxch.githubstore.core.presentation.res.repositories +import zed.rainxch.githubstore.core.presentation.res.repository_singular +import zed.rainxch.githubstore.core.presentation.res.search_repositories +import zed.rainxch.githubstore.core.presentation.res.showing_x_of_y_repositories +import zed.rainxch.githubstore.core.presentation.res.sort +import zed.rainxch.githubstore.core.presentation.res.sort_most_stars +import zed.rainxch.githubstore.core.presentation.res.sort_name +import zed.rainxch.githubstore.core.presentation.res.sort_recently_updated -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun FilterSortControls( currentFilter: RepoFilterType, @@ -56,32 +68,15 @@ fun FilterSortControls( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - OutlinedTextField( + GhsTextField( value = searchQuery, - onValueChange = { query -> - onAction(DeveloperProfileAction.OnSearchQueryChange(query)) - }, + onValueChange = { onAction(DeveloperProfileAction.OnSearchQueryChange(it)) }, modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringResource(Res.string.search_repositories), - maxLines = 1, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(Res.string.search_repositories), - modifier = Modifier.size(20.dp), - ) - }, + placeholder = stringResource(Res.string.search_repositories), + leadingIcon = Icons.Default.Search, trailingIcon = { if (searchQuery.isNotBlank()) { - IconButton( - onClick = { onAction(DeveloperProfileAction.OnSearchQueryChange("")) }, - ) { + IconButton(onClick = { onAction(DeveloperProfileAction.OnSearchQueryChange("")) }) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(Res.string.clear_search), @@ -91,148 +86,137 @@ fun FilterSortControls( } }, singleLine = true, - shape = RoundedCornerShape(12.dp), ) Row( modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - SecondaryScrollableTabRow( - selectedTabIndex = currentFilter.ordinal, - modifier = Modifier.weight(1f), - edgePadding = 0.dp, - divider = {}, + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { RepoFilterType.entries.forEach { filter -> - FilterChipTab( - selected = currentFilter == filter, - onClick = { onAction(DeveloperProfileAction.OnFilterChange(filter)) }, + FilterPill( label = filter.displayName(), + isSelected = currentFilter == filter, + onClick = { onAction(DeveloperProfileAction.OnFilterChange(filter)) }, ) } } - - SortMenu( + SortButton( currentSort = currentSort, - onSortChange = { sort -> - onAction(DeveloperProfileAction.OnSortChange(sort)) - }, + onSortChange = { onAction(DeveloperProfileAction.OnSortChange(it)) }, ) } Text( - text = - if (repoCount == totalCount) { - "$repoCount ${ - stringResource( - if (repoCount == 1) { - Res.string.repository_singular - } else { - Res.string.repositories - }, - ) - }" - } else { - stringResource( - resource = Res.string.showing_x_of_y_repositories, - repoCount, - totalCount, - ) - }, + text = if (repoCount == totalCount) { + "$repoCount ${stringResource( + if (repoCount == 1) Res.string.repository_singular else Res.string.repositories, + )}" + } else { + stringResource(Res.string.showing_x_of_y_repositories, repoCount, totalCount) + }, maxLines = 1, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @Composable -private fun FilterChipTab( - selected: Boolean, - onClick: () -> Unit, +private fun FilterPill( label: String, + isSelected: Boolean, + onClick: () -> Unit, ) { - Tab( - selected = selected, - onClick = onClick, - modifier = Modifier.height(40.dp), + val container by animateColorAsState( + targetValue = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + animationSpec = tween(durationMillis = 180), + label = "filter_container", + ) + val content by animateColorAsState( + targetValue = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + }, + animationSpec = tween(durationMillis = 180), + label = "filter_content", + ) + Box( + modifier = Modifier + .clip(Radii.chip) + .background(container) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, ) { Text( text = label, - style = MaterialTheme.typography.labelMedium, - color = - if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = content, + maxLines = 1, ) } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun SortMenu( +private fun SortButton( currentSort: RepoSortType, onSortChange: (RepoSortType) -> Unit, ) { var expanded by remember { mutableStateOf(false) } - Box { - FilledIconButton( - onClick = { expanded = true }, - modifier = Modifier.size(40.dp), - shape = MaterialShapes.Cookie9Sided.toShape(), + Box( + modifier = Modifier + .size(40.dp) + .clip(Radii.chip) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .clickable { expanded = true }, + contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(Res.string.sort), - modifier = Modifier.size(20.dp), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface, ) } - - DropdownMenu( + GhsDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, - shape = RoundedCornerShape(32.dp), ) { RepoSortType.entries.forEach { sort -> - DropdownMenuItem( - text = { - Row( - modifier = Modifier.padding(4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (currentSort == sort) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } else { - Spacer(modifier = Modifier.size(18.dp)) - } - - Text( - text = sort.displayName(), - maxLines = 1, - style = MaterialTheme.typography.bodyMedium, - color = - if (currentSort == sort) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - }, + GhsDropdownMenuItem( + text = sort.displayName(), onClick = { onSortChange(sort) expanded = false }, + trailingIcon = if (currentSort == sort) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, ) } } @@ -240,17 +224,16 @@ private fun SortMenu( } @Composable -private fun RepoFilterType.displayName(): String = - when (this) { - RepoFilterType.WITH_RELEASES -> stringResource(Res.string.filter_with_releases) - RepoFilterType.INSTALLED -> stringResource(Res.string.filter_installed) - RepoFilterType.FAVORITES -> stringResource(Res.string.filter_favorites) - } +private fun RepoFilterType.displayName(): String = when (this) { + RepoFilterType.WITH_RELEASES -> stringResource(Res.string.filter_with_releases) + RepoFilterType.WITH_INSTALLABLE -> stringResource(Res.string.filter_with_installable) + RepoFilterType.INSTALLED -> stringResource(Res.string.filter_installed) + RepoFilterType.FAVORITES -> stringResource(Res.string.filter_favorites) +} @Composable -private fun RepoSortType.displayName(): String = - when (this) { - RepoSortType.UPDATED -> stringResource(Res.string.sort_recently_updated) - RepoSortType.STARS -> stringResource(Res.string.sort_most_stars) - RepoSortType.NAME -> stringResource(Res.string.sort_name) - } +private fun RepoSortType.displayName(): String = when (this) { + RepoSortType.UPDATED -> stringResource(Res.string.sort_recently_updated) + RepoSortType.STARS -> stringResource(Res.string.sort_most_stars) + RepoSortType.NAME -> stringResource(Res.string.sort_name) +} diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ProfileInfoCard.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ProfileInfoCard.kt index 2af618a1f..ea8f248a7 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ProfileInfoCard.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ProfileInfoCard.kt @@ -1,7 +1,12 @@ package zed.rainxch.devprofile.presentation.components +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -11,185 +16,340 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Business import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.Tag -import androidx.compose.material3.AssistChip -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil3.CoilImage -import zed.rainxch.core.presentation.components.ExpressiveCard +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.utils.formatCount import zed.rainxch.devprofile.domain.model.DeveloperProfile import zed.rainxch.devprofile.presentation.DeveloperProfileAction -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private val MentionRegex = Regex("(? Unit, ) { - ExpressiveCard { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + ), + ) { + Column(modifier = Modifier.padding(20.dp)) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, + verticalAlignment = Alignment.CenterVertically, ) { CoilImage( imageModel = { profile.avatarUrl }, - modifier = - Modifier - .size(80.dp) - .clip(CircleShape), - imageOptions = - ImageOptions( - contentScale = ContentScale.Crop, - ), + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + imageOptions = ImageOptions(contentScale = ContentScale.Crop), ) - - Spacer(modifier = Modifier.width(16.dp)) - + Spacer(Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { - Text( - text = profile.name ?: profile.login, - maxLines = 2, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - - Spacer(Modifier.height(4.dp)) - + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = profile.name ?: profile.login, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (profile.isOrganization) { + Spacer(Modifier.width(8.dp)) + OrgPill() + } + } Text( text = "@${profile.login}", - maxLines = 1, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - - profile.location?.let { location -> - Spacer(Modifier.height(8.dp)) - - InfoChip( - icon = Icons.Default.LocationOn, - text = location, - ) - } - - profile.bio?.let { bio -> - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = bio, - maxLines = 4, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } } - Spacer(modifier = Modifier.height(12.dp)) + profile.bio?.takeIf { it.isNotBlank() }?.let { bio -> + Spacer(Modifier.height(12.dp)) + BioText(bio = bio, onMention = { user -> + onAction(DeveloperProfileAction.OnNavigateToUser(user)) + }) + } - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - itemVerticalAlignment = Alignment.CenterVertically, - ) { - profile.company?.let { company -> - InfoChip( - icon = Icons.Default.Business, - text = company, - ) - } + Spacer(Modifier.height(16.dp)) + MetricsStrip( + repos = profile.publicRepos, + followers = profile.followers, + following = profile.following, + ) + } + } +} - profile.blog?.takeIf { it.isNotBlank() }?.let { blog -> - val displayUrl = blog.removePrefix("https://").removePrefix("http://") - AssistChip( - onClick = { - val url = if (!blog.startsWith("http")) "https://$blog" else blog - onAction(DeveloperProfileAction.OnOpenLink(url)) - }, - label = { - Text( - text = displayUrl, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Link, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }, - ) +@Composable +private fun BioText(bio: String, onMention: (String) -> Unit) { + val cs = MaterialTheme.colorScheme + val annotated = remember(bio, cs.primary) { + buildAnnotatedString { + var cursor = 0 + for (match in MentionRegex.findAll(bio)) { + if (match.range.first > cursor) { + append(bio.substring(cursor, match.range.first)) } - - profile.twitterUsername?.let { twitter -> - AssistChip( - onClick = { - onAction(DeveloperProfileAction.OnOpenLink("https://twitter.com/$twitter")) - }, - label = { - Text( - text = "@$twitter", - style = MaterialTheme.typography.labelMedium, - ) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Tag, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }, - ) + val handle = match.groupValues[1] + withLink( + LinkAnnotation.Clickable( + tag = "mention:$handle", + styles = TextLinkStyles( + style = SpanStyle( + color = cs.primary, + fontWeight = FontWeight.SemiBold, + ), + ), + linkInteractionListener = { onMention(handle) }, + ), + ) { + append("@$handle") } + cursor = match.range.last + 1 } + if (cursor < bio.length) append(bio.substring(cursor)) } } + Text( + text = annotated, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) } @Composable -private fun InfoChip( - icon: ImageVector, - text: String, -) { +private fun MetricsStrip(repos: Int, followers: Int, following: Int) { Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Metric(value = formatCount(repos), label = "Repos", modifier = Modifier.weight(1f)) + MetricDivider() + Metric(value = formatCount(followers), label = "Followers", modifier = Modifier.weight(1f)) + MetricDivider() + Metric(value = formatCount(following), label = "Following", modifier = Modifier.weight(1f)) + } +} +@Composable +private fun Metric(value: String, label: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { Text( - text = text, + text = value, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + ), + color = MaterialTheme.colorScheme.onSurface, maxLines = 1, - style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } +} + +@Composable +private fun MetricDivider() { + Box( + modifier = Modifier + .width(1.dp) + .height(28.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)), + ) +} + +@Composable +private fun OrgPill() { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + text = "Org", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), ) } } + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun IdentityRailCard( + profile: DeveloperProfile, + onAction: (DeveloperProfileAction) -> Unit, +) { + val hasAny = profile.company?.isNotBlank() == true || + profile.location?.isNotBlank() == true || + profile.blog?.isNotBlank() == true || + profile.twitterUsername?.isNotBlank() == true + if (!hasAny) return + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + profile.company?.takeIf { it.isNotBlank() }?.let { company -> + val trimmed = company.trim() + if (trimmed.startsWith("@") && trimmed.length > 1) { + val handle = trimmed.removePrefix("@") + LinkChip( + icon = Icons.Default.Business, + text = trimmed, + onClick = { onAction(DeveloperProfileAction.OnNavigateToUser(handle)) }, + ) + } else { + StaticChip(icon = Icons.Default.Business, text = trimmed) + } + } + profile.location?.takeIf { it.isNotBlank() }?.let { location -> + StaticChip(icon = Icons.Default.LocationOn, text = location) + } + profile.blog?.takeIf { it.isNotBlank() }?.let { blog -> + val display = blog.removePrefix("https://").removePrefix("http://") + LinkChip( + icon = Icons.Default.Link, + text = display, + onClick = { + val url = if (!blog.startsWith("http")) "https://$blog" else blog + onAction(DeveloperProfileAction.OnOpenLink(url)) + }, + ) + } + profile.twitterUsername?.takeIf { it.isNotBlank() }?.let { twitter -> + LinkChip( + icon = Icons.Default.Tag, + text = "@$twitter", + onClick = { + onAction(DeveloperProfileAction.OnOpenLink("https://twitter.com/$twitter")) + }, + ) + } + } + } +} + +@Composable +private fun StaticChip(icon: ImageVector, text: String) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = text, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun LinkChip(icon: ImageVector, text: String, onClick: () -> Unit) { + Surface( + modifier = Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = text, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/StatsRow.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/StatsRow.kt index e15d6c9d3..9042cced9 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/StatsRow.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/StatsRow.kt @@ -1,20 +1,22 @@ package zed.rainxch.devprofile.presentation.components +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import zed.rainxch.core.presentation.components.ExpressiveCard +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.core.presentation.utils.formatCount import zed.rainxch.devprofile.domain.model.DeveloperProfile import zed.rainxch.githubstore.core.presentation.res.* @@ -25,19 +27,17 @@ fun StatsRow(profile: DeveloperProfile) { modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - StatCard( + StatPill( label = stringResource(Res.string.repositories), value = profile.publicRepos.toString(), modifier = Modifier.weight(1f), ) - - StatCard( + StatPill( label = stringResource(Res.string.followers), value = formatCount(profile.followers), modifier = Modifier.weight(1f), ) - - StatCard( + StatPill( label = stringResource(Res.string.following), value = formatCount(profile.following), modifier = Modifier.weight(1f), @@ -45,35 +45,43 @@ fun StatsRow(profile: DeveloperProfile) { } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun StatCard( +private fun StatPill( label: String, value: String, modifier: Modifier = Modifier, ) { - ExpressiveCard(modifier = modifier) { + Surface( + modifier = modifier, + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f), + ), + ) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 6.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( text = value, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, maxLines = 1, - style = MaterialTheme.typography.titleLarge, overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface, ) - Text( text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - style = MaterialTheme.typography.bodyMedium, overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/feature/favourites/data/build.gradle.kts b/feature/favourites/data/build.gradle.kts index 4cab43faa..6ac324cf6 100644 --- a/feature/favourites/data/build.gradle.kts +++ b/feature/favourites/data/build.gradle.kts @@ -13,15 +13,5 @@ kotlin { implementation(projects.feature.favourites.domain) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/favourites/domain/build.gradle.kts b/feature/favourites/domain/build.gradle.kts index 7ab581f42..0bac5a50c 100644 --- a/feature/favourites/domain/build.gradle.kts +++ b/feature/favourites/domain/build.gradle.kts @@ -11,15 +11,5 @@ kotlin { implementation(projects.core.domain) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/favourites/presentation/build.gradle.kts b/feature/favourites/presentation/build.gradle.kts index e66031a15..dc41938f1 100644 --- a/feature/favourites/presentation/build.gradle.kts +++ b/feature/favourites/presentation/build.gradle.kts @@ -7,27 +7,16 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.collections.immutable) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.favourites.domain) - implementation(projects.feature.profile.domain) - - implementation(libs.bundles.landscapist) - implementation(libs.kotlinx.collections.immutable) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.jetbrains.compose.components.resources) - } - } - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { + implementation(libs.bundles.landscapist) } } } diff --git a/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesRoot.kt b/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesRoot.kt index 4d5716578..60e2bed3d 100644 --- a/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesRoot.kt +++ b/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesRoot.kt @@ -30,8 +30,6 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -40,7 +38,6 @@ 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.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -48,6 +45,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.toImmutableList import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.presentation.components.inputs.GhsTextField import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.components.ScrollbarContainer import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled @@ -198,14 +196,14 @@ private fun FavouritesTopbar( title = { Text( text = stringResource(Res.string.favourites), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, ) }, navigationIcon = { IconButton( - shapes = IconButtonDefaults.shapes(), onClick = { onAction(FavouritesAction.OnNavigateBackClick) }, @@ -267,15 +265,15 @@ private fun FavouritesSearchBar( query: String, onQueryChange: (String) -> Unit, ) { - TextField( + GhsTextField( value = query, onValueChange = onQueryChange, modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp), - placeholder = { Text(stringResource(Res.string.search_repositories_hint)) }, - leadingIcon = { Icon(imageVector = Icons.Filled.Search, contentDescription = null) }, + placeholder = stringResource(Res.string.search_repositories_hint), + leadingIcon = Icons.Filled.Search, trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { @@ -287,12 +285,6 @@ private fun FavouritesSearchBar( } }, singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), ) } diff --git a/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesViewModel.kt b/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesViewModel.kt index fd4ea7a79..2359885b9 100644 --- a/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesViewModel.kt +++ b/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/FavouritesViewModel.kt @@ -16,16 +16,16 @@ import kotlinx.coroutines.launch import zed.rainxch.core.domain.model.FavoriteRepo import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.favourites.presentation.mappers.toFavouriteRepositoryUi import zed.rainxch.favourites.presentation.model.FavouritesSortRule -import zed.rainxch.profile.domain.repository.ProfileRepository import kotlin.time.Clock import kotlin.time.ExperimentalTime class FavouritesViewModel( private val favouritesRepository: FavouritesRepository, - private val profileRepository: ProfileRepository, private val tweaksRepository: TweaksRepository, + private val userSessionRepository: UserSessionRepository ) : ViewModel() { private var hasLoadedInitialData = false @@ -48,7 +48,7 @@ class FavouritesViewModel( viewModelScope.launch { combine( favouritesRepository.getAllFavorites(), - profileRepository.getUser(), + userSessionRepository.getUser(), tweaksRepository.getFavouritesSortRule(), ) { favorites, user, sortStored -> val sortRule = FavouritesSortRule.fromName(sortStored) diff --git a/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/components/FavouriteRepositoryItem.kt b/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/components/FavouriteRepositoryItem.kt index 60043c950..a1f4ac109 100644 --- a/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/components/FavouriteRepositoryItem.kt +++ b/feature/favourites/presentation/src/commonMain/kotlin/zed/rainxch/favourites/presentation/components/FavouriteRepositoryItem.kt @@ -1,10 +1,11 @@ package zed.rainxch.favourites.presentation.components +import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -12,25 +13,17 @@ 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.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CalendarToday import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.NewReleases -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,14 +34,18 @@ import androidx.compose.ui.unit.dp import com.skydoves.landscapist.coil3.CoilImage import com.skydoves.landscapist.components.rememberImageComponent import com.skydoves.landscapist.crossfade.CrossfadePlugin +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.core.presentation.components.OfficialBadge +import zed.rainxch.core.presentation.components.chips.StatChip +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.favourites.presentation.model.FavouriteRepository import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.remove_from_favourites -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) @Composable fun FavouriteRepositoryItem( favouriteRepository: FavouriteRepository, @@ -62,26 +59,22 @@ fun FavouriteRepositoryItem( onClick = onItemClick, ) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), ) { Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .clickable(onClick = onDevProfileClick), + modifier = Modifier + .fillMaxWidth() + .clip(Radii.chip) + .clickable(onClick = onDevProfileClick) + .padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { CoilImage( imageModel = { favouriteRepository.repoOwnerAvatarUrl }, - modifier = - Modifier - .size(32.dp) - .clip(CircleShape), + modifier = Modifier + .size(28.dp) + .clip(CircleShape), loading = { Box( modifier = Modifier.fillMaxSize(), @@ -90,148 +83,146 @@ fun FavouriteRepositoryItem( CircularWavyProgressIndicator() } }, - component = - rememberImageComponent { - CrossfadePlugin() - }, + component = rememberImageComponent { + CrossfadePlugin() + }, ) - Text( text = favouriteRepository.repoOwner, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.outline, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - if (favouriteRepository.isCurrentUserOwner) { OfficialBadge() } } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Column( - modifier = Modifier.weight(1f), - ) { + Column(modifier = Modifier.weight(1f)) { Text( text = favouriteRepository.repoName, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - favouriteRepository.repoDescription?.let { - Spacer(modifier = Modifier.height(4.dp)) - + Spacer(Modifier.height(4.dp)) Text( text = it, - fontWeight = FontWeight.Medium, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } } - - IconButton( + FavoriteToggle( + favorited = true, onClick = onToggleFavouriteClick, - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = Modifier.align(Alignment.CenterVertically), - shape = MaterialShapes.Cookie6Sided.toShape(), - ) { - Icon( - imageVector = Icons.Default.Favorite, - contentDescription = stringResource(Res.string.remove_from_favourites), - ) - } + ) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(Modifier.height(12.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + FlowRow( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { favouriteRepository.primaryLanguage?.let { language -> - AssistChip( - onClick = { /* No action */ }, - label = { - Text( - text = language, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - leadingIcon = { + StatChip( + label = language, + leading = { Icon( imageVector = Icons.Default.Code, contentDescription = null, - modifier = Modifier.size(AssistChipDefaults.IconSize), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, - colors = - AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer, - leadingIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), + background = MaterialTheme.colorScheme.surfaceContainerHigh, + border = MaterialTheme.colorScheme.outline, + contentColor = MaterialTheme.colorScheme.onSurface, ) } - favouriteRepository.latestRelease?.let { release -> - AssistChip( - onClick = { /* No action */ }, - label = { - Text( - text = release, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - leadingIcon = { + StatChip( + label = release, + leading = { Icon( imageVector = Icons.Default.NewReleases, contentDescription = null, - modifier = Modifier.size(AssistChipDefaults.IconSize), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary, ) }, + background = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + border = MaterialTheme.colorScheme.primary.copy(alpha = 0.30f), + contentColor = MaterialTheme.colorScheme.primary, ) } - - AssistChip( - onClick = { /* No action */ }, - label = { - Text( - text = favouriteRepository.addedAtFormatter, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - leadingIcon = { + StatChip( + label = favouriteRepository.addedAtFormatter, + leading = { Icon( imageVector = Icons.Default.CalendarToday, contentDescription = null, - modifier = Modifier.size(AssistChipDefaults.IconSize), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, + background = MaterialTheme.colorScheme.surfaceContainerHigh, + border = MaterialTheme.colorScheme.outline, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } + +@Composable +private fun FavoriteToggle( + favorited: Boolean, + onClick: () -> Unit, +) { + val tint = if (favorited) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + val container = if (favorited) { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.45f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(container), + contentAlignment = Alignment.Center, + ) { + IconButton(onClick = onClick, modifier = Modifier.size(38.dp)) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = stringResource(Res.string.remove_from_favourites), + tint = tint, + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/feature/home/data/build.gradle.kts b/feature/home/data/build.gradle.kts index fd7b5970e..dc4a98681 100644 --- a/feature/home/data/build.gradle.kts +++ b/feature/home/data/build.gradle.kts @@ -8,27 +8,16 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.home.domain) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.datetime) - implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt index 3148bfdff..248081474 100644 --- a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt +++ b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt @@ -83,7 +83,6 @@ class CachedRepositoriesDataSourceImpl( } } - // Try backend first val backendResult = fetchTopicFromBackend(topic, platform) if (backendResult != null) { cacheMutex.withLock { @@ -93,7 +92,6 @@ class CachedRepositoriesDataSourceImpl( return backendResult } - // Fallback to raw GitHub JSON logger.debug("Backend failed for topic $topicCacheKey, falling back to GitHub raw JSON") return fetchTopicFromFallback(topic, platform, topicCacheKey) } @@ -115,7 +113,6 @@ class CachedRepositoriesDataSourceImpl( } } - // Try backend first val backendResult = fetchCategoryFromBackend(category, platform) if (backendResult != null) { cacheMutex.withLock { @@ -125,13 +122,10 @@ class CachedRepositoriesDataSourceImpl( return backendResult } - // Fallback to raw GitHub JSON logger.debug("Backend failed for $cacheKey, falling back to GitHub raw JSON") return fetchCategoryFromFallback(category, platform, cacheKey) } - // ── Backend fetchers ────────────────────────────────────────────── - private suspend fun fetchCategoryFromBackend( category: HomeCategory, platform: DiscoveryPlatform, @@ -170,22 +164,11 @@ class CachedRepositoriesDataSourceImpl( platforms.map { plat -> async { val r = backendApiClient.getCategory(categorySlug, plat) - val discoveryPlatform = when (plat) { - "android" -> DiscoveryPlatform.Android - "windows" -> DiscoveryPlatform.Windows - "macos" -> DiscoveryPlatform.Macos - "linux" -> DiscoveryPlatform.Linux - else -> return@async null - } - r.getOrNull()?.map { - it.toCachedGithubRepoSummary() - .copy(availablePlatforms = listOf(discoveryPlatform)) - } + r.getOrNull()?.map { it.toCachedGithubRepoSummary() } } }.awaitAll().filterNotNull() } - // Only use backend result if all 4 platforms succeeded (mirrors fallback behavior) if (responses.isEmpty() || responses.size < platforms.size) return@withContext null val merged = responses @@ -252,22 +235,13 @@ class CachedRepositoriesDataSourceImpl( val responses = coroutineScope { platforms.map { plat -> async { - val discoveryPlatform = when (plat) { - "android" -> DiscoveryPlatform.Android - "windows" -> DiscoveryPlatform.Windows - "macos" -> DiscoveryPlatform.Macos - "linux" -> DiscoveryPlatform.Linux - else -> return@async null - } backendApiClient.getTopic(topicSlug, plat).getOrNull()?.map { it.toCachedGithubRepoSummary() - .copy(availablePlatforms = listOf(discoveryPlatform)) } } }.awaitAll().filterNotNull() } - // Only use backend result if all 4 platforms succeeded (mirrors fallback behavior) if (responses.isEmpty() || responses.size < platforms.size) return@withContext null val merged = responses @@ -296,8 +270,6 @@ class CachedRepositoriesDataSourceImpl( ) } - // ── Fallback fetchers (existing raw GitHub JSON) ────────────────── - private suspend fun fetchCategoryFromFallback( category: HomeCategory, platform: DiscoveryPlatform, @@ -462,8 +434,12 @@ class CachedRepositoriesDataSourceImpl( json.decodeFromString(response.bodyAsText()) .let { repoResponse -> repoResponse.copy( - repositories = repoResponse.repositories.map { - it.copy(availablePlatforms = listOf(filePlatform)) + repositories = repoResponse.repositories.map { repo -> + if (repo.availablePlatforms.isEmpty()) { + repo.copy(availablePlatforms = listOf(filePlatform)) + } else { + repo + } }, ) } @@ -482,8 +458,6 @@ class CachedRepositoriesDataSourceImpl( } } - // ── Helpers ─────────────────────────────────────────────────────── - private fun DiscoveryPlatform.toApiSlug(): String? = when (this) { DiscoveryPlatform.Android -> "android" DiscoveryPlatform.Windows -> "windows" diff --git a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt index fe55e0383..0e8afda3c 100644 --- a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt +++ b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/repository/HomeRepositoryImpl.kt @@ -27,6 +27,8 @@ import zed.rainxch.core.data.cache.CacheManager import zed.rainxch.core.data.cache.CacheManager.CacheTtl.HOME_REPOS import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.GithubRepoSearchResponse +import zed.rainxch.core.data.dto.RepoByIdNetwork +import zed.rainxch.core.domain.model.GithubUser import zed.rainxch.core.data.mappers.toSummary import zed.rainxch.core.data.network.GitHubClientProvider import zed.rainxch.core.data.network.executeRequest @@ -68,9 +70,6 @@ class HomeRepositoryImpl( return "home:$category:$token:page$page" } - // Mirror cache only ships per-platform snapshots and a merged "all" file. - // For 2-3 platform selections we pull the merged set and filter by - // intersection on the client side rather than fetching N snapshots. private suspend fun loadCachedReposForSet( platforms: Set, fetchSingle: suspend (DiscoveryPlatform) -> CachedRepoResponse?, @@ -545,8 +544,7 @@ class HomeRepositoryImpl( return when { topics.isEmpty() -> baseQuery topics.size == 1 -> "$baseQuery topic:${topics.first()}" - // Classic REST search doesn't support parenthesized qualifier grouping. - // Repeat the full base query per topic joined with OR. + else -> topics.joinToString(separator = " OR ") { "($baseQuery topic:$it)" } } } @@ -560,7 +558,6 @@ class HomeRepositoryImpl( DiscoveryPlatform.Linux -> "linux" } - /** Treat `All` and a fully-covering set as "no filter" (empty set). */ private fun Set.normalize(): Set { if (contains(DiscoveryPlatform.All)) return emptySet() val real = filter { it != DiscoveryPlatform.All }.toSet() @@ -669,4 +666,47 @@ class HomeRepositoryImpl( private data class AssetNetworkModel( val name: String, ) + + override suspend fun getRepositoryById(id: Long): GithubRepoSummary? { + val cacheKey = "home:repo_id:$id" + cacheManager.get(cacheKey)?.let { return it } + return try { + val result = httpClient + .executeRequest { + get("/repositories/$id") { + header("Accept", "application/vnd.github+json") + } + }.getOrThrow() + .toSummary() + cacheManager.put(cacheKey, result, HOME_REPOS) + result + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + cacheManager.getStale(cacheKey)?.let { return it } + logger.warn("getRepositoryById($id) failed: ${e.message}") + null + } + } + + private fun RepoByIdNetwork.toSummary(): GithubRepoSummary = GithubRepoSummary( + id = id, + name = name, + fullName = fullName, + owner = GithubUser( + id = owner.id, + login = owner.login, + avatarUrl = owner.avatarUrl, + htmlUrl = owner.htmlUrl, + ), + description = description, + defaultBranch = defaultBranch, + htmlUrl = htmlUrl, + stargazersCount = stars, + forksCount = forks, + language = language, + topics = topics.orEmpty(), + releasesUrl = "https://api.github.com/repos/$fullName/releases{/id}", + updatedAt = updatedAt, + ) } diff --git a/feature/home/domain/build.gradle.kts b/feature/home/domain/build.gradle.kts index b5a257f7a..d400db432 100644 --- a/feature/home/domain/build.gradle.kts +++ b/feature/home/domain/build.gradle.kts @@ -7,10 +7,9 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) implementation(projects.core.domain) - - implementation(libs.kotlinx.coroutines.core) } } diff --git a/feature/home/domain/src/commonMain/kotlin/zed/rainxch/home/domain/repository/HomeRepository.kt b/feature/home/domain/src/commonMain/kotlin/zed/rainxch/home/domain/repository/HomeRepository.kt index a9cd01f1b..69e95efd8 100644 --- a/feature/home/domain/src/commonMain/kotlin/zed/rainxch/home/domain/repository/HomeRepository.kt +++ b/feature/home/domain/src/commonMain/kotlin/zed/rainxch/home/domain/repository/HomeRepository.kt @@ -31,4 +31,6 @@ interface HomeRepository { topic: TopicCategory, platforms: Set, ): Flow + + suspend fun getRepositoryById(id: Long): zed.rainxch.core.domain.model.GithubRepoSummary? } diff --git a/feature/home/presentation/build.gradle.kts b/feature/home/presentation/build.gradle.kts index 4b8703a77..3a5fcb2df 100644 --- a/feature/home/presentation/build.gradle.kts +++ b/feature/home/presentation/build.gradle.kts @@ -7,28 +7,15 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.collections.immutable) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.home.domain) - implementation(projects.feature.profile.domain) - - implementation(libs.kotlinx.collections.immutable) implementation(libs.jetbrains.compose.components.resources) - implementation(libs.androidx.compose.ui.tooling.preview) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeAction.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeAction.kt index c415a27b4..9937b1f4d 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeAction.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeAction.kt @@ -2,15 +2,11 @@ package zed.rainxch.home.presentation import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.presentation.model.GithubRepoSummaryUi -import zed.rainxch.home.domain.model.HomeCategory -import zed.rainxch.home.domain.model.TopicCategory sealed interface HomeAction { - data object Refresh : HomeAction + data object OnRefreshClick : HomeAction - data object Retry : HomeAction - - data object LoadMore : HomeAction + data object OnRetry : HomeAction data object OnSearchClick : HomeAction @@ -18,34 +14,28 @@ sealed interface HomeAction { data object OnAppsClick : HomeAction - data object OnTogglePlatformPopup : HomeAction + data object OnPlatformPopupOpen : HomeAction - data object OnSelectAllPlatforms : HomeAction + data object OnPlatformPopupDismiss : HomeAction - data class OnShareClick( + data class OnRepoClick( val repo: GithubRepoSummaryUi, ) : HomeAction - data class SwitchCategory( - val category: HomeCategory, + data class OnRepoLongClick( + val repoId: Long, ) : HomeAction - data class SwitchTopic( - val topic: TopicCategory, - ) : HomeAction + data object OnActionSheetDismiss : HomeAction - data class TogglePlatform( - val platform: DiscoveryPlatform, + data class OnDeveloperClick( + val username: String, ) : HomeAction - data class OnRepositoryClick( + data class OnShareClick( val repo: GithubRepoSummaryUi, ) : HomeAction - data class OnRepositoryDeveloperClick( - val username: String, - ) : HomeAction - data class OnHideRepository( val repo: GithubRepoSummaryUi, ) : HomeAction @@ -61,4 +51,12 @@ sealed interface HomeAction { data class OnMarkAsUnseen( val repoId: Long, ) : HomeAction + + data object OnSeeAllHot : HomeAction + + data object OnSeeAllTrending : HomeAction + + data object OnSeeAllPopular : HomeAction + + data object OnSeeAllStarred : HomeAction } diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt index ec3001f98..2f77b745a 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt @@ -1,100 +1,62 @@ package zed.rainxch.home.presentation -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets 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.layout.width -import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState -import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.items -import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Done +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel -import zed.rainxch.core.domain.model.DiscoveryPlatform -import zed.rainxch.core.presentation.components.GithubStoreButton -import zed.rainxch.core.presentation.components.RepositoryCard import zed.rainxch.core.presentation.components.ScrollbarContainer +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.section.SectionHeader import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled -import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents -import zed.rainxch.core.presentation.utils.arrowKeyScroll -import zed.rainxch.core.presentation.utils.toIcons -import zed.rainxch.core.presentation.utils.toLabel -import zed.rainxch.githubstore.core.presentation.res.* +import zed.rainxch.core.presentation.utils.constrainedContentWidth +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.home_finding_repositories +import zed.rainxch.githubstore.core.presentation.res.home_retry +import zed.rainxch.githubstore.core.presentation.res.home_section_from_your_stars +import zed.rainxch.githubstore.core.presentation.res.home_section_hot_releases +import zed.rainxch.githubstore.core.presentation.res.home_section_most_popular +import zed.rainxch.githubstore.core.presentation.res.home_section_trending_now import zed.rainxch.home.domain.model.HomeCategory -import zed.rainxch.home.domain.model.TopicCategory -import zed.rainxch.home.presentation.components.LiquidGlassCategoryChips -import zed.rainxch.home.presentation.utils.displayText -import zed.rainxch.home.presentation.utils.icon +import zed.rainxch.home.presentation.components.HomeTopBar +import zed.rainxch.home.presentation.components.HotCardItem +import zed.rainxch.home.presentation.components.LeadCard +import zed.rainxch.home.presentation.components.PopularRowItem +import zed.rainxch.home.presentation.components.RepositoryActionsSheet +import zed.rainxch.home.presentation.components.SeeAllHotTile +import zed.rainxch.home.presentation.components.SeeMoreRow +import zed.rainxch.home.presentation.components.StarredRowItem +import zed.rainxch.home.presentation.components.TrendingRowItem @Composable fun HomeRoot( @@ -103,26 +65,19 @@ fun HomeRoot( onNavigateToApps: () -> Unit, onNavigateToDetails: (repoId: Long) -> Unit, onNavigateToDeveloperProfile: (username: String) -> Unit, + onNavigateToCategoryList: (HomeCategory) -> Unit, + onNavigateToStarredRepos: () -> Unit, viewModel: HomeViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - val listState = rememberLazyStaggeredGridState() - val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() val snackbarHost = remember { SnackbarHostState() } ObserveAsEvents(viewModel.events) { event -> when (event) { - HomeEvent.OnScrollToListTop -> { - scope.launch { - listState.animateScrollToItem(0) - } - } - - is HomeEvent.OnMessage -> { - scope.launch { - snackbarHost.showSnackbar(event.message) - } - } + HomeEvent.OnScrollToListTop -> coroutineScope.launch { listState.animateScrollToItem(0) } + is HomeEvent.OnMessage -> coroutineScope.launch { snackbarHost.showSnackbar(event.message) } } } @@ -131,70 +86,31 @@ fun HomeRoot( snackbarHost = snackbarHost, onAction = { action -> when (action) { - HomeAction.OnSearchClick -> { - onNavigateToSearch() - } - - HomeAction.OnSettingsClick -> { - onNavigateToSettings() - } - - HomeAction.OnAppsClick -> { - onNavigateToApps() - } - - is HomeAction.OnRepositoryClick -> { - onNavigateToDetails(action.repo.id) - } - - is HomeAction.OnRepositoryDeveloperClick -> { - onNavigateToDeveloperProfile(action.username) - } - - else -> { - viewModel.onAction(action) - } + HomeAction.OnSearchClick -> onNavigateToSearch() + HomeAction.OnSettingsClick -> onNavigateToSettings() + HomeAction.OnAppsClick -> onNavigateToApps() + HomeAction.OnSeeAllHot -> onNavigateToCategoryList(HomeCategory.HOT_RELEASE) + HomeAction.OnSeeAllTrending -> onNavigateToCategoryList(HomeCategory.TRENDING) + HomeAction.OnSeeAllPopular -> onNavigateToCategoryList(HomeCategory.MOST_POPULAR) + HomeAction.OnSeeAllStarred -> onNavigateToStarredRepos() + is HomeAction.OnRepoClick -> onNavigateToDetails(action.repo.id) + is HomeAction.OnDeveloperClick -> onNavigateToDeveloperProfile(action.username) + else -> viewModel.onAction(action) } }, - listState = listState, ) } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun HomeScreen( +private fun HomeScreen( state: HomeState, snackbarHost: SnackbarHostState, onAction: (HomeAction) -> Unit, - listState: LazyStaggeredGridState, ) { val bottomNavHeight = LocalBottomNavigationHeight.current - - val shouldLoadMore by remember { - derivedStateOf { - val layoutInfo = listState.layoutInfo - val totalItems = layoutInfo.totalItemsCount - val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull() - - totalItems > 0 && - lastVisibleItem != null && - lastVisibleItem.index >= (totalItems - 5) && - !state.isLoadingMore && - !state.isLoading && - state.hasMorePages - } - } - - LaunchedEffect(shouldLoadMore) { - if (shouldLoadMore) { - onAction(HomeAction.LoadMore) - } - } - - // Material 3's enter-always behavior: heightOffset ticks 1:1 with scroll - // delta (finger-speed tracking), snaps/flings settle naturally, and any - // upward scroll re-reveals the header instantly. See #440. - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + val listState = rememberLazyListState() + val uriHandler = LocalUriHandler.current Scaffold( snackbarHost = { @@ -204,571 +120,215 @@ fun HomeScreen( ) }, containerColor = MaterialTheme.colorScheme.background, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { innerPadding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 8.dp), + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.TopCenter, ) { - CollapsibleHeader(scrollBehavior = scrollBehavior) { - HomeTopAppBar( - selectedPlatforms = state.selectedPlatforms, - onTogglePlatformPopup = { - onAction(HomeAction.OnTogglePlatformPopup) - }, - ) - - FilterChips(state, onAction) - - TopicChips( - selectedTopics = state.selectedTopics, - onTopicSelected = { topic -> - onAction(HomeAction.SwitchTopic(topic)) - }, - ) - - Spacer(modifier = Modifier.height(4.dp)) - } - - Box(Modifier.fillMaxSize()) { - LoadingState(state) + Box(modifier = Modifier.constrainedContentWidth().fillMaxSize()) { + val sectionsAreEmpty = state.lead == null && state.hot.isEmpty() && + state.trending.isEmpty() && state.popular.isEmpty() && state.starred.isEmpty() + val isAnyLoading = state.isHotLoading || state.isTrendingLoading || + state.isPopularLoading || state.isStarredLoading + + when { + isAnyLoading && sectionsAreEmpty -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(Res.string.home_finding_repositories), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } - ErrorState(state, onAction) + state.errorMessage != null && sectionsAreEmpty -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + GhsButton( + onClick = { onAction(HomeAction.OnRetry) }, + label = stringResource(Res.string.home_retry), + variant = GhsButtonVariant.Outline, + ) + } + } - MainState( - state = state, + else -> ScrollbarContainer( listState = listState, - onAction = onAction, - ) - } - } - - // Popup is hoisted out of the TopAppBar so its parent's - // `LayoutCoordinates` are the (stable) Scaffold root rather - // than the (resizing) icons row. Without this, every icon - // count change during multi-select toggles the parent layout - // pass and the Popup window briefly tears down and re-creates. - if (state.isPlatformPopupVisible) { - PlatformsPopup( - onTogglePlatformPopup = { - onAction(HomeAction.OnTogglePlatformPopup) - }, - onTogglePlatform = { - onAction(HomeAction.TogglePlatform(it)) - }, - onSelectAllPlatforms = { - onAction(HomeAction.OnSelectAllPlatforms) - }, - selectedPlatforms = state.selectedPlatforms, - ) - } - } -} - -// Custom-layout analogue of Material 3's `TopAppBarLayout`. Measures children -// at their natural size, reports a shrunk height (natural + heightOffset) so -// siblings in the outer Column reflow upward, and translates the children by -// the same offset so the header slides rather than collapsing contents onto -// each other. `heightOffsetLimit` is synced to the measured natural height so -// `scrollBehavior` knows the full collapsible range. -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CollapsibleHeader( - scrollBehavior: TopAppBarScrollBehavior, - content: @Composable () -> Unit, -) { - var naturalHeightPx by remember { mutableFloatStateOf(0f) } - LaunchedEffect(naturalHeightPx) { - if (naturalHeightPx > 0f) { - scrollBehavior.state.heightOffsetLimit = -naturalHeightPx - } - } - - Layout( - content = content, - modifier = Modifier.clipToBounds(), - ) { measurables, constraints -> - val loose = constraints.copy(minHeight = 0, maxHeight = Int.MAX_VALUE) - val placeables = measurables.map { it.measure(loose) } - val width = - placeables.maxOfOrNull { it.width } - ?.coerceAtLeast(constraints.minWidth) - ?: constraints.minWidth - val natural = placeables.sumOf { it.height } - naturalHeightPx = natural.toFloat() - - val offset = scrollBehavior.state.heightOffset.toInt().coerceIn(-natural, 0) - val rendered = (natural + offset).coerceAtLeast(0) - - layout(width, rendered) { - var y = offset - placeables.forEach { p -> - p.place(0, y) - y += p.height - } - } - } -} - -@Composable -private fun TopicChips( - selectedTopics: Set, - onTopicSelected: (TopicCategory) -> Unit, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 4.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - TopicCategory.entries.forEach { topic -> - val isSelected = topic in selectedTopics + enabled = LocalScrollbarEnabled.current, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + top = 0.dp, + bottom = bottomNavHeight + 32.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item(key = "top_bar") { HomeTopBar() } + + state.lead?.let { lead -> + item(key = "lead_${lead.id}") { + LeadCard( + card = lead, + onClick = { onAction(HomeAction.OnRepoClick(lead.rawRepository)) }, + onLongClick = { onAction(HomeAction.OnRepoLongClick(lead.id)) }, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + } - val containerColor by animateColorAsState( - targetValue = - if (isSelected) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - }, - animationSpec = tween(250), - label = "topicChipContainer", - ) + if (state.hot.isNotEmpty()) { + item(key = "hot_header") { + SectionHeader( + title = stringResource(Res.string.home_section_hot_releases), + subCount = state.hot.size.toString(), + onSeeAll = { onAction(HomeAction.OnSeeAllHot) }, + ) + } + item(key = "hot_row") { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + items(items = state.hot, key = { "hot_${it.id}" }) { card -> + HotCardItem( + card = card, + onClick = { onAction(HomeAction.OnRepoClick(card.rawRepository)) }, + onLongClick = { onAction(HomeAction.OnRepoLongClick(card.id)) }, + modifier = Modifier.animateItem(), + ) + } + item(key = "hot_see_all") { + SeeAllHotTile( + onClick = { onAction(HomeAction.OnSeeAllHot) }, + ) + } + } + } + } - val labelColor by animateColorAsState( - targetValue = - if (isSelected) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - animationSpec = tween(250), - label = "topicChipLabel", - ) + if (state.trending.isNotEmpty()) { + item(key = "trending_header") { + SectionHeader( + title = stringResource(Res.string.home_section_trending_now), + subCount = state.trending.size.toString(), + onSeeAll = { onAction(HomeAction.OnSeeAllTrending) }, + ) + } + itemsIndexed( + items = state.trending, + key = { _, card -> "trending_${card.id}" }, + ) { index, card -> + TrendingRowItem( + card = card, + rank = index + 1, + onClick = { onAction(HomeAction.OnRepoClick(card.rawRepository)) }, + onLongClick = { onAction(HomeAction.OnRepoLongClick(card.id)) }, + modifier = Modifier.animateItem(), + ) + } + item(key = "trending_see_more") { + SeeMoreRow(onClick = { onAction(HomeAction.OnSeeAllTrending) }) + } + } - FilterChip( - selected = isSelected, - onClick = { onTopicSelected(topic) }, - label = { - Text( - text = topic.displayText(), - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - ) - }, - leadingIcon = { - Icon( - imageVector = topic.icon(), - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - }, - colors = - FilterChipDefaults.filterChipColors( - containerColor = containerColor, - labelColor = labelColor, - iconColor = labelColor, - selectedContainerColor = containerColor, - selectedLabelColor = labelColor, - selectedLeadingIconColor = labelColor, - ), - border = - FilterChipDefaults.filterChipBorder( - borderColor = Color.Transparent, - selectedBorderColor = Color.Transparent, - enabled = true, - selected = isSelected, - ), - shape = RoundedCornerShape(12.dp), - ) - } - } -} + if (state.popular.isNotEmpty()) { + item(key = "popular_header") { + SectionHeader( + title = stringResource(Res.string.home_section_most_popular), + subCount = state.popular.size.toString(), + onSeeAll = { onAction(HomeAction.OnSeeAllPopular) }, + ) + } + itemsIndexed( + items = state.popular, + key = { _, card -> "popular_${card.id}" }, + ) { index, card -> + PopularRowItem( + card = card, + rank = index + 1, + onClick = { onAction(HomeAction.OnRepoClick(card.rawRepository)) }, + onLongClick = { onAction(HomeAction.OnRepoLongClick(card.id)) }, + modifier = Modifier.animateItem(), + ) + } + item(key = "popular_see_more") { + SeeMoreRow(onClick = { onAction(HomeAction.OnSeeAllPopular) }) + } + } -@Composable -private fun MainState( - state: HomeState, - listState: LazyStaggeredGridState, - onAction: (HomeAction) -> Unit, -) { - val bottomNavHeight = LocalBottomNavigationHeight.current - val visibleRepos by remember( - state.repos, - state.isHideSeenEnabled, - state.seenRepoIds, - state.hiddenRepoIds, - ) { - derivedStateOf { - val hidden = state.hiddenRepoIds - val needsHideSeen = state.isHideSeenEnabled && state.seenRepoIds.isNotEmpty() - if (hidden.isEmpty() && !needsHideSeen) { - state.repos - } else { - state.repos.filter { repo -> - repo.repository.id !in hidden && - (!needsHideSeen || repo.repository.id !in state.seenRepoIds) + if (state.isUserSignedIn && state.starred.isNotEmpty()) { + item(key = "starred_header") { + SectionHeader( + title = stringResource(Res.string.home_section_from_your_stars), + subCount = state.starred.size.toString(), + onSeeAll = { onAction(HomeAction.OnSeeAllStarred) }, + ) + } + items(items = state.starred, key = { "starred_${it.id}" }) { card -> + StarredRowItem( + card = card, + onClick = { onAction(HomeAction.OnRepoClick(card.rawRepository)) }, + onLongClick = { onAction(HomeAction.OnRepoLongClick(card.id)) }, + modifier = Modifier.animateItem(), + ) + } + item(key = "starred_see_more") { + SeeMoreRow(onClick = { onAction(HomeAction.OnSeeAllStarred) }) + } + } + } } } - } - } - if (visibleRepos.isNotEmpty()) { - val isScrollbarEnabled = LocalScrollbarEnabled.current - ScrollbarContainer( - gridState = listState, - enabled = isScrollbarEnabled, - modifier = Modifier.fillMaxSize(), - ) { - LazyVerticalStaggeredGrid( - state = listState, - columns = StaggeredGridCells.Adaptive(350.dp), - verticalItemSpacing = 12.dp, - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = - PaddingValues( - start = 8.dp, - end = 8.dp, - top = 12.dp, - bottom = bottomNavHeight + 32.dp, - ), - modifier = Modifier.fillMaxSize().arrowKeyScroll(listState, autoFocus = true), - ) { - items( - items = visibleRepos, - key = { it.repository.id }, - contentType = { "repo" }, - ) { discoveryRepository -> - RepositoryCard( - discoveryRepositoryUi = discoveryRepository, - onClick = { - onAction(HomeAction.OnRepositoryClick(discoveryRepository.repository)) + state.actionSheetCard?.let { card -> + RepositoryActionsSheet( + repository = card.rawRepository, + isSeen = card.isSeen, + onDismiss = { onAction(HomeAction.OnActionSheetDismiss) }, + onShare = { + onAction(HomeAction.OnActionSheetDismiss) + onAction(HomeAction.OnShareClick(card.rawRepository)) }, - onDeveloperClick = { username -> - onAction(HomeAction.OnRepositoryDeveloperClick(username)) - }, - onShareClick = { - onAction(HomeAction.OnShareClick(discoveryRepository.repository)) - }, - onHideClick = { - onAction(HomeAction.OnHideRepository(discoveryRepository.repository)) + onOpenOnGithub = { + onAction(HomeAction.OnActionSheetDismiss) + uriHandler.openUri(card.rawRepository.htmlUrl) }, onToggleSeen = { - if (discoveryRepository.isSeen) { - onAction(HomeAction.OnMarkAsUnseen(discoveryRepository.repository.id)) + onAction(HomeAction.OnActionSheetDismiss) + if (card.isSeen) { + onAction(HomeAction.OnMarkAsUnseen(card.id)) } else { - onAction(HomeAction.OnMarkAsSeen(discoveryRepository.repository)) + onAction(HomeAction.OnMarkAsSeen(card.rawRepository)) } }, - modifier = Modifier.animateItem(), - ) - } - - if (state.isLoadingMore || state.isLoadingTopicSupplement) { - item(key = "loading_indicator") { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = stringResource(Res.string.home_loading_more), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - - if (!state.hasMorePages && !state.isLoadingMore && !state.isLoadingTopicSupplement) { - item(key = "end_message") { - Text( - text = stringResource(Res.string.home_no_more_repositories), - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.titleMedium, - ) - } - } - } - } // ScrollbarContainer - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun LoadingState(state: HomeState) { - if (state.isLoading && state.repos.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularWavyProgressIndicator() - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(Res.string.home_finding_repositories), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun ErrorState( - state: HomeState, - onAction: (HomeAction) -> Unit, -) { - if (state.errorMessage != null && state.repos.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(16.dp), - ) { - Text( - text = state.errorMessage, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - GithubStoreButton( - text = stringResource(Res.string.home_retry), - onClick = { - onAction(HomeAction.Retry) + onHide = { + onAction(HomeAction.OnActionSheetDismiss) + onAction(HomeAction.OnHideRepository(card.rawRepository)) }, ) } } - } -} - -@Composable -private fun FilterChips( - state: HomeState, - onAction: (HomeAction) -> Unit, -) { - LiquidGlassCategoryChips( - categories = HomeCategory.entries.toList(), - selectedCategory = state.currentCategory, - onCategorySelected = { category -> - onAction(HomeAction.SwitchCategory(category)) - }, - ) -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun HomeTopAppBar( - selectedPlatforms: Set, - onTogglePlatformPopup: () -> Unit, -) { - TopAppBar( - navigationIcon = { - Image( - painter = painterResource(Res.drawable.app_icon), - contentDescription = null, - modifier = Modifier.size(48.dp), - contentScale = ContentScale.Crop, - ) - }, - title = { - Text( - text = stringResource(Res.string.app_name), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Black, - modifier = Modifier.padding(start = 4.dp), - maxLines = 2, - softWrap = false, - overflow = TextOverflow.Ellipsis, - ) - }, - actions = { - val icons = selectedPlatformsIcons(selectedPlatforms) - - Row( - modifier = - Modifier - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .clickable(onClick = onTogglePlatformPopup) - .padding(vertical = 4.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - icons.forEach { icon -> - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - }, - modifier = Modifier.padding(12.dp), - contentPadding = PaddingValues(), - windowInsets = WindowInsets(), - ) -} - -@Composable -private fun selectedPlatformsIcons( - selectedPlatforms: Set, -) = if (selectedPlatforms.isEmpty()) { - DiscoveryPlatform.All.toIcons() -} else { - DiscoveryPlatform.selectablePlatforms - .filter { it in selectedPlatforms } - .flatMap { it.toIcons() } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun PlatformsPopup( - onTogglePlatformPopup: () -> Unit, - onTogglePlatform: (DiscoveryPlatform) -> Unit, - onSelectAllPlatforms: () -> Unit, - selectedPlatforms: Set, -) { - val density = LocalDensity.current - val positionProvider = remember(density) { - WindowAnchoredTopEndPopupPositionProvider( - topPaddingPx = with(density) { 80.dp.roundToPx() }, - endPaddingPx = with(density) { 16.dp.roundToPx() }, - ) - } - Popup( - popupPositionProvider = positionProvider, - onDismissRequest = onTogglePlatformPopup, - ) { - Column( - modifier = - Modifier - .width(250.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - ) { - PlatformPopupRow( - label = DiscoveryPlatform.All.toLabel(), - isSelected = selectedPlatforms.isEmpty(), - onClick = onSelectAllPlatforms, - ) - - DiscoveryPlatform.selectablePlatforms.forEach { platform -> - PlatformPopupRow( - label = platform.toLabel(), - isSelected = platform in selectedPlatforms, - onClick = { onTogglePlatform(platform) }, - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun PlatformPopupRow( - label: String, - isSelected: Boolean, - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 24.dp, vertical = 8.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = - Arrangement.spacedBy( - 6.dp, - Alignment.Start, - ), - ) { - if (isSelected) { - Icon( - imageVector = Icons.Default.Done, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } - - Text( - text = label, - style = MaterialTheme.typography.titleMediumEmphasized, - color = MaterialTheme.colorScheme.onSurface, - ) } } } - -// Pins the popup to the window's top-end corner instead of anchoring to its -// parent's `LayoutCoordinates`. The default Compose anchor jitters when the -// parent (here, the platform-icons Row inside the TopAppBar action slot) -// resizes during multi-select toggles, causing the popup to flicker. -private class WindowAnchoredTopEndPopupPositionProvider( - private val topPaddingPx: Int, - private val endPaddingPx: Int, -) : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset { - val x = - if (layoutDirection == LayoutDirection.Ltr) { - windowSize.width - popupContentSize.width - endPaddingPx - } else { - endPaddingPx - } - return IntOffset(x.coerceAtLeast(0), topPaddingPx) - } -} - -@Preview -@Composable -private fun Preview() { - GithubStoreTheme { - HomeScreen( - state = HomeState(), - onAction = {}, - snackbarHost = SnackbarHostState(), - listState = rememberLazyStaggeredGridState(), - ) - } -} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeState.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeState.kt index f0f636e8b..8ecacb1c6 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeState.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeState.kt @@ -1,33 +1,28 @@ package zed.rainxch.home.presentation +import androidx.compose.runtime.Stable import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import zed.rainxch.core.domain.model.DiscoveryPlatform -import zed.rainxch.core.domain.model.InstalledApp -import zed.rainxch.core.presentation.model.DiscoveryRepositoryUi -import zed.rainxch.home.domain.model.HomeCategory -import zed.rainxch.home.domain.model.TopicCategory +import zed.rainxch.home.presentation.model.HomeRepoCardUi +@Stable data class HomeState( - val repos: ImmutableList = persistentListOf(), - val installedApps: ImmutableList = persistentListOf(), - val isLoading: Boolean = false, - val isLoadingMore: Boolean = false, - val isLoadingTopicSupplement: Boolean = false, + val lead: HomeRepoCardUi? = null, + val hot: ImmutableList = persistentListOf(), + val trending: ImmutableList = persistentListOf(), + val popular: ImmutableList = persistentListOf(), + val starred: ImmutableList = persistentListOf(), + val isHotLoading: Boolean = false, + val isTrendingLoading: Boolean = false, + val isPopularLoading: Boolean = false, + val isStarredLoading: Boolean = false, val errorMessage: String? = null, - val hasMorePages: Boolean = true, - val currentCategory: HomeCategory = HomeCategory.TRENDING, - /** Empty set means "no topic filter" (show all topics). */ - val selectedTopics: Set = emptySet(), - val isAppsSectionVisible: Boolean = false, - val isUpdateAvailable: Boolean = false, - /** - * Empty set means "all platforms" (no filter). Anything else is the - * subset the user explicitly opted into via the platform popup. - */ val selectedPlatforms: Set = emptySet(), val isPlatformPopupVisible: Boolean = false, + val isAppsSectionVisible: Boolean = false, + val isUpdateAvailable: Boolean = false, val isHideSeenEnabled: Boolean = false, - val seenRepoIds: Set = emptySet(), - val hiddenRepoIds: Set = emptySet(), + val isUserSignedIn: Boolean = false, + val actionSheetCard: HomeRepoCardUi? = null, ) diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt index 8caba7ac6..8d2048944 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeViewModel.kt @@ -2,26 +2,31 @@ package zed.rainxch.home.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString +import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.DiscoveryPlatform +import zed.rainxch.core.domain.model.GithubRepoSummary +import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.hasActualUpdate import zed.rainxch.core.domain.model.isReallyInstalled @@ -31,619 +36,358 @@ import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.utils.ShareManager -import zed.rainxch.core.presentation.model.DiscoveryRepositoryUi -import zed.rainxch.core.presentation.utils.toUi -import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.home.domain.model.HomeCategory -import zed.rainxch.home.domain.model.TopicCategory +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.failed_to_share_link +import zed.rainxch.githubstore.core.presentation.res.home_failed_to_load_repositories +import zed.rainxch.githubstore.core.presentation.res.link_copied_to_clipboard import zed.rainxch.home.domain.repository.HomeRepository -import zed.rainxch.home.presentation.HomeEvent.* -import zed.rainxch.profile.domain.repository.ProfileRepository +import zed.rainxch.home.presentation.model.HomeRepoCardUi +import zed.rainxch.home.presentation.model.toHomeRepoCardUi + +private data class RawRepo( + val raw: GithubRepoSummary, + val isInstalled: Boolean, + val isUpdateAvailable: Boolean, + val isFavourite: Boolean, + val isStarred: Boolean, +) + +private data class RawHomeState( + val hot: List = emptyList(), + val trending: List = emptyList(), + val popular: List = emptyList(), + val starred: List = emptyList(), + val installedById: Map> = emptyMap(), + val favouriteIds: Set = emptySet(), + val starredIds: Set = emptySet(), + val seenIds: Set = emptySet(), + val hiddenIds: Set = emptySet(), + val isHideSeenEnabled: Boolean = false, + val isHotLoading: Boolean = false, + val isTrendingLoading: Boolean = false, + val isPopularLoading: Boolean = false, + val isStarredLoading: Boolean = false, + val errorMessage: String? = null, + val selectedPlatforms: Set = emptySet(), + val isPlatformPopupVisible: Boolean = false, + val isUpdateAvailable: Boolean = false, + val isUserSignedIn: Boolean = false, + val currentUserLogin: String? = null, + val actionSheetRepoId: Long? = null, +) class HomeViewModel( private val homeRepository: HomeRepository, private val installedAppsRepository: InstalledAppsRepository, - private val platform: Platform, private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase, private val favouritesRepository: FavouritesRepository, private val starredRepository: StarredRepository, - private val logger: GitHubStoreLogger, - private val shareManager: ShareManager, private val tweaksRepository: TweaksRepository, private val seenReposRepository: SeenReposRepository, private val hiddenReposRepository: HiddenReposRepository, - private val profileRepository: ProfileRepository, + private val userSessionRepository: UserSessionRepository, + private val logger: GitHubStoreLogger, + private val shareManager: ShareManager, ) : ViewModel() { + private var hasLoadedInitialData = false - private var currentJob: Job? = null - private var switchCategoryJob: Job? = null - private var topicSupplementJob: Job? = null - private var nextPageIndex = 1 - - // Cached so each repo mapping doesn't re-hit ProfileRepository (which - // walks a suspend cache + DataStore on every call). Refreshed by the - // observer below — login/logout flips badges without restarting the VM. - @Volatile private var currentUserLogin: String? = null - - private val _state = MutableStateFlow(HomeState()) - val state = - _state - .onStart { - if (!hasLoadedInitialData) { - observeCurrentUser() - syncSystemState() - - loadPlatform() - loadRepos(isInitial = true) - observeInstalledApps() - observeFavourites() - observeStarredRepos() - observeSeenRepos() - observeHiddenRepos() - observeDiscoveryPlatforms() - observeHideSeenEnabled() - - hasLoadedInitialData = true - } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = HomeState(), - ) + private var loadJob: Job? = null + + private val rawState = MutableStateFlow(RawHomeState()) + + val state: StateFlow = rawState + .onStart { + if (!hasLoadedInitialData) { + observeCurrentUser() + syncSystemState() + refreshAllSections(isInitial = true) + observeInstalledApps() + observeFavourites() + observeStarredRepos() + observeSeenRepos() + observeHiddenRepos() + observeDiscoveryPlatforms() + observeHideSeenEnabled() + hasLoadedInitialData = true + } + } + .map { it.toView() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = HomeState(), + ) private val _events = Channel(capacity = Channel.BUFFERED) val events = _events.receiveAsFlow() - private fun syncSystemState() { - viewModelScope.launch { - try { - val result = syncInstalledAppsUseCase() - if (result.isFailure) { - logger.warn("Initial sync had issues: ${result.exceptionOrNull()?.message}") - } - } catch (e: Exception) { - logger.error("Initial sync failed: ${e.message}") + fun onAction(action: HomeAction) { + when (action) { + HomeAction.OnRefreshClick -> viewModelScope.launch { + syncInstalledAppsUseCase() + refreshAllSections(isInitial = false) } - } - } - private fun loadPlatform() { - _state.update { - it.copy(isAppsSectionVisible = platform == Platform.ANDROID) - } - } + HomeAction.OnRetry -> refreshAllSections(isInitial = true) - private fun observeInstalledApps() { - viewModelScope.launch { - installedAppsRepository.getAllInstalledApps().collect { installedApps -> - val installedMap = installedApps.groupBy { it.repoId } - _state.update { current -> - current.copy( - repos = - current.repos - .map { homeRepo -> - val apps = installedMap[homeRepo.repository.id].orEmpty() - homeRepo.copy( - isInstalled = apps.any { it.isReallyInstalled() }, - isUpdateAvailable = apps.any { it.hasActualUpdate() }, - ) - }.toImmutableList(), - isUpdateAvailable = installedMap.values.flatten().any { it.hasActualUpdate() }, - ) - } - } - } - } + HomeAction.OnPlatformPopupOpen -> + rawState.update { it.copy(isPlatformPopupVisible = true) } - private fun observeDiscoveryPlatforms() { - viewModelScope.launch { - tweaksRepository.getDiscoveryPlatforms().collect { platforms -> - _state.update { - it.copy( - selectedPlatforms = platforms, - ) - } - } - } - } + HomeAction.OnPlatformPopupDismiss -> + rawState.update { it.copy(isPlatformPopupVisible = false) } - private fun loadRepos( - isInitial: Boolean = false, - category: HomeCategory? = null, - platforms: Set? = null, - topics: Set? = null, - topicsExplicitlySet: Boolean = false, - ): Job? { - currentJob?.cancel() - topicSupplementJob?.cancel() - - if (_state.value.isLoading || _state.value.isLoadingMore) { - logger.debug("Already loading, skipping...") - return null - } + is HomeAction.OnRepoLongClick -> + rawState.update { it.copy(actionSheetRepoId = action.repoId) } - if (isInitial) { - nextPageIndex = 1 - } + HomeAction.OnActionSheetDismiss -> + rawState.update { it.copy(actionSheetRepoId = null) } - val targetCategory = category ?: _state.value.currentCategory - val targetPlatformsDeferred = - viewModelScope.async { - tweaksRepository.getDiscoveryPlatforms().first() + is HomeAction.OnShareClick -> viewModelScope.launch { + runCatching { + shareManager.shareText("https://github-store.org/app?repo=${action.repo.fullName}") + }.onFailure { t -> + logger.error("Failed to share link: ${t.message}") + _events.send(HomeEvent.OnMessage(getString(Res.string.failed_to_share_link))) + return@launch + } + if (getPlatform() != Platform.ANDROID) { + _events.send(HomeEvent.OnMessage(getString(Res.string.link_copied_to_clipboard))) + } } - val targetTopics = if (topicsExplicitlySet) topics.orEmpty() else _state.value.selectedTopics - - logger.debug("Loading repos: category=$targetCategory, topics=$targetTopics, page=$nextPageIndex, isInitial=$isInitial") - return viewModelScope - .launch { - val targetPlatforms = platforms ?: targetPlatformsDeferred.await() + is HomeAction.OnHideRepository -> viewModelScope.launch { + val repo = action.repo + try { + hiddenReposRepository.hide( + repoId = repo.id, + repoName = repo.name, + repoOwner = repo.owner.login, + repoOwnerAvatarUrl = repo.owner.avatarUrl, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.warn("Hide repository failed for ${repo.id}: ${e.message}") + } + } - if (platforms != null) { - tweaksRepository.setDiscoveryPlatforms(targetPlatforms) + is HomeAction.OnUndoHideRepository -> viewModelScope.launch { + try { + hiddenReposRepository.unhide(action.repoId) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.warn("Unhide repository failed for ${action.repoId}: ${e.message}") } + } - _state.update { - it.copy( - isLoading = isInitial, - isLoadingMore = !isInitial, - errorMessage = null, - selectedPlatforms = targetPlatforms, - currentCategory = targetCategory, - selectedTopics = targetTopics, - repos = if (isInitial) persistentListOf() else it.repos, + is HomeAction.OnMarkAsSeen -> viewModelScope.launch { + val repo = action.repo + try { + seenReposRepository.markAsSeen( + repoId = repo.id, + repoName = repo.name, + repoOwner = repo.owner.login, + repoOwnerAvatarUrl = repo.owner.avatarUrl, + repoDescription = repo.description, + primaryLanguage = repo.language, + repoUrl = repo.htmlUrl, ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.warn("Mark as seen failed for ${repo.id}: ${e.message}") } + } + is HomeAction.OnMarkAsUnseen -> viewModelScope.launch { try { - val flow = - when (targetCategory) { - HomeCategory.TRENDING -> { - homeRepository.getTrendingRepositories( - platforms = targetPlatforms, - page = nextPageIndex, - ) - } - - HomeCategory.HOT_RELEASE -> { - homeRepository.getHotReleaseRepositories( - platforms = targetPlatforms, - page = nextPageIndex, - ) - } - - HomeCategory.MOST_POPULAR -> { - homeRepository.getMostPopular( - platforms = targetPlatforms, - page = nextPageIndex, - ) - } - } - - flow.collect { paginatedRepos -> - logger.debug( - "Received ${paginatedRepos.repos.size} repos, hasMore=${paginatedRepos.hasMore}, nextPage=${paginatedRepos.nextPageIndex}", - ) - - this@HomeViewModel.nextPageIndex = paginatedRepos.nextPageIndex - - val repos = - if (targetTopics.isEmpty()) { - paginatedRepos.repos - } else { - paginatedRepos.repos.filter { repo -> - targetTopics.any { topic -> - topic.matchesRepo(repo.topics, repo.description, repo.name) - } - } - } - - val newReposWithStatus = mapReposToUi(repos) - - _state.update { currentState -> - val rawList = currentState.repos + newReposWithStatus - val uniqueList = rawList.distinctBy { it.repository.fullName } - - currentState.copy( - repos = uniqueList.toImmutableList(), - hasMorePages = paginatedRepos.hasMore, - errorMessage = - if (uniqueList.isEmpty() && !paginatedRepos.hasMore) { - getString(Res.string.no_repositories_found) - } else { - null - }, - ) - } - } - - logger.debug("Flow completed") - _state.update { - it.copy(isLoading = false, isLoadingMore = false) - } - - if (targetTopics.isNotEmpty() && isInitial) { - loadTopicSupplement(targetTopics, targetPlatforms) - } - } catch (t: Throwable) { - if (t is CancellationException) { - logger.debug("Load cancelled (expected)") - throw t - } - - logger.error("Load failed: ${t.message}") - _state.update { - it.copy( - isLoading = false, - isLoadingMore = false, - errorMessage = - t.message - ?: getString(Res.string.home_failed_to_load_repositories), - ) - } + seenReposRepository.removeFromHistory(action.repoId) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.warn("Mark as unseen failed for ${action.repoId}: ${e.message}") } - }.also { - currentJob = it } - } - private fun loadTopicSupplement( - topics: Set, - platforms: Set, - ) { - topicSupplementJob?.cancel() - topicSupplementJob = - viewModelScope.launch { - _state.update { it.copy(isLoadingTopicSupplement = true) } + HomeAction.OnSearchClick, + HomeAction.OnSettingsClick, + HomeAction.OnAppsClick, + is HomeAction.OnRepoClick, + is HomeAction.OnDeveloperClick, + HomeAction.OnSeeAllHot, + HomeAction.OnSeeAllTrending, + HomeAction.OnSeeAllPopular, + HomeAction.OnSeeAllStarred -> Unit // Handled in composable + } + } - try { - // Phase 1: Pre-fetched cached topic repos (instant, no API cost). - // Run mirror fetches per selected topic in parallel, merge once. - val cachedRepos = - coroutineScope { - topics.map { topic -> - async { - homeRepository - .getTopicRepositories(topic = topic, platforms = platforms) - .firstOrNull() - ?.repos - .orEmpty() - } - }.awaitAll().flatten() - } - if (cachedRepos.isNotEmpty()) { - val cachedReposWithStatus = mapReposToUi(cachedRepos) - _state.update { currentState -> - val merged = - (currentState.repos + cachedReposWithStatus) - .distinctBy { it.repository.fullName } - currentState.copy(repos = merged.toImmutableList()) - } - logger.debug("Loaded ${cachedRepos.size} cached topic repos for $topics") - } + private fun refreshAllSections(isInitial: Boolean) { + loadJob?.cancel() + loadJob = viewModelScope.launch { + val platforms = tweaksRepository.getDiscoveryPlatforms().first() + rawState.update { + it.copy( + selectedPlatforms = platforms, + isHotLoading = true, + isTrendingLoading = true, + isPopularLoading = true, + isStarredLoading = it.isUserSignedIn, + errorMessage = null, + hot = if (isInitial) emptyList() else it.hot, + trending = if (isInitial) emptyList() else it.trending, + popular = if (isInitial) emptyList() else it.popular, + starred = if (isInitial) emptyList() else it.starred, + ) + } + coroutineScope { + launch { loadHot(platforms) } + launch { loadTrending(platforms) } + launch { loadPopular(platforms) } + launch { loadStarred() } + } + } + } - // Phase 2: Live GitHub search (fills gaps). One search per selected - // topic so each topic's keywords AND together correctly inside its - // own query, while results from different topics OR via merge. - topics.forEach { topic -> - homeRepository - .searchByTopic( - searchKeywords = topic.searchKeywords, - platforms = platforms, - page = 1, - ).collect { paginatedRepos -> - val newReposWithStatus = mapReposToUi(paginatedRepos.repos) - - _state.update { currentState -> - val merged = - (currentState.repos + newReposWithStatus) - .distinctBy { it.repository.fullName } - - currentState.copy( - repos = merged.toImmutableList(), - hasMorePages = currentState.hasMorePages || paginatedRepos.hasMore, - ) - } - } - } - } catch (t: Throwable) { - if (t is CancellationException) throw t - logger.warn("Topic supplement search failed: ${t.message}") - } finally { - _state.update { it.copy(isLoadingTopicSupplement = false) } - } + private suspend fun loadHot(platforms: Set) { + try { + val page = homeRepository.getHotReleaseRepositories(platforms, page = 1).first() + val mapped = wrapAll(page.repos) + rawState.update { it.copy(hot = mapped, isHotLoading = false) } + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + logger.error("Hot section load failed: ${t.message}") + rawState.update { + it.copy( + isHotLoading = false, + errorMessage = it.errorMessage ?: t.message + ?: getString(Res.string.home_failed_to_load_repositories), + ) } + } } - private suspend fun mapReposToUi(repos: List): List { - val installedAppsMap = - installedAppsRepository - .getAllInstalledApps() - .first() - .groupBy { it.repoId } + private suspend fun loadTrending(platforms: Set) { + try { + val page = homeRepository.getTrendingRepositories(platforms, page = 1).first() + rawState.update { it.copy(trending = wrapAll(page.repos), isTrendingLoading = false) } + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + logger.error("Trending section load failed: ${t.message}") + rawState.update { it.copy(isTrendingLoading = false) } + } + } - val favoritesMap = - favouritesRepository - .getAllFavorites() - .first() - .associateBy { it.repoId } + private suspend fun loadPopular(platforms: Set) { + try { + val page = homeRepository.getMostPopular(platforms, page = 1).first() + rawState.update { it.copy(popular = wrapAll(page.repos), isPopularLoading = false) } + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + logger.error("Popular section load failed: ${t.message}") + rawState.update { it.copy(isPopularLoading = false) } + } + } - val starredReposMap = - starredRepository + private suspend fun loadStarred() { + if (!rawState.value.isUserSignedIn) { + rawState.update { it.copy(starred = emptyList(), isStarredLoading = false) } + return + } + try { + runCatching { starredRepository.syncStarredRepos(forceRefresh = false) } + val topIds = starredRepository .getAllStarred() .first() - .associateBy { it.repoId } - - val seenIds = _state.value.seenRepoIds - val currentLogin = currentUserLogin + .sortedByDescending { it.stargazersCount } + .take(5) + .map { it.repoId } + val fetched = coroutineScope { + topIds.map { id -> + async { runCatching { homeRepository.getRepositoryById(id) }.getOrNull() } + }.awaitAll().filterNotNull() + } + rawState.update { it.copy(starred = wrapAll(fetched), isStarredLoading = false) } + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + logger.error("Starred section load failed: ${t.message}") + rawState.update { it.copy(isStarredLoading = false) } + } + } + private suspend fun wrapAll(repos: List): List { + val installed = installedAppsRepository.getAllInstalledApps().first().groupBy { it.repoId } + val favourites = favouritesRepository.getAllFavorites().first().map { it.repoId }.toSet() + val starred = starredRepository.getAllStarred().first().map { it.repoId }.toSet() + rawState.update { + it.copy( + installedById = installed, + favouriteIds = favourites, + starredIds = starred, + ) + } return repos.map { repo -> - val apps = installedAppsMap[repo.id].orEmpty() - val favourite = favoritesMap[repo.id] - val starred = starredReposMap[repo.id] - - DiscoveryRepositoryUi( + val apps = installed[repo.id].orEmpty() + RawRepo( + raw = repo, isInstalled = apps.any { it.isReallyInstalled() }, - isFavourite = favourite != null, - isStarred = starred != null, - isSeen = repo.id in seenIds, - isCurrentUserOwner = - currentLogin != null && - repo.owner.login.equals(currentLogin, ignoreCase = true), isUpdateAvailable = apps.any { it.hasActualUpdate() }, - repository = repo.toUi(), + isFavourite = repo.id in favourites, + isStarred = repo.id in starred, ) } } - fun onAction(action: HomeAction) { - when (action) { - HomeAction.Refresh -> { - viewModelScope.launch { - syncInstalledAppsUseCase() - nextPageIndex = 1 - loadRepos(isInitial = true) - } - } - - HomeAction.Retry -> { - nextPageIndex = 1 - loadRepos(isInitial = true) - } - - HomeAction.LoadMore -> { - logger.debug( - "LoadMore action: isLoading=${_state.value.isLoading}, isLoadingMore=${_state.value.isLoadingMore}, hasMore=${_state.value.hasMorePages}", - ) - - if (!_state.value.isLoadingMore && !_state.value.isLoading && _state.value.hasMorePages) { - loadRepos(isInitial = false) - } - } - - is HomeAction.SwitchTopic -> { - val current = _state.value.selectedTopics - val target = - if (action.topic in current) current - action.topic else current + action.topic - if (target != current) { - nextPageIndex = 1 - switchCategoryJob?.cancel() - switchCategoryJob = - viewModelScope.launch { - loadRepos( - isInitial = true, - topics = target, - topicsExplicitlySet = true, - )?.join() ?: return@launch - _events.send(HomeEvent.OnScrollToListTop) - } - } - } - - is HomeAction.SwitchCategory -> { - if (_state.value.currentCategory != action.category) { - nextPageIndex = 1 - switchCategoryJob?.cancel() - switchCategoryJob = - viewModelScope.launch { - loadRepos(isInitial = true, category = action.category)?.join() - ?: return@launch - _events.send(HomeEvent.OnScrollToListTop) - } - } - } - - is HomeAction.OnShareClick -> { - viewModelScope.launch { - runCatching { - shareManager.shareText("https://github-store.org/app?repo=${action.repo.fullName}") - }.onFailure { t -> - logger.error("Failed to share link: ${t.message}") - _events.send( - OnMessage(getString(Res.string.failed_to_share_link)), - ) - return@launch - } - - if (platform != Platform.ANDROID) { - _events.send(OnMessage(getString(Res.string.link_copied_to_clipboard))) - } - } - } - - is HomeAction.TogglePlatform -> { - val current = _state.value.selectedPlatforms - val target = current.toggle(action.platform) - if (target != current) { - nextPageIndex = 1 - switchCategoryJob?.cancel() - switchCategoryJob = - viewModelScope.launch { - loadRepos(isInitial = true, platforms = target)?.join() - ?: return@launch - _events.send(OnScrollToListTop) - } - } - } - - HomeAction.OnSelectAllPlatforms -> { - val target = - if (_state.value.selectedPlatforms.isEmpty()) { - setOf(devicePlatformAsDiscovery()) - } else { - emptySet() - } - if (target != _state.value.selectedPlatforms) { - nextPageIndex = 1 - switchCategoryJob?.cancel() - switchCategoryJob = - viewModelScope.launch { - loadRepos(isInitial = true, platforms = target)?.join() - ?: return@launch - _events.send(OnScrollToListTop) - } + private fun syncSystemState() { + viewModelScope.launch { + try { + val result = syncInstalledAppsUseCase() + if (result.isFailure) { + logger.warn("Initial sync had issues: ${result.exceptionOrNull()?.message}") } + } catch (e: Exception) { + logger.error("Initial sync failed: ${e.message}") } + } + } - HomeAction.OnTogglePlatformPopup -> { - _state.update { - it.copy( - isPlatformPopupVisible = !it.isPlatformPopupVisible, + private fun observeInstalledApps() { + viewModelScope.launch { + installedAppsRepository.getAllInstalledApps().collect { apps -> + val byRepo = apps.groupBy { it.repoId } + rawState.update { snapshot -> + snapshot.copy( + installedById = byRepo, + isUpdateAvailable = apps.any { it.hasActualUpdate() }, + hot = snapshot.hot.restampInstall(byRepo), + trending = snapshot.trending.restampInstall(byRepo), + popular = snapshot.popular.restampInstall(byRepo), + starred = snapshot.starred.restampInstall(byRepo), ) } } - - is HomeAction.OnRepositoryClick -> { - // Handled in composable - } - - is HomeAction.OnRepositoryDeveloperClick -> { - // Handled in composable - } - - is HomeAction.OnHideRepository -> { - val repo = action.repo - viewModelScope.launch { - try { - hiddenReposRepository.hide( - repoId = repo.id, - repoName = repo.name, - repoOwner = repo.owner.login, - repoOwnerAvatarUrl = repo.owner.avatarUrl, - ) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - logger.warn("Hide repository failed for ${repo.id}: ${e.message}") - } - } - } - - is HomeAction.OnUndoHideRepository -> { - viewModelScope.launch { - try { - hiddenReposRepository.unhide(action.repoId) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - logger.warn("Unhide repository failed for ${action.repoId}: ${e.message}") - } - } - } - - is HomeAction.OnMarkAsSeen -> { - val repo = action.repo - viewModelScope.launch { - try { - seenReposRepository.markAsSeen( - repoId = repo.id, - repoName = repo.name, - repoOwner = repo.owner.login, - repoOwnerAvatarUrl = repo.owner.avatarUrl, - repoDescription = repo.description, - primaryLanguage = repo.language, - repoUrl = repo.htmlUrl, - ) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - logger.warn("Mark as seen failed for ${repo.id}: ${e.message}") - } - } - } - - is HomeAction.OnMarkAsUnseen -> { - viewModelScope.launch { - try { - seenReposRepository.removeFromHistory(action.repoId) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - logger.warn("Mark as unseen failed for ${action.repoId}: ${e.message}") - } - } - } - - HomeAction.OnSearchClick -> { - // Handled in composable - } - - HomeAction.OnSettingsClick -> { - // Handled in composable - } - - HomeAction.OnAppsClick -> { - // Handled in composable - } } } - /** - * Tap-from-`All` (empty selection) selects only the tapped platform — - * not "every other platform" — which is what users intuit from the - * popup. Tapping the only remaining platform deselects it and falls - * back to the device's own platform so the home feed never ends up - * empty. Reaching every selectable platform collapses to the `All` - * representation (empty set) to keep the chip row tidy. - */ - private fun Set.toggle(platform: DiscoveryPlatform): Set { - if (platform == DiscoveryPlatform.All) return emptySet() - - if (isEmpty()) return setOf(platform) - - val mutated = - if (platform in this) this - platform else this + platform - - return when { - mutated.size == DiscoveryPlatform.selectablePlatforms.size -> emptySet() - mutated.isEmpty() -> setOf(devicePlatformAsDiscovery()) - else -> mutated + private fun observeDiscoveryPlatforms() { + viewModelScope.launch { + tweaksRepository.getDiscoveryPlatforms().collect { platforms -> + rawState.update { it.copy(selectedPlatforms = platforms) } + } } } - private fun devicePlatformAsDiscovery(): DiscoveryPlatform = - when (platform) { - Platform.ANDROID -> DiscoveryPlatform.Android - Platform.WINDOWS -> DiscoveryPlatform.Windows - Platform.MACOS -> DiscoveryPlatform.Macos - Platform.LINUX -> DiscoveryPlatform.Linux - } - private fun observeSeenRepos() { viewModelScope.launch { seenReposRepository.getAllSeenRepoIds().collect { ids -> - _state.update { current -> - current.copy( - seenRepoIds = ids, - repos = - current.repos - .map { repo -> - repo.copy(isSeen = repo.repository.id in ids) - }.toImmutableList(), - ) - } + rawState.update { it.copy(seenIds = ids) } } } } @@ -651,11 +395,7 @@ class HomeViewModel( private fun observeHiddenRepos() { viewModelScope.launch { hiddenReposRepository.getAllHiddenRepoIds().collect { ids -> - // Track IDs only — mirror the `seenRepoIds` pattern so - // unhiding restores the repo to the visible list without - // a refresh. `HomeRoot.visibleRepos` filters at render - // time using these IDs. - _state.update { it.copy(hiddenRepoIds = ids) } + rawState.update { it.copy(hiddenIds = ids) } } } } @@ -663,32 +403,27 @@ class HomeViewModel( private fun observeHideSeenEnabled() { viewModelScope.launch { tweaksRepository.getHideSeenEnabled().collect { enabled -> - _state.update { it.copy(isHideSeenEnabled = enabled) } + rawState.update { it.copy(isHideSeenEnabled = enabled) } } } } private fun observeCurrentUser() { viewModelScope.launch { - profileRepository.getUser().collect { user -> - currentUserLogin = user?.username - // Re-stamp `isCurrentUserOwner` on already-loaded repos so - // logging in (or switching accounts) immediately flips - // badges without forcing a full reload. - val login = user?.username - _state.update { current -> - current.copy( - repos = - current.repos - .map { repo -> - repo.copy( - isCurrentUserOwner = - login != null && - repo.repository.owner.login.equals(login, ignoreCase = true), - ) - }.toImmutableList(), + userSessionRepository.getUser().collect { user -> + val signedIn = user != null + val previouslySignedIn = rawState.value.isUserSignedIn + rawState.update { + it.copy( + isUserSignedIn = signedIn, + currentUserLogin = user?.username, ) } + if (signedIn != previouslySignedIn) { + if (signedIn) loadStarred() else rawState.update { + it.copy(starred = emptyList(), isStarredLoading = false) + } + } } } } @@ -696,16 +431,14 @@ class HomeViewModel( private fun observeFavourites() { viewModelScope.launch { favouritesRepository.getAllFavorites().collect { favourites -> - val favouritesMap = favourites.associateBy { it.repoId } - _state.update { current -> - current.copy( - repos = - current.repos - .map { homeRepo -> - homeRepo.copy( - isFavourite = favouritesMap.containsKey(homeRepo.repository.id), - ) - }.toImmutableList(), + val ids = favourites.map { it.repoId }.toSet() + rawState.update { snapshot -> + snapshot.copy( + favouriteIds = ids, + hot = snapshot.hot.restampFavourite(ids), + trending = snapshot.trending.restampFavourite(ids), + popular = snapshot.popular.restampFavourite(ids), + starred = snapshot.starred.restampFavourite(ids), ) } } @@ -715,16 +448,14 @@ class HomeViewModel( private fun observeStarredRepos() { viewModelScope.launch { starredRepository.getAllStarred().collect { starredRepos -> - val starredReposById = starredRepos.associateBy { it.repoId } - _state.update { current -> - current.copy( - repos = - current.repos - .map { homeRepo -> - homeRepo.copy( - isStarred = starredReposById.containsKey(homeRepo.repository.id), - ) - }.toImmutableList(), + val ids = starredRepos.map { it.repoId }.toSet() + rawState.update { snapshot -> + snapshot.copy( + starredIds = ids, + hot = snapshot.hot.restampStarred(ids), + trending = snapshot.trending.restampStarred(ids), + popular = snapshot.popular.restampStarred(ids), + starred = snapshot.starred.restampStarred(ids), ) } } @@ -733,7 +464,76 @@ class HomeViewModel( override fun onCleared() { super.onCleared() - currentJob?.cancel() - topicSupplementJob?.cancel() + loadJob?.cancel() } } + +private fun List.restampInstall(byRepo: Map>) = map { item -> + val apps = byRepo[item.raw.id].orEmpty() + item.copy( + isInstalled = apps.any { it.isReallyInstalled() }, + isUpdateAvailable = apps.any { it.hasActualUpdate() }, + ) +} + +private fun List.restampFavourite(ids: Set) = map { + it.copy(isFavourite = it.raw.id in ids) +} + +private fun List.restampStarred(ids: Set) = map { + it.copy(isStarred = it.raw.id in ids) +} + +private fun RawHomeState.toView(): HomeState { + val hotVisible = hot.filterVisible(hiddenIds, seenIds, isHideSeenEnabled) + val leadRaw = hotVisible.firstOrNull() + val leadCard = leadRaw?.toCard(currentUserLogin, seenIds) + val hotCards = hotVisible.drop(1).take(6).map { it.toCard(currentUserLogin, seenIds) }.toImmutableList() + val trendingCards = trending.filterVisible(hiddenIds, seenIds, isHideSeenEnabled).take(6) + .map { it.toCard(currentUserLogin, seenIds) }.toImmutableList() + val popularCards = popular.filterVisible(hiddenIds, seenIds, isHideSeenEnabled).take(6) + .map { it.toCard(currentUserLogin, seenIds) }.toImmutableList() + val starredCards = starred.filterVisible(hiddenIds, seenIds, isHideSeenEnabled).take(5) + .map { it.toCard(currentUserLogin, seenIds) }.toImmutableList() + val actionSheetCard = actionSheetRepoId?.let { id -> + (hot + trending + popular + starred).firstOrNull { it.raw.id == id }?.toCard(currentUserLogin, seenIds) + } + return HomeState( + lead = leadCard, + hot = hotCards, + trending = trendingCards, + popular = popularCards, + starred = starredCards, + isHotLoading = isHotLoading, + isTrendingLoading = isTrendingLoading, + isPopularLoading = isPopularLoading, + isStarredLoading = isStarredLoading, + errorMessage = errorMessage, + selectedPlatforms = selectedPlatforms, + isPlatformPopupVisible = isPlatformPopupVisible, + isUpdateAvailable = isUpdateAvailable, + isHideSeenEnabled = isHideSeenEnabled, + isUserSignedIn = isUserSignedIn, + actionSheetCard = actionSheetCard, + ) +} + +private fun List.filterVisible( + hiddenIds: Set, + seenIds: Set, + hideSeenEnabled: Boolean, +): List = filter { item -> + item.raw.id !in hiddenIds && (!hideSeenEnabled || item.raw.id !in seenIds) +} + +private fun RawRepo.toCard(currentUserLogin: String?, seenIds: Set): HomeRepoCardUi = + toHomeRepoCardUi( + repo = raw, + isInstalled = isInstalled, + isUpdateAvailable = isUpdateAvailable, + isFavourite = isFavourite, + isStarred = isStarred, + isSeen = raw.id in seenIds, + isCurrentUserOwner = currentUserLogin != null && + raw.owner.login.equals(currentUserLogin, ignoreCase = true), + ) diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListAction.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListAction.kt new file mode 100644 index 000000000..bd62ccd0d --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListAction.kt @@ -0,0 +1,8 @@ +package zed.rainxch.home.presentation.categorylist + +sealed interface CategoryListAction { + data object OnNavigateBack : CategoryListAction + data object OnLoadMore : CategoryListAction + data object OnRefresh : CategoryListAction + data class OnRepoClick(val repoId: Long) : CategoryListAction +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListEvent.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListEvent.kt new file mode 100644 index 000000000..5dbcbef4b --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListEvent.kt @@ -0,0 +1,5 @@ +package zed.rainxch.home.presentation.categorylist + +sealed interface CategoryListEvent { + data class NavigateToDetails(val repoId: Long) : CategoryListEvent +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListRoot.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListRoot.kt new file mode 100644 index 000000000..2941a74a6 --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListRoot.kt @@ -0,0 +1,178 @@ +package zed.rainxch.home.presentation.categorylist + +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 +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.cd_back +import zed.rainxch.githubstore.core.presentation.res.home_section_hot_releases +import zed.rainxch.githubstore.core.presentation.res.home_section_most_popular +import zed.rainxch.githubstore.core.presentation.res.home_section_trending_now +import zed.rainxch.core.presentation.components.buttons.IconButton +import zed.rainxch.core.presentation.utils.ObserveAsEvents +import zed.rainxch.core.presentation.vocabulary.Squiggle +import zed.rainxch.home.domain.model.HomeCategory +import zed.rainxch.home.presentation.components.PopularRowItem +import zed.rainxch.home.presentation.components.TrendingRowItem + +@Composable +fun CategoryListRoot( + category: HomeCategory, + onNavigateBack: () -> Unit, + onNavigateToDetails: (Long) -> Unit, + viewModel: CategoryListViewModel = koinViewModel { parametersOf(category) }, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + ObserveAsEvents(viewModel.events) { event -> + when (event) { + is CategoryListEvent.NavigateToDetails -> onNavigateToDetails(event.repoId) + } + } + CategoryListScreen( + state = state, + onAction = viewModel::onAction, + onBack = onNavigateBack, + ) +} + +@Composable +fun CategoryListScreen( + state: CategoryListState, + onAction: (CategoryListAction) -> Unit, + onBack: () -> Unit, +) { + val listState = rememberLazyListState() + val shouldLoadMore by remember { + derivedStateOf { + val total = listState.layoutInfo.totalItemsCount + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + total > 0 && lastVisible >= total - 4 + } + } + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore && !state.isLoadingMore && state.hasMorePages) { + onAction(CategoryListAction.OnLoadMore) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .systemBarsPadding(), + ) { + CategoryListTopBar(state.category, onBack) + if (state.isLoading && state.cards.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Column + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(items = state.cards, key = { it.id }) { card -> + val rank = state.cards.indexOf(card) + 1 + when (state.category) { + HomeCategory.MOST_POPULAR -> PopularRowItem( + rank = rank, + card = card, + onClick = { onAction(CategoryListAction.OnRepoClick(card.id)) }, + onLongClick = { }, + ) + else -> TrendingRowItem( + rank = rank, + card = card, + onClick = { onAction(CategoryListAction.OnRepoClick(card.id)) }, + onLongClick = { }, + ) + } + } + if (state.isLoadingMore) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + } + } +} + +@Composable +private fun CategoryListTopBar(category: HomeCategory, onBack: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.cd_back), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Column(modifier = Modifier.padding(start = 4.dp)) { + Text( + text = stringResource( + when (category) { + HomeCategory.HOT_RELEASE -> Res.string.home_section_hot_releases + HomeCategory.TRENDING -> Res.string.home_section_trending_now + HomeCategory.MOST_POPULAR -> Res.string.home_section_most_popular + }, + ), + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 26.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.size(4.dp)) + Squiggle() + } + } +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListState.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListState.kt new file mode 100644 index 000000000..14642bead --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListState.kt @@ -0,0 +1,15 @@ +package zed.rainxch.home.presentation.categorylist + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import zed.rainxch.home.domain.model.HomeCategory +import zed.rainxch.home.presentation.model.HomeRepoCardUi + +data class CategoryListState( + val category: HomeCategory = HomeCategory.HOT_RELEASE, + val cards: ImmutableList = persistentListOf(), + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val hasMorePages: Boolean = true, + val errorMessage: String? = null, +) diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListViewModel.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListViewModel.kt new file mode 100644 index 000000000..9a5fc3497 --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/categorylist/CategoryListViewModel.kt @@ -0,0 +1,115 @@ +package zed.rainxch.home.presentation.categorylist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import zed.rainxch.home.domain.model.HomeCategory +import zed.rainxch.home.domain.repository.HomeRepository +import zed.rainxch.home.presentation.model.toHomeRepoCardUi + +class CategoryListViewModel( + private val category: HomeCategory, + private val homeRepository: HomeRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(CategoryListState(category = category)) + val state = _state.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + private var nextPage = 1 + + init { + loadPage(initial = true) + } + + fun onAction(action: CategoryListAction) { + when (action) { + CategoryListAction.OnLoadMore -> + if (!_state.value.isLoadingMore && _state.value.hasMorePages) loadPage(initial = false) + CategoryListAction.OnRefresh -> { + nextPage = 1 + _state.update { + it.copy( + cards = persistentListOf(), + hasMorePages = true, + errorMessage = null, + ) + } + loadPage(initial = true) + } + is CategoryListAction.OnRepoClick -> viewModelScope.launch { + _events.send(CategoryListEvent.NavigateToDetails(action.repoId)) + } + CategoryListAction.OnNavigateBack -> Unit + } + } + + private fun loadPage(initial: Boolean) { + viewModelScope.launch { + _state.update { + if (initial) it.copy(isLoading = true) else it.copy(isLoadingMore = true) + } + val flow = when (category) { + HomeCategory.HOT_RELEASE -> homeRepository.getHotReleaseRepositories(emptySet(), nextPage) + HomeCategory.TRENDING -> homeRepository.getTrendingRepositories(emptySet(), nextPage) + HomeCategory.MOST_POPULAR -> homeRepository.getMostPopular(emptySet(), nextPage) + } + runCatching { flow.firstOrNull() } + .onSuccess { paginated -> + if (paginated == null) { + _state.update { + it.copy( + isLoading = false, + isLoadingMore = false, + hasMorePages = false, + ) + } + return@onSuccess + } + val existing = _state.value.cards + val incoming = paginated.repos.map { repo -> + toHomeRepoCardUi( + repo = repo, + isInstalled = false, + isUpdateAvailable = false, + isFavourite = false, + isStarred = false, + isSeen = false, + isCurrentUserOwner = false, + ) + } + val existingIds = existing.map { it.id }.toHashSet() + val merged = (existing + incoming.filter { it.id !in existingIds }).toImmutableList() + nextPage += 1 + _state.update { + it.copy( + cards = merged, + isLoading = false, + isLoadingMore = false, + hasMorePages = incoming.isNotEmpty(), + errorMessage = null, + ) + } + } + .onFailure { e -> + _state.update { + it.copy( + isLoading = false, + isLoadingMore = false, + errorMessage = e.message ?: "Failed to load", + ) + } + } + } + } +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomeFilterChips.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomeFilterChips.kt deleted file mode 100644 index 4452539e0..000000000 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomeFilterChips.kt +++ /dev/null @@ -1,360 +0,0 @@ -package zed.rainxch.home.presentation.components - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import zed.rainxch.home.domain.model.HomeCategory -import zed.rainxch.home.presentation.utils.displayText - -@Composable -fun LiquidGlassCategoryChips( - categories: List, - selectedCategory: HomeCategory, - onCategorySelected: (HomeCategory) -> Unit, - modifier: Modifier = Modifier, -) { - val density = LocalDensity.current - - val isDarkTheme = - !MaterialTheme.colorScheme.background - .luminance() - .let { it > 0.5f } - - val itemPositions = remember { mutableMapOf>() } - var selectedItemPos by remember { mutableStateOf?>(null) } - - val selectedIndex = categories.indexOf(selectedCategory) - - val rowPaddingDp = 6.dp - val rowPaddingPx = with(density) { rowPaddingDp.toPx() } - val insetPx = with(density) { 2.dp.toPx() } - - val indicatorX = remember { Animatable(0f) } - val indicatorWidth = remember { Animatable(0f) } - - LaunchedEffect(selectedIndex, selectedItemPos) { - val raw = selectedItemPos ?: itemPositions[selectedIndex] ?: return@LaunchedEffect - val targetX = raw.first + rowPaddingPx - insetPx - val targetW = raw.second + insetPx * 2f - - launch { - // E4.2: tween over spring for the chip-row indicator — same - // jitter rationale as BottomNavigation. Selecting a category - // hundreds of times per session shouldn't overshoot. - indicatorX.animateTo( - targetValue = targetX, - animationSpec = tween(durationMillis = 220), - ) - } - launch { - indicatorWidth.animateTo( - targetValue = targetW, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), - ) - } - } - - val glassHighColor = - if (isDarkTheme) Color.White.copy(alpha = .14f) else Color.White.copy(alpha = .50f) - val glassLowColor = - if (isDarkTheme) Color.White.copy(alpha = .05f) else Color.White.copy(alpha = .18f) - val specularColor = - if (isDarkTheme) Color.White.copy(alpha = .20f) else Color.White.copy(alpha = .55f) - val innerGlowColor = - if (isDarkTheme) Color.White.copy(alpha = .04f) else Color.White.copy(alpha = .10f) - val borderColor = if (isDarkTheme) Color.White.copy(alpha = .10f) else Color.Transparent - - val containerShape = RoundedCornerShape(20.dp) - - Box( - modifier = - modifier - .fillMaxWidth() - .clip(containerShape) - .background(MaterialTheme.colorScheme.surfaceContainer) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, - shape = containerShape, - ), - ) { - Box( - modifier = - Modifier - .matchParentSize() - .drawBehind { - if (indicatorWidth.value > 0f) { - val pillTop = 5.dp.toPx() - val pillHeight = size.height - 10.dp.toPx() - val pillCorner = 14.dp.toPx() - val pillRadius = CornerRadius(pillCorner) - - if (isDarkTheme) { - drawRoundRect( - color = borderColor, - topLeft = - Offset( - indicatorX.value - .5.dp.toPx(), - pillTop - .5.dp.toPx(), - ), - size = - Size( - indicatorWidth.value + 1.dp.toPx(), - pillHeight + 1.dp.toPx(), - ), - cornerRadius = pillRadius, - style = Stroke(width = 1.dp.toPx()), - ) - } - - drawRoundRect( - brush = - Brush.verticalGradient( - colors = listOf(glassHighColor, glassLowColor), - startY = pillTop, - endY = pillTop + pillHeight, - ), - topLeft = Offset(indicatorX.value, pillTop), - size = Size(indicatorWidth.value, pillHeight), - cornerRadius = pillRadius, - ) - - val specLeft = indicatorX.value + indicatorWidth.value * .12f - val specWidth = indicatorWidth.value * .76f - drawRoundRect( - brush = - Brush.horizontalGradient( - colors = - listOf( - Color.Transparent, - specularColor, - specularColor.copy(alpha = specularColor.alpha * .6f), - Color.Transparent, - ), - startX = specLeft, - endX = specLeft + specWidth, - ), - topLeft = Offset(specLeft, pillTop + 1.dp.toPx()), - size = Size(specWidth, 1.5.dp.toPx()), - cornerRadius = CornerRadius(1.dp.toPx()), - ) - - drawRoundRect( - brush = - Brush.verticalGradient( - colors = listOf(Color.Transparent, innerGlowColor), - startY = pillTop + pillHeight - 6.dp.toPx(), - endY = pillTop + pillHeight, - ), - topLeft = - Offset( - indicatorX.value + 6.dp.toPx(), - pillTop + pillHeight - 5.dp.toPx(), - ), - size = Size(indicatorWidth.value - 12.dp.toPx(), 4.dp.toPx()), - cornerRadius = CornerRadius(2.dp.toPx()), - ) - - val edgeAlpha = if (isDarkTheme) .06f else .12f - drawRoundRect( - brush = - Brush.horizontalGradient( - colors = - listOf( - Color.White.copy(alpha = edgeAlpha), - Color.Transparent, - ), - startX = indicatorX.value, - endX = indicatorX.value + 4.dp.toPx(), - ), - topLeft = Offset(indicatorX.value, pillTop + 4.dp.toPx()), - size = Size(3.dp.toPx(), pillHeight - 8.dp.toPx()), - cornerRadius = CornerRadius(1.5.dp.toPx()), - ) - drawRoundRect( - brush = - Brush.horizontalGradient( - colors = - listOf( - Color.Transparent, - Color.White.copy(alpha = edgeAlpha), - ), - startX = indicatorX.value + indicatorWidth.value - 4.dp.toPx(), - endX = indicatorX.value + indicatorWidth.value, - ), - topLeft = - Offset( - indicatorX.value + indicatorWidth.value - 3.dp.toPx(), - pillTop + 4.dp.toPx(), - ), - size = Size(3.dp.toPx(), pillHeight - 8.dp.toPx()), - cornerRadius = CornerRadius(1.5.dp.toPx()), - ) - } - }, - ) - - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = rowPaddingDp, vertical = 3.dp), - horizontalArrangement = Arrangement.spacedBy(0.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - categories.forEachIndexed { index, category -> - LiquidGlassCategoryChip( - category = category, - isSelected = category == selectedCategory, - onSelect = { onCategorySelected(category) }, - modifier = Modifier.weight(1f), - onPositioned = { x, width -> - itemPositions[index] = x to width - if (index == selectedIndex) { - selectedItemPos = x to width - } - if (index == selectedIndex && indicatorWidth.value == 0f) { - indicatorX.snapTo(x + rowPaddingPx - insetPx) - indicatorWidth.snapTo(width + insetPx * 2f) - } - }, - ) - } - } - } -} - -@Composable -private fun LiquidGlassCategoryChip( - category: HomeCategory, - isSelected: Boolean, - onSelect: () -> Unit, - modifier: Modifier = Modifier, - onPositioned: suspend (x: Float, width: Float) -> Unit, -) { - val scope = rememberCoroutineScope() - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - - val pressScale by animateFloatAsState( - targetValue = if (isPressed) 0.90f else 1f, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - label = "chipPressScale", - ) - - val selectedAlpha by animateFloatAsState( - targetValue = if (isSelected) 1f else 0f, - animationSpec = tween(200), - label = "selectedAlpha", - ) - - val textColor by animateColorAsState( - targetValue = - if (isSelected) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = .65f) - }, - animationSpec = tween(250), - label = "chipTextColor", - ) - - Box( - modifier = - modifier - .clip(CircleShape) - .clickable( - interactionSource = interactionSource, - indication = null, - ) { onSelect() } - .onGloballyPositioned { coordinates -> - val x = coordinates.positionInParent().x - val width = coordinates.size.width.toFloat() - scope.launch { onPositioned(x, width) } - }.graphicsLayer { - scaleX = pressScale - scaleY = pressScale - }.padding(vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = category.displayText(), - style = - MaterialTheme.typography.labelLarge.copy( - fontWeight = FontWeight.Medium, - ), - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.graphicsLayer { alpha = 1f - selectedAlpha }, - ) - Text( - text = category.displayText(), - style = - MaterialTheme.typography.labelLarge.copy( - fontWeight = FontWeight.Bold, - ), - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.graphicsLayer { alpha = selectedAlpha }, - ) - } - } -} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomeTopBar.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomeTopBar.kt new file mode 100644 index 000000000..6201cb2ca --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomeTopBar.kt @@ -0,0 +1,17 @@ +package zed.rainxch.home.presentation.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.chrome.GhsHomeTopBar +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.home_topbar_discover + +@Composable +fun HomeTopBar(modifier: Modifier = Modifier) { + GhsHomeTopBar( + title = stringResource(Res.string.home_topbar_discover), + modifier = modifier, + applyStatusBarPadding = false, + ) +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HotCardItem.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HotCardItem.kt new file mode 100644 index 000000000..44c6b7dec --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HotCardItem.kt @@ -0,0 +1,232 @@ +package zed.rainxch.home.presentation.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import zed.rainxch.core.presentation.components.GitHubStoreImage +import zed.rainxch.core.presentation.theme.shapes.CornerRadii +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape +import zed.rainxch.core.presentation.theme.tokens.EmberPalette +import zed.rainxch.core.presentation.utils.formatCount +import zed.rainxch.core.presentation.vocabulary.FreshnessRing +import zed.rainxch.core.presentation.vocabulary.PlatformGlyph +import zed.rainxch.core.presentation.vocabulary.TopicGlyph +import zed.rainxch.home.presentation.model.HomeRepoCardUi + +private val HotCardShape = WonkySquircleShape( + topStart = CornerRadii(24.dp, 18.dp), + topEnd = CornerRadii(18.dp, 24.dp), + bottomEnd = CornerRadii(24.dp, 18.dp), + bottomStart = CornerRadii(18.dp, 24.dp), +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HotCardItem( + card: HomeRepoCardUi, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val transition = rememberInfiniteTransition(label = "hot-row-flame") + val flamePulse by transition.animateFloat( + initialValue = 0.65f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1100, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "hot-row-flame-pulse", + ) + + Box( + modifier = modifier + .width(280.dp) + .height(186.dp) + .clip(HotCardShape) + .background(MaterialTheme.colorScheme.surface) + .border(width = 1.dp, color = MaterialTheme.colorScheme.outline, shape = HotCardShape) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth(), + ) { + FreshnessRing( + daysSinceRelease = card.daysSinceUpdate, + sizeDp = 42, + color = MaterialTheme.colorScheme.primary, + ) { + GitHubStoreImage( + imageModel = { card.ownerAvatarUrl }, + modifier = Modifier.size(42.dp).clip(CircleShape), + extractDominantFor = card.ownerAvatarUrl, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = card.ownerLogin, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = card.name, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(EmberPalette.Deep) + .padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon( + imageVector = Icons.Default.LocalFireDepartment, + contentDescription = null, + modifier = Modifier.size(11.dp), + tint = Color.White.copy(alpha = flamePulse), + ) + Text( + text = card.relativeAgoLabel, + color = Color.White, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + ), + ) + } + } + if (card.description.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + text = card.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.weight(1f)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon( + imageVector = Icons.Outlined.Star, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatCount(card.starsCount), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + } + if (card.downloadsCount > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatCount(card.downloadsCount), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + if (card.topics.isNotEmpty() || card.platforms.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 0.5.dp, + ) + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + card.topics.take(2).forEach { topic -> + TopicGlyph(topic = topic, sizeDp = 14) + } + Box(Modifier.weight(1f)) + card.platforms.forEach { kind -> + PlatformGlyph(kind = kind, supported = true, sizeDp = 14) + } + } + } + } + } +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/LeadCard.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/LeadCard.kt new file mode 100644 index 000000000..3aaa7a801 --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/LeadCard.kt @@ -0,0 +1,280 @@ +package zed.rainxch.home.presentation.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.GitHubStoreImage +import zed.rainxch.core.presentation.theme.shapes.CornerRadii +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape +import zed.rainxch.core.presentation.theme.tokens.EmberPalette +import zed.rainxch.core.presentation.utils.formatCount +import zed.rainxch.core.presentation.vocabulary.PlatformGlyph +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.home_action_get +import zed.rainxch.githubstore.core.presentation.res.open +import zed.rainxch.githubstore.core.presentation.res.update +import zed.rainxch.home.presentation.model.HomeRepoCardUi + +private val LeadShape = WonkySquircleShape( + topStart = CornerRadii(32.dp, 26.dp), + topEnd = CornerRadii(26.dp, 32.dp), + bottomEnd = CornerRadii(32.dp, 26.dp), + bottomStart = CornerRadii(26.dp, 32.dp), +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LeadCard( + card: HomeRepoCardUi, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val borderColor = EmberPalette.Hot.copy(alpha = 0.55f) + val inkColor = Color.White + val ownerAlpha = 0.78f + val descAlpha = 0.92f + val statAlpha = 0.95f + + Box( + modifier = modifier + .fillMaxWidth() + .height(248.dp) + .clip(LeadShape) + .background(EmberPalette.Ash) + .drawBehind { + val warmth = Brush.linearGradient( + colorStops = arrayOf( + 0f to EmberPalette.Hot.copy(alpha = 0.55f), + 0.6f to EmberPalette.Warm.copy(alpha = 0.28f), + 1f to Color.Transparent, + ), + start = Offset(0f, 0f), + end = Offset(size.width * 0.85f, size.height), + ) + drawRect(brush = warmth) + val sun = Brush.radialGradient( + colors = listOf( + EmberPalette.Amber.copy(alpha = 0.22f), + Color.Transparent, + ), + center = Offset(size.width * 0.18f, size.height * 0.25f), + radius = size.minDimension * 0.7f, + ) + drawRect(brush = sun) + } + .border(width = 1.5.dp, color = borderColor, shape = LeadShape) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 18.dp, vertical = 18.dp), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + modifier = Modifier + .size(76.dp) + .clip(CircleShape) + .background(EmberPalette.Ash) + .border(2.5.dp, EmberPalette.Deep, CircleShape), + contentAlignment = Alignment.Center, + ) { + GitHubStoreImage( + imageModel = { card.ownerAvatarUrl }, + modifier = Modifier.size(68.dp).clip(CircleShape), + extractDominantFor = card.ownerAvatarUrl, + ) + } + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = card.ownerLogin, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 12.sp, + ), + color = inkColor.copy(alpha = ownerAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.size(6.dp)) + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(EmberPalette.Deep) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "HOT", + color = Color.White, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Black, + fontSize = 9.5.sp, + letterSpacing = 0.8.sp, + ), + ) + Text( + text = "·", + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), + ) + Text( + text = card.relativeAgoLabel, + color = Color.White, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + ), + ) + } + } + Text( + text = card.name, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Black, + fontSize = 28.sp, + letterSpacing = (-0.3).sp, + ), + color = inkColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(Modifier.height(10.dp)) + if (card.description.isNotBlank()) { + Text( + text = card.description, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp), + color = inkColor.copy(alpha = descAlpha), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(10.dp)) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Outlined.Star, + contentDescription = null, + tint = inkColor.copy(alpha = statAlpha), + modifier = Modifier.size(15.dp), + ) + Text( + text = formatCount(card.starsCount), + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + ), + color = inkColor.copy(alpha = statAlpha), + ) + } + if (card.downloadsCount > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = null, + tint = inkColor.copy(alpha = statAlpha), + modifier = Modifier.size(15.dp), + ) + Text( + text = formatCount(card.downloadsCount), + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + ), + color = inkColor.copy(alpha = statAlpha), + ) + } + } + Box(modifier = Modifier.weight(1f)) + if (card.platforms.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + card.platforms.forEach { kind -> + PlatformGlyph(kind = kind, supported = true, sizeDp = 15) + } + } + } + } + Spacer(Modifier.weight(1f)) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(50)) + .background(EmberPalette.Deep) + .padding(vertical = 13.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource( + when { + card.isUpdateAvailable -> Res.string.update + card.isInstalled -> Res.string.open + else -> Res.string.home_action_get + }, + ), + color = Color.White, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.sp, + ), + ) + } + } + } +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/RepositoryActionsSheet.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/RepositoryActionsSheet.kt new file mode 100644 index 000000000..5778f01c9 --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/RepositoryActionsSheet.kt @@ -0,0 +1,134 @@ +package zed.rainxch.home.presentation.components + +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.GitHubStoreImage +import zed.rainxch.core.presentation.components.overlays.GhsBottomSheet +import zed.rainxch.core.presentation.model.GithubRepoSummaryUi +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.hide_repository +import zed.rainxch.githubstore.core.presentation.res.mark_as_unviewed +import zed.rainxch.githubstore.core.presentation.res.mark_as_viewed +import zed.rainxch.githubstore.core.presentation.res.open_on_github +import zed.rainxch.githubstore.core.presentation.res.share_repository + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RepositoryActionsSheet( + repository: GithubRepoSummaryUi, + isSeen: Boolean, + onDismiss: () -> Unit, + onShare: () -> Unit, + onOpenOnGithub: () -> Unit, + onToggleSeen: () -> Unit, + onHide: () -> Unit, +) { + GhsBottomSheet(onDismissRequest = onDismiss) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GitHubStoreImage( + imageModel = { repository.owner.avatarUrl }, + modifier = Modifier.size(36.dp).clip(CircleShape), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = repository.fullName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + repository.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + Spacer(Modifier.height(4.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + SheetRow( + label = stringResource(Res.string.share_repository), + icon = Icons.Default.Share, + onClick = onShare, + ) + SheetRow( + label = stringResource(Res.string.open_on_github), + icon = Icons.Default.OpenInBrowser, + onClick = onOpenOnGithub, + ) + SheetRow( + label = if (isSeen) stringResource(Res.string.mark_as_unviewed) + else stringResource(Res.string.mark_as_viewed), + icon = Icons.Outlined.Visibility, + onClick = onToggleSeen, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + SheetRow( + label = stringResource(Res.string.hide_repository), + icon = Icons.Default.VisibilityOff, + onClick = onHide, + tint = MaterialTheme.colorScheme.error, + ) + } +} + +@Composable +private fun SheetRow( + label: String, + icon: ImageVector, + onClick: () -> Unit, + tint: Color = MaterialTheme.colorScheme.onSurface, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 4.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Icon(imageVector = icon, contentDescription = null, tint = tint) + Text( + text = label, + color = tint, + style = MaterialTheme.typography.bodyLarge, + ) + } +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/SeeAllTile.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/SeeAllTile.kt new file mode 100644 index 000000000..2744acb9b --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/SeeAllTile.kt @@ -0,0 +1,129 @@ +package zed.rainxch.home.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.theme.shapes.CornerRadii +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.home_view_all +import zed.rainxch.githubstore.core.presentation.res.home_view_more + +private val SeeAllHotShape = WonkySquircleShape( + topStart = CornerRadii(24.dp, 18.dp), + topEnd = CornerRadii(18.dp, 24.dp), + bottomEnd = CornerRadii(24.dp, 18.dp), + bottomStart = CornerRadii(18.dp, 24.dp), +) + +@Composable +fun SeeAllHotTile( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(width = 130.dp, height = 186.dp) + .clip(SeeAllHotShape) + .background(MaterialTheme.colorScheme.surface) + .border(width = 1.dp, color = MaterialTheme.colorScheme.outline, shape = SeeAllHotShape) + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 14.dp, vertical = 18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(Res.string.home_view_all), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +fun SeeMoreRow( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val borderColor = MaterialTheme.colorScheme.outline + Box( + modifier = modifier + .fillMaxWidth() + .height(56.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(28.dp)) + .border( + width = 1.dp, + color = borderColor, + shape = androidx.compose.foundation.shape.RoundedCornerShape(28.dp), + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(Res.string.home_view_more), + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/StarredRowItem.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/StarredRowItem.kt new file mode 100644 index 000000000..7e017d43f --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/StarredRowItem.kt @@ -0,0 +1,113 @@ +package zed.rainxch.home.presentation.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.home_action_get +import zed.rainxch.githubstore.core.presentation.res.open +import zed.rainxch.githubstore.core.presentation.res.update +import zed.rainxch.core.presentation.components.GitHubStoreImage +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.cards.RowCard +import zed.rainxch.core.presentation.vocabulary.PlatformGlyph +import zed.rainxch.core.presentation.vocabulary.StarTier +import zed.rainxch.home.presentation.model.HomeRepoCardUi + +private val StarredTint = Color(0xFFC49652) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun StarredRowItem( + card: HomeRepoCardUi, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .combinedClickable(onClick = onClick, onLongClick = onLongClick), + ) { + RowCard { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(StarredTint.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + GitHubStoreImage( + imageModel = { card.ownerAvatarUrl }, + modifier = Modifier.size(36.dp).clip(CircleShape), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = card.name, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + tint = StarredTint, + modifier = Modifier.size(12.dp), + ) + StarTier(stars = card.starsCount, size = 10) + } + } + if (card.platforms.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + card.platforms.forEach { kind -> + PlatformGlyph(kind = kind, supported = true, sizeDp = 13) + } + } + } + GhsButton( + onClick = onClick, + label = stringResource( + when { + card.isUpdateAvailable -> Res.string.update + card.isInstalled -> Res.string.open + else -> Res.string.home_action_get + }, + ), + variant = GhsButtonVariant.Outline, + ) + } + } +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/TrendingRowItem.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/TrendingRowItem.kt new file mode 100644 index 000000000..32005d13a --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/TrendingRowItem.kt @@ -0,0 +1,176 @@ +package zed.rainxch.home.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.outlined.AccountTree +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.color.avatarColorFor +import zed.rainxch.core.presentation.components.GitHubStoreImage +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.cards.RepoStripeCard +import zed.rainxch.core.presentation.components.chips.PlatformsChip +import zed.rainxch.core.presentation.components.chips.StatChip +import zed.rainxch.core.presentation.utils.formatCount +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.home_action_get +import zed.rainxch.githubstore.core.presentation.res.home_rank_format +import zed.rainxch.githubstore.core.presentation.res.open +import zed.rainxch.githubstore.core.presentation.res.update +import zed.rainxch.home.presentation.model.HomeRepoCardUi + +@Composable +fun TrendingRowItem( + card: HomeRepoCardUi, + rank: Int, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + RankRowItem( + card = card, + rank = rank, + rankColor = MaterialTheme.colorScheme.onSurface, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + ) +} + +@Composable +fun PopularRowItem( + card: HomeRepoCardUi, + rank: Int, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + RankRowItem( + card = card, + rank = rank, + rankColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RankRowItem( + card: HomeRepoCardUi, + rank: Int, + rankColor: Color, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val accent = avatarColorFor(card.ownerAvatarUrl, MaterialTheme.colorScheme.primary) + RepoStripeCard( + accent = accent, + ownerLogin = card.ownerLogin, + name = card.name, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + stripeTrailing = { + Text( + text = stringResource(Res.string.home_rank_format, rank), + color = rankColor, + style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.Black, + fontSize = 34.sp, + letterSpacing = (-0.5).sp, + ), + ) + }, + avatar = { + GitHubStoreImage( + imageModel = { card.ownerAvatarUrl }, + modifier = Modifier.size(72.dp).clip(CircleShape), + extractDominantFor = card.ownerAvatarUrl, + ) + }, + chips = { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (card.starsCount > 0) { + StatChip( + label = formatCount(card.starsCount), + leading = { + Icon( + imageVector = Icons.Outlined.Star, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } + if (card.rawRepository.forksCount > 0) { + StatChip( + label = formatCount(card.rawRepository.forksCount), + leading = { + Icon( + imageVector = Icons.Outlined.AccountTree, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } + if (card.downloadsCount > 0) { + StatChip( + label = formatCount(card.downloadsCount), + leading = { + Icon( + imageVector = Icons.Default.Download, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } + if (card.platforms.isNotEmpty()) { + PlatformsChip(platforms = card.platforms) + } + } + }, + languagePill = null, + cta = { + GhsButton( + onClick = onClick, + label = stringResource( + when { + card.isUpdateAvailable -> Res.string.update + card.isInstalled -> Res.string.open + else -> Res.string.home_action_get + }, + ), + variant = GhsButtonVariant.Primary, + trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, + ) + }, + ) +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/model/HomeRepoCardMapper.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/model/HomeRepoCardMapper.kt new file mode 100644 index 000000000..7576cbda0 --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/model/HomeRepoCardMapper.kt @@ -0,0 +1,96 @@ +package zed.rainxch.home.presentation.model + +import kotlinx.collections.immutable.toImmutableList +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import zed.rainxch.core.domain.model.DiscoveryPlatform +import zed.rainxch.core.domain.model.GithubRepoSummary +import zed.rainxch.core.presentation.utils.toUi +import zed.rainxch.core.presentation.vocabulary.AppAccentResolver +import zed.rainxch.core.presentation.vocabulary.PlatformKind +import zed.rainxch.core.presentation.vocabulary.freshnessOf + +fun toHomeRepoCardUi( + repo: GithubRepoSummary, + isInstalled: Boolean, + isUpdateAvailable: Boolean, + isFavourite: Boolean, + isStarred: Boolean, + isSeen: Boolean, + isCurrentUserOwner: Boolean, +): HomeRepoCardUi { + val ui = repo.toUi() + val days = daysSinceIso(repo.updatedAt) + val freshness = freshnessOf(days) + val accent = AppAccentResolver.resolve( + backendHex = null, + topics = repo.topics.orEmpty(), + primaryLanguage = repo.language, + ) + return HomeRepoCardUi( + id = ui.id, + name = ui.name, + ownerLogin = ui.owner.login, + ownerAvatarUrl = ui.owner.avatarUrl, + description = ui.description.orEmpty(), + starsCount = ui.stargazersCount, + downloadsCount = repo.downloadCount, + language = repo.language, + daysSinceUpdate = days, + relativeAgoLabel = relativeAgo(repo.updatedAt), + freshnessState = freshness.state, + freshnessFraction = freshness.ringFraction, + freshnessColor = freshness.color, + accentSaturated = accent.c, + accentLightTint = accent.lt, + accentDarkAlpha = accent.dtAlpha, + topics = ui.topics.orEmpty().toImmutableList(), + platforms = ui.availablePlatforms.mapNotNull { platform -> + when (platform) { + DiscoveryPlatform.Android -> PlatformKind.ANDROID + DiscoveryPlatform.Windows -> PlatformKind.WINDOWS + DiscoveryPlatform.Macos -> PlatformKind.MACOS + DiscoveryPlatform.Linux -> PlatformKind.LINUX + else -> null + } + }.toImmutableList(), + isInstalled = isInstalled, + isUpdateAvailable = isUpdateAvailable, + isFavourite = isFavourite, + isStarred = isStarred, + isSeen = isSeen, + isCurrentUserOwner = isCurrentUserOwner, + rawRepository = ui, + ) +} + +@OptIn(ExperimentalTime::class) +private fun daysSinceIso(isoInstant: String): Int { + val trimmed = isoInstant.trim() + if (trimmed.isEmpty()) return Int.MAX_VALUE + val parsed = runCatching { Instant.parse(trimmed) }.getOrNull() ?: return Int.MAX_VALUE + val diffMs = Clock.System.now().toEpochMilliseconds() - parsed.toEpochMilliseconds() + if (diffMs <= 0L) return 0 + return (diffMs / 86_400_000L).toInt() +} + +@OptIn(ExperimentalTime::class) +private fun relativeAgo(isoInstant: String): String { + val trimmed = isoInstant.trim() + if (trimmed.isEmpty()) return "" + val parsed = runCatching { Instant.parse(trimmed) }.getOrNull() ?: return "" + val diffMs = Clock.System.now().toEpochMilliseconds() - parsed.toEpochMilliseconds() + if (diffMs <= 0L) return "now" + val minutes = diffMs / 60_000L + if (minutes < 1L) return "now" + if (minutes < 60L) return "${minutes}m" + val hours = minutes / 60L + if (hours < 24L) return "${hours}h" + val days = hours / 24L + if (days < 30L) return "${days}d" + val months = days / 30L + if (months < 12L) return "${months}mo" + val years = days / 365L + return "${years}y" +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/model/HomeRepoCardUi.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/model/HomeRepoCardUi.kt new file mode 100644 index 000000000..71b9f1317 --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/model/HomeRepoCardUi.kt @@ -0,0 +1,37 @@ +package zed.rainxch.home.presentation.model + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import kotlinx.collections.immutable.ImmutableList +import zed.rainxch.core.presentation.model.GithubRepoSummaryUi +import zed.rainxch.core.presentation.vocabulary.FreshnessState +import zed.rainxch.core.presentation.vocabulary.PlatformKind + +@Immutable +data class HomeRepoCardUi( + val id: Long, + val name: String, + val ownerLogin: String, + val ownerAvatarUrl: String, + val description: String, + val starsCount: Int, + val downloadsCount: Long, + val language: String?, + val daysSinceUpdate: Int, + val relativeAgoLabel: String, + val freshnessState: FreshnessState, + val freshnessFraction: Float, + val freshnessColor: Color, + val accentSaturated: Color, + val accentLightTint: Color, + val accentDarkAlpha: Float, + val topics: ImmutableList, + val platforms: ImmutableList, + val isInstalled: Boolean, + val isUpdateAvailable: Boolean, + val isFavourite: Boolean, + val isStarred: Boolean, + val isSeen: Boolean, + val isCurrentUserOwner: Boolean, + val rawRepository: GithubRepoSummaryUi, +) diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/utils/HomeCategoryMapper.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/utils/HomeCategoryMapper.kt deleted file mode 100644 index 91c3a9157..000000000 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/utils/HomeCategoryMapper.kt +++ /dev/null @@ -1,42 +0,0 @@ -package zed.rainxch.home.presentation.utils - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Code -import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.MusicNote -import androidx.compose.material.icons.outlined.Speed -import androidx.compose.material.icons.outlined.Wifi -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.vector.ImageVector -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.home.domain.model.HomeCategory -import zed.rainxch.home.domain.model.HomeCategory.* -import zed.rainxch.home.domain.model.TopicCategory - -@Composable -fun HomeCategory.displayText(): String = - when (this) { - TRENDING -> stringResource(Res.string.home_category_trending) - HOT_RELEASE -> stringResource(Res.string.home_category_hot_release) - MOST_POPULAR -> stringResource(Res.string.home_category_most_popular) - } - -@Composable -fun TopicCategory.displayText(): String = - when (this) { - TopicCategory.PRIVACY -> stringResource(Res.string.home_topic_privacy) - TopicCategory.MEDIA -> stringResource(Res.string.home_topic_media) - TopicCategory.PRODUCTIVITY -> stringResource(Res.string.home_topic_productivity) - TopicCategory.NETWORKING -> stringResource(Res.string.home_topic_networking) - TopicCategory.DEV_TOOLS -> stringResource(Res.string.home_topic_dev_tools) - } - -fun TopicCategory.icon(): ImageVector = - when (this) { - TopicCategory.PRIVACY -> Icons.Outlined.Lock - TopicCategory.MEDIA -> Icons.Outlined.MusicNote - TopicCategory.PRODUCTIVITY -> Icons.Outlined.Speed - TopicCategory.NETWORKING -> Icons.Outlined.Wifi - TopicCategory.DEV_TOOLS -> Icons.Outlined.Code - } diff --git a/feature/profile/CLAUDE.md b/feature/profile/CLAUDE.md index 7a4c53643..5d22c2e66 100644 --- a/feature/profile/CLAUDE.md +++ b/feature/profile/CLAUDE.md @@ -1,6 +1,6 @@ # Profile Feature -Account-level — GitHub user profile, login/logout, sponsor entry, version info. **Settings live in `feature/tweaks/`** — this module narrow on purpose. Owns account identity + exposes `ProfileRepository.getUser()` to other features (home/search/details/starred/favourites/tweaks/dev-profile consume it for E20 self-owned badge and account-aware flows). +Account-level — GitHub user profile, login/logout, version info. **Settings live in `feature/tweaks/`** — this module narrow on purpose. Owns account identity + exposes `ProfileRepository.getUser()` to other features (home/search/details/starred/favourites/tweaks/dev-profile consume it for E20 self-owned badge and account-aware flows). ## Structure @@ -9,7 +9,7 @@ feature/profile/ ├── domain/ # ProfileRepository, UserProfile ├── data/ # ProfileRepositoryImpl, UserProfileMappers └── presentation/ - ├── ProfileViewModel / State / Action / Event / Root, SponsorScreen + ├── ProfileViewModel / State / Action / Event / Root ├── model/ProxyType (legacy — proxy lives in tweaks now) └── components/ LogoutDialog, SectionText, sections/{Account, AccountSection, ProfileSection} ``` @@ -29,7 +29,7 @@ interface ProfileRepository { ## Navigation -`GithubStoreGraph.ProfileScreen`, `SponsorScreen`. +`GithubStoreGraph.ProfileScreen`. ## Notes diff --git a/feature/profile/data/.gitignore b/feature/profile/data/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/feature/profile/data/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/feature/profile/data/build.gradle.kts b/feature/profile/data/build.gradle.kts deleted file mode 100644 index 5c6431d62..000000000 --- a/feature/profile/data/build.gradle.kts +++ /dev/null @@ -1,32 +0,0 @@ -plugins { - alias(libs.plugins.convention.kmp.library) - alias(libs.plugins.convention.buildkonfig) -} - -kotlin { - sourceSets { - commonMain { - dependencies { - implementation(libs.kotlin.stdlib) - - implementation(projects.core.domain) - implementation(projects.core.data) - implementation(projects.feature.profile.domain) - - implementation(libs.bundles.koin.common) - implementation(libs.bundles.ktor.common) - implementation(libs.kotlinx.coroutines.core) - } - } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } - } -} diff --git a/feature/profile/data/src/androidMain/AndroidManifest.xml b/feature/profile/data/src/androidMain/AndroidManifest.xml deleted file mode 100644 index a5918e68a..000000000 --- a/feature/profile/data/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt deleted file mode 100644 index 212d1c768..000000000 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package zed.rainxch.profile.data.di - -import org.koin.dsl.module -import zed.rainxch.profile.data.repository.ProfileRepositoryImpl -import zed.rainxch.profile.domain.repository.ProfileRepository - -val profileModule = - module { - single { - ProfileRepositoryImpl( - authenticationState = get(), - tokenStore = get(), - clientProvider = get(), - cacheManager = get(), - logger = get(), - fileLocationsProvider = get(), - ) - } - } diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt deleted file mode 100644 index 2f564e103..000000000 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt +++ /dev/null @@ -1,106 +0,0 @@ -package zed.rainxch.profile.data.repository - -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.http.HttpHeaders -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import zed.rainxch.core.data.cache.CacheManager -import zed.rainxch.core.data.cache.CacheManager.CacheTtl.USER_PROFILE -import zed.rainxch.core.data.data_source.TokenStore -import zed.rainxch.core.data.dto.UserProfileNetwork -import zed.rainxch.core.data.network.GitHubClientProvider -import zed.rainxch.core.data.network.executeRequest -import zed.rainxch.core.data.services.FileLocationsProvider -import zed.rainxch.core.domain.logging.GitHubStoreLogger -import zed.rainxch.core.domain.repository.AuthenticationState -import zed.rainxch.feature.profile.data.BuildKonfig -import zed.rainxch.profile.data.mappers.toUserProfile -import zed.rainxch.profile.domain.model.UserProfile -import zed.rainxch.profile.domain.repository.ProfileRepository - -class ProfileRepositoryImpl( - private val authenticationState: AuthenticationState, - private val tokenStore: TokenStore, - private val clientProvider: GitHubClientProvider, - private val cacheManager: CacheManager, - private val logger: GitHubStoreLogger, - private val fileLocationsProvider: FileLocationsProvider, -) : ProfileRepository { - private val httpClient: HttpClient get() = clientProvider.client - - companion object { - private const val CACHE_KEY = "profile:me" - } - - override val isUserLoggedIn: Flow - get() = - authenticationState - .isUserLoggedIn() - .flowOn(Dispatchers.IO) - - override fun getUser(): Flow = - flow { - val token = tokenStore.currentToken() - if (token == null) { - cacheManager.invalidate(CACHE_KEY) - emit(null) - return@flow - } - - val cached = cacheManager.get(CACHE_KEY) - if (cached != null) { - logger.debug("Profile cache hit") - emit(cached) - return@flow - } - - try { - val networkProfile = - httpClient - .executeRequest { - get("/user") { - header(HttpHeaders.Accept, "application/vnd.github+json") - } - }.getOrThrow() - - val userProfile = networkProfile.toUserProfile() - cacheManager.put(CACHE_KEY, userProfile, USER_PROFILE) - logger.debug("Fetched and cached user profile: ${userProfile.username}") - emit(userProfile) - } catch (e: Exception) { - logger.error("Failed to fetch user profile: ${e.message}") - - val stale = cacheManager.getStale(CACHE_KEY) - if (stale != null) { - logger.debug("Using stale cached profile as fallback") - emit(stale) - } else { - emit(null) - } - } - }.flowOn(Dispatchers.IO) - - override fun getVersionName(): String = BuildKonfig.VERSION_NAME - - override suspend fun logout() { - tokenStore.clear() - cacheManager.clearAll() - } - - override fun observeCacheSize(): Flow = - flow { - val sizeBytes = fileLocationsProvider.getCacheSizeBytes() - emit(sizeBytes) - }.flowOn(Dispatchers.IO) - - override suspend fun clearCache() { - fileLocationsProvider.clearCacheFiles() - cacheManager.clearAll() - logger.debug("Cache cleared successfully") - } -} diff --git a/feature/profile/domain/.gitignore b/feature/profile/domain/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/feature/profile/domain/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/feature/profile/domain/build.gradle.kts b/feature/profile/domain/build.gradle.kts deleted file mode 100644 index b5a257f7a..000000000 --- a/feature/profile/domain/build.gradle.kts +++ /dev/null @@ -1,27 +0,0 @@ -plugins { - alias(libs.plugins.convention.kmp.library) -} - -kotlin { - sourceSets { - commonMain { - dependencies { - implementation(libs.kotlin.stdlib) - - implementation(projects.core.domain) - - implementation(libs.kotlinx.coroutines.core) - } - } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } - } -} diff --git a/feature/profile/domain/src/androidMain/AndroidManifest.xml b/feature/profile/domain/src/androidMain/AndroidManifest.xml deleted file mode 100644 index a5918e68a..000000000 --- a/feature/profile/domain/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/repository/ProfileRepository.kt b/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/repository/ProfileRepository.kt deleted file mode 100644 index c4fa64a15..000000000 --- a/feature/profile/domain/src/commonMain/kotlin/zed/rainxch/profile/domain/repository/ProfileRepository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package zed.rainxch.profile.domain.repository - -import kotlinx.coroutines.flow.Flow -import zed.rainxch.profile.domain.model.UserProfile - -interface ProfileRepository { - val isUserLoggedIn: Flow - - fun getUser(): Flow - - fun getVersionName(): String - - suspend fun logout() - - fun observeCacheSize(): Flow - - suspend fun clearCache() -} diff --git a/feature/profile/presentation/build.gradle.kts b/feature/profile/presentation/build.gradle.kts index 655f418f9..93a21587f 100644 --- a/feature/profile/presentation/build.gradle.kts +++ b/feature/profile/presentation/build.gradle.kts @@ -10,21 +10,9 @@ kotlin { implementation(projects.core.domain) implementation(projects.core.presentation) - implementation(projects.feature.profile.domain) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.jetbrains.compose.components.resources) - - } - } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { } } } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt index eee8d4ffd..781e7c7ec 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt @@ -16,8 +16,6 @@ sealed interface ProfileAction { data object OnRecentlyViewedClick : ProfileAction - data object OnSponsorClick : ProfileAction - data object OnWhatsNewClick : ProfileAction data object OnWhatsNewLongClick : ProfileAction @@ -25,4 +23,8 @@ sealed interface ProfileAction { data object OnAnnouncementsClick : ProfileAction data object OnAnnouncementsLongClick : ProfileAction + + data object OnTweaksClick : ProfileAction + + data object OnAboutClick : ProfileAction } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt index 9639b6034..2f3d0bbdd 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt @@ -1,6 +1,8 @@ package zed.rainxch.profile.presentation +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -13,17 +15,16 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar +import zed.rainxch.core.presentation.components.chrome.GhsHomeTopBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString @@ -33,11 +34,15 @@ import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.core.presentation.utils.arrowKeyScroll -import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.profile.presentation.components.ClearDownloadsDialog +import zed.rainxch.core.presentation.utils.constrainedContentWidth +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.downloads_cleared +import zed.rainxch.githubstore.core.presentation.res.logout_success +import zed.rainxch.githubstore.core.presentation.res.profile_title +import zed.rainxch.githubstore.core.presentation.res.proxy_saved +import zed.rainxch.githubstore.core.presentation.res.seen_history_cleared import zed.rainxch.profile.presentation.components.LogoutDialog -import zed.rainxch.profile.presentation.components.sections.logout -import zed.rainxch.profile.presentation.components.sections.profile +import zed.rainxch.profile.presentation.components.profileSections @Composable fun ProfileRoot( @@ -47,11 +52,12 @@ fun ProfileRoot( onNavigateToStarredRepos: () -> Unit, onNavigateToFavouriteRepos: () -> Unit, onNavigateToRecentlyViewed: () -> Unit, - onNavigateToSponsor: () -> Unit, onNavigateToWhatsNew: () -> Unit, onPreviewWhatsNewSheet: () -> Unit, onNavigateToAnnouncements: () -> Unit, onPreviewAnnouncements: () -> Unit, + onNavigateToTweaks: () -> Unit, + onNavigateToAbout: () -> Unit, hasUnreadAnnouncements: Boolean, viewModel: ProfileViewModel = koinViewModel(), ) { @@ -132,10 +138,6 @@ fun ProfileRoot( onNavigateToRecentlyViewed() } - ProfileAction.OnSponsorClick -> { - onNavigateToSponsor() - } - ProfileAction.OnWhatsNewClick -> { onNavigateToWhatsNew() } @@ -152,6 +154,14 @@ fun ProfileRoot( onPreviewAnnouncements() } + ProfileAction.OnTweaksClick -> { + onNavigateToTweaks() + } + + ProfileAction.OnAboutClick -> { + onNavigateToAbout() + } + else -> { viewModel.onAction(action) } @@ -189,57 +199,38 @@ fun ProfileScreen( ) }, topBar = { - TopAppBar() + GhsHomeTopBar(title = stringResource(Res.string.profile_title)) }, containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> val listState = rememberLazyListState() - LazyColumn( - state = listState, - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(16.dp) - .arrowKeyScroll(listState, autoFocus = true), + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.TopCenter, ) { - profile( - state = state, - hasUnreadAnnouncements = hasUnreadAnnouncements, - onAction = onAction, - ) - - item { - Spacer(Modifier.height(32.dp)) - } - - if (state.isUserLoggedIn) { - logout( + LazyColumn( + state = listState, + modifier = + Modifier + .constrainedContentWidth() + .fillMaxHeight() + .padding(16.dp) + .arrowKeyScroll(listState, autoFocus = true), + ) { + profileSections( + state = state, + hasUnreadAnnouncements = hasUnreadAnnouncements, onAction = onAction, ) - } - item { - Spacer(Modifier.height(bottomNavHeight + 32.dp)) + item { + Spacer(Modifier.height(bottomNavHeight + 32.dp)) + } } } } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun TopAppBar() { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.profile_title), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - }, - ) -} @Preview @Composable diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt index b03df8998..bac4583f0 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt @@ -1,6 +1,6 @@ package zed.rainxch.profile.presentation -import zed.rainxch.profile.domain.model.UserProfile +import zed.rainxch.core.domain.model.UserProfile data class ProfileState( val userProfile: UserProfile? = null, diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt index 34f2a1062..ca8095e6c 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt @@ -11,10 +11,10 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import zed.rainxch.profile.domain.repository.ProfileRepository +import zed.rainxch.core.domain.repository.UserSessionRepository class ProfileViewModel( - private val profileRepository: ProfileRepository, + private val userSessionRepository: UserSessionRepository ) : ViewModel() { private var userProfileJob: Job? = null @@ -56,7 +56,7 @@ class ProfileViewModel( private fun observeLoggedInStatus() { viewModelScope.launch { - profileRepository.isUserLoggedIn + userSessionRepository.isUserLoggedIn() .collect { isLoggedIn -> _state.update { it.copy(isUserLoggedIn = isLoggedIn) } if (isLoggedIn) { @@ -73,7 +73,7 @@ class ProfileViewModel( userProfileJob = viewModelScope.launch { - profileRepository.getUser().collect { profile -> + userSessionRepository.getUser().collect { profile -> _state.update { it.copy(userProfile = profile) } } } @@ -92,7 +92,7 @@ class ProfileViewModel( ProfileAction.OnLogoutConfirmClick -> { viewModelScope.launch { runCatching { - profileRepository.logout() + userSessionRepository.logout() }.onSuccess { _state.update { it.copy(isLogoutDialogVisible = false, userProfile = null) } _events.send(ProfileEvent.OnLogoutSuccessful) @@ -114,43 +114,47 @@ class ProfileViewModel( } ProfileAction.OnLoginClick -> { - // Handed in composable + } ProfileAction.OnFavouriteReposClick -> { - // Handed in composable + } ProfileAction.OnStarredReposClick -> { - // Handed in composable + } is ProfileAction.OnRepositoriesClick -> { - // Handed in composable - } - ProfileAction.OnSponsorClick -> { - // Handed in composable } ProfileAction.OnRecentlyViewedClick -> { - // Handed in composable + } ProfileAction.OnWhatsNewClick -> { - // Handed in composable + } ProfileAction.OnWhatsNewLongClick -> { - // Handed in composable + } ProfileAction.OnAnnouncementsClick -> { - // Handed in composable + } ProfileAction.OnAnnouncementsLongClick -> { - // Handed in composable + + } + + ProfileAction.OnTweaksClick -> { + + } + + ProfileAction.OnAboutClick -> { + } } } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/SponsorScreen.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/SponsorScreen.kt deleted file mode 100644 index 9810d6c02..000000000 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/SponsorScreen.kt +++ /dev/null @@ -1,356 +0,0 @@ -package zed.rainxch.profile.presentation - -import androidx.compose.foundation.background -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.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.Coffee -import androidx.compose.material.icons.filled.EmojiEvents -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.IosShare -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.VolunteerActivism -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedButton -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -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.graphics.Brush -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.githubstore.core.presentation.res.* - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun SponsorScreen(onNavigateBack: () -> Unit) { - val uriHandler = LocalUriHandler.current - val onOpenUrl: (String) -> Unit = { url -> - runCatching { uriHandler.openUri(url) } - } - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.sponsor_title), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.navigate_back), - ) - } - }, - ) - }, - containerColor = MaterialTheme.colorScheme.background, - ) { innerPadding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - HeroSection() - - SponsorOptionCard( - icon = Icons.Filled.Favorite, - title = stringResource(Res.string.sponsor_github_sponsors), - description = stringResource(Res.string.sponsor_github_sponsors_desc), - onClick = { - onOpenUrl("https://github.com/sponsors/rainxchzed") - }, - ) - - SponsorOptionCard( - icon = Icons.Filled.Coffee, - title = stringResource(Res.string.sponsor_buy_me_coffee), - description = stringResource(Res.string.sponsor_buy_me_coffee_desc), - onClick = { - onOpenUrl("https://buymeacoffee.com/rainxchzed") - }, - ) - - Spacer(Modifier.height(8.dp)) - - OtherWaysSection(onOpenUrl = onOpenUrl) - - Text( - text = stringResource(Res.string.sponsor_thank_you), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) - - Spacer(Modifier.height(16.dp)) - } - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun HeroSection() { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - imageVector = Icons.Filled.VolunteerActivism, - contentDescription = null, - modifier = - Modifier - .size(64.dp) - .clip(CircleShape) - .background( - Brush.linearGradient( - listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary, - ), - ), - ).padding(14.dp), - tint = MaterialTheme.colorScheme.onPrimary, - ) - - Spacer(Modifier.height(16.dp)) - - Text( - text = stringResource(Res.string.sponsor_hero_title), - style = MaterialTheme.typography.headlineSmallEmphasized, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - ) - - Spacer(Modifier.height(8.dp)) - - Text( - text = stringResource(Res.string.sponsor_hero_subtitle), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 8.dp), - ) - - Spacer(Modifier.height(8.dp)) - - Text( - text = stringResource(Res.string.sponsor_personal_note), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 8.dp), - ) - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun SponsorOptionCard( - icon: ImageVector, - title: String, - description: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - ElevatedCard( - modifier = modifier.fillMaxWidth(), - onClick = onClick, - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - shape = RoundedCornerShape(24.dp), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = - Modifier - .size(44.dp) - .clip(CircleShape) - .background( - Brush.linearGradient( - listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.secondary, - ), - ), - ).padding(10.dp), - tint = MaterialTheme.colorScheme.onPrimary, - ) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun OtherWaysSection(onOpenUrl: (String) -> Unit) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(Res.string.sponsor_other_ways_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp), - ) - - Spacer(Modifier.height(4.dp)) - - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - shape = RoundedCornerShape(24.dp), - ) { - Column(modifier = Modifier.padding(4.dp)) { - OtherWayItem( - icon = Icons.Filled.Star, - title = stringResource(Res.string.sponsor_star_repo), - description = stringResource(Res.string.sponsor_star_repo_desc), - onClick = { - onOpenUrl("https://github.com/OpenHub-Store/GitHub-Store") - }, - ) - - OtherWayItem( - icon = Icons.Filled.BugReport, - title = stringResource(Res.string.sponsor_report_bugs), - description = stringResource(Res.string.sponsor_report_bugs_desc), - onClick = { - onOpenUrl("https://github.com/OpenHub-Store/GitHub-Store/issues") - }, - ) - - OtherWayItem( - icon = Icons.Filled.IosShare, - title = stringResource(Res.string.sponsor_share), - description = stringResource(Res.string.sponsor_share_desc), - onClick = { - onOpenUrl("https://github.com/OpenHub-Store/GitHub-Store") - }, - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun OtherWayItem( - icon: ImageVector, - title: String, - description: String, - onClick: () -> Unit, -) { - FilledTonalButton( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/ClearDownloadsDialog.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/ClearDownloadsDialog.kt deleted file mode 100644 index d34596874..000000000 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/ClearDownloadsDialog.kt +++ /dev/null @@ -1,97 +0,0 @@ -package zed.rainxch.profile.presentation.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.githubstore.core.presentation.res.* - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ClearDownloadsDialog( - cacheSize: String, - onDismissRequest: () -> Unit, - onConfirm: () -> Unit, - modifier: Modifier = Modifier, -) { - BasicAlertDialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - dismissOnClickOutside = true, - usePlatformDefaultWidth = false, - ), - modifier = - modifier - .padding(16.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(16.dp), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(Res.string.delete_downloads_confirmation_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - ) - - Text( - text = stringResource(Res.string.delete_downloads_confirmation_message, cacheSize), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - TextButton( - onClick = onDismissRequest, - ) { - Text( - text = stringResource(Res.string.cancel), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - Button( - onClick = onConfirm, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Text( - text = stringResource(Res.string.delete_all), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - } - } - } - } -} diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt index 827c0602a..04a407c3c 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt @@ -1,104 +1,28 @@ package zed.rainxch.profile.presentation.components -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton 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.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties import org.jetbrains.compose.resources.stringResource -import zed.rainxch.githubstore.core.presentation.res.* +import zed.rainxch.core.presentation.components.overlays.GhsConfirmDialog +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.close +import zed.rainxch.githubstore.core.presentation.res.logout +import zed.rainxch.githubstore.core.presentation.res.logout_confirmation +import zed.rainxch.githubstore.core.presentation.res.logout_revocation_note +import zed.rainxch.githubstore.core.presentation.res.warning -@OptIn(ExperimentalMaterial3Api::class) @Composable fun LogoutDialog( onDismissRequest: () -> Unit, onLogout: () -> Unit, - modifier: Modifier = Modifier, ) { - BasicAlertDialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - dismissOnClickOutside = false, - usePlatformDefaultWidth = false, - ), - modifier = - modifier - .padding(16.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(16.dp), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(Res.string.warning), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - ) - - Text( - text = stringResource(Res.string.logout_confirmation), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Text( - text = stringResource(Res.string.logout_revocation_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - TextButton( - onClick = { - onDismissRequest() - }, - ) { - Text( - text = stringResource(Res.string.close), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - Button( - onClick = onLogout, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Text( - text = stringResource(Res.string.logout), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - } + GhsConfirmDialog( + title = stringResource(Res.string.warning), + body = stringResource(Res.string.logout_confirmation), + note = stringResource(Res.string.logout_revocation_note), + confirmLabel = stringResource(Res.string.logout), + cancelLabel = stringResource(Res.string.close), + destructive = true, + onConfirm = onLogout, + onDismiss = onDismissRequest, + ) } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/ProfileSections.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/ProfileSections.kt new file mode 100644 index 000000000..8ea914d9a --- /dev/null +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/ProfileSections.kt @@ -0,0 +1,400 @@ +package zed.rainxch.profile.presentation.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.outlined.Campaign +import androidx.compose.material.icons.outlined.Favorite +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.GitHubStoreImage +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.hub.GhsEntryRow +import zed.rainxch.core.presentation.components.hub.GhsSectionHeader +import zed.rainxch.core.presentation.theme.tokens.GhsAccents +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.utils.formatCount +import zed.rainxch.githubstore.core.presentation.res.* +import zed.rainxch.profile.presentation.ProfileAction +import zed.rainxch.profile.presentation.ProfileState + +fun LazyListScope.profileSections( + state: ProfileState, + hasUnreadAnnouncements: Boolean, + onAction: (ProfileAction) -> Unit, +) { + item(key = "identity") { + HeroIdentityCard(state = state, onAction = onAction) + } + + if (state.isUserLoggedIn) { + item(key = "library_header") { + Spacer(Modifier.height(8.dp)) + GhsSectionHeader(text = stringResource(Res.string.profile_section_library)) + Spacer(Modifier.height(8.dp)) + } + item(key = "row_stars") { + GhsEntryRow( + title = stringResource(Res.string.stars), + subtitle = stringResource(Res.string.profile_stars_description), + icon = Icons.Outlined.Star, + accentColor = GhsAccents.Gold, + onClick = { onAction(ProfileAction.OnStarredReposClick) }, + ) + Spacer(Modifier.height(8.dp)) + } + item(key = "row_favourites") { + GhsEntryRow( + title = stringResource(Res.string.favourites), + subtitle = stringResource(Res.string.profile_favourites_description), + icon = Icons.Outlined.Favorite, + accentColor = GhsAccents.Rose, + onClick = { onAction(ProfileAction.OnFavouriteReposClick) }, + ) + Spacer(Modifier.height(8.dp)) + } + item(key = "row_recent") { + GhsEntryRow( + title = stringResource(Res.string.recently_viewed), + subtitle = stringResource(Res.string.profile_recently_viewed_description), + icon = Icons.Outlined.Schedule, + accentColor = GhsAccents.Sky, + onClick = { onAction(ProfileAction.OnRecentlyViewedClick) }, + ) + } + } + + item(key = "updates_header") { + Spacer(Modifier.height(8.dp)) + GhsSectionHeader(text = stringResource(Res.string.profile_section_updates)) + Spacer(Modifier.height(8.dp)) + } + item(key = "row_whats_new") { + GhsEntryRow( + title = stringResource(Res.string.whats_new_title), + subtitle = stringResource(Res.string.whats_new_profile_description), + icon = Icons.Outlined.Campaign, + accentColor = GhsAccents.Mint, + onClick = { onAction(ProfileAction.OnWhatsNewClick) }, + onLongClick = { onAction(ProfileAction.OnWhatsNewLongClick) }, + ) + Spacer(Modifier.height(8.dp)) + } + item(key = "row_announcements") { + GhsEntryRow( + title = stringResource(Res.string.announcements_title), + subtitle = stringResource(Res.string.announcements_profile_description), + icon = Icons.Outlined.Notifications, + accentColor = GhsAccents.Lavender, + onClick = { onAction(ProfileAction.OnAnnouncementsClick) }, + onLongClick = { onAction(ProfileAction.OnAnnouncementsLongClick) }, + badge = if (hasUnreadAnnouncements) { + { UnreadDot() } + } else { + null + }, + ) + } + + item(key = "app_header") { + Spacer(Modifier.height(8.dp)) + GhsSectionHeader(text = stringResource(Res.string.section_app_block)) + Spacer(Modifier.height(8.dp)) + } + item(key = "row_tweaks") { + GhsEntryRow( + title = stringResource(Res.string.tweaks_title), + subtitle = stringResource(Res.string.profile_tweaks_description), + icon = Icons.Outlined.Tune, + accentColor = GhsAccents.Sage, + onClick = { onAction(ProfileAction.OnTweaksClick) }, + ) + Spacer(Modifier.height(8.dp)) + } + item(key = "row_about") { + GhsEntryRow( + title = stringResource(Res.string.profile_entry_about_title), + subtitle = stringResource(Res.string.profile_entry_about_subtitle), + icon = Icons.Outlined.Info, + accentColor = GhsAccents.Aqua, + onClick = { onAction(ProfileAction.OnAboutClick) }, + ) + } + + if (state.isUserLoggedIn) { + item(key = "account_header") { + Spacer(Modifier.height(8.dp)) + GhsSectionHeader(text = stringResource(Res.string.profile_section_account)) + Spacer(Modifier.height(8.dp)) + } + item(key = "row_logout") { + GhsEntryRow( + title = stringResource(Res.string.logout), + icon = Icons.AutoMirrored.Filled.Logout, + destructive = true, + trailingChevron = false, + onClick = { onAction(ProfileAction.OnLogoutClick) }, + ) + } + } +} + +@Composable +private fun HeroIdentityCard( + state: ProfileState, + onAction: (ProfileAction) -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (state.userProfile == null) { + SignedOutContent(onAction = onAction) + } else { + SignedInContent(state = state, onAction = onAction) + } + } + } +} + +@Composable +private fun SignedOutContent(onAction: (ProfileAction) -> Unit) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.profile_sign_in_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(Res.string.profile_sign_in_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(4.dp)) + GhsButton( + onClick = { onAction(ProfileAction.OnLoginClick) }, + label = stringResource(Res.string.profile_login), + variant = GhsButtonVariant.Primary, + size = GhsButtonSize.Lg, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun SignedInContent( + state: ProfileState, + onAction: (ProfileAction) -> Unit, +) { + val profile = state.userProfile ?: return + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + GitHubStoreImage( + imageModel = { profile.imageUrl }, + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + extractDominantFor = profile.imageUrl, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + val displayName = profile.name.takeIf { it.isNotBlank() } ?: profile.username + Text( + text = displayName, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@${profile.username}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + profile.bio?.takeIf { it.isNotBlank() }?.let { bio -> + Spacer(Modifier.height(2.dp)) + Text( + text = bio, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + Spacer(Modifier.height(4.dp)) + MetricsStrip( + repos = profile.repositoryCount, + followers = profile.followers, + following = profile.following, + onReposClick = { onAction(ProfileAction.OnRepositoriesClick(profile.username)) }, + ) +} + +@Composable +private fun MetricsStrip( + repos: Int, + followers: Int, + following: Int, + onReposClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Metric( + value = formatCount(repos), + label = stringResource(Res.string.profile_repos), + modifier = Modifier + .weight(1f) + .clip(Radii.chip) + .clickable(onClick = onReposClick) + .padding(vertical = 6.dp), + ) + MetricDivider() + Metric( + value = formatCount(followers), + label = stringResource(Res.string.followers), + modifier = Modifier + .weight(1f) + .padding(vertical = 6.dp), + ) + MetricDivider() + Metric( + value = formatCount(following), + label = stringResource(Res.string.following), + modifier = Modifier + .weight(1f) + .padding(vertical = 6.dp), + ) + } +} + +@Composable +private fun Metric( + value: String, + label: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = value, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun MetricDivider() { + Box( + modifier = Modifier + .width(1.dp) + .height(28.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)), + ) +} + +@Composable +private fun UnreadDot() { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.error), + ) +} diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/SectionText.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/SectionText.kt deleted file mode 100644 index e3a6ed2a5..000000000 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/SectionText.kt +++ /dev/null @@ -1,33 +0,0 @@ -package zed.rainxch.profile.presentation.components - -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun SectionTitle(text: String) { - Text( - text = text, - style = MaterialTheme.typography.titleMediumEmphasized, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp), - ) -} - -@Composable -fun SectionHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp), - ) -} diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/ToggleSettingCard.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/ToggleSettingCard.kt deleted file mode 100644 index 6622db1aa..000000000 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/ToggleSettingCard.kt +++ /dev/null @@ -1,75 +0,0 @@ -package zed.rainxch.profile.presentation.components - -import androidx.compose.foundation.interaction.MutableInteractionSource -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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.toggleable -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import zed.rainxch.core.presentation.components.ExpressiveCard - -@Composable -fun ToggleSettingCard( - title: String, - description: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - ExpressiveCard { - Row( - modifier = - Modifier - .fillMaxWidth() - .toggleable( - value = checked, - onValueChange = onCheckedChange, - role = Role.Switch, - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(), - ).padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = - Modifier - .weight(1f) - .padding(end = 16.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - - Spacer(Modifier.height(4.dp)) - - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Switch( - checked = checked, - onCheckedChange = null, - ) - } - } -} diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Account.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Account.kt deleted file mode 100644 index c974ac1a0..000000000 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Account.kt +++ /dev/null @@ -1,116 +0,0 @@ -package zed.rainxch.profile.presentation.components.sections - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.filled.Logout -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.profile.presentation.ProfileAction - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -fun LazyListScope.logout(onAction: (ProfileAction) -> Unit) { - item { - Spacer(Modifier.height(8.dp)) - - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - shape = RoundedCornerShape(32.dp), - onClick = { - onAction(ProfileAction.OnLogoutClick) - }, - ) { - AccountItem( - icon = Icons.AutoMirrored.Filled.Logout, - title = stringResource(Res.string.logout), - actions = { - IconButton( - onClick = { - onAction(ProfileAction.OnLogoutClick) - }, - colors = - IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } - }, - ) - } - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun AccountItem( - icon: ImageVector, - title: String, - actions: @Composable () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = - modifier - .fillMaxWidth() - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - shapes = IconButtonDefaults.shapes(), - onClick = { }, - colors = - IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } - - Text( - text = title, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f), - ) - - actions.invoke() - } -} diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt deleted file mode 100644 index 2c07f063e..000000000 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt +++ /dev/null @@ -1,262 +0,0 @@ -package zed.rainxch.profile.presentation.components.sections - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.core.presentation.components.GitHubStoreImage -import zed.rainxch.core.presentation.components.GithubStoreButton -import zed.rainxch.core.presentation.theme.GithubStoreTheme -import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.profile.domain.model.UserProfile -import zed.rainxch.profile.presentation.ProfileAction -import zed.rainxch.profile.presentation.ProfileState - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -fun LazyListScope.accountSection( - state: ProfileState, - onAction: (ProfileAction) -> Unit, -) { - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (state.userProfile == null) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = null, - modifier = - Modifier - .size(100.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(20.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - } else { - GitHubStoreImage( - imageModel = { - state.userProfile.imageUrl - }, - modifier = - Modifier - .size(128.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHigh), - ) - - Spacer(Modifier.height(8.dp)) - } - - if (state.userProfile != null) { - val displayName = - state.userProfile.name.takeIf { it.isNotBlank() } - ?: state.userProfile.username - Text( - text = displayName, - style = MaterialTheme.typography.titleLargeEmphasized, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - ) - - Text( - text = "@${state.userProfile.username}", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - - state.userProfile.bio?.let { bio -> - Text( - text = bio, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - } else { - Spacer(Modifier.height(8.dp)) - - Text( - text = stringResource(Res.string.profile_sign_in_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - ) - - Spacer(Modifier.height(4.dp)) - - Text( - text = stringResource(Res.string.profile_sign_in_description), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - - if (state.userProfile != null) { - Spacer(Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - StatCard( - label = stringResource(Res.string.profile_repos), - value = state.userProfile.repositoryCount.toString(), - modifier = Modifier.weight(1f), - onClick = { - onAction(ProfileAction.OnRepositoriesClick(state.userProfile.username)) - }, - ) - - StatCard( - label = stringResource(Res.string.followers), - value = state.userProfile.followers.toString(), - modifier = Modifier.weight(1f), - ) - - StatCard( - label = stringResource(Res.string.following), - value = state.userProfile.following.toString(), - modifier = Modifier.weight(1f), - ) - } - } - - if (state.userProfile == null) { - Spacer(Modifier.height(8.dp)) - - GithubStoreButton( - text = stringResource(Res.string.profile_login), - onClick = { - onAction(ProfileAction.OnLoginClick) - }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun StatCard( - label: String, - value: String, - modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null, -) { - Card( - modifier = modifier, - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - shape = RoundedCornerShape(32.dp), - border = - BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.secondary, - ), - onClick = { onClick?.invoke() }, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = value, - maxLines = 1, - style = MaterialTheme.typography.titleLargeEmphasized, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface, - ) - - Text( - text = label, - maxLines = 1, - style = MaterialTheme.typography.bodyLargeEmphasized, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Preview(showBackground = true) -@Composable -fun AccountSectionPreview() { - GithubStoreTheme { - LazyColumn { - accountSection( - state = ProfileState(), - onAction = { }, - ) - } - } -} - -@Preview(showBackground = true) -@Composable -fun AccountSectionUserPreview() { - GithubStoreTheme { - LazyColumn { - accountSection( - state = - ProfileState( - userProfile = - UserProfile( - id = 1, - imageUrl = "", - name = "Octocat", - username = "the_octocat", - bio = "Language Savant.", - repositoryCount = 8, - followers = 21900, - following = 9, - ), - ), - onAction = { }, - ) - } - } -} diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt deleted file mode 100644 index 715d30b7d..000000000 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt +++ /dev/null @@ -1,320 +0,0 @@ -package zed.rainxch.profile.presentation.components.sections - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Campaign -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.VolunteerActivism -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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.graphics.Brush -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.profile.presentation.ProfileAction - -fun LazyListScope.options( - isUserLoggedIn: Boolean, - hasUnreadAnnouncements: Boolean, - onAction: (ProfileAction) -> Unit, -) { - item { - if (isUserLoggedIn) { - OptionCard( - icon = Icons.Default.Star, - label = stringResource(Res.string.stars), - description = stringResource(Res.string.profile_stars_description), - onClick = { - onAction(ProfileAction.OnStarredReposClick) - }, - ) - } - - Spacer(Modifier.height(4.dp)) - - OptionCard( - icon = Icons.Default.Favorite, - label = stringResource(Res.string.favourites), - description = stringResource(Res.string.profile_favourites_description), - onClick = { - onAction(ProfileAction.OnFavouriteReposClick) - }, - ) - - Spacer(Modifier.height(4.dp)) - - OptionCard( - icon = Icons.Default.Schedule, - label = stringResource(Res.string.recently_viewed), - description = stringResource(Res.string.profile_recently_viewed_description), - onClick = { - onAction(ProfileAction.OnRecentlyViewedClick) - }, - ) - - Spacer(Modifier.height(4.dp)) - - OptionCard( - icon = Icons.Default.Campaign, - label = stringResource(Res.string.whats_new_title), - description = stringResource(Res.string.whats_new_profile_description), - onClick = { - onAction(ProfileAction.OnWhatsNewClick) - }, - onLongClick = { - onAction(ProfileAction.OnWhatsNewLongClick) - }, - ) - - Spacer(Modifier.height(4.dp)) - - OptionCard( - icon = Icons.Default.Notifications, - label = stringResource(Res.string.announcements_title), - description = stringResource(Res.string.announcements_profile_description), - onClick = { - onAction(ProfileAction.OnAnnouncementsClick) - }, - onLongClick = { - onAction(ProfileAction.OnAnnouncementsLongClick) - }, - hasBadge = hasUnreadAnnouncements, - ) - - Spacer(Modifier.height(4.dp)) - - SponsorCard( - onClick = { - onAction(ProfileAction.OnSponsorClick) - }, - ) - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) -@Composable -private fun OptionCard( - icon: ImageVector, - label: String, - description: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - onLongClick: (() -> Unit)? = null, - hasBadge: Boolean = false, -) { - val cardColors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - contentColor = MaterialTheme.colorScheme.onSurface, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = .7f), - disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .7f), - ) - val cardShape = RoundedCornerShape(32.dp) - val cardBorder = BorderStroke( - width = .5.dp, - color = MaterialTheme.colorScheme.surface, - ) - - if (onLongClick != null) { - Card( - modifier = modifier.combinedClickable( - onClick = onClick, - onLongClick = onLongClick, - ), - colors = cardColors, - shape = cardShape, - border = cardBorder, - ) { - OptionCardContent( - icon = icon, - label = label, - description = description, - hasBadge = hasBadge - ) - } - return - } - - Card( - modifier = modifier, - colors = cardColors, - onClick = onClick, - shape = cardShape, - border = cardBorder, - ) { - OptionCardContent( - icon = icon, - label = label, - description = description - ) - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun OptionCardContent( - icon: ImageVector, - label: String, - description: String, - hasBadge: Boolean = false, -) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Box { - Icon( - imageVector = icon, - contentDescription = null, - modifier = - Modifier - .size(36.dp) - .clip(CircleShape) - .background( - Brush.linearGradient( - listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.secondary, - ), - ), - ).padding(6.dp), - tint = MaterialTheme.colorScheme.onPrimary, - ) - if (hasBadge) { - Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.error) - .align(Alignment.TopEnd), - ) - } - } - - Column( - modifier = - Modifier - .weight(1f) - .padding(12.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.Start, - ) { - Text( - text = label, - maxLines = 1, - style = MaterialTheme.typography.titleMedium, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface, - ) - - Text( - text = description, - maxLines = 2, - style = MaterialTheme.typography.bodyLargeEmphasized, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun SponsorCard( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Card( - modifier = modifier, - onClick = onClick, - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - shape = RoundedCornerShape(32.dp), - border = - BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), - ), - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = Icons.Default.VolunteerActivism, - contentDescription = null, - modifier = - Modifier - .size(36.dp) - .clip(CircleShape) - .background( - Brush.linearGradient( - listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary, - ), - ), - ).padding(6.dp), - tint = MaterialTheme.colorScheme.onPrimary, - ) - - Column( - modifier = - Modifier - .weight(1f) - .padding(12.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.Start, - ) { - Text( - text = stringResource(Res.string.sponsor_button), - maxLines = 1, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - - Text( - text = stringResource(Res.string.sponsor_hero_subtitle), - maxLines = 2, - style = MaterialTheme.typography.bodySmall, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), - ) - } - } - } -} diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/ProfileSection.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/ProfileSection.kt deleted file mode 100644 index 01381c651..000000000 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/ProfileSection.kt +++ /dev/null @@ -1,30 +0,0 @@ -package zed.rainxch.profile.presentation.components.sections - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import zed.rainxch.profile.presentation.ProfileAction -import zed.rainxch.profile.presentation.ProfileState - -fun LazyListScope.profile( - state: ProfileState, - hasUnreadAnnouncements: Boolean, - onAction: (ProfileAction) -> Unit, -) { - accountSection( - state = state, - onAction = onAction, - ) - - item { - Spacer(Modifier.height(20.dp)) - } - - options( - isUserLoggedIn = state.isUserLoggedIn, - hasUnreadAnnouncements = hasUnreadAnnouncements, - onAction = onAction, - ) -} diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/model/ProxyType.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/model/ProxyType.kt deleted file mode 100644 index 5581a719a..000000000 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/model/ProxyType.kt +++ /dev/null @@ -1,21 +0,0 @@ -package zed.rainxch.profile.presentation.model - -import zed.rainxch.core.domain.model.ProxyConfig - -enum class ProxyType { - NONE, - SYSTEM, - HTTP, - SOCKS, - ; - - companion object { - fun fromConfig(config: ProxyConfig): ProxyType = - when (config) { - is ProxyConfig.None -> NONE - is ProxyConfig.System -> SYSTEM - is ProxyConfig.Http -> HTTP - is ProxyConfig.Socks -> SOCKS - } - } -} diff --git a/feature/recently-viewed/presentation/build.gradle.kts b/feature/recently-viewed/presentation/build.gradle.kts index 614614048..d6cb115a8 100644 --- a/feature/recently-viewed/presentation/build.gradle.kts +++ b/feature/recently-viewed/presentation/build.gradle.kts @@ -18,15 +18,5 @@ kotlin { implementation(libs.jetbrains.compose.components.resources) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/recently-viewed/presentation/src/commonMain/kotlin/zed/rainxch/recentlyviewed/presentation/RecentlyViewedRoot.kt b/feature/recently-viewed/presentation/src/commonMain/kotlin/zed/rainxch/recentlyviewed/presentation/RecentlyViewedRoot.kt index df21ad0f2..d9a25017a 100644 --- a/feature/recently-viewed/presentation/src/commonMain/kotlin/zed/rainxch/recentlyviewed/presentation/RecentlyViewedRoot.kt +++ b/feature/recently-viewed/presentation/src/commonMain/kotlin/zed/rainxch/recentlyviewed/presentation/RecentlyViewedRoot.kt @@ -143,14 +143,14 @@ private fun RecentlyViewedTopbar(onAction: (RecentlyViewedAction) -> Unit) { title = { Text( text = stringResource(Res.string.recently_viewed), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, ) }, navigationIcon = { IconButton( - shapes = IconButtonDefaults.shapes(), onClick = { onAction(RecentlyViewedAction.OnNavigateBackClick) }, diff --git a/feature/recently-viewed/presentation/src/commonMain/kotlin/zed/rainxch/recentlyviewed/presentation/components/RecentlyViewedItem.kt b/feature/recently-viewed/presentation/src/commonMain/kotlin/zed/rainxch/recentlyviewed/presentation/components/RecentlyViewedItem.kt index 4dfcbd003..3670ce7e0 100644 --- a/feature/recently-viewed/presentation/src/commonMain/kotlin/zed/rainxch/recentlyviewed/presentation/components/RecentlyViewedItem.kt +++ b/feature/recently-viewed/presentation/src/commonMain/kotlin/zed/rainxch/recentlyviewed/presentation/components/RecentlyViewedItem.kt @@ -1,10 +1,12 @@ package zed.rainxch.recentlyviewed.presentation.components +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -12,20 +14,15 @@ 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.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.outlined.Close -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,9 +36,11 @@ import com.skydoves.landscapist.coil3.CoilImage import com.skydoves.landscapist.components.rememberImageComponent import com.skydoves.landscapist.crossfade.CrossfadePlugin import zed.rainxch.core.presentation.components.ExpressiveCard +import zed.rainxch.core.presentation.components.chips.StatChip +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.recentlyviewed.presentation.model.RecentlyViewedRepo -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) @Composable fun RecentlyViewedItem( repo: RecentlyViewedRepo, @@ -55,26 +54,22 @@ fun RecentlyViewedItem( onClick = onItemClick, ) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), ) { Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .clickable(onClick = onDevProfileClick), + modifier = Modifier + .fillMaxWidth() + .clip(Radii.chip) + .clickable(onClick = onDevProfileClick) + .padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { CoilImage( imageModel = { repo.repoOwnerAvatarUrl }, - modifier = - Modifier - .size(32.dp) - .clip(CircleShape), + modifier = Modifier + .size(28.dp) + .clip(CircleShape), loading = { Box( modifier = Modifier.fillMaxSize(), @@ -83,122 +78,111 @@ fun RecentlyViewedItem( CircularWavyProgressIndicator() } }, - component = - rememberImageComponent { - CrossfadePlugin() - }, + component = rememberImageComponent { + CrossfadePlugin() + }, ) - Text( text = repo.repoOwner, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.outline, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Column( - modifier = Modifier.weight(1f), - ) { + Column(modifier = Modifier.weight(1f)) { Text( text = repo.repoName, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - repo.repoDescription?.let { - Spacer(modifier = Modifier.height(4.dp)) - + Spacer(Modifier.height(4.dp)) Text( text = it, - fontWeight = FontWeight.Medium, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } } - - IconButton( - onClick = onRemoveClick, - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = Modifier.align(Alignment.CenterVertically), - ) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = null, - ) - } + RemoveToggle(onClick = onRemoveClick) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(Modifier.height(12.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + FlowRow( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { repo.primaryLanguage?.let { language -> - AssistChip( - onClick = { }, - label = { - Text( - text = language, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - leadingIcon = { + StatChip( + label = language, + leading = { Icon( imageVector = Icons.Default.Code, contentDescription = null, - modifier = Modifier.size(AssistChipDefaults.IconSize), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, - colors = - AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer, - leadingIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), + background = MaterialTheme.colorScheme.surfaceContainerHigh, + border = MaterialTheme.colorScheme.outline, + contentColor = MaterialTheme.colorScheme.onSurface, ) } - - AssistChip( - onClick = { }, - label = { - Text( - text = repo.viewedAtFormatted, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - leadingIcon = { + StatChip( + label = repo.viewedAtFormatted, + leading = { Icon( imageVector = Icons.Default.Schedule, contentDescription = null, - modifier = Modifier.size(AssistChipDefaults.IconSize), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, + background = MaterialTheme.colorScheme.surfaceContainerHigh, + border = MaterialTheme.colorScheme.outline, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } + +@Composable +private fun RemoveToggle(onClick: () -> Unit) { + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + IconButton(onClick = onClick, modifier = Modifier.size(38.dp)) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/feature/search/data/build.gradle.kts b/feature/search/data/build.gradle.kts index 34e191b23..c166e95e1 100644 --- a/feature/search/data/build.gradle.kts +++ b/feature/search/data/build.gradle.kts @@ -8,25 +8,15 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.feature.search.domain) - implementation(libs.kotlinx.coroutines.core) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt index 14b546eb8..6cb80e2e0 100644 --- a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/di/SharedModule.kt @@ -1,7 +1,9 @@ package zed.rainxch.search.data.di import org.koin.dsl.module +import zed.rainxch.domain.repository.SearchHistoryRepository import zed.rainxch.domain.repository.SearchRepository +import zed.rainxch.search.data.repository.SearchHistoryRepositoryImpl import zed.rainxch.search.data.repository.SearchRepositoryImpl val searchModule = @@ -14,4 +16,10 @@ val searchModule = forgejoClientRegistry = get(), ) } + + single { + SearchHistoryRepositoryImpl( + searchHistoryDao = get(), + ) + } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/SearchHistoryRepositoryImpl.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchHistoryRepositoryImpl.kt similarity index 90% rename from core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/SearchHistoryRepositoryImpl.kt rename to feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchHistoryRepositoryImpl.kt index f44a45507..6cf0cdb79 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/SearchHistoryRepositoryImpl.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchHistoryRepositoryImpl.kt @@ -1,11 +1,11 @@ -package zed.rainxch.core.data.repository +package zed.rainxch.search.data.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlin.time.Clock import zed.rainxch.core.data.local.db.dao.SearchHistoryDao import zed.rainxch.core.data.local.db.entities.SearchHistoryEntity -import zed.rainxch.core.domain.repository.SearchHistoryRepository +import zed.rainxch.domain.repository.SearchHistoryRepository +import kotlin.time.Clock class SearchHistoryRepositoryImpl( private val searchHistoryDao: SearchHistoryDao, @@ -33,4 +33,4 @@ class SearchHistoryRepositoryImpl( override suspend fun clearAll() { searchHistoryDao.clearAll() } -} +} \ No newline at end of file diff --git a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt index 323ba903a..cddeba343 100644 --- a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt @@ -98,7 +98,6 @@ class SearchRepositoryImpl( return@channelFlow } - // Try backend search first val backendResult = tryBackendSearch(query, platform, sortBy, page) if (backendResult != null) { cacheManager.put(cacheKey, backendResult, SEARCH_RESULTS) @@ -106,7 +105,6 @@ class SearchRepositoryImpl( return@channelFlow } - // Fallback to GitHub REST search fallbackGithubSearch(query, platform, language, sortBy, sortOrder, page, cacheKey) }.flowOn(Dispatchers.IO) @@ -153,8 +151,6 @@ class SearchRepositoryImpl( ) } - // ── Backend search ──────────────────────────────────────────────── - private suspend fun tryBackendSearch( query: String, platform: DiscoveryPlatform, @@ -163,9 +159,6 @@ class SearchRepositoryImpl( ): PaginatedDiscoveryRepositories? { if (query.isBlank()) return null - // Backend doesn't support forks sorting — fall through to GitHub - // REST. RecentlyUpdated and RecentlyReleased route through backend - // (sort=updated / sort=releases respectively). if (sortBy == SortBy.MostForks) return null val platformSlug = when (platform) { @@ -181,7 +174,7 @@ class SearchRepositoryImpl( SortBy.BestMatch -> "relevance" SortBy.RecentlyUpdated -> "updated" SortBy.RecentlyReleased -> "releases" - SortBy.MostForks -> null // unreachable, guarded above + SortBy.MostForks -> null } val offset = (page - 1) * BACKEND_PAGE_SIZE @@ -206,20 +199,7 @@ class SearchRepositoryImpl( ) }, onFailure = { e -> - // Centralized fallback policy. Side effects: - // * 429 → throws domain RateLimitException so the - // caller surfaces a friendly retry-after toast - // (prevents the direct-GitHub /search + per-repo - // /releases verify storm that would otherwise burn - // the user's quota and trip the global rate-limit - // dialog). - // * CancellationException → re-thrown to preserve - // structured concurrency. - // * BackendException 5xx / network → returns true → - // fall through to GitHub REST fallback below. - // * BackendException 4xx (other than 429) → returns - // false → backend's answer is authoritative; do - // NOT silently retry against direct GitHub. + if (!shouldFallbackToGithubOrRethrow(e)) { throw e } @@ -228,8 +208,6 @@ class SearchRepositoryImpl( ) } - // ── Fallback GitHub REST search ─────────────────────────────────── - private suspend fun kotlinx.coroutines.channels.ProducerScope.fallbackGithubSearch( query: String, platform: DiscoveryPlatform, diff --git a/feature/search/domain/build.gradle.kts b/feature/search/domain/build.gradle.kts index b5a257f7a..4a38ddd7f 100644 --- a/feature/search/domain/build.gradle.kts +++ b/feature/search/domain/build.gradle.kts @@ -7,20 +7,9 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) - - implementation(projects.core.domain) - implementation(libs.kotlinx.coroutines.core) - } - } - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { + implementation(projects.core.domain) } } } diff --git a/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/SortBy.kt b/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/SortBy.kt index f3373a6b4..a474c0b54 100644 --- a/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/SortBy.kt +++ b/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/SortBy.kt @@ -8,13 +8,6 @@ enum class SortBy { RecentlyReleased, ; - /** - * GitHub's REST `/search/repositories?sort=...` doesn't expose a - * "by latest release date" axis — only repo-level activity. When the - * backend isn't reachable and we fall through here, RecentlyReleased - * borrows `updated` semantics (closest available approximation) so - * the UI doesn't degrade silently to relevance order. - */ fun toGithubSortParam(): String? = when (this) { MostStars -> "stars" diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/SearchHistoryRepository.kt b/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchHistoryRepository.kt similarity index 84% rename from core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/SearchHistoryRepository.kt rename to feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchHistoryRepository.kt index e101c5096..763b03876 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/SearchHistoryRepository.kt +++ b/feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchHistoryRepository.kt @@ -1,4 +1,4 @@ -package zed.rainxch.core.domain.repository +package zed.rainxch.domain.repository import kotlinx.coroutines.flow.Flow @@ -10,4 +10,4 @@ interface SearchHistoryRepository { suspend fun removeSearch(query: String) suspend fun clearAll() -} +} \ No newline at end of file diff --git a/feature/search/presentation/build.gradle.kts b/feature/search/presentation/build.gradle.kts index 669d5ab03..ed014bd95 100644 --- a/feature/search/presentation/build.gradle.kts +++ b/feature/search/presentation/build.gradle.kts @@ -7,17 +7,14 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.collections.immutable) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.search.domain) - implementation(projects.feature.profile.domain) - implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.jetbrains.compose.components.resources) - - implementation(libs.kotlinx.collections.immutable) } } } diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt index ce52a0db3..4dbca53b8 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt @@ -65,6 +65,8 @@ sealed interface SearchAction { data object OnFabClick : SearchAction + data object OnToggleFiltersSheet : SearchAction + data object DismissClipboardBanner : SearchAction data class OnHistoryItemClick( diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt index bd492e35a..f6b7efc53 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt @@ -5,10 +5,13 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -37,6 +40,7 @@ import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.KeyboardArrowDown @@ -48,7 +52,6 @@ import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilterChip -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -87,11 +90,14 @@ import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.components.RepositoryCard import zed.rainxch.core.presentation.components.ScrollbarContainer +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.core.presentation.utils.arrowKeyScroll +import zed.rainxch.core.presentation.utils.constrainedContentWidth import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.search.presentation.components.LanguageFilterBottomSheet import zed.rainxch.search.presentation.components.SearchHistorySection @@ -99,6 +105,7 @@ import zed.rainxch.search.presentation.components.SortByBottomSheet import zed.rainxch.search.presentation.model.ParsedGithubLink import zed.rainxch.search.presentation.model.ProgrammingLanguageUi import zed.rainxch.search.presentation.model.SearchPlatformUi +import zed.rainxch.search.presentation.model.SearchSourceUi import zed.rainxch.search.presentation.model.SortByUi import zed.rainxch.search.presentation.utils.label @@ -153,6 +160,34 @@ fun SearchRoot( }, ) + if (state.isFiltersSheetVisible) { + zed.rainxch.search.presentation.components.SearchFiltersSheet( + selectedSource = state.selectedSource, + availableSources = state.availableSources, + selectedPlatform = state.selectedSearchPlatform, + selectedLanguage = state.selectedLanguage, + selectedSortBy = state.selectedSortBy, + onSourceSelected = { viewModel.onAction(SearchAction.OnSourceSelected(it)) }, + onPlatformSelected = { viewModel.onAction(SearchAction.OnPlatformTypeSelected(it)) }, + onOpenLanguagePicker = { + viewModel.onAction(SearchAction.OnToggleFiltersSheet) + viewModel.onAction(SearchAction.OnToggleLanguageSheetVisibility) + }, + onOpenSortPicker = { + viewModel.onAction(SearchAction.OnToggleFiltersSheet) + viewModel.onAction(SearchAction.OnToggleSortByDialogVisibility) + }, + onReset = { + viewModel.onAction(SearchAction.OnLanguageSelected(ProgrammingLanguageUi.All)) + viewModel.onAction(SearchAction.OnPlatformTypeSelected(SearchPlatformUi.All)) + viewModel.onAction(SearchAction.OnSortBySelected(SortByUi.BestMatch)) + }, + onDismiss = { + viewModel.onAction(SearchAction.OnToggleFiltersSheet) + }, + ) + } + if (state.isLanguageSheetVisible) { LanguageFilterBottomSheet( selectedLanguage = state.selectedLanguage, @@ -229,11 +264,6 @@ fun SearchScreen( } } - // Auto-paginate while every loaded repo is filtered out by the global - // "Hide seen" tweak. The grid is empty → scroll-based shouldLoadMore can - // never fire (totalItemsCount == 0), so without this the user is stranded - // on a blank screen with no way to reach page 2+ where unseen repos may - // live. Stops once hasMorePages flips false; the banner then takes over. LaunchedEffect( state.repositories.size, state.visibleRepos.size, @@ -323,14 +353,18 @@ fun SearchScreen( }, containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.TopCenter, + ) { Column( modifier = Modifier - .fillMaxSize() - .padding(innerPadding) + .constrainedContentWidth() + .fillMaxHeight() .padding(horizontal = 16.dp), ) { - // Clipboard banner + AnimatedVisibility( visible = state.isClipboardBannerVisible && state.clipboardLinks.isNotEmpty(), enter = slideInVertically() + fadeIn(), @@ -347,7 +381,6 @@ fun SearchScreen( ) } - // Detected links from search query AnimatedVisibility( visible = state.detectedLinks.isNotEmpty(), enter = slideInVertically() + fadeIn(), @@ -361,153 +394,7 @@ fun SearchScreen( ) } - LazyRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - items(state.availableSources) { source -> - FilterChip( - selected = state.selectedSource == source, - label = { - Text( - text = source.label, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onBackground, - ) - }, - onClick = { - onAction(SearchAction.OnSourceSelected(source)) - }, - ) - } - } - - LazyRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - items(SearchPlatformUi.entries) { sortBy -> - FilterChip( - selected = state.selectedSearchPlatform == sortBy, - label = { - Text( - text = sortBy.name.lowercase().replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onBackground, - ) - }, - onClick = { - onAction(SearchAction.OnPlatformTypeSelected(sortBy)) - }, - ) - } - } - - Row( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.language_label), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Medium, - ) - - FilterChip( - selected = state.selectedLanguage != ProgrammingLanguageUi.All, - onClick = { - onAction(SearchAction.OnToggleLanguageSheetVisibility) - }, - label = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = stringResource(state.selectedLanguage.label()), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - ) - Icon( - imageVector = Icons.Outlined.KeyboardArrowDown, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - }, - ) - - if (state.selectedLanguage != ProgrammingLanguageUi.All) { - IconButton( - onClick = { - onAction(SearchAction.OnLanguageSelected(ProgrammingLanguageUi.All)) - }, - modifier = Modifier.size(32.dp), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.sort_label), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Medium, - ) - - FilterChip( - selected = state.selectedSortBy != SortByUi.BestMatch, - onClick = { - onAction(SearchAction.OnToggleSortByDialogVisibility) - }, - label = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Sort, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text( - text = stringResource(state.selectedSortBy.label()), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - ) - Icon( - imageVector = Icons.Outlined.KeyboardArrowDown, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - }, - ) - } - } + ActiveFiltersStrip(state = state, onAction = onAction) Spacer(Modifier.height(6.dp)) @@ -518,8 +405,10 @@ fun SearchScreen( Res.string.results_found, state.totalCount, ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .fillMaxWidth() @@ -527,7 +416,6 @@ fun SearchScreen( ) } - // Show search history when query is empty if (state.query.isBlank() && state.repositories.isEmpty() && state.recentSearches.isNotEmpty() && @@ -593,10 +481,6 @@ fun SearchScreen( Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(text = stringResource(Res.string.no_repositories_found)) - // Backend already did its own passthrough and still found - // nothing — don't tease a manual explore that would just - // redo the same work. Any other case (false / null for - // older backends) keeps the CTA. if (state.passthroughAttempted != true) { Spacer(Modifier.height(8.dp)) ExploreFromGithubButton( @@ -608,12 +492,6 @@ fun SearchScreen( } } - // Auto-paginate is fetching the next page in the background - // because every loaded repo is filtered out. Without an - // explicit indicator the content area is blank: the top-level - // spinner only renders while repositories.isEmpty(), and the - // in-grid load-more spinner is unreachable because the grid - // itself only renders when visibleRepos is non-empty. if (state.repositories.isNotEmpty() && state.visibleRepos.isEmpty() && state.isHideSeenEnabled && @@ -637,16 +515,6 @@ fun SearchScreen( } } - // All currently loaded API hits filtered out by the global - // "Hide seen" tweak AND no further pages remain — results - // counter still shows the raw total, so without this banner - // the user sees "N results found" above an empty grid and - // assumes the app is broken (issue #574). `hasMorePages` is - // required because the scroll-based pagination above stalls - // when totalItemsCount == 0, so we surface the banner only - // when there is genuinely nothing more to fetch. While - // hasMorePages is true, the auto-paginate effect below pulls - // the next page in the background. if (state.repositories.isNotEmpty() && state.visibleRepos.isEmpty() && state.isHideSeenEnabled && @@ -685,9 +553,7 @@ fun SearchScreen( columns = StaggeredGridCells.Adaptive(350.dp), verticalItemSpacing = 12.dp, horizontalArrangement = Arrangement.spacedBy(12.dp), - // Bottom clearance = nav pill + FAB (~56dp standard M3 - // FAB, positioned at bottomNavHeight + 16.dp) + breathing - // room so the last card scrolls fully above both. + contentPadding = PaddingValues( start = 8.dp, @@ -745,7 +611,6 @@ fun SearchScreen( } } - // "Fetch more from GitHub" explore button if (!state.isLoading && !state.isLoadingMore && state.query.isNotBlank()) { item { ExploreFromGithubButton( @@ -759,6 +624,7 @@ fun SearchScreen( } } } + } } } @@ -768,23 +634,18 @@ private fun ClipboardBanner( onOpenLink: (ParsedGithubLink) -> Unit, onDismiss: () -> Unit, ) { - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ), - shape = RoundedCornerShape(12.dp), + androidx.compose.material3.Surface( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + shape = zed.rainxch.core.presentation.theme.tokens.Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = androidx.compose.foundation.BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ), ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(12.dp), - ) { + Column(modifier = Modifier.padding(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -793,47 +654,43 @@ private fun ClipboardBanner( Text( text = stringResource(Res.string.clipboard_link_detected), style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, ) - IconButton( onClick = onDismiss, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(28.dp).clip(CircleShape), ) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(Res.string.dismiss), modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - Spacer(Modifier.height(4.dp)) - links.forEach { link -> Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { onOpenLink(link) } - .padding(vertical = 6.dp, horizontal = 4.dp), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { onOpenLink(link) } + .padding(vertical = 8.dp, horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Icon( imageVector = Icons.Default.Link, contentDescription = null, modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer, + tint = MaterialTheme.colorScheme.primary, ) Text( text = "${link.owner}/${link.repo}", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), ) Icon( @@ -854,58 +711,55 @@ private fun DetectedLinksSection( onOpenLink: (ParsedGithubLink) -> Unit, ) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), ) { Text( text = stringResource(Res.string.detected_links), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(bottom = 4.dp), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 6.dp), ) - links.forEach { link -> - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), + androidx.compose.material3.Surface( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), onClick = { onOpenLink(link) }, - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ), - shape = RoundedCornerShape(8.dp), + shape = zed.rainxch.core.presentation.theme.tokens.Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = androidx.compose.foundation.BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ), ) { Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Icon( imageVector = Icons.Default.Link, contentDescription = null, modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, + tint = MaterialTheme.colorScheme.primary, ) Text( text = "${link.owner}/${link.repo}", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), ) Text( text = stringResource(Res.string.open_in_app), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.SemiBold, ) } } @@ -919,14 +773,15 @@ private fun SearchTopbar( state: SearchState, focusRequester: FocusRequester, ) { + val activeFilterCount = activeFilterCount(state) Row( modifier = Modifier .fillMaxWidth() .statusBarsPadding() - .padding(horizontal = 8.dp, vertical = 8.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { TextField( value = state.query, @@ -938,28 +793,30 @@ private fun SearchTopbar( imageVector = Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, - trailingIcon = { - IconButton( - onClick = { - onAction(SearchAction.OnClearClick) - }, - modifier = - Modifier - .size(24.dp) + trailingIcon = if (state.query.isNotEmpty()) { + { + IconButton( + onClick = { onAction(SearchAction.OnClearClick) }, + modifier = Modifier + .size(28.dp) .clip(CircleShape), - ) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = null, - ) + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(Res.string.dismiss), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - }, + } else null, placeholder = { Text( text = stringResource(Res.string.search_repositories_hint), - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, softWrap = false, maxLines = 1, @@ -967,7 +824,7 @@ private fun SearchTopbar( ) }, textStyle = - MaterialTheme.typography.bodyLarge.copy( + MaterialTheme.typography.bodyMedium.copy( color = MaterialTheme.colorScheme.onSurface, ), keyboardOptions = @@ -988,15 +845,160 @@ private fun SearchTopbar( TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, ), - shape = CircleShape, + shape = RoundedCornerShape(50), modifier = Modifier .weight(1f) .focusRequester(focusRequester), ) + + FiltersPillButton( + activeCount = activeFilterCount, + onClick = { onAction(SearchAction.OnToggleFiltersSheet) }, + ) + } +} + +private fun activeFilterCount(state: SearchState): Int { + var count = 0 + if (state.selectedSource != SearchSourceUi.GitHub) count++ + if (state.selectedSearchPlatform != SearchPlatformUi.All) count++ + if (state.selectedLanguage != ProgrammingLanguageUi.All) count++ + if (state.selectedSortBy != SortByUi.BestMatch) count++ + return count +} + +@Composable +private fun FiltersPillButton( + activeCount: Int, + onClick: () -> Unit, +) { + val shape = RoundedCornerShape(50) + val container = + if (activeCount > 0) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceContainerLow + val content = + if (activeCount > 0) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface + Row( + modifier = Modifier + .height(48.dp) + .clip(shape) + .background(container, shape) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = stringResource(Res.string.search_filters_button), + modifier = Modifier.size(18.dp), + tint = content, + ) + if (activeCount > 0) { + Text( + text = activeCount.toString(), + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = content, + ) + } + } +} + +@Composable +private fun ActiveFiltersStrip( + state: SearchState, + onAction: (SearchAction) -> Unit, +) { + val items = buildList Unit, androidx.compose.ui.graphics.vector.ImageVector?>> { + if (state.selectedSource != SearchSourceUi.GitHub) { + add(Triple(state.selectedSource.label, { onAction(SearchAction.OnSourceSelected(SearchSourceUi.GitHub)) }, null)) + } + if (state.selectedSearchPlatform != SearchPlatformUi.All) { + add( + Triple( + state.selectedSearchPlatform.name.lowercase().replaceFirstChar { it.uppercase() }, + { onAction(SearchAction.OnPlatformTypeSelected(SearchPlatformUi.All)) }, + null, + ), + ) + } + if (state.selectedLanguage != ProgrammingLanguageUi.All) { + add( + Triple( + "${state.selectedLanguage}", + { onAction(SearchAction.OnLanguageSelected(ProgrammingLanguageUi.All)) }, + Icons.Outlined.KeyboardArrowDown, + ), + ) + } + if (state.selectedSortBy != SortByUi.BestMatch) { + add( + Triple( + "${state.selectedSortBy}", + { onAction(SearchAction.OnSortBySelected(SortByUi.BestMatch)) }, + Icons.AutoMirrored.Filled.Sort, + ), + ) + } + } + if (items.isEmpty()) return + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEach { (label, onRemove, leading) -> + ActiveFilterChip(label = label, leadingIcon = leading, onRemove = onRemove) + } + } +} + +@Composable +private fun ActiveFilterChip( + label: String, + leadingIcon: androidx.compose.ui.graphics.vector.ImageVector?, + onRemove: () -> Unit, +) { + val shape = RoundedCornerShape(50) + Row( + modifier = Modifier + .clip(shape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), shape) + .border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.4f), shape) + .clickable(onClick = onRemove) + .padding(start = 12.dp, end = 8.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.primary, + ) + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.search_clear_filter_cd), + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) } } @@ -1013,26 +1015,22 @@ private fun ExploreFromGithubButton( ) { when (status) { SearchState.ExploreStatus.IDLE -> { - OutlinedButton(onClick = onExplore) { - Icon( - imageVector = Icons.Outlined.TravelExplore, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text(text = stringResource(Res.string.fetch_more_from_github)) - } + GhsButton( + onClick = onExplore, + label = stringResource(Res.string.fetch_more_from_github), + variant = GhsButtonVariant.Outline, + leadingIcon = Icons.Outlined.TravelExplore, + ) } SearchState.ExploreStatus.LOADING -> { - OutlinedButton(onClick = {}, enabled = false) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - Spacer(Modifier.width(8.dp)) - Text(text = stringResource(Res.string.fetching_from_github)) - } + GhsButton( + onClick = {}, + label = stringResource(Res.string.fetching_from_github), + variant = GhsButtonVariant.Outline, + enabled = false, + loading = true, + ) } SearchState.ExploreStatus.EXHAUSTED -> { diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt index a45607d49..998eec7ee 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt @@ -38,6 +38,7 @@ data class SearchState( val selectedSource: SearchSourceUi = SearchSourceUi.GitHub, val availableSources: ImmutableList = persistentListOf(SearchSourceUi.GitHub, SearchSourceUi.Codeberg), + val isFiltersSheetVisible: Boolean = false, ) { enum class ExploreStatus { IDLE, diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt index 945d6a1c9..7806f0831 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt @@ -26,18 +26,17 @@ import zed.rainxch.core.domain.model.isReallyInstalled import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.HiddenReposRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository -import zed.rainxch.core.domain.repository.SearchHistoryRepository +import zed.rainxch.domain.repository.SearchHistoryRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository -import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.utils.ClipboardHelper import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.core.presentation.model.DiscoveryRepositoryUi import zed.rainxch.core.presentation.utils.toUi import zed.rainxch.domain.repository.SearchRepository -import zed.rainxch.profile.domain.repository.ProfileRepository import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.failed_to_share_link import zed.rainxch.githubstore.core.presentation.res.link_copied_to_clipboard @@ -45,7 +44,6 @@ import zed.rainxch.githubstore.core.presentation.res.no_github_link_in_clipboard import zed.rainxch.githubstore.core.presentation.res.explore_error import zed.rainxch.githubstore.core.presentation.res.rate_limit_exceeded import zed.rainxch.githubstore.core.presentation.res.rate_limit_exceeded_retry_in -import zed.rainxch.githubstore.core.presentation.res.rate_limit_exceeded_signin_hint import zed.rainxch.githubstore.core.presentation.res.search_failed import zed.rainxch.search.presentation.mappers.toDomain import zed.rainxch.search.presentation.model.SearchPlatformUi @@ -65,8 +63,7 @@ class SearchViewModel( private val tweaksRepository: TweaksRepository, private val seenReposRepository: SeenReposRepository, private val searchHistoryRepository: SearchHistoryRepository, - private val telemetryRepository: TelemetryRepository, - private val profileRepository: ProfileRepository, + private val userSessionRepository: UserSessionRepository, private val hiddenReposRepository: HiddenReposRepository, private val initialPlatform: SearchPlatformUi? = null, ) : ViewModel() { @@ -76,19 +73,12 @@ class SearchViewModel( private var explorePage = 1 private var lastExploreQuery = "" - // Cached so each pagination/explore mapping doesn't re-hit - // ProfileRepository on every result page. observeCurrentUser() keeps - // it in sync with login/logout. - @Volatile private var currentUserLogin: String? = null + @Volatile + private var currentUserLogin: String? = null private val exploreLog = logger.withTag("SearchExplore") companion object { - // 2 covers common CJK app names that are exactly two characters - // (`B站`, `微博`, `抖音`, `美团`, …) which the previous 3-char floor - // silently rejected even when the user explicitly tapped the IME - // search action (#372). Single-character queries are still gated - // because they'd return millions of GitHub results. private const val MIN_QUERY_LENGTH = 2 } @@ -132,7 +122,7 @@ class SearchViewModel( return state.repositories .filter { repo -> repo.repository.id !in hidden && - (!needsHideSeenFilter || repo.repository.id !in state.seenRepoIds) + (!needsHideSeenFilter || repo.repository.id !in state.seenRepoIds) } .toImmutableList() } @@ -173,14 +163,15 @@ class SearchViewModel( .filter { it.isNotBlank() && !it.equals("codeberg.org", ignoreCase = true) } .sorted() .map { zed.rainxch.search.presentation.model.SearchSourceUi.CustomForge(it) } - val all = kotlinx.collections.immutable.persistentListOf() - .addAll(base + extra) + val all = + kotlinx.collections.immutable.persistentListOf() + .addAll(base + extra) _state.update { current -> - val current_sel = current.selectedSource - val stillValid = all.contains(current_sel) + val currentSel = current.selectedSource + val stillValid = all.contains(currentSel) current.copy( availableSources = all, - selectedSource = if (stillValid) current_sel + selectedSource = if (stillValid) currentSel else zed.rainxch.search.presentation.model.SearchSourceUi.GitHub, ) } @@ -191,10 +182,6 @@ class SearchViewModel( private fun observeHiddenRepos() { viewModelScope.launch { hiddenReposRepository.getAllHiddenRepoIds().collect { ids -> - // Track IDs only — `computeVisibleRepos` already filters - // hidden at render time. Removing from `repositories` - // would break `OnUndoHideRepository`: once the entity is - // gone there's nothing to bring back without re-fetching. _state.update { it.copy(hiddenRepoIds = ids) } } } @@ -202,7 +189,7 @@ class SearchViewModel( private fun observeCurrentUser() { viewModelScope.launch { - profileRepository.getUser().collect { user -> + userSessionRepository.getUser().collect { user -> currentUserLogin = user?.username val login = user?.username _state.update { current -> @@ -211,11 +198,14 @@ class SearchViewModel( current.repositories .map { repo -> repo.copy( - isCurrentUserOwner = - login != null && - repo.repository.owner.login.equals(login, ignoreCase = true), + isCurrentUserOwner = login != null && + repo.repository.owner.login.equals( + login, + ignoreCase = true + ), ) - }.toImmutableList(), + } + .toImmutableList(), ) } } @@ -448,7 +438,10 @@ class SearchViewModel( isSeen = repo.id in seenIds, isCurrentUserOwner = currentLogin != null && - repo.owner.login.equals(currentLogin, ignoreCase = true), + repo.owner.login.equals( + currentLogin, + ignoreCase = true + ), isUpdateAvailable = apps.any { it.hasActualUpdate() }, repository = repo.toUi(), ) @@ -492,12 +485,6 @@ class SearchViewModel( _state.update { it.copy(isLoading = false, isLoadingMore = false) } - - if (isInitial) { - telemetryRepository.recordSearchPerformed( - resultCount = _state.value.repositories.size, - ) - } } catch (e: RateLimitException) { logger.debug("Rate limit exceeded: ${e.message}") val seconds = e.rateLimitInfo.timeUntilReset().inWholeSeconds @@ -555,7 +542,7 @@ class SearchViewModel( it.copy(selectedLanguage = action.language) } currentPage = 1 - + performSearch(isInitial = true) } } @@ -600,6 +587,12 @@ class SearchViewModel( } } + SearchAction.OnToggleFiltersSheet -> { + _state.update { + it.copy(isFiltersSheetVisible = !it.isFiltersSheetVisible) + } + } + SearchAction.OnSearchImeClick -> { if (_state.value.detectedLinks.isNotEmpty() && isEntirelyGithubUrls(_state.value.query)) { val link = _state.value.detectedLinks.first() @@ -638,7 +631,7 @@ class SearchViewModel( it.copy(selectedSortBy = action.sortBy) } currentPage = 1 - + performSearch(isInitial = true) } } @@ -649,7 +642,7 @@ class SearchViewModel( it.copy(selectedSortOrder = action.sortOrder) } currentPage = 1 - + performSearch(isInitial = true) } } @@ -739,7 +732,6 @@ class SearchViewModel( } is SearchAction.OnRepositoryClick -> { - telemetryRepository.recordSearchResultClicked(action.repository.id) // Navigation handled in composable } @@ -858,7 +850,7 @@ class SearchViewModel( exploreLog.debug( "click: query='$query' platform=$platformUi " + - "page=$explorePage lastQuery='$lastExploreQuery' status=$prevStatus", + "page=$explorePage lastQuery='$lastExploreQuery' status=$prevStatus", ) if (query.isBlank()) { @@ -890,8 +882,8 @@ class SearchViewModel( val existingCount = _state.value.repositories.size exploreLog.debug( "response: items=${exploreResult.repos.size} " + - "returnedPage=${exploreResult.page} hasMore=${exploreResult.hasMore} " + - "existingVisible=$existingCount", + "returnedPage=${exploreResult.page} hasMore=${exploreResult.hasMore} " + + "existingVisible=$existingCount", ) val before = _state.value.repositories.size @@ -910,7 +902,7 @@ class SearchViewModel( } else { exploreLog.debug( "-> EXHAUSTED: appended=$added dupes=$dupes " + - "rawItems=${exploreResult.repos.size}", + "rawItems=${exploreResult.repos.size}", ) _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.EXHAUSTED) } } @@ -927,7 +919,8 @@ class SearchViewModel( private suspend fun appendExploreResults( newRepos: List, ) { - val installedMap = installedAppsRepository.getAllInstalledApps().first().groupBy { it.repoId } + val installedMap = + installedAppsRepository.getAllInstalledApps().first().groupBy { it.repoId } val favoritesMap = favouritesRepository.getAllFavorites().first().associateBy { it.repoId } val starredMap = starredRepository.getAllStarred().first().associateBy { it.repoId } val seenIds = _state.value.seenRepoIds @@ -946,7 +939,7 @@ class SearchViewModel( isSeen = repo.id in seenIds, isCurrentUserOwner = currentLogin != null && - repo.owner.login.equals(currentLogin, ignoreCase = true), + repo.owner.login.equals(currentLogin, ignoreCase = true), isUpdateAvailable = apps.any { it.hasActualUpdate() }, repository = repo.toUi(), ) diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SearchFiltersSheet.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SearchFiltersSheet.kt new file mode 100644 index 000000000..b1010625b --- /dev/null +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SearchFiltersSheet.kt @@ -0,0 +1,283 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +package zed.rainxch.search.presentation.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.collections.immutable.ImmutableList +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.overlays.GhsBottomSheet +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.search_filters_apply +import zed.rainxch.githubstore.core.presentation.res.search_filters_reset +import zed.rainxch.githubstore.core.presentation.res.search_filters_section_language +import zed.rainxch.githubstore.core.presentation.res.search_filters_section_platform +import zed.rainxch.githubstore.core.presentation.res.search_filters_section_sort +import zed.rainxch.githubstore.core.presentation.res.search_filters_section_source +import zed.rainxch.githubstore.core.presentation.res.search_filters_title +import zed.rainxch.search.presentation.model.ProgrammingLanguageUi +import zed.rainxch.search.presentation.model.SearchPlatformUi +import zed.rainxch.search.presentation.model.SearchSourceUi +import zed.rainxch.search.presentation.model.SortByUi +import zed.rainxch.search.presentation.utils.label + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun SearchFiltersSheet( + selectedSource: SearchSourceUi, + availableSources: ImmutableList, + selectedPlatform: SearchPlatformUi, + selectedLanguage: ProgrammingLanguageUi, + selectedSortBy: SortByUi, + onSourceSelected: (SearchSourceUi) -> Unit, + onPlatformSelected: (SearchPlatformUi) -> Unit, + onOpenLanguagePicker: () -> Unit, + onOpenSortPicker: () -> Unit, + onReset: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + GhsBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.search_filters_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + GhsButton( + onClick = onReset, + label = stringResource(Res.string.search_filters_reset), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) + } + + FilterSection(title = stringResource(Res.string.search_filters_section_source)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + availableSources.forEach { source -> + SelectableChip( + text = source.label, + selected = selectedSource == source, + onClick = { onSourceSelected(source) }, + ) + } + } + } + + FilterSection(title = stringResource(Res.string.search_filters_section_platform)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SearchPlatformUi.entries.forEach { platform -> + SelectableChip( + text = platform.name + .lowercase() + .replaceFirstChar { it.uppercase() }, + selected = selectedPlatform == platform, + onClick = { onPlatformSelected(platform) }, + ) + } + } + } + + FilterSection(title = stringResource(Res.string.search_filters_section_language)) { + NavRow( + leadingIcon = Icons.Outlined.Language, + value = stringResource(selectedLanguage.label()), + onClick = onOpenLanguagePicker, + ) + } + + FilterSection(title = stringResource(Res.string.search_filters_section_sort)) { + NavRow( + leadingIcon = Icons.AutoMirrored.Filled.Sort, + value = stringResource(selectedSortBy.label()), + onClick = onOpenSortPicker, + ) + } + + Spacer(Modifier.height(4.dp)) + + GhsButton( + onClick = onDismiss, + label = stringResource(Res.string.search_filters_apply), + variant = GhsButtonVariant.Primary, + size = GhsButtonSize.Lg, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun FilterSection( + title: String, + content: @Composable () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + content() + } +} + +@Composable +private fun SelectableChip( + text: String, + selected: Boolean, + onClick: () -> Unit, +) { + val container = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val content = if (selected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + } + val border = if (selected) { + BorderStroke(0.dp, MaterialTheme.colorScheme.primary) + } else { + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .border(border, RoundedCornerShape(50)) + .background(container) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 9.dp), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = content, + ) + } + Text( + text = text, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + ), + color = content, + ) + } + } +} + +@Composable +private fun NavRow( + leadingIcon: androidx.compose.ui.graphics.vector.ImageVector, + value: String, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(14.dp), + ) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 14.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SearchHistorySection.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SearchHistorySection.kt index 6f4c88ecc..12fc029f3 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SearchHistorySection.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SearchHistorySection.kt @@ -15,7 +15,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,6 +24,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import zed.rainxch.githubstore.core.presentation.res.* @Composable @@ -48,12 +50,12 @@ fun SearchHistorySection( fontWeight = FontWeight.Medium, ) - TextButton(onClick = onClearAll) { - Text( - text = stringResource(Res.string.clear_all_history), - style = MaterialTheme.typography.labelMedium, - ) - } + GhsButton( + onClick = onClearAll, + label = stringResource(Res.string.clear_all_history), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } recentSearches.forEach { query -> diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SortByBottomSheet.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SortByBottomSheet.kt index 49c3189b3..7b64a14e1 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SortByBottomSheet.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/components/SortByBottomSheet.kt @@ -11,8 +11,10 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -37,9 +39,12 @@ fun SortByBottomSheet( onDismissRequest = onDismissRequest, confirmButton = {}, dismissButton = { - TextButton(onClick = onDismissRequest) { - Text(text = stringResource(Res.string.close)) - } + GhsButton( + onClick = onDismissRequest, + label = stringResource(Res.string.close), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, title = { Text( @@ -54,18 +59,18 @@ fun SortByBottomSheet( ) { SortByUi.entries.forEach { option -> val isSelected = option == selectedSortBy - TextButton( - onClick = { - onSortBySelected(option) - }, + GhsButton( + onClick = { onSortBySelected(option) }, + label = stringResource(option.label()) + if (isSelected) " ✓" else "", + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(option.label()) + if (isSelected) " ✓" else "", - style = MaterialTheme.typography.bodyMedium, - color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - ) - } + contentColorOverride = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) } HorizontalDivider() diff --git a/feature/starred/data/build.gradle.kts b/feature/starred/data/build.gradle.kts index dc5ff3ffb..73578f11d 100644 --- a/feature/starred/data/build.gradle.kts +++ b/feature/starred/data/build.gradle.kts @@ -13,15 +13,5 @@ kotlin { implementation(projects.feature.starred.domain) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/starred/domain/build.gradle.kts b/feature/starred/domain/build.gradle.kts index 7ab581f42..0bac5a50c 100644 --- a/feature/starred/domain/build.gradle.kts +++ b/feature/starred/domain/build.gradle.kts @@ -11,15 +11,5 @@ kotlin { implementation(projects.core.domain) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/starred/presentation/build.gradle.kts b/feature/starred/presentation/build.gradle.kts index 1b65fc473..2279634a9 100644 --- a/feature/starred/presentation/build.gradle.kts +++ b/feature/starred/presentation/build.gradle.kts @@ -7,29 +7,17 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.collections.immutable) implementation(projects.core.domain) implementation(projects.core.presentation) implementation(projects.feature.starred.domain) - implementation(projects.feature.profile.domain) implementation(libs.bundles.landscapist) - implementation(libs.kotlinx.collections.immutable) - implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.jetbrains.compose.components.resources) } } - - androidMain { - dependencies { - } - } - - jvmMain { - dependencies { - } - } } } diff --git a/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposRoot.kt b/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposRoot.kt index dcaffdc9e..035d2d9c8 100644 --- a/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposRoot.kt +++ b/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposRoot.kt @@ -37,9 +37,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState @@ -50,7 +47,6 @@ 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.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -63,6 +59,10 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.components.ScrollbarContainer +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsTextField import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.arrowKeyScroll @@ -241,15 +241,14 @@ fun StarredScreen( .align(Alignment.BottomCenter) .padding(16.dp), action = { - TextButton( + GhsButton( onClick = { onAction(StarredReposAction.OnRetrySync) }, - ) { - Text( - text = stringResource(Res.string.retry), - ) - } + label = stringResource(Res.string.retry), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, dismissAction = { IconButton( @@ -292,9 +291,10 @@ private fun StarredTopBar( Column { Text( text = stringResource(Res.string.starred_repositories), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, ) if (lastSyncTime != null && !isSyncing) { @@ -310,7 +310,6 @@ private fun StarredTopBar( }, navigationIcon = { IconButton( - shapes = IconButtonDefaults.shapes(), onClick = { onAction(StarredReposAction.OnNavigateBackClick) }, ) { Icon( @@ -382,15 +381,15 @@ private fun StarredSearchBar( query: String, onQueryChange: (String) -> Unit, ) { - TextField( + GhsTextField( value = query, onValueChange = onQueryChange, modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp), - placeholder = { Text(stringResource(Res.string.search_repositories_hint)) }, - leadingIcon = { Icon(imageVector = Icons.Filled.Search, contentDescription = null) }, + placeholder = stringResource(Res.string.search_repositories_hint), + leadingIcon = Icons.Filled.Search, trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { @@ -402,12 +401,6 @@ private fun StarredSearchBar( } }, singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), ) } @@ -453,9 +446,10 @@ private fun EmptyStateContent( if (actionText != null && onActionClick != null) { Spacer(modifier = Modifier.height(16.dp)) - GithubStoreButton( - text = actionText, + GhsButton( onClick = onActionClick, + label = actionText, + variant = GhsButtonVariant.Primary, ) } } diff --git a/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposViewModel.kt b/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposViewModel.kt index b1c4fe288..dd61610b6 100644 --- a/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposViewModel.kt +++ b/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/StarredReposViewModel.kt @@ -17,12 +17,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.model.FavoriteRepo -import zed.rainxch.core.domain.repository.AuthenticationState +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.profile.domain.repository.ProfileRepository import zed.rainxch.starred.presentation.mappers.toStarredRepositoryUi import zed.rainxch.starred.presentation.model.StarredRepositoryUi import zed.rainxch.starred.presentation.model.StarredSortRule @@ -30,10 +29,9 @@ import kotlin.time.Clock import kotlin.time.ExperimentalTime class StarredReposViewModel( - private val authenticationState: AuthenticationState, + private val userSessionRepository: UserSessionRepository, private val starredRepository: StarredRepository, private val favouritesRepository: FavouritesRepository, - private val profileRepository: ProfileRepository, private val tweaksRepository: TweaksRepository, ) : ViewModel() { private var hasLoadedInitialData = false @@ -54,7 +52,7 @@ class StarredReposViewModel( private fun checkAuthAndLoad() { viewModelScope.launch { - val isAuthenticated = authenticationState.isCurrentlyUserLoggedIn() + val isAuthenticated = userSessionRepository.isCurrentlyUserLoggedIn() _state.update { it.copy(isAuthenticated = isAuthenticated) } @@ -70,7 +68,7 @@ class StarredReposViewModel( combine( starredRepository.getAllStarred(), favouritesRepository.getAllFavorites(), - profileRepository.getUser(), + userSessionRepository.getUser(), tweaksRepository.getStarredSortRule(), ) { starred, favorites, user, sortStored -> val sortRule = StarredSortRule.fromName(sortStored) @@ -171,10 +169,7 @@ class StarredReposViewModel( } StarredReposAction.OnRefresh -> { - // Refresh may return a list that no longer matches the active - // search query, leaving the user staring at an empty grid with - // a stale filter still applied. Clearing the query removes the - // ambiguity. + _state.update { it.copy(searchQuery = "") } syncStarredRepos(forceRefresh = true) } diff --git a/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/components/StarredRepositoryItem.kt b/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/components/StarredRepositoryItem.kt index 457bffd52..f01704af2 100644 --- a/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/components/StarredRepositoryItem.kt +++ b/feature/starred/presentation/src/commonMain/kotlin/zed/rainxch/starred/presentation/components/StarredRepositoryItem.kt @@ -1,8 +1,10 @@ package zed.rainxch.starred.presentation.components +import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -11,32 +13,24 @@ 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.layout.width -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallSplit +import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.Warning -import androidx.compose.material3.Badge -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledIconToggleButton import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text -import androidx.compose.material3.toShape 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.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -45,12 +39,14 @@ import com.skydoves.landscapist.coil3.CoilImage import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.core.presentation.components.OfficialBadge +import zed.rainxch.core.presentation.components.chips.StatChip import zed.rainxch.core.presentation.theme.GithubStoreTheme +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.core.presentation.utils.formatCount import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.starred.presentation.model.StarredRepositoryUi -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalLayoutApi::class) @Composable fun StarredRepositoryItem( repository: StarredRepositoryUi, @@ -64,48 +60,36 @@ fun StarredRepositoryItem( modifier = modifier.fillMaxWidth(), ) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { CoilImage( imageModel = { repository.repoOwnerAvatarUrl }, - modifier = - Modifier - .size(40.dp) - .clip(CircleShape) - .clickable(onClick = { - onDevProfileClick() - }), - imageOptions = - ImageOptions( - contentScale = ContentScale.Crop, - ), + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .clickable(onClick = onDevProfileClick), + imageOptions = ImageOptions(contentScale = ContentScale.Crop), ) - Spacer(modifier = Modifier.width(12.dp)) - Column( - modifier = - Modifier - .weight(1f) - .clickable(onClick = { - onDevProfileClick() - }), + modifier = Modifier + .weight(1f) + .clickable(onClick = onDevProfileClick), ) { Text( text = repository.repoName, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -118,124 +102,69 @@ fun StarredRepositoryItem( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - if (repository.isCurrentUserOwner) { OfficialBadge() } } } - FilledIconToggleButton( - checked = repository.isFavorite, - onCheckedChange = { onToggleFavoriteClick() }, - modifier = Modifier.size(40.dp), - shape = MaterialShapes.Cookie6Sided.toShape(), - ) { - Icon( - imageVector = - if (repository.isFavorite) { - Icons.Filled.Favorite - } else { - Icons.Outlined.FavoriteBorder - }, - contentDescription = - if (repository.isFavorite) { - stringResource(Res.string.remove_from_favourites) - } else { - stringResource(Res.string.add_to_favourites) - }, - modifier = Modifier.size(20.dp), - ) - } + FavoriteToggle( + favorited = repository.isFavorite, + onClick = onToggleFavoriteClick, + ) } repository.repoDescription?.let { description -> - Spacer(modifier = Modifier.height(12.dp)) - + Spacer(Modifier.height(10.dp)) Text( text = description, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, ) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(Modifier.height(12.dp)) - Row( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - StatChip( + StatChipNeutral( icon = Icons.Default.Star, label = formatCount(repository.stargazersCount), - contentDescription = "${repository.stargazersCount} ${stringResource(Res.string.stars)}", ) - - StatChip( + StatChipNeutral( icon = Icons.AutoMirrored.Filled.CallSplit, label = formatCount(repository.forksCount), - contentDescription = "${repository.forksCount} ${stringResource(Res.string.forks)}", ) - if (repository.openIssuesCount > 0) { - StatChip( + StatChipNeutral( icon = Icons.Outlined.Warning, label = formatCount(repository.openIssuesCount), - contentDescription = "${repository.openIssuesCount} ${stringResource(Res.string.issues)}", ) } - repository.primaryLanguage?.let { language -> - SuggestionChip( - onClick = {}, - label = { - Text( - text = language, - style = MaterialTheme.typography.labelSmall, - ) - }, - modifier = Modifier.height(32.dp), + StatChipNeutral( + icon = Icons.Default.Code, + label = language, ) } - } - - if (repository.isInstalled || repository.latestRelease != null) { - Spacer(modifier = Modifier.height(12.dp)) - - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (repository.isInstalled) { - Badge( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ) { - Text( - text = stringResource(Res.string.installed), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - } - - repository.latestRelease?.let { version -> - Badge( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ) { - Text( - text = version, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } + if (repository.isInstalled) { + TonalBadge( + text = stringResource(Res.string.installed), + container = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.18f), + content = MaterialTheme.colorScheme.tertiary, + ) + } + repository.latestRelease?.let { version -> + TonalBadge( + text = version, + container = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + content = MaterialTheme.colorScheme.primary, + ) } } } @@ -243,56 +172,108 @@ fun StarredRepositoryItem( } @Composable -private fun StatChip( - icon: ImageVector, - label: String, - contentDescription: String, - modifier: Modifier = Modifier, +private fun StatChipNeutral(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String) { + StatChip( + label = label, + leading = { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + background = MaterialTheme.colorScheme.surfaceContainerHigh, + border = MaterialTheme.colorScheme.outline, + contentColor = MaterialTheme.colorScheme.onSurface, + ) +} + +@Composable +private fun TonalBadge( + text: String, + container: androidx.compose.ui.graphics.Color, + content: androidx.compose.ui.graphics.Color, ) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, + Box( + modifier = Modifier + .clip(Radii.chip) + .background(container) + .padding(horizontal = 10.dp, vertical = 5.dp), ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, + text = text, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = content, ) } } +@Composable +private fun FavoriteToggle( + favorited: Boolean, + onClick: () -> Unit, +) { + val tint = if (favorited) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + val container = if (favorited) { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.45f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(container), + contentAlignment = Alignment.Center, + ) { + IconButton(onClick = onClick, modifier = Modifier.size(38.dp)) { + Icon( + imageVector = if (favorited) { + Icons.Filled.Favorite + } else { + Icons.Outlined.FavoriteBorder + }, + contentDescription = if (favorited) { + stringResource(Res.string.remove_from_favourites) + } else { + stringResource(Res.string.add_to_favourites) + }, + tint = tint, + modifier = Modifier.size(18.dp), + ) + } + } +} + @Preview @Composable private fun PreviewStarredRepoItem() { GithubStoreTheme { StarredRepositoryItem( - repository = - StarredRepositoryUi( - repoId = 1, - repoName = "awesome-app", - repoOwner = "developer", - repoOwnerAvatarUrl = "", - repoDescription = "An awesome application that does amazing things", - primaryLanguage = "Kotlin", - repoUrl = "", - stargazersCount = 1234, - forksCount = 567, - openIssuesCount = 12, - isInstalled = true, - isFavorite = false, - latestRelease = "v1.2.3", - latestReleaseUrl = null, - starredAt = null, - ), + repository = StarredRepositoryUi( + repoId = 1, + repoName = "awesome-app", + repoOwner = "developer", + repoOwnerAvatarUrl = "", + repoDescription = "An awesome application that does amazing things", + primaryLanguage = "Kotlin", + repoUrl = "", + stargazersCount = 1234, + forksCount = 567, + openIssuesCount = 12, + isInstalled = true, + isFavorite = false, + latestRelease = "v1.2.3", + latestReleaseUrl = null, + starredAt = null, + ), onToggleFavoriteClick = {}, onItemClick = {}, onDevProfileClick = {}, diff --git a/feature/tweaks/presentation/build.gradle.kts b/feature/tweaks/presentation/build.gradle.kts index 882f70e35..2a2d4c242 100644 --- a/feature/tweaks/presentation/build.gradle.kts +++ b/feature/tweaks/presentation/build.gradle.kts @@ -7,29 +7,22 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.collections.immutable) implementation(projects.core.domain) implementation(projects.core.data) implementation(projects.core.presentation) - implementation(projects.feature.profile.domain) - - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.jetbrains.compose.components.resources) - implementation(libs.touchlab.kermit) - implementation(libs.kotlinx.collections.immutable) api(libs.ktor.client.core) - } - } + implementation(libs.touchlab.kermit) + implementation(libs.kotlinx.serialization.json) - androidMain { - dependencies { - } - } + implementation(libs.coil3.compose) + implementation(libs.coil3.svg) - jvmMain { - dependencies { + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.jetbrains.compose.components.resources) } } } diff --git a/feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt b/feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt index 32b3b03ad..c0d151dbd 100644 --- a/feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt +++ b/feature/tweaks/presentation/src/androidMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.android.kt @@ -1,13 +1,5 @@ package zed.rainxch.tweaks.presentation -/** - * No-op on Android — runtime language changes are applied via - * `Activity.recreate()` from `MainActivity`, and the triggering - * event (`OnAppLanguageChangeRequiresRestart`) is never emitted on - * this platform. We keep an actual so common code compiles; if the - * invariant ever breaks we'd rather silently skip than kill the - * process. - */ actual fun restartAppAfterLanguageChange() { - // Intentionally empty. + } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.kt index 885fb28f0..4f68e683f 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.kt @@ -1,19 +1,3 @@ package zed.rainxch.tweaks.presentation -/** - * Platform hook for the "restart now" Snackbar action after a - * language change on Desktop. Tries to spawn a fresh JVM with the - * same command line as the current process and then exit — so the - * user ends up in a freshly-started app with their new locale - * applied by `DesktopApp.main`. If that isn't possible (IDE/Gradle - * runs, sandbox restrictions, etc.) the implementation falls back - * to a plain exit; the user's preference is already persisted, so - * they just need to reopen the app manually. - * - * This should never be invoked on Android — `MainActivity` handles - * runtime language changes via `Activity.recreate()`, and the - * `OnAppLanguageChangeRequiresRestart` event that triggers this is - * never emitted there. The Android actual is therefore a no-op for - * safety. - */ expect fun restartAppAfterLanguageChange() diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt index 8a06f018c..839a68acc 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt @@ -138,12 +138,6 @@ sealed interface TweaksAction { data object OnHiddenRepositoriesClick : TweaksAction - data class OnTelemetryToggled( - val enabled: Boolean, - ) : TweaksAction - - data object OnResetAnalyticsId : TweaksAction - data class OnTranslationProviderSelected( val provider: TranslationProvider, ) : TweaksAction @@ -200,10 +194,6 @@ sealed interface TweaksAction { val tag: String?, ) : TweaksAction - /** - * User picked a UI language. `tag == null` means "follow system - * locale" — cleared persisted preference. - */ data class OnAppLanguageSelected( val tag: String?, ) : TweaksAction @@ -212,12 +202,6 @@ sealed interface TweaksAction { data object OnDismissBatteryOptimizationCard : TweaksAction - /** - * Re-evaluates whether the battery-optimization card should still - * show. Fired on Tweaks resume so the card disappears immediately - * after the user grants the exemption from the system Settings - * screen. - */ data object OnReevaluateBatteryOptimizationCard : TweaksAction data object OnOpenCustomForgesDialog : TweaksAction @@ -225,4 +209,34 @@ sealed interface TweaksAction { data class OnCustomForgeDraftChanged(val draft: String) : TweaksAction data object OnAddCustomForge : TweaksAction data class OnRemoveCustomForge(val host: String) : TweaksAction + + data class OnDiscoveryPlatformToggled( + val platform: zed.rainxch.core.domain.model.DiscoveryPlatform, + ) : TweaksAction + + data object OnRestartNowClick : TweaksAction + data object OnRestartLaterClick : TweaksAction + + data class OnMasterProxyTypeSelected(val type: ProxyType) : TweaksAction + data class OnMasterProxyHostChanged(val host: String) : TweaksAction + data class OnMasterProxyPortChanged(val port: String) : TweaksAction + data class OnMasterProxyUsernameChanged(val username: String) : TweaksAction + data class OnMasterProxyPasswordChanged(val password: String) : TweaksAction + data object OnMasterProxyPasswordVisibilityToggle : TweaksAction + data object OnMasterProxySave : TweaksAction + data object OnMasterProxyTest : TweaksAction + + data class OnMasterProxyPasteUrl( + val type: ProxyType, + val host: String, + val port: Int, + val username: String?, + val password: String?, + ) : TweaksAction + + data class OnScopeUseMainToggled(val scope: ProxyScope, val useMain: Boolean) : TweaksAction + + data object OnClearSeenHistoryRequest : TweaksAction + data object OnClearSeenHistoryDismiss : TweaksAction + data object OnClearSeenHistoryConfirm : TweaksAction } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt index a9bee5694..21bc997f8 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksEvent.kt @@ -15,6 +15,12 @@ sealed interface TweaksEvent { val message: String, ) : TweaksEvent + data class OnMasterProxyTestResult( + val searchMs: Long?, + val downloadMs: Long?, + val translationMs: Long?, + ) : TweaksEvent + data object OnCacheCleared : TweaksEvent data class OnCacheClearError( @@ -23,8 +29,6 @@ sealed interface TweaksEvent { data object OnSeenHistoryCleared : TweaksEvent - data object OnAnalyticsIdReset : TweaksEvent - data object OnTranslationProviderSaved : TweaksEvent data object OnYoudaoCredentialsSaved : TweaksEvent @@ -35,13 +39,5 @@ sealed interface TweaksEvent { data object OnMicrosoftTranslatorCredentialsSaved : TweaksEvent - /** - * Fired on platforms where changing the UI language cannot be - * applied in-place (currently Desktop — no `Activity.recreate()` - * equivalent). The UI prompts the user to restart so the new - * locale takes effect on the next cold start. On Android this - * event is never emitted; `MainActivity` handles runtime changes - * via `recreate()` directly. - */ data object OnAppLanguageChangeRequiresRestart : TweaksEvent } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt index 7c6479c70..f588f3005 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt @@ -1,13 +1,34 @@ package zed.rainxch.tweaks.presentation +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Column 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.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Feedback +import androidx.compose.material.icons.outlined.GTranslate +import androidx.compose.material.icons.outlined.Hub +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.InstallMobile +import androidx.compose.material.icons.outlined.Inventory2 +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.PrivacyTip +import androidx.compose.material.icons.outlined.SearchOff +import androidx.compose.material.icons.outlined.Translate +import androidx.compose.material.icons.outlined.Update +import androidx.compose.material.icons.outlined.VpnKey +import androidx.compose.material.icons.outlined.Wifi import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -18,10 +39,17 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LocalLifecycleOwner @@ -34,25 +62,37 @@ import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.core.presentation.utils.arrowKeyScroll +import zed.rainxch.core.presentation.utils.constrainedContentWidth import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.tweaks.presentation.components.ClearDownloadsDialog -import zed.rainxch.tweaks.presentation.components.sections.about -import zed.rainxch.tweaks.presentation.components.sections.othersSection -import zed.rainxch.tweaks.presentation.components.sections.settings +import zed.rainxch.tweaks.presentation.components.RestartBanner +import zed.rainxch.core.domain.getPlatform +import zed.rainxch.core.domain.model.Platform +import zed.rainxch.core.presentation.components.hub.GhsEntryRow +import zed.rainxch.core.presentation.components.hub.GhsSectionHeader +import zed.rainxch.core.presentation.theme.tokens.GhsAccents +import zed.rainxch.tweaks.presentation.components.TweaksSearchField import zed.rainxch.tweaks.presentation.feedback.components.FeedbackBottomSheet import zed.rainxch.tweaks.presentation.feedback.model.FeedbackChannel @Composable fun TweaksRoot( - onNavigateToMirrorPicker: () -> Unit, - onNavigateToSkippedUpdates: () -> Unit, - onNavigateToHiddenRepositories: () -> Unit, - onNavigateToHostTokens: () -> Unit = {}, + onNavigateBack: () -> Unit, + onNavigateToAppearance: () -> Unit, + onNavigateToLanguage: () -> Unit, + onNavigateToConnection: () -> Unit, + onNavigateToSources: () -> Unit, + onNavigateToTranslation: () -> Unit, + onNavigateToInstallMethod: () -> Unit, + onNavigateToUpdates: () -> Unit, + onNavigateToStorage: () -> Unit, + onNavigateToPrivacy: () -> Unit, + onNavigateToHostTokens: () -> Unit, viewModel: TweaksViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val snackbarState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() + var feedbackSheetOpen by rememberSaveable { mutableStateOf(false) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { @@ -60,8 +100,6 @@ fun TweaksRoot( androidx.lifecycle.LifecycleEventObserver { _, event -> if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onAction(TweaksAction.OnRefreshCacheSize) - // Re-eval after returning from system battery-optimization - // settings so the card disappears immediately on grant. viewModel.onAction(TweaksAction.OnReevaluateBatteryOptimizationCard) } } @@ -73,151 +111,51 @@ fun TweaksRoot( ObserveAsEvents(viewModel.events) { event -> when (event) { - TweaksEvent.OnProxySaved -> { - coroutineScope.launch { - snackbarState.showSnackbar(getString(Res.string.proxy_saved)) - } - } - - is TweaksEvent.OnProxySaveError -> { - coroutineScope.launch { - snackbarState.showSnackbar(event.message) - } - } - - is TweaksEvent.OnProxyTestSuccess -> { - coroutineScope.launch { - snackbarState.showSnackbar( - getString(Res.string.proxy_test_success, event.latencyMs), - ) - } - } - - is TweaksEvent.OnProxyTestError -> { - coroutineScope.launch { - snackbarState.showSnackbar(event.message) - } - } - - TweaksEvent.OnCacheCleared -> { - coroutineScope.launch { - snackbarState.showSnackbar(getString(Res.string.downloads_cleared)) - } - } - - is TweaksEvent.OnCacheClearError -> { - coroutineScope.launch { - snackbarState.showSnackbar(event.message) - } - } - - TweaksEvent.OnSeenHistoryCleared -> { - coroutineScope.launch { - snackbarState.showSnackbar(getString(Res.string.seen_history_cleared)) - } - } - - TweaksEvent.OnAnalyticsIdReset -> { - coroutineScope.launch { - snackbarState.showSnackbar(getString(Res.string.analytics_id_reset)) - } - } - - TweaksEvent.OnTranslationProviderSaved -> { - coroutineScope.launch { - snackbarState.showSnackbar(getString(Res.string.translation_provider_saved)) - } - } - - TweaksEvent.OnYoudaoCredentialsSaved -> { - coroutineScope.launch { - snackbarState.showSnackbar(getString(Res.string.translation_youdao_saved)) - } - } - - TweaksEvent.OnLibreTranslateCredentialsSaved -> { - coroutineScope.launch { - snackbarState.showSnackbar(getString(Res.string.translation_libre_saved)) - } - } - - TweaksEvent.OnDeeplCredentialsSaved -> { - coroutineScope.launch { - snackbarState.showSnackbar(getString(Res.string.translation_deepl_saved)) - } - } - - TweaksEvent.OnMicrosoftTranslatorCredentialsSaved -> { - coroutineScope.launch { - snackbarState.showSnackbar(getString(Res.string.translation_microsoft_saved)) - } - } - TweaksEvent.OnAppLanguageChangeRequiresRestart -> { coroutineScope.launch { - val result = - snackbarState.showSnackbar( - message = getString(Res.string.language_restart_required), - actionLabel = getString(Res.string.language_restart_action), - withDismissAction = true, - ) + val result = snackbarState.showSnackbar( + message = getString(Res.string.language_restart_required), + actionLabel = getString(Res.string.language_restart_action), + withDismissAction = true, + ) if (result == SnackbarResult.ActionPerformed) { restartAppAfterLanguageChange() } } } + else -> Unit } } - TweaksScreen( + TweaksHubScreen( state = state, - onAction = { action -> - when (action) { - TweaksAction.OnMirrorPickerClick -> onNavigateToMirrorPicker() - TweaksAction.OnSkippedUpdatesClick -> onNavigateToSkippedUpdates() - - TweaksAction.OnHiddenRepositoriesClick -> onNavigateToHiddenRepositories() - else -> viewModel.onAction(action) - } - }, - snackbarState = snackbarState, + onNavigateBack = onNavigateBack, + onNavigateToAppearance = onNavigateToAppearance, + onNavigateToLanguage = onNavigateToLanguage, + onNavigateToConnection = onNavigateToConnection, + onNavigateToSources = onNavigateToSources, + onNavigateToTranslation = onNavigateToTranslation, + onNavigateToInstallMethod = onNavigateToInstallMethod, + onNavigateToUpdates = onNavigateToUpdates, + onNavigateToStorage = onNavigateToStorage, + onNavigateToPrivacy = onNavigateToPrivacy, onNavigateToHostTokens = onNavigateToHostTokens, + onSendFeedbackClick = { feedbackSheetOpen = true }, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + snackbarState = snackbarState, ) - if (state.isClearDownloadsDialogVisible) { - ClearDownloadsDialog( - cacheSize = state.cacheSize, - onDismissRequest = { - viewModel.onAction(TweaksAction.OnClearDownloadsDismiss) - }, - onConfirm = { - viewModel.onAction(TweaksAction.OnClearDownloadsConfirm) - }, - ) - } - - if (state.showCustomForgesDialog) { - zed.rainxch.tweaks.presentation.components.CustomForgesDialog( - state = state, - onAction = { viewModel.onAction(it) }, - ) - } - - if (state.isFeedbackSheetVisible) { + if (feedbackSheetOpen) { FeedbackBottomSheet( - onDismiss = { - viewModel.onAction(TweaksAction.OnFeedbackDismiss) - }, + onDismiss = { feedbackSheetOpen = false }, onSent = { channel -> - viewModel.onAction(TweaksAction.OnFeedbackDismiss) + feedbackSheetOpen = false coroutineScope.launch { - val msg = - when (channel) { - FeedbackChannel.EMAIL -> - getString(Res.string.feedback_send_success_email) - FeedbackChannel.GITHUB -> - getString(Res.string.feedback_send_success_github) - } + val msg = when (channel) { + FeedbackChannel.EMAIL -> getString(Res.string.feedback_send_success_email) + FeedbackChannel.GITHUB -> getString(Res.string.feedback_send_success_github) + } snackbarState.showSnackbar(msg) } }, @@ -232,15 +170,170 @@ fun TweaksRoot( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +private data class TweaksHubEntry( + val title: String, + val subtitle: String, + val icon: ImageVector, + val onClick: () -> Unit, + val accent: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Unspecified, +) + +private data class TweaksHubBlock( + val title: String, + val entries: List, +) + +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun TweaksScreen( +fun TweaksHubScreen( state: TweaksState, - onAction: (TweaksAction) -> Unit, + onNavigateBack: () -> Unit, + onNavigateToAppearance: () -> Unit, + onNavigateToLanguage: () -> Unit, + onNavigateToConnection: () -> Unit, + onNavigateToSources: () -> Unit, + onNavigateToTranslation: () -> Unit, + onNavigateToInstallMethod: () -> Unit, + onNavigateToUpdates: () -> Unit, + onNavigateToStorage: () -> Unit, + onNavigateToPrivacy: () -> Unit, + onNavigateToHostTokens: () -> Unit, + onSendFeedbackClick: () -> Unit, + onRestartNow: () -> Unit, + onRestartLater: () -> Unit, snackbarState: SnackbarHostState, - onNavigateToHostTokens: () -> Unit = {}, ) { val bottomNavHeight = LocalBottomNavigationHeight.current + var query by rememberSaveable { mutableStateOf("") } + + val tapToManage = stringResource(Res.string.tweaks_entry_subtitle_tap) + val isAndroid = getPlatform() == Platform.ANDROID + + val blocks = listOfNotNull( + TweaksHubBlock( + title = stringResource(Res.string.section_look_and_feel), + entries = listOf( + TweaksHubEntry( + title = stringResource(Res.string.tweaks_entry_appearance), + subtitle = tapToManage, + icon = Icons.Outlined.Palette, + onClick = onNavigateToAppearance, + accent = GhsAccents.Lavender, + ), + TweaksHubEntry( + title = stringResource(Res.string.tweaks_entry_language), + subtitle = tapToManage, + icon = Icons.Outlined.Translate, + onClick = onNavigateToLanguage, + accent = GhsAccents.Mint, + ), + ), + ), + TweaksHubBlock( + title = stringResource(Res.string.section_connectivity), + entries = listOf( + TweaksHubEntry( + title = stringResource(Res.string.tweaks_entry_connection), + subtitle = tapToManage, + icon = Icons.Outlined.Wifi, + onClick = onNavigateToConnection, + accent = GhsAccents.Sky, + ), + TweaksHubEntry( + title = stringResource(Res.string.tweaks_entry_sources), + subtitle = tapToManage, + icon = Icons.Outlined.Hub, + onClick = onNavigateToSources, + accent = GhsAccents.Blush, + ), + TweaksHubEntry( + title = stringResource(Res.string.tweaks_entry_translation), + subtitle = tapToManage, + icon = Icons.Outlined.GTranslate, + onClick = onNavigateToTranslation, + accent = GhsAccents.Peach, + ), + ), + ), + if (isAndroid) { + TweaksHubBlock( + title = stringResource(Res.string.section_installs_and_updates), + entries = listOf( + TweaksHubEntry( + title = stringResource(Res.string.tweaks_entry_install_method), + subtitle = tapToManage, + icon = Icons.Outlined.InstallMobile, + onClick = onNavigateToInstallMethod, + accent = GhsAccents.Sage, + ), + TweaksHubEntry( + title = stringResource(Res.string.tweaks_entry_updates), + subtitle = tapToManage, + icon = Icons.Outlined.Update, + onClick = onNavigateToUpdates, + accent = GhsAccents.Amber, + ), + ), + ) + } else { + null + }, + TweaksHubBlock( + title = stringResource(Res.string.section_privacy_and_data), + entries = listOf( + TweaksHubEntry( + title = stringResource(Res.string.tweaks_entry_storage), + subtitle = state.cacheSize.ifBlank { tapToManage }, + icon = Icons.Outlined.Inventory2, + onClick = onNavigateToStorage, + accent = GhsAccents.Periwinkle, + ), + TweaksHubEntry( + title = stringResource(Res.string.tweaks_entry_privacy), + subtitle = tapToManage, + icon = Icons.Outlined.PrivacyTip, + onClick = onNavigateToPrivacy, + accent = GhsAccents.Rose, + ), + TweaksHubEntry( + title = stringResource(Res.string.tweaks_entry_access_tokens), + subtitle = tapToManage, + icon = Icons.Outlined.VpnKey, + onClick = onNavigateToHostTokens, + accent = GhsAccents.Gold, + ), + ), + ), + TweaksHubBlock( + title = stringResource(Res.string.section_app_block), + entries = listOf( + TweaksHubEntry( + title = stringResource(Res.string.tweaks_entry_feedback), + subtitle = stringResource(Res.string.feedback_hub_subtitle), + icon = Icons.Outlined.Feedback, + onClick = onSendFeedbackClick, + accent = GhsAccents.Tan, + ), + ), + ), + ) + + val filteredBlocks = remember(query, blocks) { + if (query.isBlank()) { + blocks + } else { + val q = query.trim() + blocks.map { block -> + block.copy( + entries = block.entries.filter { entry -> + entry.title.contains(q, ignoreCase = true) || + entry.subtitle.contains(q, ignoreCase = true) + }, + ) + }.filter { it.entries.isNotEmpty() } + } + } + Scaffold( snackbarHost = { SnackbarHost( @@ -249,76 +342,157 @@ fun TweaksScreen( ) }, topBar = { - TopAppBar() + TopAppBar( + title = { + Text( + text = stringResource(Res.string.tweaks_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back_cd), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + }, + ) }, containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> val listState = rememberLazyListState() + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.TopCenter, + ) { LazyColumn( state = listState, - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(16.dp) - .arrowKeyScroll(listState, autoFocus = true), + modifier = Modifier + .constrainedContentWidth() + .fillMaxHeight() + .padding(horizontal = 16.dp) + .arrowKeyScroll(listState, autoFocus = true), ) { - settings( - state = state, - onAction = onAction, - ) + if (state.restartBannerVisible) { + item(key = "restart_banner") { + Spacer(Modifier.height(8.dp)) + RestartBanner( + reasons = state.needsRestartReasons, + onRestartNow = onRestartNow, + onLater = onRestartLater, + ) + } + } - item { - Spacer(Modifier.height(16.dp)) - zed.rainxch.tweaks.presentation.hosttokens.HostTokensEntryCard( - onClick = onNavigateToHostTokens, + item(key = "search_field") { + Spacer(Modifier.height(12.dp)) + TweaksSearchField( + query = query, + onQueryChange = { query = it }, + onClear = { query = "" }, ) Spacer(Modifier.height(16.dp)) } - othersSection( - state = state, - onAction = onAction, - ) - - item { - Spacer(Modifier.height(32.dp)) + if (filteredBlocks.isEmpty()) { + item(key = "search_empty") { + EmptySearchResult(query = query) + Spacer(Modifier.height(32.dp)) + } + } else if (query.isBlank()) { + blocks.forEachIndexed { idx, block -> + item(key = "block_header_${block.title}") { + if (idx > 0) Spacer(Modifier.height(24.dp)) + GhsSectionHeader(text = block.title) + Spacer(Modifier.height(8.dp)) + } + block.entries.forEach { entry -> + item(key = "entry_${block.title}_${entry.title}") { + GhsEntryRow( + title = entry.title, + subtitle = entry.subtitle, + icon = entry.icon, + onClick = entry.onClick, + accentColor = entry.accent, + ) + Spacer(Modifier.height(8.dp)) + } + } + } + } else { + filteredBlocks.flatMap { it.entries }.forEach { entry -> + item(key = "search_entry_${entry.title}") { + GhsEntryRow( + title = entry.title, + subtitle = entry.subtitle, + icon = entry.icon, + onClick = entry.onClick, + ) + Spacer(Modifier.height(8.dp)) + } + } } - about( - versionName = state.versionName, - onAction = onAction, - ) - - item { + item(key = "bottom_spacer") { Spacer(Modifier.height(bottomNavHeight + 32.dp)) } } + } } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun TopAppBar() { - TopAppBar( - title = { +private fun EmptySearchResult(query: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.SearchOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) Text( - text = stringResource(Res.string.tweaks_title), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + text = stringResource(Res.string.tweaks_search_empty, query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) - }, - ) + } + } } @Preview @Composable private fun Preview() { GithubStoreTheme { - TweaksScreen( + TweaksHubScreen( state = TweaksState(), - onAction = {}, + onNavigateBack = {}, + onNavigateToAppearance = {}, + onNavigateToLanguage = {}, + onNavigateToConnection = {}, + onNavigateToSources = {}, + onNavigateToTranslation = {}, + onNavigateToInstallMethod = {}, + onNavigateToUpdates = {}, + onNavigateToStorage = {}, + onNavigateToPrivacy = {}, + onNavigateToHostTokens = {}, + onSendFeedbackClick = {}, + onRestartNow = {}, + onRestartLater = {}, snackbarState = SnackbarHostState(), ) } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt index 3a25d49a8..5f70e1b2b 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt @@ -3,17 +3,19 @@ package zed.rainxch.tweaks.presentation import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.ContentWidth import zed.rainxch.core.domain.model.DhizukuAvailability +import zed.rainxch.core.domain.model.DiscoveryPlatform import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.InstallerAttribution import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.ProxyScope +import zed.rainxch.core.domain.model.RestartReason import zed.rainxch.core.domain.model.RootAvailability import zed.rainxch.core.domain.model.ShizukuAvailability import zed.rainxch.core.domain.model.TranslationProvider import zed.rainxch.tweaks.presentation.model.ProxyScopeFormState data class TweaksState( - val selectedThemeColor: AppTheme = AppTheme.OCEAN, + val selectedThemeColor: AppTheme = AppTheme.NORD, val selectedFontTheme: FontTheme = FontTheme.CUSTOM, val isAmoledThemeEnabled: Boolean = false, val isDarkTheme: Boolean? = null, @@ -38,17 +40,8 @@ data class TweaksState( val isHideSeenEnabled: Boolean = false, val isScrollbarEnabled: Boolean = false, val contentWidth: ContentWidth = ContentWidth.COMPACT, - val isTelemetryEnabled: Boolean = false, val translationProvider: TranslationProvider = TranslationProvider.Default, - /** - * Transient UI-only selection used when the user picks a provider - * that needs more configuration before it can be activated (e.g. - * Youdao with missing credentials). Rendered as the "selected - * chip" when non-null; persisted [translationProvider] is the - * source of truth for what the app actually uses for translation. - * Cleared once the pending selection is either committed - * (credentials saved) or abandoned (another provider picked). - */ + val draftTranslationProvider: TranslationProvider? = null, val youdaoAppKey: String = "", val youdaoAppSecret: String = "", @@ -61,38 +54,35 @@ data class TweaksState( val microsoftTranslatorKey: String = "", val microsoftTranslatorRegion: String = "", val isMicrosoftTranslatorKeyVisible: Boolean = false, - /** - * User-selected UI language as a BCP 47 tag, or `null` to follow - * the system locale. Mirrors the preference observed by - * `MainViewModel` — surfaced here so the Tweaks picker can show - * which chip is selected. - */ + val selectedAppLanguage: String? = null, val autoTranslateEnabled: Boolean = false, val autoTranslateTargetLang: String? = null, val isFeedbackSheetVisible: Boolean = false, - /** - * True only on aggressive-OEM Android devices (Oppo, OnePlus, Realme, - * Xiaomi, vivo, Honor) that have NOT yet whitelisted the app from - * battery optimization AND the user has not dismissed the prompt. - * Drives the "Allow background updates" card in the Updates section. - */ + val showBatteryOptimizationCard: Boolean = false, val customForgeHosts: Set = emptySet(), val showCustomForgesDialog: Boolean = false, val customForgeDraft: String = "", val customForgeError: String? = null, + val needsRestartReasons: Set = emptySet(), + val restartBannerSessionDismissed: Boolean = false, + val masterProxyForm: ProxyScopeFormState = ProxyScopeFormState(), + val useMasterByScope: Map = + ProxyScope.entries.associateWith { false }, + val isClearSeenHistoryDialogVisible: Boolean = false, + val selectedDiscoveryPlatforms: Set = emptySet(), ) { - /** Effective provider to render as "selected" in the UI — draft - * overrides persisted when a pending selection is in flight. */ + + val restartBannerVisible: Boolean + get() = needsRestartReasons.isNotEmpty() && !restartBannerSessionDismissed + + fun useMain(scope: ProxyScope): Boolean = useMasterByScope[scope] ?: false + + val displayedTranslationProvider: TranslationProvider get() = draftTranslationProvider ?: translationProvider - /** Convenience accessor — returns a fresh default if the map is - * missing an entry for [scope]. The constructor seeds all scopes, - * but `copy(proxyForms = …)` call sites could in theory produce an - * incomplete map; the safe default keeps the UI from crashing in - * that case. */ fun formFor(scope: ProxyScope): ProxyScopeFormState = proxyForms[scope] ?: ProxyScopeFormState() } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index a1a8099b8..1b609a0af 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -25,12 +26,13 @@ import zed.rainxch.core.domain.model.TranslationProvider import zed.rainxch.core.domain.network.ProxyTestOutcome import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.network.ProxyTester -import zed.rainxch.core.domain.repository.DeviceIdentityRepository +import zed.rainxch.core.domain.repository.CacheRepository import zed.rainxch.core.domain.repository.ProxyRepository import zed.rainxch.core.domain.repository.SeenReposRepository -import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.core.domain.system.AggressiveOemDetector +import zed.rainxch.core.domain.system.AppVersionInfo import zed.rainxch.core.domain.system.InstallerStatusProvider import zed.rainxch.core.domain.system.UpdateScheduleManager import zed.rainxch.tweaks.presentation.model.ProxyScopeFormState @@ -45,41 +47,31 @@ import zed.rainxch.githubstore.core.presentation.res.proxy_test_error_status import zed.rainxch.githubstore.core.presentation.res.proxy_test_error_timeout import zed.rainxch.githubstore.core.presentation.res.proxy_test_error_unknown import zed.rainxch.githubstore.core.presentation.res.proxy_test_error_unreachable -import zed.rainxch.profile.domain.repository.ProfileRepository import zed.rainxch.tweaks.presentation.model.ProxyType class TweaksViewModel( private val tweaksRepository: TweaksRepository, - private val profileRepository: ProfileRepository, + private val appVersionInfo: AppVersionInfo, private val installerStatusProvider: InstallerStatusProvider, private val proxyRepository: ProxyRepository, private val proxyTester: ProxyTester, private val updateScheduleManager: UpdateScheduleManager, private val seenReposRepository: SeenReposRepository, - private val deviceIdentityRepository: DeviceIdentityRepository, - private val telemetryRepository: TelemetryRepository, private val logger: GitHubStoreLogger, private val aggressiveOemDetector: AggressiveOemDetector, + private val cacheRepository: CacheRepository, ) : ViewModel() { private companion object { private const val BATTERY_OPT_PREF_READ_TIMEOUT_MS: Long = 1_000 - // IPv4 dotted-quad, each octet 0..255. private val IPV4_PATTERN = Regex( "^(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)" + "(\\.(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)){3}$", ) - // IPv6 literal — character-level only (hex groups + colons, - // optional `::` shortcut). Permissive on canonical form because - // proxy clients normalize it; we only need to reject "looks - // wrong" inputs like `not a url`. private val IPV6_PATTERN = Regex("^[0-9A-Fa-f:]+$") - // RFC 1123 hostname: labels of 1..63 alphanumeric / hyphen, - // must start and end with alphanumeric, separated by dots. - // Total length capped at 253 by the caller. private val HOSTNAME_PATTERN = Regex( "^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)" + @@ -106,7 +98,6 @@ class TweaksViewModel( loadHideSeenEnabled() loadScrollbarEnabled() loadContentWidth() - loadTelemetryEnabled() loadTranslationSettings() loadAppLanguage() loadAutoTranslate() @@ -115,14 +106,15 @@ class TweaksViewModel( observeDhizukuStatus() observeRootStatus() observeInstallerAttribution() + observeNeedsRestartReasons() + observeMasterProxyConfig() + observeUseMasterFlags() + observeDiscoveryPlatforms() hasLoadedInitialData = true } refreshCacheSize() - // Re-evaluate on every Tweaks (re-)entry: the user may - // have whitelisted the app from system Settings since - // the last evaluation, in which case the card should - // disappear without requiring an explicit dismiss. + evaluateBatteryOptimizationCard() }.stateIn( scope = viewModelScope, @@ -137,7 +129,7 @@ class TweaksViewModel( if (cacheSizeJob?.isActive == true) return cacheSizeJob = viewModelScope.launch { - profileRepository.observeCacheSize().collect { sizeBytes -> + cacheRepository.observeCacheSize().collect { sizeBytes -> _state.update { it.copy(cacheSize = formatCacheSize(sizeBytes)) } @@ -162,13 +154,7 @@ class TweaksViewModel( } private fun loadVersionName() { - viewModelScope.launch { - _state.update { - it.copy( - versionName = profileRepository.getVersionName(), - ) - } - } + _state.update { it.copy(versionName = appVersionInfo.versionName) } } private fun loadCurrentTheme() { @@ -220,17 +206,7 @@ class TweaksViewModel( } private fun loadProxyConfig() { - // Start one collector per scope. Each updates its slot in the - // [TweaksState.proxyForms] map — scopes are independent so the - // flows intentionally don't share state. - // - // If the user has an in-progress edit on a scope (isDraftDirty) - // we skip hydration for that scope until they commit (save) or - // reset (switch type via OnProxyTypeSelected for None/System). - // DataStore emits on *any* preference change — without this - // guard, toggling any unrelated setting while the user is mid- - // typing in the host field would snap the form back to persisted - // values. + ProxyScope.entries.forEach { scope -> viewModelScope.launch { proxyRepository.getProxyConfig(scope).collect { config -> @@ -274,8 +250,6 @@ class TweaksViewModel( } } - /** User-triggered form edit — marks the scope dirty so the - * preferences flow won't clobber the edit on an unrelated emit. */ private fun mutateForm( scope: ProxyScope, block: (ProxyScopeFormState) -> ProxyScopeFormState, @@ -288,11 +262,6 @@ class TweaksViewModel( } } - /** Transient UI-state mutation (password visibility, test-in- - * progress) — does *not* mark the scope dirty, so toggling the eye - * icon or running a test doesn't block preference hydration. Only - * use for flags that don't represent a real config change the user - * expects to save. */ private fun mutateFormUi( scope: ProxyScope, block: (ProxyScopeFormState) -> ProxyScopeFormState, @@ -304,9 +273,6 @@ class TweaksViewModel( } } - /** Clears the dirty flag — call after a successful save or an - * explicit reset so the next preferences emission can re-hydrate - * the form. */ private fun clearDirty(scope: ProxyScope) { _state.update { state -> val form = state.formFor(scope) @@ -462,16 +428,6 @@ class TweaksViewModel( } } - private fun loadTelemetryEnabled() { - viewModelScope.launch { - tweaksRepository.getTelemetryEnabled().collect { enabled -> - _state.update { - it.copy(isTelemetryEnabled = enabled) - } - } - } - } - private fun loadTranslationSettings() { viewModelScope.launch { tweaksRepository.getTranslationProvider().collect { provider -> @@ -619,10 +575,7 @@ class TweaksViewModel( is TweaksAction.OnProxyTypeSelected -> { mutateForm(action.scope) { it.copy(type = action.type) } - // NONE / SYSTEM have no form fields — persist immediately - // since there's nothing left for the user to fill in. For - // HTTP / SOCKS, wait for an explicit Save so validation - // can run against a completed form. + if (action.type == ProxyType.NONE || action.type == ProxyType.SYSTEM) { val config = if (action.type == ProxyType.NONE) { @@ -634,8 +587,7 @@ class TweaksViewModel( runCatching { proxyRepository.setProxyConfig(action.scope, config) }.onSuccess { - // Committed — allow preferences-flow hydration - // to resume for this scope. + clearDirty(action.scope) _events.send(TweaksEvent.OnProxySaved) }.onFailure { error -> @@ -673,11 +625,7 @@ class TweaksViewModel( is TweaksAction.OnProxySave -> { val form = _state.value.formFor(action.scope) - // Only HTTP/SOCKS need host+port — validate for those - // only. NONE/SYSTEM carry no form fields and would - // otherwise be rejected with "host required" for no - // reason if something ever triggered Save for them - // (today the UI doesn't, but defense in depth). + val config: ProxyConfig = when (form.type) { ProxyType.NONE -> ProxyConfig.None @@ -748,7 +696,7 @@ class TweaksViewModel( try { proxyTester.test(config) } catch (e: CancellationException) { - // Preserve structured concurrency — never swallow. + throw e } catch (e: Exception) { ProxyTestOutcome.Failure.Unknown(e.message) @@ -892,7 +840,7 @@ class TweaksViewModel( _state.update { it.copy(isClearDownloadsDialogVisible = false) } viewModelScope.launch { runCatching { - profileRepository.clearCache() + cacheRepository.clearCache() }.onSuccess { cacheSizeJob?.cancel() cacheSizeJob = null @@ -921,30 +869,10 @@ class TweaksViewModel( TweaksAction.OnFeedbackDismiss -> _state.update { it.copy(isFeedbackSheetVisible = false) } - is TweaksAction.OnTelemetryToggled -> { - viewModelScope.launch { - tweaksRepository.setTelemetryEnabled(action.enabled) - } - } - - TweaksAction.OnResetAnalyticsId -> { - viewModelScope.launch { - // Clear the telemetry buffer *before* resetting the ID. - // Order matters: any buffered event still carries the - // old device ID in its EventRequest payload, so draining - // them after the reset would leak the old ID to the - // backend attached to "fresh start" identity semantics. - telemetryRepository.clearPending() - deviceIdentityRepository.resetDeviceId() - _events.send(TweaksEvent.OnAnalyticsIdReset) - } - } - is TweaksAction.OnTranslationProviderSelected -> { when (action.provider) { TranslationProvider.GOOGLE -> { - // No credentials required — persist immediately - // and clear any pending draft selection. + _state.update { it.copy(draftTranslationProvider = null) } viewModelScope.launch { tweaksRepository.setTranslationProvider(action.provider) @@ -963,24 +891,14 @@ class TweaksViewModel( _events.send(TweaksEvent.OnTranslationProviderSaved) } } else { - // No credentials yet — expose the selection as - // a draft so the UI expands the credentials - // form, but don't commit to storage. If we - // persisted here the next translation attempt - // would fail with "not configured" and any - // other repository that observes the flow - // would snap back on the next re-emission. - // Committed later from [OnYoudaoCredentialsSave]. + _state.update { it.copy(draftTranslationProvider = TranslationProvider.YOUDAO) } } } TranslationProvider.LIBRE_TRANSLATE -> { - // No gating — repository falls back to the - // public Disroot mirror when no URL configured, - // so first-tap "just works" without going - // through a config dialog. + _state.update { it.copy(draftTranslationProvider = null) } viewModelScope.launch { tweaksRepository.setTranslationProvider(action.provider) @@ -1039,12 +957,7 @@ class TweaksViewModel( viewModelScope.launch { tweaksRepository.setYoudaoAppKey(current.youdaoAppKey) tweaksRepository.setYoudaoAppSecret(current.youdaoAppSecret) - // Auto-switch to YOUDAO when the user explicitly saves - // credentials — saves them an extra tap and matches - // the implicit intent ("I just configured this, use - // it"). Also covers the "draft" case where the chip - // was picked but not yet persisted because creds - // were missing. + val shouldActivate = current.youdaoAppKey.isNotBlank() && current.youdaoAppSecret.isNotBlank() && @@ -1055,8 +968,7 @@ class TweaksViewModel( if (shouldActivate) { tweaksRepository.setTranslationProvider(TranslationProvider.YOUDAO) } - // Drop any draft — either we committed it above or - // the user emptied fields and cancelled implicitly. + _state.update { it.copy(draftTranslationProvider = null) } _events.send(TweaksEvent.OnYoudaoCredentialsSaved) } @@ -1160,6 +1072,11 @@ class TweaksViewModel( if (action.tag == _state.value.selectedAppLanguage) return viewModelScope.launch { tweaksRepository.setAppLanguage(action.tag) + runCatching { + tweaksRepository.addRestartReason( + zed.rainxch.core.domain.model.RestartReason.LANGUAGE, + ) + } if (getPlatform() != Platform.ANDROID) { _events.send(TweaksEvent.OnAppLanguageChangeRequiresRestart) } @@ -1239,9 +1156,7 @@ class TweaksViewModel( if (result.isSuccess) { _state.update { it.copy(customForgeDraft = "", customForgeError = null) } } else { - // Surface persistence failure (KSafe write - // rejected by hardware-backed Keystore, etc.) - // instead of silently clearing the draft. + _state.update { it.copy( customForgeError = result.exceptionOrNull()?.message @@ -1252,6 +1167,18 @@ class TweaksViewModel( } } + is TweaksAction.OnDiscoveryPlatformToggled -> { + viewModelScope.launch { + val current = _state.value.selectedDiscoveryPlatforms + val next = if (action.platform in current) { + current - action.platform + } else { + current + action.platform + } + runCatching { tweaksRepository.setDiscoveryPlatforms(next) } + } + } + is TweaksAction.OnRemoveCustomForge -> { viewModelScope.launch { val result = runCatching { tweaksRepository.removeCustomForgeHost(action.host) } @@ -1265,15 +1192,346 @@ class TweaksViewModel( } } } + + TweaksAction.OnRestartNowClick -> { + viewModelScope.launch { + runCatching { tweaksRepository.clearRestartReasons() } + restartAppAfterLanguageChange() + } + } + + TweaksAction.OnRestartLaterClick -> { + _state.update { it.copy(restartBannerSessionDismissed = true) } + } + + is TweaksAction.OnMasterProxyTypeSelected -> { + mutateMasterForm { it.copy(type = action.type) } + + if (action.type == ProxyType.NONE || action.type == ProxyType.SYSTEM) { + val config = + if (action.type == ProxyType.NONE) { + ProxyConfig.None + } else { + ProxyConfig.System + } + viewModelScope.launch { + runCatching { + proxyRepository.setMasterProxyConfig(config) + ProxyScope.entries.forEach { scope -> + if (_state.value.useMain(scope)) { + proxyRepository.setProxyConfig(scope, config) + } + } + }.onSuccess { + _state.update { + it.copy( + masterProxyForm = it.masterProxyForm.copy(isDraftDirty = false), + ) + } + _events.send(TweaksEvent.OnProxySaved) + }.onFailure { error -> + _events.send( + TweaksEvent.OnProxySaveError( + error.message + ?: getString(Res.string.failed_to_save_proxy_settings), + ), + ) + } + } + } + } + + is TweaksAction.OnMasterProxyHostChanged -> { + mutateMasterForm { it.copy(host = action.host) } + } + + is TweaksAction.OnMasterProxyPortChanged -> { + mutateMasterForm { it.copy(port = action.port) } + } + + is TweaksAction.OnMasterProxyUsernameChanged -> { + mutateMasterForm { it.copy(username = action.username) } + } + + is TweaksAction.OnMasterProxyPasswordChanged -> { + mutateMasterForm { it.copy(password = action.password) } + } + + TweaksAction.OnMasterProxyPasswordVisibilityToggle -> { + mutateMasterForm { it.copy(isPasswordVisible = !it.isPasswordVisible) } + } + + is TweaksAction.OnMasterProxyPasteUrl -> { + mutateMasterForm { + it.copy( + type = action.type, + host = action.host, + port = action.port.toString(), + username = action.username.orEmpty(), + password = action.password.orEmpty(), + ) + } + } + + TweaksAction.OnMasterProxySave -> { + val config = buildMasterProxyConfig() ?: return + viewModelScope.launch { + runCatching { + proxyRepository.setMasterProxyConfig(config) + ProxyScope.entries.forEach { scope -> + if (_state.value.useMain(scope)) { + proxyRepository.setProxyConfig(scope, config) + } + } + }.onSuccess { + _state.update { + it.copy( + masterProxyForm = it.masterProxyForm.copy(isDraftDirty = false), + ) + } + _events.send(TweaksEvent.OnProxySaved) + }.onFailure { error -> + _events.send( + TweaksEvent.OnProxySaveError( + error.message + ?: getString(Res.string.failed_to_save_proxy_settings), + ), + ) + } + } + } + + TweaksAction.OnMasterProxyTest -> { + val form = _state.value.masterProxyForm + if (form.isTestInProgress) return + val config = buildMasterProxyConfigForTest() ?: return + _state.update { + it.copy(masterProxyForm = it.masterProxyForm.copy(isTestInProgress = true)) + } + viewModelScope.launch { + val results = try { + kotlinx.coroutines.coroutineScope { + val search = async { + runProbe(config, "https://api.github.com/zen") + } + val download = async { + runProbe(config, "https://github.com/robots.txt") + } + val translation = async { + runProbe(config, "https://translate.disroot.org") + } + Triple(search.await(), download.await(), translation.await()) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Triple(null, null, null) + } finally { + _state.update { + it.copy( + masterProxyForm = it.masterProxyForm.copy(isTestInProgress = false), + ) + } + } + _events.send( + TweaksEvent.OnMasterProxyTestResult( + searchMs = results.first, + downloadMs = results.second, + translationMs = results.third, + ), + ) + } + } + + TweaksAction.OnClearSeenHistoryRequest -> { + _state.update { it.copy(isClearSeenHistoryDialogVisible = true) } + } + + TweaksAction.OnClearSeenHistoryDismiss -> { + _state.update { it.copy(isClearSeenHistoryDialogVisible = false) } + } + + TweaksAction.OnClearSeenHistoryConfirm -> { + _state.update { it.copy(isClearSeenHistoryDialogVisible = false) } + onAction(TweaksAction.OnClearSeenRepos) + } + + is TweaksAction.OnScopeUseMainToggled -> { + _state.update { + it.copy( + useMasterByScope = it.useMasterByScope + (action.scope to action.useMain), + ) + } + viewModelScope.launch { + runCatching { + proxyRepository.setUseMaster(action.scope, action.useMain) + if (action.useMain) { + val master = proxyRepository.getMasterProxyConfig().first() + if (master != null) { + proxyRepository.setProxyConfig(action.scope, master) + } + } + }.onFailure { error -> + _events.send( + TweaksEvent.OnProxySaveError( + error.message + ?: getString(Res.string.failed_to_save_proxy_settings), + ), + ) + } + } + } + } + } + + private suspend fun runProbe(config: ProxyConfig, url: String): Long? = try { + val outcome = proxyTester.test(config, url) + (outcome as? ProxyTestOutcome.Success)?.latencyMs + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + + private fun buildMasterProxyConfigForTest(): ProxyConfig? { + val form = _state.value.masterProxyForm + return when (form.type) { + ProxyType.NONE -> ProxyConfig.None + ProxyType.SYSTEM -> ProxyConfig.System + ProxyType.HTTP -> { + val host = form.host.trim().takeIf { it.isNotEmpty() } ?: return null + val port = form.port.toIntOrNull() ?: return null + ProxyConfig.Http( + host, + port, + form.username.takeIf { it.isNotBlank() }, + form.password.takeIf { it.isNotBlank() }, + ) + } + ProxyType.SOCKS -> { + val host = form.host.trim().takeIf { it.isNotEmpty() } ?: return null + val port = form.port.toIntOrNull() ?: return null + ProxyConfig.Socks( + host, + port, + form.username.takeIf { it.isNotBlank() }, + form.password.takeIf { it.isNotBlank() }, + ) + } + } + } + + private fun observeNeedsRestartReasons() { + viewModelScope.launch { + tweaksRepository.getNeedsRestartReasons().collect { reasons -> + _state.update { it.copy(needsRestartReasons = reasons) } + } + } + } + + private fun observeMasterProxyConfig() { + viewModelScope.launch { + proxyRepository.getMasterProxyConfig().collect { config -> + _state.update { state -> + if (state.masterProxyForm.isDraftDirty) return@update state + val existing = state.masterProxyForm + val populated = when (config) { + null -> existing.copy(type = ProxyType.NONE) + is ProxyConfig.None -> existing.copy( + type = ProxyType.NONE, + ) + is ProxyConfig.System -> existing.copy( + type = ProxyType.SYSTEM, + ) + is ProxyConfig.Http -> existing.copy( + type = ProxyType.HTTP, + host = config.host, + port = config.port.toString(), + username = config.username.orEmpty(), + password = config.password.orEmpty(), + ) + is ProxyConfig.Socks -> existing.copy( + type = ProxyType.SOCKS, + host = config.host, + port = config.port.toString(), + username = config.username.orEmpty(), + password = config.password.orEmpty(), + ) + } + state.copy(masterProxyForm = populated) + } + } + } + } + + private fun observeUseMasterFlags() { + ProxyScope.entries.forEach { scope -> + viewModelScope.launch { + proxyRepository.getUseMaster(scope).collect { useMaster -> + _state.update { state -> + state.copy( + useMasterByScope = state.useMasterByScope + (scope to useMaster), + ) + } + } + } + } + } + + private fun observeDiscoveryPlatforms() { + viewModelScope.launch { + tweaksRepository.getDiscoveryPlatforms().collect { platforms -> + _state.update { it.copy(selectedDiscoveryPlatforms = platforms) } + } + } + } + + private fun mutateMasterForm(block: (ProxyScopeFormState) -> ProxyScopeFormState) { + _state.update { state -> + val updated = block(state.masterProxyForm).copy(isDraftDirty = true) + state.copy(masterProxyForm = updated) + } + } + + private fun buildMasterProxyConfig(): ProxyConfig? { + val form = _state.value.masterProxyForm + return when (form.type) { + ProxyType.NONE -> ProxyConfig.None + ProxyType.SYSTEM -> ProxyConfig.System + ProxyType.HTTP, ProxyType.SOCKS -> { + val port = form.port.toIntOrNull()?.takeIf { it in 1..65535 } ?: run { + viewModelScope.launch { + _events.send( + TweaksEvent.OnProxySaveError( + getString(Res.string.invalid_proxy_port), + ), + ) + } + return null + } + val host = form.host.trim().takeIf { isValidProxyHost(it) } ?: run { + val isBlank = form.host.isBlank() + viewModelScope.launch { + val msg = if (isBlank) { + getString(Res.string.proxy_host_required) + } else { + getString(Res.string.proxy_host_invalid) + } + _events.send(TweaksEvent.OnProxySaveError(msg)) + } + return null + } + val username = form.username.takeIf { it.isNotBlank() } + val password = form.password.takeIf { it.isNotBlank() } + if (form.type == ProxyType.HTTP) { + ProxyConfig.Http(host, port, username, password) + } else { + ProxyConfig.Socks(host, port, username, password) + } + } } } - /** - * Builds the [ProxyConfig] to test from the current form state for [scope]. - * For [ProxyType.HTTP] / [ProxyType.SOCKS] this requires a valid host and - * port — if either is missing the user is told via an error event and - * `null` is returned, mirroring the validation in [TweaksAction.OnProxySave]. - */ private fun buildProxyConfigForTest(scope: ProxyScope): ProxyConfig? { val form = _state.value.formFor(scope) return when (form.type) { @@ -1320,19 +1578,6 @@ class TweaksViewModel( } } - /** - * Light-but-real proxy host validator. Accepts: - * - IPv4 dotted-quad (`192.168.1.1`) with octet range 0..255 - * - IPv6 literal, with or without surrounding brackets (`::1`, - * `[2001:db8::1]`) — character-level only, doesn't validate - * canonical form - * - RFC 1123 hostname — labels of 1..63 alphanumeric / hyphen - * characters separated by dots, must start/end with alphanumeric - * - * Rejects: garbage strings (`not a url`), schemes (`http://...`), - * paths (`example.com/api`), spaces, control characters. Mirrors the - * port validator's "reject and show clear error" contract. - */ private fun isValidProxyHost(raw: String): Boolean { val host = raw.trim() if (host.isBlank()) return false @@ -1372,10 +1617,7 @@ class TweaksViewModel( ) is ProxyTestOutcome.Failure.Unknown -> - // Raw exception messages are platform-specific, untranslated, - // and may leak internal detail — always show the localized - // fallback to the user. The original `message` is intentionally - // dropped here; surface it via logging if diagnostics are needed. + TweaksEvent.OnProxyTestError(getString(Res.string.proxy_test_error_unknown)) } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/appearance/TweaksAppearanceRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/appearance/TweaksAppearanceRoot.kt new file mode 100644 index 000000000..ce57eb238 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/appearance/TweaksAppearanceRoot.kt @@ -0,0 +1,39 @@ +package zed.rainxch.tweaks.presentation.appearance + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_appearance +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksViewModel +import zed.rainxch.tweaks.presentation.components.TweaksSubScreenScaffold +import zed.rainxch.tweaks.presentation.components.sections.appearanceSection + +@Composable +fun TweaksAppearanceRoot( + onNavigateBack: () -> Unit, + viewModel: TweaksViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarState = remember { SnackbarHostState() } + + TweaksSubScreenScaffold( + title = stringResource(Res.string.tweaks_entry_appearance), + onNavigateBack = onNavigateBack, + snackbarState = snackbarState, + restartReasons = state.needsRestartReasons, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + showRestartBanner = state.restartBannerVisible, + ) { + appearanceSection( + state = state, + onAction = { viewModel.onAction(it) }, + ) + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/appinfo/TweaksAppInfoRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/appinfo/TweaksAppInfoRoot.kt new file mode 100644 index 000000000..f9792b8ee --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/appinfo/TweaksAppInfoRoot.kt @@ -0,0 +1,491 @@ +package zed.rainxch.tweaks.presentation.appinfo + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.AlternateEmail +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Forum +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Store +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.theme.tokens.GhsAccents +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.hub.GhsSectionHeader +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_app_name +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_community_business_cta +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_community_business_subtitle +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_community_business_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_community_section +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_community_subtitle +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_community_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_licenses_subtitle +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_licenses_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_privacy_policy_subtitle +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_privacy_policy_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_source_code_subtitle +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_source_code_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_tagline +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_whats_new_subtitle +import zed.rainxch.githubstore.core.presentation.res.tweaks_app_info_whats_new_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_app_info +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksViewModel +import zed.rainxch.tweaks.presentation.components.TweaksSubScreenScaffold + +private const val PRIVACY_POLICY_URL = "https://github-store.org/privacy-policy" +private const val SOURCE_CODE_URL = "https://github.com/OpenHub-Store/GitHub-Store" + +private const val TELEGRAM_URL = "https://t.me/githubstore" +private const val DISCORD_URL = "https://discord.gg/githubstore" +private const val MASTODON_URL = "https://mastodon.social/@githubstore" +private const val REDDIT_URL = "https://reddit.com/r/githubstore" +private const val GITHUB_ORG_URL = "https://github.com/OpenHub-Store" +private const val WEBSITE_URL = "https://github-store.org" +private const val BUSINESS_EMAIL = "mailto:hello@github-store.org" + +@Composable +fun TweaksAppInfoRoot( + onNavigateBack: () -> Unit, + onNavigateToLicenses: () -> Unit, + viewModel: TweaksViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarState = remember { SnackbarHostState() } + val uriHandler = LocalUriHandler.current + + TweaksSubScreenScaffold( + title = stringResource(Res.string.tweaks_entry_app_info), + onNavigateBack = onNavigateBack, + snackbarState = snackbarState, + restartReasons = state.needsRestartReasons, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + showRestartBanner = state.restartBannerVisible, + ) { + item(key = "app_identity") { + AppIdentityCard(versionName = state.versionName) + Spacer(Modifier.height(20.dp)) + } + + item(key = "community_section_header") { + GhsSectionHeader(text = stringResource(Res.string.tweaks_app_info_community_section)) + Spacer(Modifier.height(8.dp)) + } + + item(key = "community_card") { + CommunityCard( + onTelegram = { runCatching { uriHandler.openUri(TELEGRAM_URL) } }, + onDiscord = { runCatching { uriHandler.openUri(DISCORD_URL) } }, + onMastodon = { runCatching { uriHandler.openUri(MASTODON_URL) } }, + onReddit = { runCatching { uriHandler.openUri(REDDIT_URL) } }, + onGithub = { runCatching { uriHandler.openUri(GITHUB_ORG_URL) } }, + onWebsite = { runCatching { uriHandler.openUri(WEBSITE_URL) } }, + onBusiness = { runCatching { uriHandler.openUri(BUSINESS_EMAIL) } }, + ) + Spacer(Modifier.height(20.dp)) + } + + item(key = "action_licenses") { + ActionRow( + icon = Icons.Outlined.Code, + title = stringResource(Res.string.tweaks_app_info_licenses_title), + subtitle = stringResource(Res.string.tweaks_app_info_licenses_subtitle), + accent = GhsAccents.Sage, + onClick = onNavigateToLicenses, + ) + Spacer(Modifier.height(8.dp)) + } + + item(key = "action_privacy") { + ActionRow( + icon = Icons.Outlined.Description, + title = stringResource(Res.string.tweaks_app_info_privacy_policy_title), + subtitle = stringResource(Res.string.tweaks_app_info_privacy_policy_subtitle), + accent = GhsAccents.Rose, + onClick = { + runCatching { uriHandler.openUri(PRIVACY_POLICY_URL) } + }, + ) + Spacer(Modifier.height(8.dp)) + } + + item(key = "action_source") { + ActionRow( + icon = Icons.AutoMirrored.Outlined.OpenInNew, + title = stringResource(Res.string.tweaks_app_info_source_code_title), + subtitle = stringResource(Res.string.tweaks_app_info_source_code_subtitle), + accent = GhsAccents.Aqua, + onClick = { + runCatching { uriHandler.openUri(SOURCE_CODE_URL) } + }, + ) + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun AppIdentityCard(versionName: String) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(Radii.cardSm) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Store, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResource(Res.string.tweaks_app_info_app_name), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = versionName.ifBlank { "—" }, + style = MaterialTheme.typography.labelLarge.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.tweaks_app_info_tagline), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun CommunityCard( + onTelegram: () -> Unit, + onDiscord: () -> Unit, + onMastodon: () -> Unit, + onReddit: () -> Unit, + onGithub: () -> Unit, + onWebsite: () -> Unit, + onBusiness: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.tweaks_app_info_community_title), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(Res.string.tweaks_app_info_community_subtitle), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(14.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + SocialTile( + label = "Telegram", + iconUrl = "https://cdn.simpleicons.org/telegram/000000", + accent = GhsAccents.Sky, + onClick = onTelegram, + modifier = Modifier.weight(1f), + ) + SocialTile( + label = "Discord", + iconUrl = "https://cdn.simpleicons.org/discord/000000", + accent = GhsAccents.Periwinkle, + onClick = onDiscord, + modifier = Modifier.weight(1f), + ) + SocialTile( + label = "Mastodon", + iconUrl = "https://cdn.simpleicons.org/mastodon/000000", + accent = GhsAccents.Lavender, + onClick = onMastodon, + modifier = Modifier.weight(1f), + ) + } + Spacer(Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + SocialTile( + label = "Reddit", + iconUrl = "https://cdn.simpleicons.org/reddit/000000", + accent = GhsAccents.Peach, + onClick = onReddit, + modifier = Modifier.weight(1f), + ) + SocialTile( + label = "GitHub", + iconUrl = "https://cdn.simpleicons.org/github/000000", + accent = GhsAccents.Tan, + onClick = onGithub, + modifier = Modifier.weight(1f), + ) + SocialTile( + label = "Website", + iconFallback = Icons.Outlined.Language, + accent = GhsAccents.Sage, + onClick = onWebsite, + modifier = Modifier.weight(1f), + ) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)) + Spacer(Modifier.height(14.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(Res.string.tweaks_app_info_community_business_title), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.tweaks_app_info_community_business_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + GhsButton( + onClick = onBusiness, + label = stringResource(Res.string.tweaks_app_info_community_business_cta), + variant = GhsButtonVariant.Outline, + size = GhsButtonSize.Sm, + trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, + ) + } + } + } +} + +@Composable +private fun SocialTile( + label: String, + accent: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, + iconUrl: String? = null, + iconFallback: ImageVector? = null, +) { + Surface( + modifier = modifier + .clip(Radii.row) + .clickable(onClick = onClick), + shape = Radii.row, + color = accent.copy(alpha = 0.14f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.35f)), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp, horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(Radii.chip) + .background(accent.copy(alpha = 0.22f)), + contentAlignment = Alignment.Center, + ) { + if (iconUrl != null) { + coil3.compose.AsyncImage( + model = iconUrl, + contentDescription = null, + modifier = Modifier.size(20.dp), + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(accent), + ) + } else if (iconFallback != null) { + Icon( + imageVector = iconFallback, + contentDescription = null, + tint = accent, + modifier = Modifier.size(20.dp), + ) + } + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun ActionRow( + icon: ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, + accent: Color = Color.Unspecified, +) { + val tileBg = if (accent == Color.Unspecified) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + accent.copy(alpha = 0.14f) + } + val tint = if (accent == Color.Unspecified) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + accent + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .clickable(onClick = onClick), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(Radii.chip) + .background(tileBg), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(22.dp), + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/ClearDownloadsDialog.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/ClearDownloadsDialog.kt index a28f7b1cc..34fcf9e2d 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/ClearDownloadsDialog.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/ClearDownloadsDialog.kt @@ -8,13 +8,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,9 +42,9 @@ fun ClearDownloadsDialog( modifier = modifier .padding(16.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(16.dp), + .clip(zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape.Dialog) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(20.dp), ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -67,30 +67,19 @@ fun ClearDownloadsDialog( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), ) { - TextButton( + GhsButton( onClick = onDismissRequest, - ) { - Text( - text = stringResource(Res.string.cancel), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) - Button( + GhsButton( onClick = onConfirm, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Text( - text = stringResource(Res.string.delete_all), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - } + label = stringResource(Res.string.delete_all), + variant = GhsButtonVariant.Destructive, + size = GhsButtonSize.Sm, + ) } } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/CustomForgesDialog.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/CustomForgesDialog.kt index 9575a8105..1ee969c14 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/CustomForgesDialog.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/CustomForgesDialog.kt @@ -17,13 +17,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsTextField import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.tweaks.presentation.TweaksAction import zed.rainxch.tweaks.presentation.TweaksState @@ -35,12 +37,11 @@ fun CustomForgesDialog( ) { AlertDialog( onDismissRequest = { onAction(TweaksAction.OnDismissCustomForgesDialog) }, + shape = zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape.Dialog, title = { Text(stringResource(Res.string.custom_forges_dialog_title)) }, text = { Column { - // Reassure the user: Codeberg ships built-in, so adding - // it here is a no-op. The dialog is purely for users - // running their own Forgejo / Gitea host. + Surface( color = MaterialTheme.colorScheme.tertiaryContainer, shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), @@ -63,17 +64,20 @@ fun CustomForgesDialog( verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - OutlinedTextField( + GhsTextField( value = state.customForgeDraft, onValueChange = { onAction(TweaksAction.OnCustomForgeDraftChanged(it)) }, - placeholder = { Text("forgejo.example.com") }, + placeholder = "forgejo.example.com", singleLine = true, isError = state.customForgeError != null, modifier = Modifier.weight(1f), ) - TextButton(onClick = { onAction(TweaksAction.OnAddCustomForge) }) { - Text(stringResource(Res.string.custom_forges_add_button)) - } + GhsButton( + onClick = { onAction(TweaksAction.OnAddCustomForge) }, + label = stringResource(Res.string.custom_forges_add_button), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } if (state.customForgeError != null) { Text( @@ -113,9 +117,12 @@ fun CustomForgesDialog( } }, confirmButton = { - TextButton(onClick = { onAction(TweaksAction.OnDismissCustomForgesDialog) }) { - Text(stringResource(Res.string.done)) - } + GhsButton( + onClick = { onAction(TweaksAction.OnDismissCustomForgesDialog) }, + label = stringResource(Res.string.done), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/RestartBanner.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/RestartBanner.kt new file mode 100644 index 000000000..6ec620ae5 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/RestartBanner.kt @@ -0,0 +1,102 @@ +package zed.rainxch.tweaks.presentation.components + +import androidx.compose.foundation.BorderStroke +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.domain.model.RestartReason +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.restart_banner_body +import zed.rainxch.githubstore.core.presentation.res.restart_banner_later +import zed.rainxch.githubstore.core.presentation.res.restart_banner_reason_language +import zed.rainxch.githubstore.core.presentation.res.restart_banner_reason_theme +import zed.rainxch.githubstore.core.presentation.res.restart_banner_reasons_prefix +import zed.rainxch.githubstore.core.presentation.res.restart_banner_restart_now + +@Composable +fun RestartBanner( + reasons: Set, + onRestartNow: () -> Unit, + onLater: () -> Unit, + modifier: Modifier = Modifier, +) { + if (reasons.isEmpty()) return + + val reasonLabels = reasons.map { reason -> + stringResource( + when (reason) { + RestartReason.LANGUAGE -> Res.string.restart_banner_reason_language + RestartReason.THEME_MIGRATION -> Res.string.restart_banner_reason_theme + }, + ) + } + + Surface( + modifier = modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.tertiaryContainer, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + ), + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(Res.string.restart_banner_body), + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + if (reasonLabels.isNotEmpty()) { + Text( + text = stringResource( + Res.string.restart_banner_reasons_prefix, + reasonLabels.joinToString(", "), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + GhsButton( + onClick = onLater, + label = stringResource(Res.string.restart_banner_later), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) + Spacer(Modifier.height(0.dp)) + GhsButton( + onClick = onRestartNow, + label = stringResource(Res.string.restart_banner_restart_now), + variant = GhsButtonVariant.Primary, + size = GhsButtonSize.Sm, + ) + } + } + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/SectionText.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/SectionText.kt deleted file mode 100644 index dd7dc8fcf..000000000 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/SectionText.kt +++ /dev/null @@ -1,20 +0,0 @@ -package zed.rainxch.tweaks.presentation.components - -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp - -@Composable -fun SectionHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp), - ) -} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/ToggleSettingCard.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/ToggleSettingCard.kt index d5a1d6ca5..014603412 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/ToggleSettingCard.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/ToggleSettingCard.kt @@ -1,5 +1,6 @@ package zed.rainxch.tweaks.presentation.components +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.ripple @@ -20,7 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import zed.rainxch.core.presentation.components.ExpressiveCard +import zed.rainxch.core.presentation.theme.tokens.Radii @Composable fun ToggleSettingCard( @@ -29,43 +31,48 @@ fun ToggleSettingCard( checked: Boolean, onCheckedChange: (Boolean) -> Unit, ) { - ExpressiveCard { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f), + ), + ) { Row( - modifier = - Modifier - .fillMaxWidth() - .toggleable( - value = checked, - onValueChange = onCheckedChange, - role = Role.Switch, - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(), - ).padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = checked, + onValueChange = onCheckedChange, + role = Role.Switch, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + ) + .padding(horizontal = 16.dp, vertical = 14.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column( - modifier = - Modifier - .weight(1f) - .padding(end = 16.dp), + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), ) { Text( text = title, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, ) - - Spacer(Modifier.height(4.dp)) - + Spacer(Modifier.height(2.dp)) Text( text = description, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Switch( checked = checked, onCheckedChange = null, diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/TweaksSearchField.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/TweaksSearchField.kt new file mode 100644 index 000000000..b6d17e5e4 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/TweaksSearchField.kt @@ -0,0 +1,65 @@ +package zed.rainxch.tweaks.presentation.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.tweaks_search_placeholder + +@Composable +fun TweaksSearchField( + query: String, + onQueryChange: (String) -> Unit, + onClear: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(Res.string.tweaks_search_placeholder), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + singleLine = true, + shape = WonkySquircleShape.Search, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedBorderColor = MaterialTheme.colorScheme.outline, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + ), + ) +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/TweaksStubScreen.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/TweaksStubScreen.kt new file mode 100644 index 000000000..7a46f0708 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/TweaksStubScreen.kt @@ -0,0 +1,96 @@ +package zed.rainxch.tweaks.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Construction +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.back_cd +import zed.rainxch.githubstore.core.presentation.res.tweaks_stub_coming_soon_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_stub_coming_soon_title + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TweaksStubScreen( + title: String, + onNavigateBack: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back_cd), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + }, + ) + }, + containerColor = MaterialTheme.colorScheme.background, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 32.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.Construction, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Text( + text = stringResource(Res.string.tweaks_stub_coming_soon_title), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(Res.string.tweaks_stub_coming_soon_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/TweaksSubScreenScaffold.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/TweaksSubScreenScaffold.kt new file mode 100644 index 000000000..5b3822876 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/TweaksSubScreenScaffold.kt @@ -0,0 +1,111 @@ +package zed.rainxch.tweaks.presentation.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.domain.model.RestartReason +import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight +import zed.rainxch.core.presentation.utils.arrowKeyScroll +import zed.rainxch.core.presentation.utils.constrainedContentWidth +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.back_cd + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TweaksSubScreenScaffold( + title: String, + onNavigateBack: () -> Unit, + snackbarState: SnackbarHostState, + restartReasons: Set, + onRestartNow: () -> Unit, + onRestartLater: () -> Unit, + showRestartBanner: Boolean, + content: LazyListScope.() -> Unit, +) { + val bottomNavHeight = LocalBottomNavigationHeight.current + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back_cd), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + }, + ) + }, + snackbarHost = { + SnackbarHost( + hostState = snackbarState, + modifier = Modifier.padding(bottom = bottomNavHeight + 16.dp), + ) + }, + containerColor = MaterialTheme.colorScheme.background, + ) { innerPadding -> + val listState = rememberLazyListState() + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.TopCenter, + ) { + LazyColumn( + state = listState, + modifier = Modifier + .constrainedContentWidth() + .fillMaxHeight() + .padding(horizontal = 16.dp) + .arrowKeyScroll(listState, autoFocus = true), + contentPadding = PaddingValues(top = 8.dp, bottom = bottomNavHeight + 32.dp), + ) { + if (showRestartBanner && restartReasons.isNotEmpty()) { + item(key = "restart_banner") { + RestartBanner( + reasons = restartReasons, + onRestartNow = onRestartNow, + onLater = onRestartLater, + ) + Spacer(Modifier.height(16.dp)) + } + } + content() + } + } + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/About.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/About.kt deleted file mode 100644 index 181a45cc5..000000000 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/About.kt +++ /dev/null @@ -1,146 +0,0 @@ -package zed.rainxch.tweaks.presentation.components.sections - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.lazy.LazyListScope -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Feedback -import androidx.compose.material.icons.filled.Info -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.tweaks.presentation.TweaksAction -import zed.rainxch.tweaks.presentation.components.SectionHeader - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -fun LazyListScope.about( - versionName: String, - onAction: (TweaksAction) -> Unit, -) { - item { - SectionHeader( - text = stringResource(Res.string.section_about), - ) - - Spacer(Modifier.height(8.dp)) - - ElevatedCard( - modifier = - Modifier - .fillMaxWidth() - .padding(4.dp), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - shape = RoundedCornerShape(32.dp), - ) { - AboutItem( - icon = Icons.Filled.Info, - title = stringResource(Res.string.version), - actions = { - Text( - text = versionName, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.outline, - modifier = Modifier.padding(horizontal = 8.dp), - ) - }, - ) - - HorizontalDivider() - - AboutItem( - icon = Icons.Default.Feedback, - title = stringResource(Res.string.feedback_send), - actions = { - IconButton( - shape = IconButtonDefaults.shapes().shape, - onClick = { - onAction(TweaksAction.OnFeedbackClick) - }, - colors = - IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } - }, - ) - } - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun AboutItem( - icon: ImageVector, - title: String, - actions: @Composable () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = - modifier - .fillMaxWidth() - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondaryContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(24.dp), - ) - } - - Text( - text = title, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f), - ) - - actions.invoke() - } -} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Appearance.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Appearance.kt index e713bb8a3..c751138a3 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Appearance.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Appearance.kt @@ -1,12 +1,14 @@ package zed.rainxch.tweaks.presentation.components.sections +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -14,35 +16,31 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow 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.layout.width import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Colorize -import androidx.compose.material.icons.filled.DarkMode -import androidx.compose.material.icons.filled.Done -import androidx.compose.material.icons.filled.LightMode -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource @@ -51,59 +49,87 @@ import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.ContentWidth import zed.rainxch.core.domain.model.FontTheme import zed.rainxch.core.domain.model.Platform -import zed.rainxch.core.presentation.components.ExpressiveCard +import zed.rainxch.core.presentation.components.hub.GhsSectionHeader +import zed.rainxch.core.presentation.theme.dynamicColorScheme import zed.rainxch.core.presentation.theme.isDynamicColorAvailable +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.theme.tokens.Tokens +import zed.rainxch.core.presentation.theme.tokens.colorSchemeFor import zed.rainxch.core.presentation.utils.displayName -import zed.rainxch.core.presentation.utils.primaryColor +import zed.rainxch.core.presentation.utils.toTokenPalette import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.tweaks.presentation.TweaksAction import zed.rainxch.tweaks.presentation.TweaksState -import zed.rainxch.tweaks.presentation.components.SectionHeader import zed.rainxch.tweaks.presentation.components.ToggleSettingCard -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private enum class ModeChoice { LIGHT, DARK, SYSTEM } + +private fun isDarkToChoice(value: Boolean?): ModeChoice = when (value) { + true -> ModeChoice.DARK + false -> ModeChoice.LIGHT + null -> ModeChoice.SYSTEM +} + +private fun choiceToIsDark(choice: ModeChoice): Boolean? = when (choice) { + ModeChoice.DARK -> true + ModeChoice.LIGHT -> false + ModeChoice.SYSTEM -> null +} + +@OptIn(ExperimentalLayoutApi::class) fun LazyListScope.appearanceSection( state: TweaksState, onAction: (TweaksAction) -> Unit, ) { - item { - SectionHeader( - text = stringResource(Res.string.section_appearance), + item(key = "mode_header") { + GhsSectionHeader(text = stringResource(Res.string.appearance_section_mode)) + Spacer(Modifier.height(8.dp)) + } + item(key = "mode_tiles") { + ModeTiles( + current = isDarkToChoice(state.isDarkTheme), + paletteForPreview = state.selectedThemeColor, + onSelected = { onAction(TweaksAction.OnDarkThemeChange(choiceToIsDark(it))) }, ) - + Spacer(Modifier.height(16.dp)) + } + item(key = "palette_header") { + GhsSectionHeader(text = stringResource(Res.string.theme_color)) Spacer(Modifier.height(8.dp)) - - ThemeSelectionCard( + } + item(key = "palette_grid") { + PaletteGrid( isDarkTheme = state.isDarkTheme, - onDarkThemeChange = { isDarkTheme -> - onAction(TweaksAction.OnDarkThemeChange(isDarkTheme)) - }, + amoledEnabled = state.isAmoledThemeEnabled, + selected = state.selectedThemeColor, + onSelected = { onAction(TweaksAction.OnThemeColorSelected(it)) }, ) - - Spacer(Modifier.height(12.dp)) - - ThemeColorCard( - selectedThemeColor = state.selectedThemeColor, - onThemeColorSelected = { theme -> - onAction(TweaksAction.OnThemeColorSelected(theme)) - }, - ) - - Spacer(Modifier.height(16.dp)) - - if (state.isDarkTheme == true || (state.isDarkTheme == null && isSystemInDarkTheme())) { - ToggleSettingCard( - title = stringResource(Res.string.amoled_black_theme), - description = stringResource(Res.string.amoled_black_description), - checked = state.isAmoledThemeEnabled, - onCheckedChange = { enabled -> - onAction(TweaksAction.OnAmoledThemeToggled(enabled)) - }, - ) - - Spacer(Modifier.height(8.dp)) + } + item(key = "amoled_toggle") { + val systemDark = isSystemInDarkTheme() + val resolvedDark = state.isDarkTheme ?: systemDark + AnimatedVisibility( + visible = resolvedDark, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column { + Spacer(Modifier.height(8.dp)) + ToggleSettingCard( + title = stringResource(Res.string.amoled_black_theme), + description = stringResource(Res.string.amoled_black_description), + checked = state.isAmoledThemeEnabled, + onCheckedChange = { onAction(TweaksAction.OnAmoledThemeToggled(it)) }, + ) + } } - + } + item(key = "display_header") { + Spacer(Modifier.height(16.dp)) + GhsSectionHeader(text = stringResource(Res.string.appearance_section_display)) + Spacer(Modifier.height(8.dp)) + } + item(key = "system_font") { ToggleSettingCard( title = stringResource(Res.string.system_font), description = stringResource(Res.string.system_font_description), @@ -111,318 +137,469 @@ fun LazyListScope.appearanceSection( onCheckedChange = { enabled -> onAction( TweaksAction.OnFontThemeSelected( - if (enabled) { - FontTheme.SYSTEM - } else { - FontTheme.CUSTOM - }, + if (enabled) FontTheme.SYSTEM else FontTheme.CUSTOM, ), ) }, ) - - Spacer(Modifier.height(8.dp)) - - if (getPlatform() != Platform.ANDROID) { + } + if (getPlatform() != Platform.ANDROID) { + item(key = "scrollbar") { + Spacer(Modifier.height(8.dp)) ToggleSettingCard( title = stringResource(Res.string.scrollbar_option_title), description = stringResource(Res.string.scrollbar_option_description), checked = state.isScrollbarEnabled, - onCheckedChange = { enabled -> - onAction(TweaksAction.OnScrollbarToggled(enabled)) - }, + onCheckedChange = { onAction(TweaksAction.OnScrollbarToggled(it)) }, ) - + } + item(key = "content_width") { Spacer(Modifier.height(8.dp)) - ContentWidthCard( selected = state.contentWidth, - onSelected = { width -> onAction(TweaksAction.OnContentWidthSelected(width)) }, + onSelected = { onAction(TweaksAction.OnContentWidthSelected(it)) }, ) } } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ThemeSelectionCard( - isDarkTheme: Boolean?, - onDarkThemeChange: (Boolean?) -> Unit, +private fun ModeTiles( + current: ModeChoice, + paletteForPreview: AppTheme, + onSelected: (ModeChoice) -> Unit, ) { - ExpressiveCard { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - ThemeModeOption( - icon = Icons.Default.LightMode, - label = stringResource(Res.string.theme_light), - isSelected = isDarkTheme != null && !isDarkTheme, - onClick = { onDarkThemeChange(false) }, - modifier = Modifier.weight(1f), - ) - - ThemeModeOption( - icon = Icons.Default.DarkMode, - label = stringResource(Res.string.theme_dark), - isSelected = isDarkTheme == true, - onClick = { onDarkThemeChange(true) }, - modifier = Modifier.weight(1f), - ) - - ThemeModeOption( - icon = Icons.Default.Colorize, - label = stringResource(Res.string.theme_system), - isSelected = isDarkTheme == null, - onClick = { onDarkThemeChange(null) }, - modifier = Modifier.weight(1f), - ) - } + val token = paletteForPreview.toTokenPalette() + val lightScheme = colorSchemeFor(token, Tokens.Mode.LIGHT) + val darkScheme = colorSchemeFor(token, Tokens.Mode.DARK) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + ModeTile( + label = stringResource(Res.string.theme_light), + selected = current == ModeChoice.LIGHT, + preview = { ThemePreviewCanvas(scheme = lightScheme) }, + onClick = { onSelected(ModeChoice.LIGHT) }, + modifier = Modifier.weight(1f), + ) + ModeTile( + label = stringResource(Res.string.theme_dark), + selected = current == ModeChoice.DARK, + preview = { ThemePreviewCanvas(scheme = darkScheme) }, + onClick = { onSelected(ModeChoice.DARK) }, + modifier = Modifier.weight(1f), + ) + ModeTile( + label = stringResource(Res.string.theme_system), + selected = current == ModeChoice.SYSTEM, + preview = { + Row(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + ThemePreviewCanvas(scheme = lightScheme, edgeFade = true) + } + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + ThemePreviewCanvas(scheme = darkScheme, edgeFade = true) + } + } + }, + onClick = { onSelected(ModeChoice.SYSTEM) }, + modifier = Modifier.weight(1f), + ) } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ThemeModeOption( - icon: ImageVector, +private fun ModeTile( label: String, - isSelected: Boolean, + selected: Boolean, + preview: @Composable () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val scale by animateFloatAsState( - targetValue = if (isSelected) 1.05f else 1f, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), + val borderColor by animateColorAsState( + targetValue = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + animationSpec = tween(durationMillis = 220), + label = "mode_border", + ) + val borderWidth by animateFloatAsState( + targetValue = if (selected) 2f else 1f, + animationSpec = tween(durationMillis = 220), + label = "mode_border_w", ) - Column( - modifier = - modifier - .scale(scale) - .clip(RoundedCornerShape(24.dp)) - .background( - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - ).clickable(onClick = onClick) - .padding(vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), + modifier = modifier + .clip(Radii.row) + .clickable(onClick = onClick) + .padding(4.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - + Box( + modifier = Modifier + .fillMaxWidth() + .height(96.dp) + .clip(RoundedCornerShape(16.dp)) + .border( + width = borderWidth.dp, + color = borderColor, + shape = RoundedCornerShape(16.dp), + ), + contentAlignment = Alignment.Center, + ) { + preview() + if (selected) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .size(18.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(12.dp), + ) + } + } + } Text( text = label, - style = MaterialTheme.typography.titleMedium, - color = - if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + ), + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ThemeColorCard( - selectedThemeColor: AppTheme, - onThemeColorSelected: (AppTheme) -> Unit, +private fun ThemePreviewCanvas( + scheme: androidx.compose.material3.ColorScheme, + edgeFade: Boolean = false, ) { - ExpressiveCard { + Box( + modifier = Modifier + .fillMaxSize() + .background(scheme.background), + ) { Column( - modifier = Modifier.padding(16.dp), + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), ) { - Text( - text = stringResource(Res.string.theme_color), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, + Box( + modifier = Modifier + .fillMaxWidth(0.55f) + .height(6.dp) + .clip(RoundedCornerShape(50)) + .background(scheme.onSurface.copy(alpha = if (edgeFade) 0.55f else 0.85f)), ) - - Spacer(Modifier.height(12.dp)) - - LazyRow( + Box( + modifier = Modifier + .fillMaxWidth() + .height(28.dp) + .clip(RoundedCornerShape(8.dp)) + .background(scheme.surfaceContainerHigh) + .padding(6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(scheme.primary), + ) + Box( + modifier = Modifier + .height(6.dp) + .fillMaxWidth(0.5f) + .clip(RoundedCornerShape(50)) + .background(scheme.onSurfaceVariant.copy(alpha = 0.6f)), + ) + } + } + Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - val availableThemes = - if (isDynamicColorAvailable()) { - AppTheme.entries - } else { - AppTheme.entries.filter { it != AppTheme.DYNAMIC } - } + Box( + modifier = Modifier + .height(18.dp) + .width(44.dp) + .clip(RoundedCornerShape(50)) + .background(scheme.primary), + ) + Box( + modifier = Modifier + .height(18.dp) + .width(28.dp) + .clip(RoundedCornerShape(50)) + .background(scheme.secondaryContainer), + ) + } + } + } +} - items(availableThemes) { theme -> - ThemeColorOption( - theme = theme, - isSelected = selectedThemeColor == theme, - onClick = { onThemeColorSelected(theme) }, +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PaletteGrid( + isDarkTheme: Boolean?, + amoledEnabled: Boolean, + selected: AppTheme, + onSelected: (AppTheme) -> Unit, +) { + val systemDark = isSystemInDarkTheme() + val resolvedDark = isDarkTheme ?: systemDark + val previewMode = when { + resolvedDark && amoledEnabled -> Tokens.Mode.AMOLED + resolvedDark -> Tokens.Mode.DARK + else -> Tokens.Mode.LIGHT + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val dynamicAvailable = isDynamicColorAvailable() + AppTheme.entries + .filter { it != AppTheme.DYNAMIC || dynamicAvailable } + .forEach { palette -> + PaletteSwatch( + palette = palette, + mode = previewMode, + isSelected = palette == selected, + onClick = { onSelected(palette) }, ) } - } } } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ThemeColorOption( - theme: AppTheme, +private fun PaletteSwatch( + palette: AppTheme, + mode: Tokens.Mode, isSelected: Boolean, onClick: () -> Unit, ) { + val isDark = mode != Tokens.Mode.LIGHT + val scheme = if (palette == AppTheme.DYNAMIC) { + dynamicColorScheme(isDark = isDark) ?: colorSchemeFor(Tokens.Palette.NORD, mode) + } else { + colorSchemeFor(palette.toTokenPalette(), mode) + } val scale by animateFloatAsState( - targetValue = if (isSelected) 1.1f else 1f, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow, - ), + targetValue = if (isSelected) 1.0f else 0.96f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "swatch_scale", + ) + val borderColor by animateColorAsState( + targetValue = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + animationSpec = tween(durationMillis = 220), + label = "swatch_border", + ) + val borderWidth by animateFloatAsState( + targetValue = if (isSelected) 2f else 1f, + animationSpec = tween(durationMillis = 220), + label = "swatch_border_w", ) - Column( - modifier = Modifier.clickable(onClick = onClick), + modifier = Modifier + .width(82.dp) + .scale(scale) + .clip(RoundedCornerShape(18.dp)) + .clickable(onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { Box( - modifier = - Modifier - .size(56.dp) - .scale(scale) - .clip( - if (isSelected) { - MaterialShapes.Cookie9Sided.toShape() - } else { - CircleShape - }, - ).background( - color = theme.primaryColor ?: MaterialTheme.colorScheme.primary, - ).then( - if (theme == AppTheme.DYNAMIC) { - Modifier.border( - 2.dp, - MaterialTheme.colorScheme.outline, - if (isSelected) { - MaterialShapes.Cookie9Sided.toShape() - } else { - CircleShape - }, - ) - } else { - Modifier - }, - ), - contentAlignment = Alignment.Center, + modifier = Modifier + .size(width = 82.dp, height = 78.dp) + .clip(RoundedCornerShape(16.dp)) + .background(scheme.background) + .border( + width = borderWidth.dp, + color = borderColor, + shape = RoundedCornerShape(16.dp), + ), ) { - androidx.compose.animation.AnimatedVisibility( - visible = isSelected, - enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(), - exit = scaleOut() + fadeOut(), - ) { - Icon( - imageVector = Icons.Default.Done, - contentDescription = - stringResource( - Res.string.selected_color, - theme.displayName, - ), - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onPrimary, + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(32.dp) + .background(scheme.primary), ) + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + modifier = Modifier + .height(4.dp) + .fillMaxWidth(0.75f) + .clip(RoundedCornerShape(50)) + .background(scheme.onSurface.copy(alpha = 0.55f)), + ) + Box( + modifier = Modifier + .height(4.dp) + .fillMaxWidth(0.45f) + .clip(RoundedCornerShape(50)) + .background(scheme.onSurface.copy(alpha = 0.32f)), + ) + Spacer(Modifier.height(1.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(scheme.secondary), + ) + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(scheme.tertiary), + ) + } + } + } + } + if (isSelected) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(16.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(11.dp), + ) + } } } - Text( - text = theme.displayName, - style = MaterialTheme.typography.labelLarge, - color = - if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + text = palette.displayName, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + ), + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ContentWidthCard( selected: ContentWidth, onSelected: (ContentWidth) -> Unit, ) { - ExpressiveCard { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { Column(modifier = Modifier.padding(16.dp)) { Text( text = stringResource(Res.string.content_width_title), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, ) - Text( text = stringResource(Res.string.content_width_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(Modifier.height(12.dp)) - Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { ContentWidth.entries.forEach { width -> val isSelected = width == selected - Column( + val container by animateColorAsState( + targetValue = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + animationSpec = tween(durationMillis = 220), + label = "cw_container", + ) + val content by animateColorAsState( + targetValue = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + }, + animationSpec = tween(durationMillis = 220), + label = "cw_content", + ) + Box( modifier = Modifier .weight(1f) - .clip(RoundedCornerShape(24.dp)) - .background( - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - ) - .clickable { onSelected(width) } - .padding(vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, + .height(40.dp) + .clip(RoundedCornerShape(50)) + .background(container) + .clickable { onSelected(width) }, + contentAlignment = Alignment.Center, ) { Text( text = width.displayName, - style = MaterialTheme.typography.titleMedium, - color = if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = content, ) } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Installation.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Installation.kt index b7bfdeb16..66692e354 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Installation.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Installation.kt @@ -28,7 +28,6 @@ import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.Speed import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon @@ -55,10 +54,13 @@ import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RootAvailability import zed.rainxch.core.domain.model.ShizukuAvailability import zed.rainxch.core.presentation.components.ExpressiveCard +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsTextField import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.tweaks.presentation.TweaksAction import zed.rainxch.tweaks.presentation.TweaksState -import zed.rainxch.tweaks.presentation.components.SectionHeader @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.installationSection( @@ -68,14 +70,6 @@ fun LazyListScope.installationSection( if (getPlatform() != Platform.ANDROID) return item { - Spacer(Modifier.height(32.dp)) - - SectionHeader( - text = stringResource(Res.string.section_installation).uppercase() - ) - - Spacer(Modifier.height(8.dp)) - InstallerTypeCard( selectedType = state.installerType, shizukuAvailability = state.shizukuAvailability, @@ -95,7 +89,6 @@ fun LazyListScope.installationSection( }, ) - // Auto-update toggle — shown when a silent installer is selected and ready val silentReady = ( state.installerType == InstallerType.SHIZUKU && state.shizukuAvailability == ShizukuAvailability.READY @@ -221,30 +214,25 @@ private fun CustomInstallerEditor( onAction: (TweaksAction) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - androidx.compose.material3.OutlinedTextField( + val customSupporting = state.installerAttributionCustomError?.let { + stringResource(Res.string.installer_attribution_custom_error) + } + GhsTextField( value = state.installerAttributionCustomDraft, onValueChange = { onAction(TweaksAction.OnInstallerAttributionCustomChanged(it)) }, modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(Res.string.installer_attribution_custom_label)) }, - placeholder = { Text("com.example.installer") }, + label = stringResource(Res.string.installer_attribution_custom_label), + placeholder = "com.example.installer", singleLine = true, isError = state.installerAttributionCustomError != null, - supportingText = state.installerAttributionCustomError?.let { - { - Text(stringResource(Res.string.installer_attribution_custom_error)) - } - }, + supportingText = customSupporting, ) - FilledTonalButton( + GhsButton( onClick = { onAction(TweaksAction.OnInstallerAttributionCustomSave) }, + label = stringResource(Res.string.installer_attribution_custom_apply), + variant = GhsButtonVariant.Tonal, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) { - Text( - text = stringResource(Res.string.installer_attribution_custom_apply), - fontWeight = FontWeight.SemiBold, - ) - } + ) } } @@ -284,11 +272,6 @@ private fun AttributionRadioRow( } } -/** - * Updates section — always visible on Android (not gated on Shizuku). - * Shows the update check interval picker so all users can configure - * how often background update checks run. - */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun LazyListScope.updatesSection( state: TweaksState, @@ -297,14 +280,6 @@ fun LazyListScope.updatesSection( if (getPlatform() != Platform.ANDROID) return item { - Spacer(Modifier.height(32.dp)) - - SectionHeader( - text = stringResource(Res.string.section_updates).uppercase() - ) - - Spacer(Modifier.height(8.dp)) - if (state.showBatteryOptimizationCard) { BatteryOptimizationCard( onOpenSettings = { @@ -348,12 +323,6 @@ fun LazyListScope.updatesSection( SkippedUpdatesEntryCard( onClick = { onAction(TweaksAction.OnSkippedUpdatesClick) }, ) - - Spacer(Modifier.height(12.dp)) - - HiddenRepositoriesEntryCard( - onClick = { onAction(TweaksAction.OnHiddenRepositoriesClick) }, - ) } } @@ -565,17 +534,12 @@ private fun RootStatusActions( ) { when (availability) { RootAvailability.PERMISSION_NEEDED -> { - FilledTonalButton( + GhsButton( onClick = onRequestPermission, + label = stringResource(Res.string.root_grant_permission), + variant = GhsButtonVariant.Tonal, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) { - Text( - text = stringResource(Res.string.root_grant_permission), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - } + ) } RootAvailability.UNAVAILABLE -> { HintText(text = stringResource(Res.string.root_unavailable_hint)) @@ -612,17 +576,12 @@ private fun ShizukuStatusActions( ) { when (availability) { ShizukuAvailability.PERMISSION_NEEDED -> { - FilledTonalButton( + GhsButton( onClick = onRequestPermission, + label = stringResource(Res.string.shizuku_grant_permission), + variant = GhsButtonVariant.Tonal, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp) - ) { - Text( - text = stringResource(Res.string.shizuku_grant_permission), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - } + ) } ShizukuAvailability.UNAVAILABLE -> { HintText(text = stringResource(Res.string.shizuku_install_hint)) @@ -642,17 +601,12 @@ private fun DhizukuStatusActions( ) { when (availability) { DhizukuAvailability.PERMISSION_NEEDED -> { - FilledTonalButton( + GhsButton( onClick = onRequestPermission, + label = stringResource(Res.string.dhizuku_grant_permission), + variant = GhsButtonVariant.Tonal, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp) - ) { - Text( - text = stringResource(Res.string.dhizuku_grant_permission), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - } + ) } DhizukuAvailability.UNAVAILABLE -> { HintText(text = stringResource(Res.string.dhizuku_install_hint)) @@ -875,19 +829,42 @@ private fun AutoUpdateCard( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private val IntervalStops: List = listOf(3L, 6L, 12L, 24L, 72L, 168L, 336L, 720L) + +@Composable +private fun formatIntervalLabel(hours: Long): String { + val days = hours / 24 + return when { + hours < 24 -> stringResource(Res.string.interval_every_hours, hours.toInt()) + hours == 24L -> stringResource(Res.string.interval_daily) + hours == 168L -> stringResource(Res.string.interval_weekly) + hours == 336L -> stringResource(Res.string.interval_biweekly) + hours == 720L -> stringResource(Res.string.interval_monthly) + else -> stringResource(Res.string.interval_every_days, days.toInt()) + } +} + +@Composable +private fun formatIntervalShort(hours: Long): String { + val days = hours / 24 + return when { + hours < 24 -> "${hours}h" + days < 30 -> "${days}d" + else -> "30d" + } +} + @Composable private fun UpdateCheckIntervalCard( selectedIntervalHours: Long, enabled: Boolean, onIntervalSelected: (Long) -> Unit, ) { - val intervals = listOf( - 3L to Res.string.interval_3h, - 6L to Res.string.interval_6h, - 12L to Res.string.interval_12h, - 24L to Res.string.interval_24h, - ) + val currentIndex = IntervalStops.indexOf(selectedIntervalHours) + .let { if (it == -1) IntervalStops.indexOf(IntervalStops.minByOrNull { stop -> kotlin.math.abs(stop - selectedIntervalHours) }) else it } + .coerceAtLeast(0) + val maxIndex = IntervalStops.lastIndex + val cs = MaterialTheme.colorScheme ExpressiveCard { Column( @@ -906,52 +883,63 @@ private fun UpdateCheckIntervalCard( modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.primaryContainer) + .background(cs.primaryContainer) .padding(8.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer + tint = cs.onPrimaryContainer ) Column( + modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( text = stringResource(Res.string.update_check_interval_title), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, + color = cs.onSurface, fontWeight = FontWeight.SemiBold ) Text( text = stringResource(Res.string.update_check_interval_description), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = cs.onSurfaceVariant ) } } - FlowRow( + Text( + text = formatIntervalLabel(IntervalStops[currentIndex]), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = if (enabled) cs.primary else cs.onSurfaceVariant, + ) + + androidx.compose.material3.Slider( + value = currentIndex.toFloat(), + onValueChange = { v -> + val idx = v.toInt().coerceIn(0, maxIndex) + onIntervalSelected(IntervalStops[idx]) + }, + steps = maxIndex - 1, + valueRange = 0f..maxIndex.toFloat(), + enabled = enabled, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, ) { - intervals.forEach { (hours, labelRes) -> - val isSelected = selectedIntervalHours == hours - - FilterChip( - selected = isSelected, - enabled = enabled, - onClick = { onIntervalSelected(hours) }, - label = { - Text( - text = stringResource(labelRes), - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal - ) - }, - shape = RoundedCornerShape(12.dp), - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - ) - } + Text( + text = formatIntervalShort(IntervalStops.first()), + style = MaterialTheme.typography.labelSmall, + color = cs.onSurfaceVariant, + ) + Text( + text = formatIntervalShort(IntervalStops.last()), + style = MaterialTheme.typography.labelSmall, + color = cs.onSurfaceVariant, + ) } } } @@ -1034,12 +1022,18 @@ private fun BatteryOptimizationCard( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), ) { - androidx.compose.material3.TextButton(onClick = onDismiss) { - Text(stringResource(Res.string.battery_optimization_card_dismiss)) - } - FilledTonalButton(onClick = onOpenSettings) { - Text(stringResource(Res.string.battery_optimization_card_open)) - } + GhsButton( + onClick = onDismiss, + label = stringResource(Res.string.battery_optimization_card_dismiss), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) + GhsButton( + onClick = onOpenSettings, + label = stringResource(Res.string.battery_optimization_card_open), + variant = GhsButtonVariant.Tonal, + size = GhsButtonSize.Sm, + ) } } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt deleted file mode 100644 index 785edb813..000000000 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Language.kt +++ /dev/null @@ -1,219 +0,0 @@ -package zed.rainxch.tweaks.presentation.components.sections - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.core.domain.model.AppLanguages -import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.tweaks.presentation.TweaksAction -import zed.rainxch.tweaks.presentation.TweaksState -import zed.rainxch.tweaks.presentation.components.SectionHeader - -fun LazyListScope.languageSection( - state: TweaksState, - onAction: (TweaksAction) -> Unit, -) { - item { - SectionHeader(text = stringResource(Res.string.section_language)) - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(Res.string.language_intro), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) - Spacer(Modifier.height(8.dp)) - - LanguagePickerCard( - state = state, - onAction = onAction, - ) - } -} - -@Composable -private fun LanguagePickerCard( - state: TweaksState, - onAction: (TweaksAction) -> Unit, -) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - shape = RoundedCornerShape(32.dp), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(Res.string.language_picker_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(Res.string.language_picker_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), - ) - - Spacer(Modifier.height(12.dp)) - - LanguageDropdown( - selectedTag = state.selectedAppLanguage, - onLanguageSelected = { tag -> - onAction(TweaksAction.OnAppLanguageSelected(tag)) - }, - ) - } - } -} - -@Composable -internal fun LanguageDropdown( - selectedTag: String?, - onLanguageSelected: (String?) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - - val currentLabel = - when (val match = AppLanguages.findByTag(selectedTag)) { - null -> stringResource(Res.string.language_follow_system) - else -> match.displayName - } - - Box(modifier = Modifier.fillMaxWidth()) { - // Anchor row — tappable area that shows the current value and - // toggles the menu. Uses a `surface`-tinted background so it - // reads as a pickable control against the parent card's - // `surfaceContainer`; the plain clickable row otherwise - // blends into the card. - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) - .clickable { expanded = true } - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = currentLabel, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - Spacer(Modifier.size(8.dp)) - Icon( - imageVector = Icons.Default.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - shape = RoundedCornerShape(20.dp), - // Default menu container is `surfaceContainer`, the same - // tone the parent `ElevatedCard` uses — the menu would - // visually dissolve into the card. Step up to - // `surfaceContainerHigh` so it reads as a distinct popup - // layer with the correct elevation contrast. - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - // Follow-system first — it's the default and users - // escaping a wrong-language lock-in look for this first. - DropdownMenuItem( - text = { DropdownItemText(stringResource(Res.string.language_follow_system)) }, - onClick = { - onLanguageSelected(null) - expanded = false - }, - trailingIcon = { - if (selectedTag == null) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - } - }, - ) - - AppLanguages.ALL.forEach { language -> - DropdownMenuItem( - text = { - // Native-script label so a user stuck in the - // wrong language can still recognise their - // own and escape. - DropdownItemText(language.displayName) - }, - onClick = { - onLanguageSelected(language.tag) - expanded = false - }, - trailingIcon = { - if (selectedTag == language.tag) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - } - }, - ) - } - } - } -} - -@Composable -private fun DropdownItemText(label: String) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) -} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Network.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Network.kt deleted file mode 100644 index a6f4f70c0..000000000 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Network.kt +++ /dev/null @@ -1,516 +0,0 @@ -package zed.rainxch.tweaks.presentation.components.sections - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material.icons.filled.NetworkCheck -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.core.domain.model.ProxyScope -import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.tweaks.presentation.TweaksAction -import zed.rainxch.tweaks.presentation.TweaksState -import zed.rainxch.tweaks.presentation.components.SectionHeader -import zed.rainxch.tweaks.presentation.model.ProxyScopeFormState -import zed.rainxch.tweaks.presentation.model.ProxyType - -fun LazyListScope.networkSection( - state: TweaksState, - onAction: (TweaksAction) -> Unit, -) { - item { - SectionHeader(text = stringResource(Res.string.section_network)) - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(Res.string.proxy_scope_intro), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) - Spacer(Modifier.height(4.dp)) - } - - item { - OutlinedCard( - onClick = { onAction(TweaksAction.OnMirrorPickerClick) }, - colors = - CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - shape = RoundedCornerShape(32.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Icons.Default.NetworkCheck, - contentDescription = null, - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(Res.string.mirror_tweaks_entry_label), - style = MaterialTheme.typography.titleMedium, - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - ) - } - } - } - item { - Spacer(Modifier.height(12.dp)) - OutlinedCard( - onClick = { onAction(TweaksAction.OnOpenCustomForgesDialog) }, - colors = - CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - shape = RoundedCornerShape(32.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Default.Dns, - contentDescription = null, - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(Res.string.custom_forges_entry_label), - style = MaterialTheme.typography.titleMedium, - ) - val count = state.customForgeHosts.size - if (count > 0) { - Text( - text = stringResource(Res.string.custom_forges_count_label, count), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Text( - text = stringResource(Res.string.custom_forges_entry_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Icon( - imageVector = androidx.compose.material.icons.Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - ) - } - } - } - item { - Spacer(Modifier.height(16.dp)) - } - - // One card per scope. Ordering mirrors the user's mental model: - // browsing → downloading → translation (least-common last). - ProxyScope.entries.forEach { scope -> - item { - ProxyScopeCard( - scope = scope, - form = state.formFor(scope), - onAction = onAction, - ) - Spacer(Modifier.height(12.dp)) - } - } -} - -private fun scopeTitleRes(scope: ProxyScope): StringResource = - when (scope) { - ProxyScope.DISCOVERY -> Res.string.proxy_scope_discovery_title - ProxyScope.DOWNLOAD -> Res.string.proxy_scope_download_title - ProxyScope.TRANSLATION -> Res.string.proxy_scope_translation_title - } - -private fun scopeDescriptionRes(scope: ProxyScope): StringResource = - when (scope) { - ProxyScope.DISCOVERY -> Res.string.proxy_scope_discovery_description - ProxyScope.DOWNLOAD -> Res.string.proxy_scope_download_description - ProxyScope.TRANSLATION -> Res.string.proxy_scope_translation_description - } - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun ProxyScopeCard( - scope: ProxyScope, - form: ProxyScopeFormState, - onAction: (TweaksAction) -> Unit, -) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - shape = RoundedCornerShape(32.dp), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(scopeTitleRes(scope)), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(scopeDescriptionRes(scope)), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), - ) - - Spacer(Modifier.height(12.dp)) - - LazyRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(ProxyType.entries) { type -> - FilterChip( - selected = form.type == type, - onClick = { onAction(TweaksAction.OnProxyTypeSelected(scope, type)) }, - label = { - Text( - text = - when (type) { - ProxyType.NONE -> stringResource(Res.string.proxy_none) - ProxyType.SYSTEM -> stringResource(Res.string.proxy_system) - ProxyType.HTTP -> stringResource(Res.string.proxy_http) - ProxyType.SOCKS -> stringResource(Res.string.proxy_socks) - }, - fontWeight = if (form.type == type) FontWeight.Bold else FontWeight.Normal, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - ) - } - } - - AnimatedVisibility( - visible = form.type == ProxyType.NONE || form.type == ProxyType.SYSTEM, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Column { - Spacer(Modifier.height(12.dp)) - Text( - text = - when (form.type) { - ProxyType.SYSTEM -> stringResource(Res.string.proxy_system_description) - else -> stringResource(Res.string.proxy_none_description) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(8.dp)) - ProxyTestButton( - isInProgress = form.isTestInProgress, - enabled = !form.isTestInProgress, - onClick = { onAction(TweaksAction.OnProxyTest(scope)) }, - ) - } - } - - AnimatedVisibility( - visible = form.type == ProxyType.HTTP || form.type == ProxyType.SOCKS, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - ProxyDetailsFields( - scope = scope, - form = form, - onAction = onAction, - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun ProxyDetailsFields( - scope: ProxyScope, - form: ProxyScopeFormState, - onAction: (TweaksAction) -> Unit, -) { - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val portValue = form.port - val isPortInvalid = - portValue.isNotEmpty() && - (portValue.toIntOrNull()?.let { it !in 1..65535 } ?: true) - val hostValue = form.host - // Real-time host validation mirrors the port pattern: only flag as - // invalid once the user has typed something, so the empty initial - // state doesn't shout an error at them. - val isHostInvalid = hostValue.isNotEmpty() && !isLikelyValidProxyHost(hostValue) - val isFormValid = - hostValue.isNotEmpty() && - !isHostInvalid && - portValue.isNotEmpty() && - portValue.toIntOrNull()?.let { it in 1..65535 } == true - - Column( - modifier = Modifier.padding(top = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedTextField( - value = form.host, - onValueChange = { onAction(TweaksAction.OnProxyHostChanged(scope, it)) }, - label = { Text(stringResource(Res.string.proxy_host)) }, - placeholder = { Text("127.0.0.1") }, - singleLine = true, - isError = isHostInvalid, - supportingText = - if (isHostInvalid) { - { Text(stringResource(Res.string.proxy_host_invalid)) } - } else { - null - }, - modifier = Modifier.weight(2f), - shape = RoundedCornerShape(12.dp), - ) - - OutlinedTextField( - value = form.port, - onValueChange = { onAction(TweaksAction.OnProxyPortChanged(scope, it)) }, - label = { Text(stringResource(Res.string.proxy_port)) }, - placeholder = { Text("1080") }, - singleLine = true, - isError = isPortInvalid, - supportingText = - if (isPortInvalid) { - { Text(stringResource(Res.string.proxy_port_error)) } - } else { - null - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - ) - } - - OutlinedTextField( - value = form.username, - onValueChange = { onAction(TweaksAction.OnProxyUsernameChanged(scope, it)) }, - label = { Text(stringResource(Res.string.proxy_username)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) - - OutlinedTextField( - value = form.password, - onValueChange = { onAction(TweaksAction.OnProxyPasswordChanged(scope, it)) }, - label = { Text(stringResource(Res.string.proxy_password)) }, - singleLine = true, - visualTransformation = - if (form.isPasswordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailingIcon = { - IconButton( - onClick = { onAction(TweaksAction.OnProxyPasswordVisibilityToggle(scope)) }, - ) { - Icon( - imageVector = - if (form.isPasswordVisible) { - Icons.Default.VisibilityOff - } else { - Icons.Default.Visibility - }, - contentDescription = - if (form.isPasswordVisible) { - stringResource(Res.string.proxy_hide_password) - } else { - stringResource(Res.string.proxy_show_password) - }, - modifier = Modifier.size(20.dp), - ) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) - - Row( - modifier = Modifier.align(Alignment.End), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - ProxyTestButton( - isInProgress = form.isTestInProgress, - // Keep enabled regardless of form validity so the user - // never taps a disabled button by accident on the first - // press (the previous `isFormValid` gate raced with - // the IME-composition-commit step on some Android - // keyboards and silently swallowed taps). VM still - // validates and surfaces a clear error event. - enabled = !form.isTestInProgress, - onClick = { - keyboardController?.hide() - focusManager.clearFocus() - onAction(TweaksAction.OnProxyTest(scope)) - }, - ) - - FilledTonalButton( - onClick = { - // Hide IME first so any pending composition commits - // synchronously before the VM reads form state. - // `clearFocus()` alone isn't enough on Gboard / - // SwiftKey, which keep characters in a composition - // buffer until focus actually transfers — that's - // what made the user have to tap Save twice. - keyboardController?.hide() - focusManager.clearFocus() - onAction(TweaksAction.OnProxySave(scope)) - }, - enabled = !form.isTestInProgress, - ) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.size(8.dp)) - Text(stringResource(Res.string.proxy_save)) - } - } - } -} - -@Composable -private fun ProxyTestButton( - isInProgress: Boolean, - enabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - OutlinedButton( - onClick = onClick, - enabled = enabled, - modifier = modifier, - ) { - if (isInProgress) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.size(8.dp)) - Text(stringResource(Res.string.proxy_test_in_progress)) - } else { - Icon( - imageVector = Icons.Default.NetworkCheck, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.size(8.dp)) - Text(stringResource(Res.string.proxy_test)) - } - } -} - -/** - * UI-side mirror of `TweaksViewModel.isValidProxyHost`. Drives the - * real-time `isError` highlight on the host TextField so the user - * sees a problem before they tap Save — same UX as the port field. - * Authoritative validation still happens server-side in the VM; this - * is the optimistic, free-of-cost preview. - */ -private fun isLikelyValidProxyHost(raw: String): Boolean { - val host = raw.trim() - if (host.isBlank()) return false - if (host.length > 253) return false - if (host.any { it.isWhitespace() }) return false - if (host.contains("://") || host.contains("/") || - host.contains("?") || host.contains("#") - ) { - return false - } - if (IPV4_PATTERN.matches(host)) return true - val ipv6Candidate = host.trim('[', ']') - if (ipv6Candidate.contains(":") && IPV6_PATTERN.matches(ipv6Candidate)) return true - return HOSTNAME_PATTERN.matches(host) -} - -private val IPV4_PATTERN = - Regex( - "^(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)" + - "(\\.(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)){3}$", - ) - -private val IPV6_PATTERN = Regex("^[0-9A-Fa-f:]+$") - -private val HOSTNAME_PATTERN = - Regex( - "^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)" + - "(?:\\.(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*$", - ) diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt deleted file mode 100644 index e67ccfb63..000000000 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt +++ /dev/null @@ -1,238 +0,0 @@ -package zed.rainxch.tweaks.presentation.components.sections - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.DeleteOutline -import androidx.compose.material.icons.outlined.Inventory2 -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.core.presentation.components.ExpressiveCard -import zed.rainxch.githubstore.core.presentation.res.* -import zed.rainxch.tweaks.presentation.TweaksAction -import zed.rainxch.tweaks.presentation.TweaksState -import zed.rainxch.tweaks.presentation.components.SectionHeader -import zed.rainxch.tweaks.presentation.components.ToggleSettingCard - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -fun LazyListScope.othersSection( - state: TweaksState, - onAction: (TweaksAction) -> Unit, -) { - item { - SectionHeader( - text = stringResource(Res.string.storage).uppercase(), - ) - - Spacer(Modifier.height(8.dp)) - - ExpressiveCard { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Icons.Outlined.Inventory2, - contentDescription = null, - modifier = - Modifier - .size(44.dp) - .clip(RoundedCornerShape(36.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .padding(8.dp), - ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - horizontalAlignment = Alignment.Start, - ) { - Text( - text = stringResource(Res.string.downloaded_packages), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - - Text( - text = stringResource(Res.string.downloaded_packages_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Text( - text = "${stringResource(Res.string.current_size)} ${state.cacheSize}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.SemiBold, - ) - } - - FilledTonalButton( - onClick = { - onAction(TweaksAction.OnClearCacheClick) - }, - shape = RoundedCornerShape(12.dp), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Icon( - imageVector = Icons.Outlined.DeleteOutline, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - } - - Spacer(Modifier.height(16.dp)) - - ToggleSettingCard( - title = stringResource(Res.string.auto_detect_clipboard_links), - description = stringResource(Res.string.auto_detect_clipboard_description), - checked = state.autoDetectClipboardLinks, - onCheckedChange = { enabled -> - onAction(TweaksAction.OnAutoDetectClipboardToggled(enabled)) - }, - ) - - Spacer(Modifier.height(16.dp)) - - ToggleSettingCard( - title = stringResource(Res.string.hide_seen_title), - description = stringResource(Res.string.hide_seen_description), - checked = state.isHideSeenEnabled, - onCheckedChange = { enabled -> - onAction(TweaksAction.OnHideSeenToggled(enabled)) - }, - ) - - Spacer(Modifier.height(8.dp)) - - ClearSeenHistoryCard( - onClick = { - onAction(TweaksAction.OnClearSeenRepos) - }, - ) - - Spacer(Modifier.height(24.dp)) - - SectionHeader( - text = stringResource(Res.string.privacy_section).uppercase(), - ) - - Spacer(Modifier.height(8.dp)) - - ToggleSettingCard( - title = stringResource(Res.string.telemetry_title), - description = stringResource(Res.string.telemetry_description), - checked = state.isTelemetryEnabled, - onCheckedChange = { enabled -> - onAction(TweaksAction.OnTelemetryToggled(enabled)) - }, - ) - - Spacer(Modifier.height(8.dp)) - - ResetAnalyticsIdCard( - onClick = { - onAction(TweaksAction.OnResetAnalyticsId) - }, - ) - } -} - -@Composable -private fun ResetAnalyticsIdCard(onClick: () -> Unit) { - ExpressiveCard { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = stringResource(Res.string.reset_analytics_id_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - - Spacer(Modifier.height(4.dp)) - - Text( - text = stringResource(Res.string.reset_analytics_id_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun ClearSeenHistoryCard(onClick: () -> Unit) { - ExpressiveCard { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = stringResource(Res.string.clear_seen_history), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - - Spacer(Modifier.height(4.dp)) - - Text( - text = stringResource(Res.string.clear_seen_history_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt deleted file mode 100644 index 5eef2d946..000000000 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/SettingsSection.kt +++ /dev/null @@ -1,60 +0,0 @@ -package zed.rainxch.tweaks.presentation.components.sections - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import zed.rainxch.tweaks.presentation.TweaksAction -import zed.rainxch.tweaks.presentation.TweaksState - -fun LazyListScope.settings( - state: TweaksState, - onAction: (TweaksAction) -> Unit, -) { - appearanceSection( - state = state, - onAction = onAction, - ) - - item { - Spacer(Modifier.height(32.dp)) - } - - languageSection( - state = state, - onAction = onAction, - ) - - item { - Spacer(Modifier.height(32.dp)) - } - - networkSection( - state = state, - onAction = onAction, - ) - - item { - Spacer(Modifier.height(32.dp)) - } - - translationSection( - state = state, - onAction = onAction, - ) - - item { - Spacer(Modifier.height(12.dp)) - } - - installationSection( - state = state, - onAction = onAction, - ) - - updatesSection( - state = state, - onAction = onAction, - ) -} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt index 4b1001f79..1936c9ade 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Translation.kt @@ -24,18 +24,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -48,26 +43,27 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.foundation.text.KeyboardOptions import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.SupportedTranslationLanguages import zed.rainxch.core.domain.model.TranslationProvider +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsPasswordVisibilityIcon +import zed.rainxch.core.presentation.components.inputs.GhsTextField +import zed.rainxch.core.presentation.components.inputs.passwordVisualTransformation import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.tweaks.presentation.TweaksAction import zed.rainxch.tweaks.presentation.TweaksState -import zed.rainxch.tweaks.presentation.components.SectionHeader fun LazyListScope.translationSection( state: TweaksState, onAction: (TweaksAction) -> Unit, ) { item { - SectionHeader(text = stringResource(Res.string.section_translation)) - Spacer(Modifier.height(4.dp)) Text( text = stringResource(Res.string.translation_intro), style = MaterialTheme.typography.bodySmall, @@ -279,16 +275,6 @@ private fun providerLabel(provider: TranslationProvider): String = TranslationProvider.MICROSOFT -> stringResource(Res.string.translation_provider_microsoft) } -/** - * Dropdown for picking the auto-translate target language. - * - * Distinct from [LanguageDropdown]: that one is bound to `AppLanguages` — - * the 13 locales the app ships UI translations for. Translation targets - * are a wider set ([SupportedTranslationLanguages.all] — 33 entries - * spanning everything the translation service can produce, including - * German, Dutch, Portuguese, Ukrainian, Vietnamese, etc. that the app - * itself isn't translated into). - */ @Composable private fun TranslationTargetDropdown( selectedTag: String?, @@ -389,68 +375,41 @@ private fun YoudaoCredentialsForm( color = MaterialTheme.colorScheme.onSurfaceVariant, ) - OutlinedTextField( + GhsTextField( value = state.youdaoAppKey, onValueChange = { onAction(TweaksAction.OnYoudaoAppKeyChanged(it)) }, - label = { Text(stringResource(Res.string.translation_youdao_app_key)) }, + label = stringResource(Res.string.translation_youdao_app_key), singleLine = true, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), ) - OutlinedTextField( + GhsTextField( value = state.youdaoAppSecret, onValueChange = { onAction(TweaksAction.OnYoudaoAppSecretChanged(it)) }, - label = { Text(stringResource(Res.string.translation_youdao_app_secret)) }, + label = stringResource(Res.string.translation_youdao_app_secret), singleLine = true, - visualTransformation = - if (state.isYoudaoAppSecretVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, + visualTransformation = passwordVisualTransformation(state.isYoudaoAppSecretVisible), trailingIcon = { - IconButton( - onClick = { onAction(TweaksAction.OnYoudaoAppSecretVisibilityToggle) }, - ) { - Icon( - imageVector = - if (state.isYoudaoAppSecretVisible) { - Icons.Default.VisibilityOff - } else { - Icons.Default.Visibility - }, - contentDescription = - if (state.isYoudaoAppSecretVisible) { - stringResource(Res.string.proxy_hide_password) - } else { - stringResource(Res.string.proxy_show_password) - }, - modifier = Modifier.size(20.dp), - ) - } + GhsPasswordVisibilityIcon( + visible = state.isYoudaoAppSecretVisible, + onToggle = { onAction(TweaksAction.OnYoudaoAppSecretVisibilityToggle) }, + ) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), ) Row( modifier = Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { - FilledTonalButton( + GhsButton( onClick = { onAction(TweaksAction.OnYoudaoCredentialsSave) }, + label = stringResource(Res.string.translation_youdao_save), + variant = GhsButtonVariant.Tonal, enabled = canSave, - ) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.size(8.dp)) - Text(stringResource(Res.string.translation_youdao_save)) - } + leadingIcon = Icons.Default.Save, + ) } } } @@ -460,8 +419,7 @@ private fun LibreTranslateCredentialsForm( state: TweaksState, onAction: (TweaksAction) -> Unit, ) { - // Always allow save — empty URL is a valid state (falls back to - // the bundled public mirror in the repository layer). + val canSave = true Column( @@ -474,70 +432,43 @@ private fun LibreTranslateCredentialsForm( color = MaterialTheme.colorScheme.onSurfaceVariant, ) - OutlinedTextField( + GhsTextField( value = state.libreTranslateBaseUrl, onValueChange = { onAction(TweaksAction.OnLibreTranslateBaseUrlChanged(it)) }, - label = { Text(stringResource(Res.string.translation_libre_base_url)) }, - placeholder = { Text("https://translate.disroot.org") }, + label = stringResource(Res.string.translation_libre_base_url), + placeholder = "https://translate.disroot.org", singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), ) - OutlinedTextField( + GhsTextField( value = state.libreTranslateApiKey, onValueChange = { onAction(TweaksAction.OnLibreTranslateApiKeyChanged(it)) }, - label = { Text(stringResource(Res.string.translation_libre_api_key)) }, + label = stringResource(Res.string.translation_libre_api_key), singleLine = true, - visualTransformation = - if (state.isLibreTranslateApiKeyVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, + visualTransformation = passwordVisualTransformation(state.isLibreTranslateApiKeyVisible), trailingIcon = { - IconButton( - onClick = { onAction(TweaksAction.OnLibreTranslateApiKeyVisibilityToggle) }, - ) { - Icon( - imageVector = - if (state.isLibreTranslateApiKeyVisible) { - Icons.Default.VisibilityOff - } else { - Icons.Default.Visibility - }, - contentDescription = - if (state.isLibreTranslateApiKeyVisible) { - stringResource(Res.string.proxy_hide_password) - } else { - stringResource(Res.string.proxy_show_password) - }, - modifier = Modifier.size(20.dp), - ) - } + GhsPasswordVisibilityIcon( + visible = state.isLibreTranslateApiKeyVisible, + onToggle = { onAction(TweaksAction.OnLibreTranslateApiKeyVisibilityToggle) }, + ) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), ) Row( modifier = Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { - FilledTonalButton( + GhsButton( onClick = { onAction(TweaksAction.OnLibreTranslateCredentialsSave) }, + label = stringResource(Res.string.translation_libre_save), + variant = GhsButtonVariant.Tonal, enabled = canSave, - ) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.size(8.dp)) - Text(stringResource(Res.string.translation_libre_save)) - } + leadingIcon = Icons.Default.Save, + ) } } } @@ -560,66 +491,41 @@ private fun DeeplCredentialsForm( color = MaterialTheme.colorScheme.onSurfaceVariant, ) - androidx.compose.material3.TextButton( + GhsButton( onClick = { runCatching { uriHandler.openUri("https://www.deepl.com/pro-api") } }, + label = stringResource(Res.string.translation_deepl_get_free_key), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, modifier = Modifier.align(Alignment.Start), - ) { - Text(stringResource(Res.string.translation_deepl_get_free_key)) - } + ) - OutlinedTextField( + GhsTextField( value = state.deeplAuthKey, onValueChange = { onAction(TweaksAction.OnDeeplAuthKeyChanged(it)) }, - label = { Text(stringResource(Res.string.translation_deepl_auth_key)) }, + label = stringResource(Res.string.translation_deepl_auth_key), singleLine = true, - visualTransformation = - if (state.isDeeplAuthKeyVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, + visualTransformation = passwordVisualTransformation(state.isDeeplAuthKeyVisible), trailingIcon = { - IconButton( - onClick = { onAction(TweaksAction.OnDeeplAuthKeyVisibilityToggle) }, - ) { - Icon( - imageVector = - if (state.isDeeplAuthKeyVisible) { - Icons.Default.VisibilityOff - } else { - Icons.Default.Visibility - }, - contentDescription = - if (state.isDeeplAuthKeyVisible) { - stringResource(Res.string.proxy_hide_password) - } else { - stringResource(Res.string.proxy_show_password) - }, - modifier = Modifier.size(20.dp), - ) - } + GhsPasswordVisibilityIcon( + visible = state.isDeeplAuthKeyVisible, + onToggle = { onAction(TweaksAction.OnDeeplAuthKeyVisibilityToggle) }, + ) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), ) Row( modifier = Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { - FilledTonalButton( + GhsButton( onClick = { onAction(TweaksAction.OnDeeplCredentialsSave) }, + label = stringResource(Res.string.translation_deepl_save), + variant = GhsButtonVariant.Tonal, enabled = canSave, - ) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.size(8.dp)) - Text(stringResource(Res.string.translation_deepl_save)) - } + leadingIcon = Icons.Default.Save, + ) } } } @@ -642,76 +548,50 @@ private fun MicrosoftCredentialsForm( color = MaterialTheme.colorScheme.onSurfaceVariant, ) - androidx.compose.material3.TextButton( + GhsButton( onClick = { runCatching { uriHandler.openUri("https://portal.azure.com/#create/Microsoft.CognitiveServicesTextTranslation") } }, + label = stringResource(Res.string.translation_microsoft_get_free_key), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, modifier = Modifier.align(Alignment.Start), - ) { - Text(stringResource(Res.string.translation_microsoft_get_free_key)) - } + ) - OutlinedTextField( + GhsTextField( value = state.microsoftTranslatorKey, onValueChange = { onAction(TweaksAction.OnMicrosoftTranslatorKeyChanged(it)) }, - label = { Text(stringResource(Res.string.translation_microsoft_key)) }, + label = stringResource(Res.string.translation_microsoft_key), singleLine = true, - visualTransformation = - if (state.isMicrosoftTranslatorKeyVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, + visualTransformation = passwordVisualTransformation(state.isMicrosoftTranslatorKeyVisible), trailingIcon = { - IconButton( - onClick = { onAction(TweaksAction.OnMicrosoftTranslatorKeyVisibilityToggle) }, - ) { - Icon( - imageVector = - if (state.isMicrosoftTranslatorKeyVisible) { - Icons.Default.VisibilityOff - } else { - Icons.Default.Visibility - }, - contentDescription = - if (state.isMicrosoftTranslatorKeyVisible) { - stringResource(Res.string.proxy_hide_password) - } else { - stringResource(Res.string.proxy_show_password) - }, - modifier = Modifier.size(20.dp), - ) - } + GhsPasswordVisibilityIcon( + visible = state.isMicrosoftTranslatorKeyVisible, + onToggle = { onAction(TweaksAction.OnMicrosoftTranslatorKeyVisibilityToggle) }, + ) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), ) - OutlinedTextField( + GhsTextField( value = state.microsoftTranslatorRegion, onValueChange = { onAction(TweaksAction.OnMicrosoftTranslatorRegionChanged(it)) }, - label = { Text(stringResource(Res.string.translation_microsoft_region)) }, - placeholder = { Text("global") }, + label = stringResource(Res.string.translation_microsoft_region), + placeholder = "global", singleLine = true, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), ) Row( modifier = Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { - FilledTonalButton( + GhsButton( onClick = { onAction(TweaksAction.OnMicrosoftTranslatorCredentialsSave) }, + label = stringResource(Res.string.translation_microsoft_save), + variant = GhsButtonVariant.Tonal, enabled = canSave, - ) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.size(8.dp)) - Text(stringResource(Res.string.translation_microsoft_save)) - } + leadingIcon = Icons.Default.Save, + ) } } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/connection/PasteProxyUrlSheet.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/connection/PasteProxyUrlSheet.kt new file mode 100644 index 000000000..8007bc889 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/connection/PasteProxyUrlSheet.kt @@ -0,0 +1,152 @@ +package zed.rainxch.tweaks.presentation.connection + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsTextField +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_paste_url_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_paste_url_cta +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_paste_url_error +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_paste_url_placeholder +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_paste_url_title +import zed.rainxch.tweaks.presentation.model.ProxyType + +data class PastedProxy( + val type: ProxyType, + val host: String, + val port: Int, + val username: String?, + val password: String?, +) + +fun parseProxyUrl(raw: String): PastedProxy? { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return null + val schemeIdx = trimmed.indexOf("://") + if (schemeIdx <= 0) return null + val scheme = trimmed.substring(0, schemeIdx).lowercase() + val type = when (scheme) { + "http", "https" -> ProxyType.HTTP + "socks", "socks4", "socks5", "socks5h" -> ProxyType.SOCKS + else -> return null + } + val afterScheme = trimmed.substring(schemeIdx + 3) + .substringBefore('/') + .substringBefore('?') + .substringBefore('#') + if (afterScheme.isEmpty()) return null + + val atIdx = afterScheme.lastIndexOf('@') + val credPart = if (atIdx >= 0) afterScheme.substring(0, atIdx) else null + val hostPart = if (atIdx >= 0) afterScheme.substring(atIdx + 1) else afterScheme + + val (username, password) = if (credPart != null) { + val colonIdx = credPart.indexOf(':') + if (colonIdx >= 0) { + credPart.substring(0, colonIdx) to credPart.substring(colonIdx + 1) + } else { + credPart to "" + } + } else { + null to null + } + + val colonIdx = hostPart.lastIndexOf(':') + if (colonIdx <= 0) return null + val host = hostPart.substring(0, colonIdx) + val port = hostPart.substring(colonIdx + 1).toIntOrNull() ?: return null + if (host.isBlank() || port !in 1..65535) return null + + return PastedProxy( + type = type, + host = host, + port = port, + username = username?.takeIf { it.isNotBlank() }, + password = password?.takeIf { it.isNotBlank() }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PasteProxyUrlSheet( + onDismiss: () -> Unit, + onParsed: (PastedProxy) -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var input by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + val parseErrorMessage = stringResource(Res.string.tweaks_connection_paste_url_error) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.tweaks_connection_paste_url_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.tweaks_connection_paste_url_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + GhsTextField( + value = input, + onValueChange = { + input = it + error = null + }, + label = stringResource(Res.string.tweaks_connection_paste_url_placeholder), + isError = error != null, + supportingText = error, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(4.dp)) + GhsButton( + onClick = { + val parsed = parseProxyUrl(input) + if (parsed == null) { + error = parseErrorMessage + } else { + onParsed(parsed) + } + }, + label = stringResource(Res.string.tweaks_connection_paste_url_cta), + variant = GhsButtonVariant.Primary, + modifier = Modifier.fillMaxWidth(), + enabled = input.isNotBlank(), + ) + Spacer(Modifier.height(8.dp)) + } + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/connection/TweaksConnectionRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/connection/TweaksConnectionRoot.kt new file mode 100644 index 000000000..2bd308958 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/connection/TweaksConnectionRoot.kt @@ -0,0 +1,621 @@ +package zed.rainxch.tweaks.presentation.connection + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NetworkCheck +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material.icons.outlined.ContentPaste +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.domain.model.ProxyScope +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsPasswordVisibilityIcon +import zed.rainxch.core.presentation.components.inputs.GhsTextField +import zed.rainxch.core.presentation.components.inputs.passwordVisualTransformation +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.utils.ObserveAsEvents +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.proxy_password +import zed.rainxch.githubstore.core.presentation.res.proxy_port +import zed.rainxch.githubstore.core.presentation.res.proxy_save +import zed.rainxch.githubstore.core.presentation.res.proxy_saved +import zed.rainxch.githubstore.core.presentation.res.proxy_host +import zed.rainxch.githubstore.core.presentation.res.proxy_test_success +import zed.rainxch.githubstore.core.presentation.res.proxy_username +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_applies_to_all +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_custom +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_intro_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_intro_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_main_section +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_mode_http +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_mode_http_caption +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_mode_no_proxy +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_mode_socks +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_mode_socks_caption +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_mode_system +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_overrides_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_overrides_section +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_paste_url +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_scope_discovery_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_scope_discovery_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_scope_download_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_scope_download_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_scope_translation_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_scope_translation_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_test +import zed.rainxch.githubstore.core.presentation.res.tweaks_connection_use_main +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_connection +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksEvent +import zed.rainxch.tweaks.presentation.TweaksState +import zed.rainxch.tweaks.presentation.TweaksViewModel +import zed.rainxch.tweaks.presentation.components.TweaksSubScreenScaffold +import zed.rainxch.tweaks.presentation.model.ProxyScopeFormState +import zed.rainxch.tweaks.presentation.model.ProxyType + +@Composable +fun TweaksConnectionRoot( + onNavigateBack: () -> Unit, + viewModel: TweaksViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + var pasteSheetOpen by rememberSaveable { mutableStateOf(false) } + + ObserveAsEvents(viewModel.events) { event -> + when (event) { + TweaksEvent.OnProxySaved -> coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.proxy_saved)) + } + is TweaksEvent.OnProxySaveError -> coroutineScope.launch { + snackbarState.showSnackbar(event.message) + } + is TweaksEvent.OnProxyTestSuccess -> coroutineScope.launch { + snackbarState.showSnackbar( + getString(Res.string.proxy_test_success, event.latencyMs), + ) + } + is TweaksEvent.OnProxyTestError -> coroutineScope.launch { + snackbarState.showSnackbar(event.message) + } + is TweaksEvent.OnMasterProxyTestResult -> coroutineScope.launch { + val parts = listOfNotNull( + "Search ${event.searchMs?.let { "✓ ${it}ms" } ?: "✗"}", + "Downloads ${event.downloadMs?.let { "✓ ${it}ms" } ?: "✗"}", + "Translation ${event.translationMs?.let { "✓ ${it}ms" } ?: "✗"}", + ) + snackbarState.showSnackbar(parts.joinToString(" · ")) + } + else -> Unit + } + } + + TweaksSubScreenScaffold( + title = stringResource(Res.string.tweaks_entry_connection), + onNavigateBack = onNavigateBack, + snackbarState = snackbarState, + restartReasons = state.needsRestartReasons, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + showRestartBanner = state.restartBannerVisible, + ) { + item(key = "intro") { + IntroCard() + Spacer(Modifier.height(16.dp)) + } + + item(key = "main_card") { + MainConnectionCard( + form = state.masterProxyForm, + onAction = { viewModel.onAction(it) }, + onPasteUrl = { pasteSheetOpen = true }, + ) + Spacer(Modifier.height(16.dp)) + } + + item(key = "overrides_card") { + OverridesCard( + state = state, + onAction = { viewModel.onAction(it) }, + ) + } + } + + if (pasteSheetOpen) { + PasteProxyUrlSheet( + onDismiss = { pasteSheetOpen = false }, + onParsed = { parsed -> + viewModel.onAction( + TweaksAction.OnMasterProxyPasteUrl( + type = parsed.type, + host = parsed.host, + port = parsed.port, + username = parsed.username, + password = parsed.password, + ), + ) + pasteSheetOpen = false + }, + ) + } +} + +@Composable +private fun IntroCard() { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(Res.string.tweaks_connection_intro_title), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.tweaks_connection_intro_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun MainConnectionCard( + form: ProxyScopeFormState, + onAction: (TweaksAction) -> Unit, + onPasteUrl: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(Res.string.tweaks_connection_main_section), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(12.dp)) + + ModePillSegment( + selected = form.type, + onSelected = { onAction(TweaksAction.OnMasterProxyTypeSelected(it)) }, + ) + + AnimatedVisibility(visible = form.type == ProxyType.HTTP || form.type == ProxyType.SOCKS) { + Column { + Spacer(Modifier.height(12.dp)) + ProxyFormFields( + form = form, + onHostChange = { onAction(TweaksAction.OnMasterProxyHostChanged(it)) }, + onPortChange = { onAction(TweaksAction.OnMasterProxyPortChanged(it)) }, + onUserChange = { onAction(TweaksAction.OnMasterProxyUsernameChanged(it)) }, + onPassChange = { onAction(TweaksAction.OnMasterProxyPasswordChanged(it)) }, + onPassVisibility = { onAction(TweaksAction.OnMasterProxyPasswordVisibilityToggle) }, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.tweaks_connection_applies_to_all), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + GhsButton( + onClick = onPasteUrl, + label = stringResource(Res.string.tweaks_connection_paste_url), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + leadingIcon = Icons.Outlined.ContentPaste, + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + GhsButton( + onClick = { onAction(TweaksAction.OnMasterProxyTest) }, + label = stringResource(Res.string.tweaks_connection_test), + variant = GhsButtonVariant.Outline, + leadingIcon = Icons.Default.NetworkCheck, + enabled = !form.isTestInProgress, + loading = form.isTestInProgress, + modifier = Modifier.weight(1f), + ) + GhsButton( + onClick = { onAction(TweaksAction.OnMasterProxySave) }, + label = stringResource(Res.string.proxy_save), + variant = GhsButtonVariant.Primary, + leadingIcon = Icons.Default.Save, + modifier = Modifier.weight(1f), + ) + } + } + } + + } + } +} + +@Composable +private fun ProxyFormFields( + form: ProxyScopeFormState, + onHostChange: (String) -> Unit, + onPortChange: (String) -> Unit, + onUserChange: (String) -> Unit, + onPassChange: (String) -> Unit, + onPassVisibility: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + GhsTextField( + value = form.host, + onValueChange = onHostChange, + modifier = Modifier.weight(2f), + label = stringResource(Res.string.proxy_host), + ) + GhsTextField( + value = form.port, + onValueChange = onPortChange, + modifier = Modifier.weight(1f), + label = stringResource(Res.string.proxy_port), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + Spacer(Modifier.height(8.dp)) + GhsTextField( + value = form.username, + onValueChange = onUserChange, + modifier = Modifier.fillMaxWidth(), + label = stringResource(Res.string.proxy_username), + ) + Spacer(Modifier.height(8.dp)) + GhsTextField( + value = form.password, + onValueChange = onPassChange, + modifier = Modifier.fillMaxWidth(), + label = stringResource(Res.string.proxy_password), + visualTransformation = passwordVisualTransformation(form.isPasswordVisible), + trailingIcon = { + GhsPasswordVisibilityIcon( + visible = form.isPasswordVisible, + onToggle = onPassVisibility, + ) + }, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ModePillSegment( + selected: ProxyType, + onSelected: (ProxyType) -> Unit, +) { + val items = listOf( + ProxyType.NONE to (stringResource(Res.string.tweaks_connection_mode_no_proxy) to null), + ProxyType.SYSTEM to (stringResource(Res.string.tweaks_connection_mode_system) to null), + ProxyType.HTTP to ( + stringResource(Res.string.tweaks_connection_mode_http) to + stringResource(Res.string.tweaks_connection_mode_http_caption) + ), + ProxyType.SOCKS to ( + stringResource(Res.string.tweaks_connection_mode_socks) to + stringResource(Res.string.tweaks_connection_mode_socks_caption) + ), + ) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items.forEach { (type, labels) -> + val isSelected = type == selected + val container = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerLow + } + val content = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(container) + .clickable { onSelected(type) } + .padding(horizontal = 14.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = labels.first, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = content, + maxLines = 1, + ) + } + } + } + val caption = items.firstOrNull { it.first == selected }?.second?.second + if (!caption.isNullOrBlank()) { + Text( + text = caption, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp), + ) + } + } +} + +@Composable +private fun OverridesCard( + state: TweaksState, + onAction: (TweaksAction) -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(Res.string.tweaks_connection_overrides_section), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.tweaks_connection_overrides_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + + ProxyScope.entries.forEachIndexed { idx, scope -> + if (idx > 0) Spacer(Modifier.height(8.dp)) + ScopeOverrideRow( + scope = scope, + useMain = state.useMain(scope), + scopeForm = state.formFor(scope), + onToggle = { useMain -> + onAction(TweaksAction.OnScopeUseMainToggled(scope, useMain)) + }, + onAction = onAction, + ) + } + } + } +} + +@Composable +private fun ScopeOverrideRow( + scope: ProxyScope, + useMain: Boolean, + scopeForm: ProxyScopeFormState, + onToggle: (Boolean) -> Unit, + onAction: (TweaksAction) -> Unit, +) { + val (title, subtitle) = when (scope) { + ProxyScope.DISCOVERY -> stringResource(Res.string.tweaks_connection_scope_discovery_title) to + stringResource(Res.string.tweaks_connection_scope_discovery_body) + ProxyScope.DOWNLOAD -> stringResource(Res.string.tweaks_connection_scope_download_title) to + stringResource(Res.string.tweaks_connection_scope_download_body) + ProxyScope.TRANSLATION -> stringResource(Res.string.tweaks_connection_scope_translation_title) to + stringResource(Res.string.tweaks_connection_scope_translation_body) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.chip, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + UseMainSegment( + useMain = useMain, + onSelected = onToggle, + ) + } + + AnimatedVisibility(visible = !useMain) { + Column { + Spacer(Modifier.height(12.dp)) + ModePillSegment( + selected = scopeForm.type, + onSelected = { onAction(TweaksAction.OnProxyTypeSelected(scope, it)) }, + ) + + AnimatedVisibility( + visible = scopeForm.type == ProxyType.HTTP || + scopeForm.type == ProxyType.SOCKS, + ) { + Column { + Spacer(Modifier.height(12.dp)) + ProxyFormFields( + form = scopeForm, + onHostChange = { + onAction(TweaksAction.OnProxyHostChanged(scope, it)) + }, + onPortChange = { + onAction(TweaksAction.OnProxyPortChanged(scope, it)) + }, + onUserChange = { + onAction(TweaksAction.OnProxyUsernameChanged(scope, it)) + }, + onPassChange = { + onAction(TweaksAction.OnProxyPasswordChanged(scope, it)) + }, + onPassVisibility = { + onAction(TweaksAction.OnProxyPasswordVisibilityToggle(scope)) + }, + ) + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + GhsButton( + onClick = { onAction(TweaksAction.OnProxyTest(scope)) }, + label = stringResource(Res.string.tweaks_connection_test), + variant = GhsButtonVariant.Outline, + leadingIcon = Icons.Default.NetworkCheck, + enabled = !scopeForm.isTestInProgress, + loading = scopeForm.isTestInProgress, + modifier = Modifier.weight(1f), + ) + GhsButton( + onClick = { onAction(TweaksAction.OnProxySave(scope)) }, + label = stringResource(Res.string.proxy_save), + variant = GhsButtonVariant.Tonal, + leadingIcon = Icons.Default.Save, + modifier = Modifier.weight(1f), + ) + } + } + } + + } + } + } + } +} + +@Composable +private fun UseMainSegment( + useMain: Boolean, + onSelected: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + SegmentChip( + label = stringResource(Res.string.tweaks_connection_use_main), + selected = useMain, + onClick = { onSelected(true) }, + ) + SegmentChip( + label = stringResource(Res.string.tweaks_connection_custom), + selected = !useMain, + onClick = { onSelected(false) }, + ) + } +} + +@Composable +private fun SegmentChip( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val container = if (selected) { + MaterialTheme.colorScheme.primary + } else { + Color.Transparent + } + val content = if (selected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(9.dp)) + .background(container) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = content, + ) + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/FeedbackEvent.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/FeedbackEvent.kt index 3f6ccc56e..4350d4215 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/FeedbackEvent.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/FeedbackEvent.kt @@ -3,9 +3,7 @@ package zed.rainxch.tweaks.presentation.feedback import zed.rainxch.tweaks.presentation.feedback.model.FeedbackChannel sealed interface FeedbackEvent { - /** Emitted after `BrowserHelper.openUrl` returned without invoking - * `onFailure`. The host (TweaksRoot) collapses the sheet and - * shows a per-channel success snackbar. */ + data class OnSent(val channel: FeedbackChannel) : FeedbackEvent data class OnSendError(val message: String) : FeedbackEvent diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/FeedbackViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/FeedbackViewModel.kt index 948b59a68..9ee38650e 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/FeedbackViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/FeedbackViewModel.kt @@ -17,8 +17,9 @@ import zed.rainxch.core.domain.getSystemLocaleTag import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.repository.UserSessionRepository +import zed.rainxch.core.domain.system.AppVersionInfo import zed.rainxch.core.domain.utils.BrowserHelper -import zed.rainxch.profile.domain.repository.ProfileRepository import zed.rainxch.tweaks.presentation.feedback.model.DiagnosticsInfo import zed.rainxch.tweaks.presentation.feedback.model.FeedbackChannel import zed.rainxch.tweaks.presentation.feedback.util.FeedbackComposer @@ -26,7 +27,8 @@ import zed.rainxch.tweaks.presentation.feedback.util.FeedbackComposer class FeedbackViewModel( private val browserHelper: BrowserHelper, private val tweaksRepository: TweaksRepository, - private val profileRepository: ProfileRepository, + private val userSessionRepository: UserSessionRepository, + private val appVersionInfo: AppVersionInfo, ) : ViewModel() { private val _state = MutableStateFlow(FeedbackState()) val state = _state.asStateFlow() @@ -83,9 +85,7 @@ class FeedbackViewModel( _events.send(FeedbackEvent.OnSendError(error)) } } - // Hold the disabled state briefly so the user sees the - // buttons disable and can't double-tap; long enough to - // also let any synchronous onFailure invocation arrive. + delay(250) _state.update { it.copy(isSending = false) } if (!failed) { @@ -96,8 +96,7 @@ class FeedbackViewModel( } private fun resetForm() { - // Preserve already-collected diagnostics so we don't re-query - // repositories when the sheet reopens. + _state.update { previous -> FeedbackState(diagnostics = previous.diagnostics) } @@ -117,15 +116,26 @@ class FeedbackViewModel( } else { null } - val user = profileRepository.getUser().firstOrNull() + val user = userSessionRepository.getUser().firstOrNull() val appLanguage = tweaksRepository.getAppLanguage().firstOrNull() + val palette = tweaksRepository.getThemeColor().firstOrNull() + val isDark = tweaksRepository.getIsDarkTheme().firstOrNull() + val amoled = tweaksRepository.getAmoledTheme().firstOrNull() ?: false + val themeMode = when { + isDark == null -> "System" + isDark && amoled -> "AMOLED" + isDark -> "Dark" + else -> "Light" + } return DiagnosticsInfo( - appVersion = profileRepository.getVersionName(), + appVersion = appVersionInfo.versionName, platform = platform.displayName(), osVersion = getOsVersion(), locale = appLanguage ?: getSystemLocaleTag(), installerType = installerString, githubUsername = user?.username, + themePalette = palette?.name ?: "Nord", + themeMode = themeMode, ) } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/CategorySelector.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/CategorySelector.kt index a08b19554..8a3c8f1f1 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/CategorySelector.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/CategorySelector.kt @@ -1,25 +1,34 @@ package zed.rainxch.tweaks.presentation.feedback.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.Role +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.feedback_category_label import zed.rainxch.tweaks.presentation.feedback.model.FeedbackCategory +@OptIn(ExperimentalLayoutApi::class) @Composable fun CategorySelector( selected: FeedbackCategory, @@ -29,41 +38,66 @@ fun CategorySelector( Column(modifier = modifier) { Text( text = stringResource(Res.string.feedback_category_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - - Column( - modifier = Modifier - .fillMaxWidth() - .selectableGroup() - .padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), + Spacer(Modifier.height(8.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { FeedbackCategory.entries.forEach { category -> - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = category == selected, - onClick = { onSelected(category) }, - role = Role.RadioButton, - ) - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - RadioButton( - selected = category == selected, - onClick = null, - ) - Text( - text = stringResource(category.label), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } + FeedbackPillChip( + label = stringResource(category.label), + isSelected = category == selected, + onClick = { onSelected(category) }, + ) } } } } + +@Composable +internal fun FeedbackPillChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + val container by animateColorAsState( + targetValue = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + animationSpec = tween(durationMillis = 180), + label = "chip_container", + ) + val content by animateColorAsState( + targetValue = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + }, + animationSpec = tween(durationMillis = 180), + label = "chip_content", + ) + Box( + modifier = Modifier + .clip(Radii.chip) + .background(container) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = content, + ) + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/ConditionalFields.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/ConditionalFields.kt index 715c0c2c5..4e204bf6a 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/ConditionalFields.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/ConditionalFields.kt @@ -3,9 +3,8 @@ package zed.rainxch.tweaks.presentation.feedback.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import zed.rainxch.core.presentation.components.inputs.GhsTextField import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource @@ -67,7 +66,7 @@ fun ConditionalFields( onValueChange = { onAction(FeedbackAction.OnDesiredBehaviourChange(it)) }, ) } - FeedbackCategory.OTHER -> { /* no extras */ } + FeedbackCategory.OTHER -> { } } } } @@ -78,11 +77,12 @@ private fun MultilineField( label: String, onValueChange: (String) -> Unit, ) { - OutlinedTextField( + GhsTextField( value = value, onValueChange = onValueChange, - label = { Text(label) }, - modifier = Modifier.fillMaxWidth(), + label = label, + singleLine = false, minLines = 3, + modifier = Modifier.fillMaxWidth(), ) } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/DiagnosticsPreview.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/DiagnosticsPreview.kt index 7240f7456..77450248e 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/DiagnosticsPreview.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/DiagnosticsPreview.kt @@ -79,7 +79,8 @@ private fun formatDiagnostics(d: DiagnosticsInfo, channel: FeedbackChannel): Str val sb = StringBuilder() sb.append("- App: GitHub Store v").append(d.appVersion).append('\n') sb.append("- Platform: ").append(d.platform).append(' ').append(d.osVersion).append('\n') - sb.append("- Locale: ").append(d.locale) + sb.append("- Locale: ").append(d.locale).append('\n') + sb.append("- Theme: ").append(d.themePalette).append(" / ").append(d.themeMode) d.installerType?.let { sb.append('\n').append("- Installer: ").append(it) } if (channel == FeedbackChannel.GITHUB) { d.githubUsername?.let { sb.append('\n').append("- GitHub user: @").append(it) } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/FeedbackBottomSheet.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/FeedbackBottomSheet.kt index 48e0b5de9..89f0cffe4 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/FeedbackBottomSheet.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/FeedbackBottomSheet.kt @@ -1,14 +1,17 @@ package zed.rainxch.tweaks.presentation.feedback.components +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 import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -18,18 +21,24 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.domain.getPlatform +import zed.rainxch.core.domain.model.Platform +import zed.rainxch.core.presentation.components.inputs.GhsTextField +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.feedback_close @@ -38,6 +47,7 @@ import zed.rainxch.githubstore.core.presentation.res.feedback_field_title import zed.rainxch.githubstore.core.presentation.res.feedback_title import zed.rainxch.tweaks.presentation.feedback.FeedbackAction import zed.rainxch.tweaks.presentation.feedback.FeedbackEvent +import zed.rainxch.tweaks.presentation.feedback.FeedbackState import zed.rainxch.tweaks.presentation.feedback.FeedbackViewModel import zed.rainxch.tweaks.presentation.feedback.model.FeedbackChannel @@ -50,7 +60,6 @@ fun FeedbackBottomSheet( viewModel: FeedbackViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ObserveAsEvents(viewModel.events) { event -> when (event) { @@ -59,96 +68,147 @@ fun FeedbackBottomSheet( } } - ModalBottomSheet( - onDismissRequest = { - viewModel.onAction(FeedbackAction.OnDismiss) - onDismiss() - }, - sheetState = sheetState, - modifier = Modifier.fillMaxSize(), - ) { - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + val dismiss = { + viewModel.onAction(FeedbackAction.OnDismiss) + onDismiss() + } + + if (getPlatform() == Platform.ANDROID) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = dismiss, + sheetState = sheetState, ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + FeedbackContent( + state = state, + onAction = viewModel::onAction, + onDismiss = dismiss, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + } + } else { + Dialog( + onDismissRequest = dismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .widthIn(max = 560.dp) + .heightIn(max = 720.dp) + .clip(WonkySquircleShape.Dialog) + .background(MaterialTheme.colorScheme.surface), ) { - Text( - text = stringResource(Res.string.feedback_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + FeedbackContent( + state = state, + onAction = viewModel::onAction, + onDismiss = dismiss, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 20.dp), ) - IconButton(onClick = { - viewModel.onAction(FeedbackAction.OnDismiss) - onDismiss() - }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(Res.string.feedback_close), - modifier = Modifier.size(24.dp), - ) - } } + } + } +} - CategorySelector( - selected = state.category, - onSelected = { viewModel.onAction(FeedbackAction.OnCategoryChange(it)) }, - ) +@Composable +private fun FeedbackContent( + state: FeedbackState, + onAction: (FeedbackAction) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + Column( + modifier = modifier.verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + FeedbackHeader(onDismiss = onDismiss) - TopicSelector( - selected = state.topic, - onSelected = { viewModel.onAction(FeedbackAction.OnTopicChange(it)) }, - ) + SectionLabel(text = stringResource(Res.string.feedback_field_title) + " *", topGap = 0.dp) + GhsTextField( + value = state.title, + onValueChange = { onAction(FeedbackAction.OnTitleChange(it)) }, + label = stringResource(Res.string.feedback_field_title), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) - OutlinedTextField( - value = state.title, - onValueChange = { viewModel.onAction(FeedbackAction.OnTitleChange(it)) }, - label = { Text(stringResource(Res.string.feedback_field_title) + " *") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + SectionLabel(text = stringResource(Res.string.feedback_field_description) + " *") + GhsTextField( + value = state.description, + onValueChange = { onAction(FeedbackAction.OnDescriptionChange(it)) }, + label = stringResource(Res.string.feedback_field_description), + singleLine = false, + minLines = 4, + modifier = Modifier.fillMaxWidth(), + ) - OutlinedTextField( - value = state.description, - onValueChange = { viewModel.onAction(FeedbackAction.OnDescriptionChange(it)) }, - label = { Text(stringResource(Res.string.feedback_field_description) + " *") }, - minLines = 4, - modifier = Modifier.fillMaxWidth(), - ) + CategorySelector( + selected = state.category, + onSelected = { onAction(FeedbackAction.OnCategoryChange(it)) }, + ) - ConditionalFields( - state = state, - onAction = viewModel::onAction, - ) + TopicSelector( + selected = state.topic, + onSelected = { onAction(FeedbackAction.OnTopicChange(it)) }, + ) - // Channel for the diagnostics preview is informational only — - // the actual channel is decided when the user picks Send. We - // pass GITHUB so the preview shows the username if present - // (most permissive view); the composer still strips it for - // the email send. - DiagnosticsPreview( - diagnostics = state.diagnostics, - channel = FeedbackChannel.GITHUB, - enabled = state.attachDiagnostics, - onToggle = { viewModel.onAction(FeedbackAction.OnAttachDiagnosticsToggle) }, - ) + ConditionalFields(state = state, onAction = onAction) - SendActions( - canSend = state.canSend, - isSending = state.isSending, - onSendEmail = { viewModel.onAction(FeedbackAction.OnSendViaEmail) }, - onSendGithub = { viewModel.onAction(FeedbackAction.OnSendViaGithub) }, - ) + DiagnosticsPreview( + diagnostics = state.diagnostics, + channel = FeedbackChannel.GITHUB, + enabled = state.attachDiagnostics, + onToggle = { onAction(FeedbackAction.OnAttachDiagnosticsToggle) }, + ) + + SendActions( + canSend = state.canSend, + isSending = state.isSending, + onSendEmail = { onAction(FeedbackAction.OnSendViaEmail) }, + onSendGithub = { onAction(FeedbackAction.OnSendViaGithub) }, + ) - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(8.dp)) + } +} + +@Composable +private fun FeedbackHeader(onDismiss: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(Res.string.feedback_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.feedback_close), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } + +@Composable +private fun SectionLabel(text: String, topGap: androidx.compose.ui.unit.Dp = 4.dp) { + Column { + if (topGap > 0.dp) Spacer(Modifier.height(topGap)) + Text( + text = text, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/SendActions.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/SendActions.kt index 3cd9aeb33..8eb1ecdd1 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/SendActions.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/SendActions.kt @@ -3,16 +3,12 @@ package zed.rainxch.tweaks.presentation.feedback.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.feedback_send_via_email import zed.rainxch.githubstore.core.presentation.res.feedback_send_via_github @@ -29,35 +25,21 @@ fun SendActions( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - OutlinedButton( + GhsButton( onClick = onSendGithub, + label = stringResource(Res.string.feedback_send_via_github), + variant = GhsButtonVariant.Outline, enabled = canSend, + loading = isSending, modifier = Modifier.weight(1f), - ) { - if (isSending) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Text(stringResource(Res.string.feedback_send_via_github)) - } - } - Button( + ) + GhsButton( onClick = onSendEmail, + label = stringResource(Res.string.feedback_send_via_email), + variant = GhsButtonVariant.Primary, enabled = canSend, + loading = isSending, modifier = Modifier.weight(1f), - ) { - if (isSending) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Text(stringResource(Res.string.feedback_send_via_email)) - } - } + ) } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/TopicSelector.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/TopicSelector.kt index fcc9f9ff4..ce3d84656 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/TopicSelector.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/components/TopicSelector.kt @@ -4,14 +4,14 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material3.FilterChip +import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.githubstore.core.presentation.res.Res @@ -28,23 +28,22 @@ fun TopicSelector( Column(modifier = modifier) { Text( text = stringResource(Res.string.feedback_topic_label), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - + Spacer(Modifier.height(8.dp)) FlowRow( - modifier = Modifier - .fillMaxWidth() - .selectableGroup() - .padding(top = 8.dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { FeedbackTopic.entries.forEach { topic -> - FilterChip( - selected = topic == selected, + FeedbackPillChip( + label = stringResource(topic.label), + isSelected = topic == selected, onClick = { onSelected(topic) }, - label = { Text(stringResource(topic.label)) }, ) } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/model/DiagnosticsInfo.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/model/DiagnosticsInfo.kt index 2cee66709..f5aef8bc4 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/model/DiagnosticsInfo.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/model/DiagnosticsInfo.kt @@ -7,4 +7,6 @@ data class DiagnosticsInfo( val locale: String, val installerType: String?, val githubUsername: String?, + val themePalette: String, + val themeMode: String, ) diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/util/FeedbackComposer.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/util/FeedbackComposer.kt index 97010f1a7..252c0b5f8 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/util/FeedbackComposer.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/feedback/util/FeedbackComposer.kt @@ -5,6 +5,7 @@ import io.ktor.http.encodeURLParameter import zed.rainxch.tweaks.presentation.feedback.FeedbackState import zed.rainxch.tweaks.presentation.feedback.model.FeedbackCategory import zed.rainxch.tweaks.presentation.feedback.model.FeedbackChannel +import zed.rainxch.tweaks.presentation.feedback.model.FeedbackTopic object FeedbackComposer { const val FEEDBACK_EMAIL = "hello@github-store.org" @@ -23,6 +24,9 @@ object FeedbackComposer { fun composeBody(state: FeedbackState, channel: FeedbackChannel): String { val builder = StringBuilder() + builder.append("**Type:** ").append(state.category.displayLabel()) + .append(" · **Area:** ").append(state.topic.displayLabel()) + builder.appendSection("Description", state.description) when (state.category) { @@ -38,7 +42,7 @@ object FeedbackComposer { builder.appendSection("Current behaviour", state.currentBehaviour) builder.appendSection("Desired behaviour", state.desiredBehaviour) } - FeedbackCategory.OTHER -> { /* no extra fields */ } + FeedbackCategory.OTHER -> { } } if (state.attachDiagnostics) { @@ -47,6 +51,7 @@ object FeedbackComposer { builder.append("- App: GitHub Store v").append(d.appVersion).append('\n') builder.append("- Platform: ").append(d.platform).append(' ').append(d.osVersion).append('\n') builder.append("- Locale: ").append(d.locale).append('\n') + builder.append("- Theme: ").append(d.themePalette).append(" / ").append(d.themeMode).append('\n') d.installerType?.let { builder.append("- Installer: ").append(it).append('\n') } if (channel == FeedbackChannel.GITHUB) { d.githubUsername?.let { builder.append("- GitHub user: @").append(it).append('\n') } @@ -64,6 +69,24 @@ object FeedbackComposer { append("## ").append(title).append('\n').append(trimmed) } + private fun FeedbackCategory.displayLabel(): String = when (this) { + FeedbackCategory.BUG -> "Bug" + FeedbackCategory.FEATURE_REQUEST -> "Feature request" + FeedbackCategory.CHANGE_REQUEST -> "Change request" + FeedbackCategory.OTHER -> "Other" + } + + private fun FeedbackTopic.displayLabel(): String = when (this) { + FeedbackTopic.INSTALL_UPDATE -> "Install & updates" + FeedbackTopic.SEARCH_DISCOVERY -> "Search & discovery" + FeedbackTopic.REPO_DETAILS -> "Repo details" + FeedbackTopic.AUTH_ACCOUNT -> "Auth & account" + FeedbackTopic.UI_UX -> "UI / UX" + FeedbackTopic.TRANSLATION -> "Translation" + FeedbackTopic.PERFORMANCE -> "Performance" + FeedbackTopic.OTHER -> "Other" + } + private fun String.truncateToCap(): String { if (length <= BODY_MAX_CHARS) return this val suffix = "\n\n…[truncated]" diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hidden/HiddenRepositoriesRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hidden/HiddenRepositoriesRoot.kt index edbbf0f69..105cbc8f4 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hidden/HiddenRepositoriesRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hidden/HiddenRepositoriesRoot.kt @@ -12,22 +12,23 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -45,8 +46,10 @@ import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.components.GitHubStoreImage +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.Res +import org.jetbrains.compose.resources.pluralStringResource import zed.rainxch.githubstore.core.presentation.res.hidden_repositories_count import zed.rainxch.githubstore.core.presentation.res.hidden_repositories_empty_description import zed.rainxch.githubstore.core.presentation.res.hidden_repositories_empty_title @@ -98,12 +101,18 @@ fun HiddenRepositoriesRoot( Column { Text( text = stringResource(Res.string.hidden_repositories_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, ) if (state.items.isNotEmpty()) { Text( - text = stringResource(Res.string.hidden_repositories_count, state.items.size), + text = pluralStringResource( + Res.plurals.hidden_repositories_count, + state.items.size, + state.items.size, + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -120,13 +129,14 @@ fun HiddenRepositoriesRoot( }, actions = { if (state.items.isNotEmpty()) { - TextButton( + GhsButton( onClick = { viewModel.onAction(HiddenRepositoriesAction.OnUnhideAll) }, - ) { - Text(stringResource(Res.string.hidden_repositories_unhide_all)) - } + label = stringResource(Res.string.hidden_repositories_unhide_all), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } }, ) @@ -192,13 +202,11 @@ private fun HiddenRepoRow( item: HiddenRepoUi, onUnhide: () -> Unit, ) { - OutlinedCard( - colors = - CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - shape = RoundedCornerShape(24.dp), + Surface( modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), ) { Row( modifier = Modifier.padding(12.dp), @@ -230,9 +238,12 @@ private fun HiddenRepoRow( ) } - TextButton(onClick = onUnhide) { - Text(stringResource(Res.string.hidden_repositories_unhide_action)) - } + GhsButton( + onClick = onUnhide, + label = stringResource(Res.string.hidden_repositories_unhide_action), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hidden/HiddenRepositoriesViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hidden/HiddenRepositoriesViewModel.kt index f5473de06..cd41bedfd 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hidden/HiddenRepositoriesViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hidden/HiddenRepositoriesViewModel.kt @@ -51,8 +51,7 @@ class HiddenRepositoriesViewModel( } private fun unhide(repoId: Long) { - // Snapshot the row name BEFORE the flow re-emits without it, so the - // success snackbar can name what the user just acted on. + val fullName = _state.value.items.firstOrNull { it.repoId == repoId }?.fullName.orEmpty() viewModelScope.launch { diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensAction.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensAction.kt index 94be394ab..bbc9abdbe 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensAction.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensAction.kt @@ -6,7 +6,6 @@ import zed.rainxch.core.domain.model.HostToken sealed interface HostTokensAction { data object OnNavigateBack : HostTokensAction - // Picker / dialog lifecycle data object OnAddClicked : HostTokensAction data object OnAddDismiss : HostTokensAction data class OnPickPresetForge(val kind: ForgeKind) : HostTokensAction @@ -15,13 +14,11 @@ sealed interface HostTokensAction { data class OnReplaceToken(val existing: HostToken) : HostTokensAction data class OnEditLabel(val existing: HostToken) : HostTokensAction - // Dialog field edits data class OnDraftHostChanged(val value: String) : HostTokensAction data class OnDraftTokenChanged(val value: String) : HostTokensAction data class OnDraftDisplayNameChanged(val value: String) : HostTokensAction data object OnAddConfirm : HostTokensAction - // Row-level actions data class OnValidate(val host: String) : HostTokensAction data class OnDelete(val host: String) : HostTokensAction data object OnUndoDelete : HostTokensAction diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensEntryCard.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensEntryCard.kt index 75453d03a..21174319f 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensEntryCard.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensEntryCard.kt @@ -1,24 +1,30 @@ package zed.rainxch.tweaks.presentation.hosttokens +import androidx.compose.foundation.BorderStroke +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 import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.VpnKey -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.host_tokens_entry_subtitle import zed.rainxch.githubstore.core.presentation.res.host_tokens_title @@ -27,27 +33,42 @@ import zed.rainxch.githubstore.core.presentation.res.host_tokens_title fun HostTokensEntryCard( onClick: () -> Unit, ) { - OutlinedCard( + Surface( onClick = onClick, - colors = CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - shape = RoundedCornerShape(32.dp), modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f), + ), ) { Row( - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon( - imageVector = Icons.Default.VpnKey, - contentDescription = null, - ) + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(11.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.VpnKey, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(Res.string.host_tokens_title), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, ) Text( text = stringResource(Res.string.host_tokens_entry_subtitle), @@ -58,6 +79,7 @@ fun HostTokensEntryCard( Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensEvent.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensEvent.kt index 9980ef452..5cee79f3a 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensEvent.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensEvent.kt @@ -5,9 +5,7 @@ import zed.rainxch.core.domain.model.HostToken sealed interface HostTokensEvent { data class Message(val text: String) : HostTokensEvent - /** Snackbar with undo when a token is removed. */ data class TokenDeletedWithUndo(val deleted: HostToken) : HostTokensEvent - /** Open URL in browser (token creation pages on each forge). */ data class OpenUrl(val url: String) : HostTokensEvent } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensRoot.kt index 34a57fd7b..83c0ed08a 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensRoot.kt @@ -1,5 +1,7 @@ package zed.rainxch.tweaks.presentation.hosttokens +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,7 +16,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add @@ -25,24 +26,20 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.VpnKey import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface 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 @@ -52,6 +49,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -63,6 +61,11 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.domain.model.ForgeKind import zed.rainxch.core.domain.model.HostToken +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsTextField +import zed.rainxch.core.presentation.theme.tokens.Radii import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.host_tokens_action_add @@ -134,7 +137,15 @@ fun HostTokensRoot( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(Res.string.host_tokens_title)) }, + title = { + Text( + text = stringResource(Res.string.host_tokens_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, + ) + }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( @@ -231,11 +242,7 @@ fun HostTokensRoot( } private fun visiblePresetForges(state: HostTokensState): List { - // Hide the GitHub preset card when the user is already signed in via - // the in-app OAuth flow — for those users a PAT for github.com is - // redundant. Power users can still reach it via "Other forge". - // Always show forges the user already has a stored token for, so the - // picker stays consistent with the row list when those rows exist. + val storedHosts = state.tokens.map { it.host }.toSet() return ForgeKind.entries.filter { kind -> when { @@ -248,19 +255,18 @@ private fun visiblePresetForges(state: HostTokensState): List { @Composable private fun OAuthCoexistenceNote() { - OutlinedCard( + Surface( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - shape = RoundedCornerShape(16.dp), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), ) { Row( modifier = Modifier .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Icon( Icons.Default.Info, @@ -289,23 +295,28 @@ private fun EmptyStatePicker( verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(8.dp)) Text( text = stringResource(Res.string.host_tokens_empty_title), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), ) + Spacer(Modifier.height(4.dp)) Text( text = stringResource(Res.string.host_tokens_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) Text( text = stringResource(Res.string.host_tokens_picker_title), - style = MaterialTheme.typography.labelLarge, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + ), color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(4.dp)) } items(presetForges, key = { it.tokenHost }) { kind -> PresetForgeCard( @@ -326,9 +337,11 @@ private fun PresetForgeCard( onPick: () -> Unit, onOpenTokenCreationPage: () -> Unit, ) { - ElevatedCard( + Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), ) { Column( modifier = Modifier @@ -360,20 +373,21 @@ private fun PresetForgeCard( } Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), ) { - TextButton(onClick = onOpenTokenCreationPage) { - Icon( - Icons.Default.OpenInBrowser, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text(stringResource(Res.string.host_tokens_picker_open_page)) - } - TextButton(onClick = onPick) { - Text(stringResource(Res.string.host_tokens_picker_paste)) - } + GhsButton( + onClick = onOpenTokenCreationPage, + label = stringResource(Res.string.host_tokens_picker_open_page), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + leadingIcon = Icons.Default.OpenInBrowser, + ) + GhsButton( + onClick = onPick, + label = stringResource(Res.string.host_tokens_picker_paste), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } } } @@ -381,10 +395,14 @@ private fun PresetForgeCard( @Composable private fun OtherForgeCard(onPick: () -> Unit) { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - onClick = onPick, + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .clickable(onClick = onPick), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), ) { Row( modifier = Modifier @@ -437,9 +455,12 @@ private fun PickerDialog( } }, confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(Res.string.host_tokens_action_cancel)) - } + GhsButton( + onClick = onDismiss, + label = stringResource(Res.string.host_tokens_action_cancel), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } @@ -457,22 +478,25 @@ private fun TokenRow( ) { val forge = ForgeKind.fromHost(token.host) var menuOpen by remember { mutableStateOf(false) } - ElevatedCard( + Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), ) { Row( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( Icons.Default.VpnKey, contentDescription = null, tint = MaterialTheme.colorScheme.primary, ) - Spacer(Modifier.width(12.dp)) + Spacer(Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = forge?.displayName ?: token.host, @@ -574,60 +598,63 @@ private fun AddTokenDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (state.draftForge == null) { - OutlinedTextField( + val hostSupporting = state.draftHostError?.let { stringResource(it) } + ?: state.draftHostNormalized + .takeIf { it.isNotBlank() && it != state.draftHost.trim() } + ?.let { normalized -> + stringResource( + Res.string.host_tokens_compose_will_connect, + normalized, + ) + } + GhsTextField( value = state.draftHost, onValueChange = { onAction(HostTokensAction.OnDraftHostChanged(it)) }, - label = { Text(stringResource(Res.string.host_tokens_compose_field_forge_address)) }, + label = stringResource(Res.string.host_tokens_compose_field_forge_address), singleLine = true, isError = state.draftHostError != null, - supportingText = state.draftHostError?.let { res -> - { Text(stringResource(res)) } - } ?: state.draftHostNormalized - .takeIf { it.isNotBlank() && it != state.draftHost.trim() } - ?.let { normalized -> - { - Text( - stringResource( - Res.string.host_tokens_compose_will_connect, - normalized, - ), - ) - } - }, + supportingText = hostSupporting, ) } - OutlinedTextField( + val tokenSupporting = state.draftTokenError?.let { stringResource(it) } + ?: state.draftDetectedTokenKind?.let { kind -> + stringResource(Res.string.host_tokens_compose_detected, kind) + } + ?: replacingExisting?.let { + stringResource(Res.string.host_tokens_compose_replace_hint) + } + GhsTextField( value = state.draftToken, onValueChange = { onAction(HostTokensAction.OnDraftTokenChanged(it)) }, - label = { Text(stringResource(Res.string.host_tokens_field_token)) }, + label = stringResource(Res.string.host_tokens_field_token), singleLine = true, isError = state.draftTokenError != null, - supportingText = state.draftTokenError?.let { res -> - { Text(stringResource(res)) } - } ?: state.draftDetectedTokenKind?.let { kind -> - { Text(stringResource(Res.string.host_tokens_compose_detected, kind)) } - } ?: replacingExisting?.let { - { Text(stringResource(Res.string.host_tokens_compose_replace_hint)) } - }, + supportingText = tokenSupporting, visualTransformation = PasswordVisualTransformation(), ) - OutlinedTextField( + GhsTextField( value = state.draftDisplayName, onValueChange = { onAction(HostTokensAction.OnDraftDisplayNameChanged(it)) }, - label = { Text(stringResource(Res.string.host_tokens_field_display_name)) }, + label = stringResource(Res.string.host_tokens_field_display_name), singleLine = true, ) } }, confirmButton = { - TextButton(onClick = { onAction(HostTokensAction.OnAddConfirm) }) { - Text(stringResource(Res.string.host_tokens_action_save)) - } + GhsButton( + onClick = { onAction(HostTokensAction.OnAddConfirm) }, + label = stringResource(Res.string.host_tokens_action_save), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, dismissButton = { - TextButton(onClick = { onAction(HostTokensAction.OnAddDismiss) }) { - Text(stringResource(Res.string.host_tokens_action_cancel)) - } + GhsButton( + onClick = { onAction(HostTokensAction.OnAddDismiss) }, + label = stringResource(Res.string.host_tokens_action_cancel), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensState.kt index 0f0f62c61..3043c1bf9 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensState.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensState.kt @@ -6,10 +6,9 @@ import zed.rainxch.core.domain.model.HostToken data class HostTokensState( val tokens: List = emptyList(), - // Inline validate feedback per row. Survives Snackbar dismiss so the - // user can re-check it on screen. + val validationByHost: Map = emptyMap(), - // Hosts whose validate request is in flight (row spinner). + val validatingHosts: Set = emptySet(), val draftMode: DraftMode = DraftMode.Closed, @@ -29,10 +28,8 @@ data class HostTokensState( sealed interface DraftMode { data object Closed : DraftMode - /** Forge picker step — shown on add tap or empty state. */ data object Picker : DraftMode - /** Token entry dialog. */ data class Compose(val replacingExisting: HostToken? = null) : DraftMode } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensViewModel.kt index 39fbb21a5..e1af1462a 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/hosttokens/HostTokensViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.model.ForgeKind import zed.rainxch.core.domain.model.HostNames -import zed.rainxch.core.domain.repository.AuthenticationState +import zed.rainxch.core.domain.repository.UserSessionRepository import zed.rainxch.core.domain.repository.HostTokenRepository import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.host_tokens_saved @@ -26,7 +26,7 @@ import zed.rainxch.githubstore.core.presentation.res.host_tokens_validation_toke class HostTokensViewModel( private val repository: HostTokenRepository, - private val authenticationState: AuthenticationState, + private val userSessionRepository: UserSessionRepository, ) : ViewModel() { private val _state = MutableStateFlow(HostTokensState(isLoading = true)) @@ -37,10 +37,7 @@ class HostTokensViewModel( init { viewModelScope.launch { - // .catch so a downstream KSafe / serialization throw doesn't - // tear down the collector and leave the screen stuck on the - // initial loading spinner. We surface the failure once via - // Message and drop isLoading so the UI shows the empty state. + repository.observeAll() .catch { t -> _state.update { it.copy(isLoading = false) } @@ -60,8 +57,8 @@ class HostTokensViewModel( } } viewModelScope.launch { - authenticationState.isUserLoggedIn() - .catch { /* swallow — OAuth state is non-critical for this screen */ } + userSessionRepository.isUserLoggedIn() + .catch { } .collect { signedIn -> _state.update { it.copy(isOAuthSignedInToGithub = signedIn) } } @@ -70,7 +67,7 @@ class HostTokensViewModel( fun onAction(action: HostTokensAction) { when (action) { - HostTokensAction.OnNavigateBack -> { /* host handles */ } + HostTokensAction.OnNavigateBack -> { } HostTokensAction.OnAddClicked -> _state.update { it.copy(draftMode = DraftMode.Picker) } @@ -259,10 +256,7 @@ class HostTokensViewModel( if (result.isSuccess) { _state.update { it.copy(pendingUndoDelete = null) } } else { - // Surface the failure but keep `pendingUndoDelete` populated so - // the user could trigger another undo (if surfaced) instead of - // losing the bytes silently. The actual "another undo" surface - // does not exist today; at minimum we emit the error. + _events.send( HostTokensEvent.Message( getString(Res.string.host_tokens_undo_failed, pending.host), diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/install/TweaksInstallRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/install/TweaksInstallRoot.kt new file mode 100644 index 000000000..1a114ec5e --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/install/TweaksInstallRoot.kt @@ -0,0 +1,102 @@ +package zed.rainxch.tweaks.presentation.install + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Computer +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.domain.getPlatform +import zed.rainxch.core.domain.model.Platform +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_install_method +import zed.rainxch.githubstore.core.presentation.res.tweaks_install_method_desktop_empty_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_install_method_desktop_empty_title +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksViewModel +import zed.rainxch.tweaks.presentation.components.TweaksSubScreenScaffold +import zed.rainxch.tweaks.presentation.components.sections.installationSection + +@Composable +fun TweaksInstallRoot( + onNavigateBack: () -> Unit, + viewModel: TweaksViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarState = remember { SnackbarHostState() } + val isAndroid = getPlatform() == Platform.ANDROID + + TweaksSubScreenScaffold( + title = stringResource(Res.string.tweaks_entry_install_method), + onNavigateBack = onNavigateBack, + snackbarState = snackbarState, + restartReasons = state.needsRestartReasons, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + showRestartBanner = state.restartBannerVisible, + ) { + if (isAndroid) { + installationSection( + state = state, + onAction = { viewModel.onAction(it) }, + ) + } else { + item(key = "desktop_empty") { + DesktopEmptyState() + } + } + } +} + +@Composable +private fun DesktopEmptyState() { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp, vertical = 64.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.Computer, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Text( + text = stringResource(Res.string.tweaks_install_method_desktop_empty_title), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(Res.string.tweaks_install_method_desktop_empty_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/language/TweaksLanguageRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/language/TweaksLanguageRoot.kt new file mode 100644 index 000000000..ea9857f86 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/language/TweaksLanguageRoot.kt @@ -0,0 +1,198 @@ +package zed.rainxch.tweaks.presentation.language + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.Check +import androidx.compose.material.icons.outlined.PhoneAndroid +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.domain.model.AppLanguages +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.language_follow_system +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_language +import zed.rainxch.githubstore.core.presentation.res.tweaks_language_intro_body +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksViewModel +import zed.rainxch.tweaks.presentation.components.TweaksSearchField +import zed.rainxch.tweaks.presentation.components.TweaksSubScreenScaffold + +@Composable +fun TweaksLanguageRoot( + onNavigateBack: () -> Unit, + viewModel: TweaksViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarState = remember { SnackbarHostState() } + var query by rememberSaveable { mutableStateOf("") } + + val languages = AppLanguages.ALL + val filtered by remember(query) { + derivedStateOf { + if (query.isBlank()) languages + else languages.filter { + it.displayName.contains(query, ignoreCase = true) || + it.tag.contains(query, ignoreCase = true) + } + } + } + + TweaksSubScreenScaffold( + title = stringResource(Res.string.tweaks_entry_language), + onNavigateBack = onNavigateBack, + snackbarState = snackbarState, + restartReasons = state.needsRestartReasons, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + showRestartBanner = state.restartBannerVisible, + ) { + item(key = "language_intro") { + Text( + text = stringResource(Res.string.tweaks_language_intro_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + } + + item(key = "language_search") { + TweaksSearchField( + query = query, + onQueryChange = { query = it }, + onClear = { query = "" }, + ) + Spacer(Modifier.height(12.dp)) + } + + if (query.isBlank()) { + item(key = "follow_system_row") { + LanguageRow( + title = stringResource(Res.string.language_follow_system), + subtitleTag = null, + leadingIcon = true, + selected = state.selectedAppLanguage == null, + onClick = { viewModel.onAction(TweaksAction.OnAppLanguageSelected(null)) }, + ) + Spacer(Modifier.height(8.dp)) + } + } + + filtered.forEach { language -> + item(key = "lang_${language.tag}") { + LanguageRow( + title = language.displayName, + subtitleTag = language.tag, + leadingIcon = false, + selected = state.selectedAppLanguage == language.tag, + onClick = { + viewModel.onAction(TweaksAction.OnAppLanguageSelected(language.tag)) + }, + ) + Spacer(Modifier.height(8.dp)) + } + } + } +} + +@Composable +private fun LanguageRow( + title: String, + subtitleTag: String?, + leadingIcon: Boolean, + selected: Boolean, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .clickable(onClick = onClick), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (leadingIcon) { + Box( + modifier = Modifier + .size(40.dp) + .clip(Radii.chip) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.PhoneAndroid, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitleTag.isNullOrBlank()) { + Text( + text = subtitleTag, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + } + } + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/licenses/LicensesRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/licenses/LicensesRoot.kt new file mode 100644 index 000000000..86a36ee09 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/licenses/LicensesRoot.kt @@ -0,0 +1,186 @@ +package zed.rainxch.tweaks.presentation.licenses + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksViewModel +import zed.rainxch.tweaks.presentation.components.TweaksSubScreenScaffold + +@Serializable +private data class LibraryEntry( + val name: String, + val license: String, + val url: String, +) + +@Serializable +private data class LicensesPayload( + val libraries: List = emptyList(), +) + +private val licensesJson = Json { ignoreUnknownKeys = true } + +@OptIn(org.jetbrains.compose.resources.ExperimentalResourceApi::class) +@Composable +fun LicensesRoot( + onNavigateBack: () -> Unit, + viewModel: TweaksViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarState = remember { SnackbarHostState() } + val uriHandler = LocalUriHandler.current + + var libraries by remember { mutableStateOf?>(null) } + var loadError by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + runCatching { + val bytes = Res.readBytes("files/licenses.json") + licensesJson.decodeFromString( + LicensesPayload.serializer(), + bytes.decodeToString(), + ).libraries + }.onSuccess { libraries = it } + .onFailure { loadError = true } + } + + TweaksSubScreenScaffold( + title = "Open source licenses", + onNavigateBack = onNavigateBack, + snackbarState = snackbarState, + restartReasons = state.needsRestartReasons, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + showRestartBanner = state.restartBannerVisible, + ) { + item(key = "intro") { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "GitHub Store stands on these libraries.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Tap any entry to open its project page.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(12.dp)) + } + + val list = libraries + when { + list == null && !loadError -> { + item(key = "loading") { + Box( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + loadError -> { + item(key = "error") { + Text( + text = "Couldn't load licenses.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), + ) + } + } + list != null -> { + list.forEach { library -> + item(key = "lib_${library.name}") { + LibraryRow(library = library, onClick = { + runCatching { uriHandler.openUri(library.url) } + }) + Spacer(Modifier.height(8.dp)) + } + } + } + } + } +} + +@Composable +private fun LibraryRow(library: LibraryEntry, onClick: () -> Unit) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .clickable(onClick = onClick), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = library.name, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = library.license, + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/AutoSuggestMirrorViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/AutoSuggestMirrorViewModel.kt deleted file mode 100644 index 304b32f64..000000000 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/AutoSuggestMirrorViewModel.kt +++ /dev/null @@ -1,47 +0,0 @@ -package zed.rainxch.tweaks.presentation.mirror - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import zed.rainxch.core.domain.network.SlowDownloadDetector -import zed.rainxch.core.domain.repository.MirrorRepository - -class AutoSuggestMirrorViewModel( - private val detector: SlowDownloadDetector, - private val mirrorRepository: MirrorRepository, -) : ViewModel() { - private val _isVisible = MutableStateFlow(false) - val isVisible = _isVisible.asStateFlow() - - init { - viewModelScope.launch { - detector.suggestMirror.collect { - _isVisible.value = true - } - } - } - - fun onMaybeLater() { - _isVisible.value = false - viewModelScope.launch { - mirrorRepository.snoozeAutoSuggest(24L * 60 * 60 * 1000) - } - } - - fun onDontAskAgain() { - _isVisible.value = false - viewModelScope.launch { - mirrorRepository.dismissAutoSuggestPermanently() - } - } - - fun onPickOneClicked() { - _isVisible.value = false - } - - fun dismiss() { - _isVisible.value = false - } -} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerRoot.kt index 56f0bd4ad..b70383646 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerRoot.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -44,6 +43,8 @@ import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.domain.model.MirrorConfig import zed.rainxch.core.domain.model.MirrorPreference import zed.rainxch.core.domain.model.MirrorType +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.mirror_custom_label @@ -89,8 +90,10 @@ fun MirrorPickerRoot( title = { Text( text = stringResource(Res.string.mirror_picker_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, ) }, navigationIcon = { @@ -177,21 +180,14 @@ fun MirrorPickerRoot( } item { - Button( + GhsButton( onClick = { viewModel.onAction(MirrorPickerAction.OnTestConnection) }, + label = stringResource(Res.string.mirror_test_button), + variant = GhsButtonVariant.Primary, enabled = !state.isTesting, + loading = state.isTesting, modifier = Modifier.fillMaxWidth(), - ) { - if (state.isTesting) { - CircularProgressIndicator( - modifier = Modifier.height(18.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - ) - } else { - Text(stringResource(Res.string.mirror_test_button)) - } - } + ) state.testResult?.let { result -> Spacer(Modifier.height(8.dp)) Text( diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerViewModel.kt index eeed0d1e3..6456f5189 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/MirrorPickerViewModel.kt @@ -51,7 +51,7 @@ class MirrorPickerViewModel( fun onAction(action: MirrorPickerAction) { when (action) { - MirrorPickerAction.OnNavigateBack -> { /* host handles via callback */ } + MirrorPickerAction.OnNavigateBack -> { } is MirrorPickerAction.OnSelectMirror -> selectMirror(action.mirror) MirrorPickerAction.OnCustomMirrorClicked -> _state.update { it.copy(isCustomDialogVisible = true, customDraft = "", customDraftError = null) } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/AutoSuggestMirrorSheet.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/AutoSuggestMirrorSheet.kt deleted file mode 100644 index ba5271e0f..000000000 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/AutoSuggestMirrorSheet.kt +++ /dev/null @@ -1,75 +0,0 @@ -package zed.rainxch.tweaks.presentation.mirror.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import zed.rainxch.githubstore.core.presentation.res.Res -import zed.rainxch.githubstore.core.presentation.res.mirror_auto_suggest_body -import zed.rainxch.githubstore.core.presentation.res.mirror_auto_suggest_dont_ask_again -import zed.rainxch.githubstore.core.presentation.res.mirror_auto_suggest_maybe_later -import zed.rainxch.githubstore.core.presentation.res.mirror_auto_suggest_pick_one -import zed.rainxch.githubstore.core.presentation.res.mirror_auto_suggest_title - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AutoSuggestMirrorSheet( - onDismiss: () -> Unit, - onPickOne: () -> Unit, - onMaybeLater: () -> Unit, - onDontAskAgain: () -> Unit, -) { - val sheetState = rememberModalBottomSheetState() - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(Res.string.mirror_auto_suggest_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.mirror_auto_suggest_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( - onClick = onPickOne, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(Res.string.mirror_auto_suggest_pick_one)) - } - OutlinedButton( - onClick = onMaybeLater, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(Res.string.mirror_auto_suggest_maybe_later)) - } - TextButton( - onClick = onDontAskAgain, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(Res.string.mirror_auto_suggest_dont_ask_again)) - } - } - } -} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/CustomMirrorDialog.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/CustomMirrorDialog.kt index 32c77e9ae..47eadd919 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/CustomMirrorDialog.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/mirror/components/CustomMirrorDialog.kt @@ -4,13 +4,15 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.components.inputs.GhsTextField import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.cancel import zed.rainxch.githubstore.core.presentation.res.mirror_custom_dialog_hint @@ -30,10 +32,10 @@ fun CustomMirrorDialog( title = { Text(stringResource(Res.string.mirror_custom_dialog_title)) }, text = { Column(modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( + GhsTextField( value = draft, onValueChange = onDraftChange, - placeholder = { Text(stringResource(Res.string.mirror_custom_dialog_hint)) }, + placeholder = stringResource(Res.string.mirror_custom_dialog_hint), isError = error != null, modifier = Modifier.fillMaxWidth(), singleLine = true, @@ -48,17 +50,21 @@ fun CustomMirrorDialog( } }, confirmButton = { - TextButton( + GhsButton( onClick = onConfirm, + label = stringResource(Res.string.mirror_custom_save), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, enabled = draft.isNotBlank() && error == null, - ) { - Text(stringResource(Res.string.mirror_custom_save)) - } + ) }, dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(Res.string.cancel)) - } + GhsButton( + onClick = onDismiss, + label = stringResource(Res.string.cancel), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) }, ) } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt index 1e76aed4b..01bdb8d63 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/model/ProxyScopeFormState.kt @@ -1,12 +1,5 @@ package zed.rainxch.tweaks.presentation.model -/** - * Per-scope form backing state for the proxy section. Each - * [zed.rainxch.core.domain.model.ProxyScope] card owns one of these - * — keeps the in-progress, test, and visibility flags independent - * across scopes so the user can edit one card while a test runs on - * another. - */ data class ProxyScopeFormState( val type: ProxyType = ProxyType.SYSTEM, val host: String = "", @@ -15,13 +8,6 @@ data class ProxyScopeFormState( val password: String = "", val isPasswordVisible: Boolean = false, val isTestInProgress: Boolean = false, - /** - * True once the user has edited any field in this scope's form. - * Gates the preferences-to-form hydration path in the ViewModel: - * once a scope is dirty, incoming emissions from DataStore are - * ignored for that scope until the user saves (commits) or resets, - * so a concurrent write to *another* preference key doesn't - * clobber the in-progress edit when its Flow re-emits. - */ + val isDraftDirty: Boolean = false, ) diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/privacy/TweaksPrivacyRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/privacy/TweaksPrivacyRoot.kt new file mode 100644 index 000000000..d8102e3ec --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/privacy/TweaksPrivacyRoot.kt @@ -0,0 +1,300 @@ +package zed.rainxch.tweaks.presentation.privacy + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.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.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.presentation.components.overlays.GhsConfirmDialog +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.theme.tokens.GhsAccents +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_privacy +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_browsing_history_section +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_clear_viewed_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_clear_viewed_dialog_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_clear_viewed_dialog_confirm +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_clear_viewed_dialog_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_clear_viewed_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_clipboard_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_clipboard_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_hide_seen_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_hide_seen_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_hidden_repos_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_privacy_hidden_repos_title +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksViewModel +import zed.rainxch.tweaks.presentation.components.TweaksSubScreenScaffold + +@Composable +fun TweaksPrivacyRoot( + onNavigateBack: () -> Unit, + onNavigateToHiddenRepositories: () -> Unit, + viewModel: TweaksViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarState = remember { SnackbarHostState() } + + TweaksSubScreenScaffold( + title = stringResource(Res.string.tweaks_entry_privacy), + onNavigateBack = onNavigateBack, + snackbarState = snackbarState, + restartReasons = state.needsRestartReasons, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + showRestartBanner = state.restartBannerVisible, + ) { + + item(key = "clipboard_card") { + ToggleCard( + title = stringResource(Res.string.tweaks_privacy_clipboard_title), + subtitle = stringResource(Res.string.tweaks_privacy_clipboard_body), + checked = state.autoDetectClipboardLinks, + onCheckedChange = { + viewModel.onAction(TweaksAction.OnAutoDetectClipboardToggled(it)) + }, + ) + Spacer(Modifier.height(12.dp)) + } + + item(key = "history_header") { + Text( + text = stringResource(Res.string.tweaks_privacy_browsing_history_section), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp, top = 4.dp, bottom = 8.dp), + ) + } + + item(key = "hide_seen_card") { + ToggleCard( + title = stringResource(Res.string.tweaks_privacy_hide_seen_title), + subtitle = stringResource(Res.string.tweaks_privacy_hide_seen_body), + checked = state.isHideSeenEnabled, + onCheckedChange = { viewModel.onAction(TweaksAction.OnHideSeenToggled(it)) }, + ) + Spacer(Modifier.height(8.dp)) + } + + item(key = "clear_history_row") { + DestructiveRow( + title = stringResource(Res.string.tweaks_privacy_clear_viewed_title), + subtitle = stringResource(Res.string.tweaks_privacy_clear_viewed_body), + onClick = { viewModel.onAction(TweaksAction.OnClearSeenHistoryRequest) }, + ) + Spacer(Modifier.height(8.dp)) + } + + item(key = "hidden_repos_row") { + DrillRow( + icon = Icons.Outlined.VisibilityOff, + title = stringResource(Res.string.tweaks_privacy_hidden_repos_title), + subtitle = stringResource(Res.string.tweaks_privacy_hidden_repos_body), + accent = GhsAccents.Periwinkle, + onClick = onNavigateToHiddenRepositories, + ) + } + } + + if (state.isClearSeenHistoryDialogVisible) { + GhsConfirmDialog( + title = stringResource(Res.string.tweaks_privacy_clear_viewed_dialog_title), + body = stringResource(Res.string.tweaks_privacy_clear_viewed_dialog_body), + confirmLabel = stringResource(Res.string.tweaks_privacy_clear_viewed_dialog_confirm), + destructive = true, + onConfirm = { viewModel.onAction(TweaksAction.OnClearSeenHistoryConfirm) }, + onDismiss = { viewModel.onAction(TweaksAction.OnClearSeenHistoryDismiss) }, + ) + } +} + +@Composable +private fun ToggleCard( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .clickable { onCheckedChange(!checked) }, + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = checked, + onCheckedChange = null, + ) + } + } +} + +@Composable +private fun DrillRow( + icon: ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, + accent: Color = Color.Unspecified, +) { + val tileBg = if (accent == Color.Unspecified) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + accent.copy(alpha = 0.14f) + } + val tint = if (accent == Color.Unspecified) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + accent + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .clickable(onClick = onClick), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(Radii.chip) + .background(tileBg), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(22.dp), + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun DestructiveRow( + title: String, + subtitle: String, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .clickable(onClick = onClick), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.error, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/skipped/SkippedUpdatesRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/skipped/SkippedUpdatesRoot.kt index f418ec1b7..5c2effb8e 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/skipped/SkippedUpdatesRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/skipped/SkippedUpdatesRoot.kt @@ -11,21 +11,23 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.BorderStroke import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonSize +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.theme.tokens.Radii import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -86,8 +88,10 @@ fun SkippedUpdatesRoot( title = { Text( text = stringResource(Res.string.skipped_updates_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, ) }, navigationIcon = { @@ -165,13 +169,11 @@ private fun SkippedAppRow( item: SkippedAppUi, onUnskip: () -> Unit, ) { - OutlinedCard( - colors = - CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - shape = RoundedCornerShape(24.dp), + Surface( modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), ) { Row( modifier = Modifier.padding(12.dp), @@ -212,9 +214,12 @@ private fun SkippedAppRow( } } - TextButton(onClick = onUnskip) { - Text(stringResource(Res.string.skipped_updates_unskip_action)) - } + GhsButton( + onClick = onUnskip, + label = stringResource(Res.string.skipped_updates_unskip_action), + variant = GhsButtonVariant.Text, + size = GhsButtonSize.Sm, + ) } } } diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/skipped/SkippedUpdatesViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/skipped/SkippedUpdatesViewModel.kt index ec4dcd834..04d75f85e 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/skipped/SkippedUpdatesViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/skipped/SkippedUpdatesViewModel.kt @@ -52,8 +52,7 @@ class SkippedUpdatesViewModel( } private fun unskip(packageName: String) { - // Snapshot the row's display name BEFORE the flow emits without it, - // so the success snackbar can name the app the user just acted on. + val appName = _state.value.items.firstOrNull { it.packageName == packageName }?.appName viewModelScope.launch { try { diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/sources/TweaksSourcesRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/sources/TweaksSourcesRoot.kt new file mode 100644 index 000000000..ff8f0095c --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/sources/TweaksSourcesRoot.kt @@ -0,0 +1,381 @@ +package zed.rainxch.tweaks.presentation.sources + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.NetworkCheck +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.domain.model.DiscoveryPlatform +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.theme.tokens.GhsAccents +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.platform_section_android +import zed.rainxch.githubstore.core.presentation.res.platform_section_linux +import zed.rainxch.githubstore.core.presentation.res.platform_section_macos +import zed.rainxch.githubstore.core.presentation.res.platform_section_windows +import zed.rainxch.githubstore.core.presentation.res.tweaks_sources_platforms_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_sources_platforms_title +import zed.rainxch.githubstore.core.presentation.res.custom_forges_count +import zed.rainxch.githubstore.core.presentation.res.custom_forges_entry_label +import zed.rainxch.githubstore.core.presentation.res.remove_search_history_item +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_sources +import zed.rainxch.githubstore.core.presentation.res.tweaks_sources_add_a_host +import zed.rainxch.githubstore.core.presentation.res.tweaks_sources_added_hosts_section +import zed.rainxch.githubstore.core.presentation.res.tweaks_sources_github_mirror_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_sources_intro_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_sources_intro_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_sources_mirror_default +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksViewModel +import zed.rainxch.tweaks.presentation.components.CustomForgesDialog +import zed.rainxch.tweaks.presentation.components.TweaksSubScreenScaffold + +@Composable +fun TweaksSourcesRoot( + onNavigateBack: () -> Unit, + onNavigateToMirrorPicker: () -> Unit, + viewModel: TweaksViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarState = remember { SnackbarHostState() } + + TweaksSubScreenScaffold( + title = stringResource(Res.string.tweaks_entry_sources), + onNavigateBack = onNavigateBack, + snackbarState = snackbarState, + restartReasons = state.needsRestartReasons, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + showRestartBanner = state.restartBannerVisible, + ) { + item(key = "intro") { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(Res.string.tweaks_sources_intro_title), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.tweaks_sources_intro_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(16.dp)) + } + + item(key = "mirror_row") { + DrillRow( + icon = Icons.Outlined.NetworkCheck, + title = stringResource(Res.string.tweaks_sources_github_mirror_title), + subtitle = stringResource(Res.string.tweaks_sources_mirror_default), + accent = GhsAccents.Sky, + onClick = onNavigateToMirrorPicker, + ) + Spacer(Modifier.height(8.dp)) + } + + item(key = "custom_forges_row") { + val count = state.customForgeHosts.size + DrillRow( + icon = Icons.Outlined.Dns, + title = stringResource(Res.string.custom_forges_entry_label), + subtitle = if (count == 0) { + stringResource(Res.string.tweaks_sources_add_a_host) + } else { + pluralStringResource(Res.plurals.custom_forges_count, count, count) + }, + accent = GhsAccents.Mint, + onClick = { viewModel.onAction(TweaksAction.OnOpenCustomForgesDialog) }, + ) + Spacer(Modifier.height(16.dp)) + } + + item(key = "platforms_card") { + DiscoveryPlatformsCard( + selected = state.selectedDiscoveryPlatforms, + onToggle = { viewModel.onAction(TweaksAction.OnDiscoveryPlatformToggled(it)) }, + ) + } + + if (state.customForgeHosts.isNotEmpty()) { + item(key = "forges_subheader") { + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(Res.string.tweaks_sources_added_hosts_section), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp), + ) + } + + state.customForgeHosts.sorted().forEach { host -> + item(key = "forge_$host") { + ForgeHostRow( + host = host, + onRemove = { viewModel.onAction(TweaksAction.OnRemoveCustomForge(host)) }, + ) + Spacer(Modifier.height(8.dp)) + } + } + } + } + + if (state.showCustomForgesDialog) { + CustomForgesDialog( + state = state, + onAction = { viewModel.onAction(it) }, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun DiscoveryPlatformsCard( + selected: Set, + onToggle: (DiscoveryPlatform) -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(Res.string.tweaks_sources_platforms_title), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.tweaks_sources_platforms_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + DiscoveryPlatform.selectablePlatforms.forEach { platform -> + PlatformChip( + label = stringResource(platform.labelRes()), + isSelected = platform in selected, + onClick = { onToggle(platform) }, + ) + } + } + } + } +} + +@Composable +private fun PlatformChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + val container = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val content = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + } + Box( + modifier = Modifier + .clip(Radii.chip) + .background(container) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = content, + ) + } +} + +private fun DiscoveryPlatform.labelRes() = when (this) { + DiscoveryPlatform.Android -> Res.string.platform_section_android + DiscoveryPlatform.Macos -> Res.string.platform_section_macos + DiscoveryPlatform.Windows -> Res.string.platform_section_windows + DiscoveryPlatform.Linux -> Res.string.platform_section_linux + DiscoveryPlatform.All -> Res.string.platform_section_android +} + +@Composable +private fun DrillRow( + icon: ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, + accent: Color = Color.Unspecified, +) { + val tileBg = if (accent == Color.Unspecified) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + accent.copy(alpha = 0.14f) + } + val tint = if (accent == Color.Unspecified) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + accent + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(Radii.row) + .clickable(onClick = onClick), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(Radii.chip) + .background(tileBg), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(22.dp), + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } +} + +@Composable +private fun ForgeHostRow( + host: String, + onRemove: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = host, + style = MaterialTheme.typography.labelLarge.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = onRemove, + modifier = Modifier.size(40.dp).clip(Radii.chip), + ) { + Icon( + imageVector = Icons.Outlined.DeleteOutline, + contentDescription = stringResource(Res.string.remove_search_history_item), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + } + } + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/storage/TweaksStorageRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/storage/TweaksStorageRoot.kt new file mode 100644 index 000000000..db138c0bd --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/storage/TweaksStorageRoot.kt @@ -0,0 +1,180 @@ +package zed.rainxch.tweaks.presentation.storage + +import androidx.compose.foundation.BorderStroke +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 +import androidx.compose.foundation.layout.Spacer +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.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Inventory2 +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.presentation.components.buttons.GhsButton +import zed.rainxch.core.presentation.components.buttons.GhsButtonVariant +import zed.rainxch.core.presentation.theme.shapes.WonkySquircleShape +import zed.rainxch.core.presentation.theme.tokens.Radii +import zed.rainxch.core.presentation.utils.ObserveAsEvents +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.downloads_cleared +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_storage +import zed.rainxch.githubstore.core.presentation.res.tweaks_storage_downloaded_apks_body +import zed.rainxch.githubstore.core.presentation.res.tweaks_storage_downloaded_apks_clear +import zed.rainxch.githubstore.core.presentation.res.tweaks_storage_downloaded_apks_title +import zed.rainxch.githubstore.core.presentation.res.tweaks_storage_empty_size +import zed.rainxch.githubstore.core.presentation.res.tweaks_storage_using_label +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksEvent +import zed.rainxch.tweaks.presentation.TweaksViewModel +import zed.rainxch.tweaks.presentation.components.ClearDownloadsDialog +import zed.rainxch.tweaks.presentation.components.TweaksSubScreenScaffold + +@Composable +fun TweaksStorageRoot( + onNavigateBack: () -> Unit, + viewModel: TweaksViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + ObserveAsEvents(viewModel.events) { event -> + when (event) { + TweaksEvent.OnCacheCleared -> { + coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.downloads_cleared)) + } + } + is TweaksEvent.OnCacheClearError -> { + coroutineScope.launch { + snackbarState.showSnackbar(event.message) + } + } + else -> Unit + } + } + + TweaksSubScreenScaffold( + title = stringResource(Res.string.tweaks_entry_storage), + onNavigateBack = onNavigateBack, + snackbarState = snackbarState, + restartReasons = state.needsRestartReasons, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + showRestartBanner = state.restartBannerVisible, + ) { + item(key = "storage_card") { + DownloadsCard( + cacheSize = state.cacheSize, + onClearClick = { viewModel.onAction(TweaksAction.OnClearCacheClick) }, + ) + } + } + + if (state.isClearDownloadsDialogVisible) { + ClearDownloadsDialog( + cacheSize = state.cacheSize, + onDismissRequest = { viewModel.onAction(TweaksAction.OnClearDownloadsDismiss) }, + onConfirm = { viewModel.onAction(TweaksAction.OnClearDownloadsConfirm) }, + ) + } +} + +@Composable +private fun DownloadsCard( + cacheSize: String, + onClearClick: () -> Unit, +) { + val emptySize = stringResource(Res.string.tweaks_storage_empty_size) + val sizeDisplay = cacheSize.ifBlank { emptySize } + val isEmpty = sizeDisplay == emptySize + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = Radii.row, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(Radii.chip) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Inventory2, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResource(Res.string.tweaks_storage_downloaded_apks_title), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.tweaks_storage_downloaded_apks_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.tweaks_storage_using_label, sizeDisplay), + style = MaterialTheme.typography.labelMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + GhsButton( + onClick = onClearClick, + label = stringResource(Res.string.tweaks_storage_downloaded_apks_clear), + variant = GhsButtonVariant.Destructive, + enabled = !isEmpty, + leadingIcon = Icons.Outlined.DeleteOutline, + ) + } + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/translation/TweaksTranslationRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/translation/TweaksTranslationRoot.kt new file mode 100644 index 000000000..448182280 --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/translation/TweaksTranslationRoot.kt @@ -0,0 +1,71 @@ +package zed.rainxch.tweaks.presentation.translation + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.presentation.utils.ObserveAsEvents +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.translation_deepl_saved +import zed.rainxch.githubstore.core.presentation.res.translation_libre_saved +import zed.rainxch.githubstore.core.presentation.res.translation_microsoft_saved +import zed.rainxch.githubstore.core.presentation.res.translation_provider_saved +import zed.rainxch.githubstore.core.presentation.res.translation_youdao_saved +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_translation +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksEvent +import zed.rainxch.tweaks.presentation.TweaksViewModel +import zed.rainxch.tweaks.presentation.components.TweaksSubScreenScaffold +import zed.rainxch.tweaks.presentation.components.sections.translationSection + +@Composable +fun TweaksTranslationRoot( + onNavigateBack: () -> Unit, + viewModel: TweaksViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + ObserveAsEvents(viewModel.events) { event -> + when (event) { + TweaksEvent.OnTranslationProviderSaved -> coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.translation_provider_saved)) + } + TweaksEvent.OnYoudaoCredentialsSaved -> coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.translation_youdao_saved)) + } + TweaksEvent.OnLibreTranslateCredentialsSaved -> coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.translation_libre_saved)) + } + TweaksEvent.OnDeeplCredentialsSaved -> coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.translation_deepl_saved)) + } + TweaksEvent.OnMicrosoftTranslatorCredentialsSaved -> coroutineScope.launch { + snackbarState.showSnackbar(getString(Res.string.translation_microsoft_saved)) + } + else -> Unit + } + } + + TweaksSubScreenScaffold( + title = stringResource(Res.string.tweaks_entry_translation), + onNavigateBack = onNavigateBack, + snackbarState = snackbarState, + restartReasons = state.needsRestartReasons, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + showRestartBanner = state.restartBannerVisible, + ) { + translationSection( + state = state, + onAction = { viewModel.onAction(it) }, + ) + } +} diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/updates/TweaksUpdatesRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/updates/TweaksUpdatesRoot.kt new file mode 100644 index 000000000..dde86be2a --- /dev/null +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/updates/TweaksUpdatesRoot.kt @@ -0,0 +1,45 @@ +package zed.rainxch.tweaks.presentation.updates + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.tweaks_entry_updates +import zed.rainxch.tweaks.presentation.TweaksAction +import zed.rainxch.tweaks.presentation.TweaksViewModel +import zed.rainxch.tweaks.presentation.components.TweaksSubScreenScaffold +import zed.rainxch.tweaks.presentation.components.sections.updatesSection + +@Composable +fun TweaksUpdatesRoot( + onNavigateBack: () -> Unit, + onNavigateToSkippedUpdates: () -> Unit, + viewModel: TweaksViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarState = remember { SnackbarHostState() } + + TweaksSubScreenScaffold( + title = stringResource(Res.string.tweaks_entry_updates), + onNavigateBack = onNavigateBack, + snackbarState = snackbarState, + restartReasons = state.needsRestartReasons, + onRestartNow = { viewModel.onAction(TweaksAction.OnRestartNowClick) }, + onRestartLater = { viewModel.onAction(TweaksAction.OnRestartLaterClick) }, + showRestartBanner = state.restartBannerVisible, + ) { + updatesSection( + state = state, + onAction = { action -> + when (action) { + TweaksAction.OnSkippedUpdatesClick -> onNavigateToSkippedUpdates() + else -> viewModel.onAction(action) + } + }, + ) + } +} diff --git a/feature/tweaks/presentation/src/jvmMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.jvm.kt b/feature/tweaks/presentation/src/jvmMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.jvm.kt index 58a1f849b..6f98a1e7d 100644 --- a/feature/tweaks/presentation/src/jvmMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.jvm.kt +++ b/feature/tweaks/presentation/src/jvmMain/kotlin/zed/rainxch/tweaks/presentation/RestartApp.jvm.kt @@ -3,23 +3,6 @@ package zed.rainxch.tweaks.presentation import co.touchlab.kermit.Logger import kotlin.system.exitProcess -/** - * Best-effort "relaunch this JVM" for the Desktop language-change - * flow. In `jpackage`-built installers (DMG/MSI/DEB) the current - * process's command line is a clean invocation of the app launcher, - * and [ProcessHandle] reliably gives us the executable path plus the - * original arguments — `ProcessBuilder` can spawn a fresh instance - * from that and we just exit this one. From IDE runs / `./gradlew - * run` the command line reflects the Gradle-managed forked JVM, - * which may or may not relaunch cleanly depending on classpath and - * stdout wiring; if the spawn fails we still want to exit so the - * user can reopen manually rather than be stuck in a half-applied - * state. - * - * [inheritIO] so the relaunched process shares our stdin/stdout/ - * stderr — mostly relevant in terminal runs; packaged apps have no - * attached terminal so it's a no-op there. - */ actual fun restartAppAfterLanguageChange() { try { val info = ProcessHandle.current().info() @@ -35,9 +18,7 @@ actual fun restartAppAfterLanguageChange() { } } } catch (t: Throwable) { - // Swallow: we'd rather exit cleanly than leave the user in a - // limbo where the app is stuck with the old locale because - // the relaunch errored out. + Logger.w(t) { "restartAppAfterLanguageChange: relaunch failed, falling back to plain exit" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c864b248c..2b5b5e3f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,8 +54,8 @@ ktlint-gradle = "12.1.1" flatpak-gradle-generator = "1.7.0" projectApplicationId = "zed.rainxch.githubstore" -projectVersionName = "1.8.3" -projectVersionCode = "18" +projectVersionName = "1.9.0" +projectVersionCode = "19" projectMinSdkVersion = "26" projectTargetSdkVersion = "36" projectCompileSdkVersion = "36" diff --git a/settings.gradle.kts b/settings.gradle.kts index 866bf567b..4a7f63eac 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,6 +64,4 @@ include(":feature:starred:presentation") include(":feature:search:domain") include(":feature:search:data") include(":feature:search:presentation") -include(":feature:profile:domain") -include(":feature:profile:data") include(":feature:profile:presentation")