From e12919dd7fc927e8e6b25d68dd993e0da7eec024 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Thu, 25 Jun 2026 15:00:08 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20first-week=20friction=20improvements=20?= =?UTF-8?q?=E2=80=94=20closes=20#50=20#51=20#52=20#53=20#54=20#55=20#56=20?= =?UTF-8?q?#57=20#58?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - infra: dev.sh/dev.ps1 start all services with one command (#50) - backend: .golangci.yml + make lint target (#51) - mobile: Spotless/ktlint formatting enforcement + CI step (#52) - infra: renovate.json for automated dependency updates (#53) - infra: setup.sh/setup.ps1 first-run contributor script (#54) - backend: Swagger staleness check in backend-ci.yml (#55) - mobile: UiState sealed class + UiStateContent composable (#56 #57) - mobile: HomeViewModel + HomeScreen refactored to UiState pattern (#56 #57) - web: DataTable component (TanStack Table v8, sort/filter/paginate) + /demo (#58) - docs: ui-states.md, data-table.md, updated CONTRIBUTING.md with prereqs and dev scripts --- .github/workflows/backend-ci.yml | 5 + .github/workflows/mobile-ci.yml | 3 + CONTRIBUTING.md | 56 ++++- TEMPLATE_STATUS.md | 67 ++++++ backend/.golangci.yml | 30 +++ backend/Makefile | 7 +- backend/docs/environment.md | 13 ++ dev.ps1 | 25 +++ dev.sh | 35 +++ mobile/.editorconfig | 4 + mobile/app/build.gradle.kts | 1 + .../java/com/company/template/GreetingTest.kt | 1 - .../company/template/auth/LoginScreenTest.kt | 1 - .../template/auth/RegisterScreenTest.kt | 14 +- .../company/template/home/HomeScreenTest.kt | 1 - .../onboarding/OnboardingScreenTest.kt | 1 - .../ui/components/UiStateContentTest.kt | 113 ++++++++++ .../java/com/company/template/MainActivity.kt | 10 +- .../company/template/auth/AuthRepository.kt | 15 +- .../company/template/auth/AuthViewModel.kt | 96 ++++---- .../template/auth/FirebaseAuthRepository.kt | 71 +++--- .../com/company/template/auth/LoginScreen.kt | 101 +++++---- .../company/template/auth/RegisterScreen.kt | 23 +- .../template/data/network/ApiClient.kt | 46 ++-- .../template/data/network/ApiResponse.kt | 13 +- .../company/template/data/network/UserApi.kt | 27 ++- .../fcm/MyFirebaseMessagingService.kt | 61 +++-- .../com/company/template/home/HomeScreen.kt | 148 ++++++------ .../company/template/home/HomeViewModel.kt | 58 +++++ .../template/navigation/AppNavGraph.kt | 17 +- .../template/navigation/AppViewModel.kt | 10 +- .../DataStoreOnboardingRepository.kt | 8 +- .../onboarding/OnboardingRepository.kt | 1 + .../template/onboarding/OnboardingScreen.kt | 17 +- .../onboarding/OnboardingViewModel.kt | 17 +- .../template/storage/UploadRepository.kt | 50 +++-- .../template/ui/components/UiStateContent.kt | 56 +++++ .../com/company/template/ui/state/UiState.kt | 15 ++ .../com/company/template/ui/theme/Color.kt | 2 +- .../com/company/template/ui/theme/Theme.kt | 49 ++-- .../com/company/template/ui/theme/Type.kt | 22 +- .../template/websocket/WebSocketManager.kt | 68 ++++-- .../template/websocket/WebSocketViewModel.kt | 1 - .../company/template/GreetingFormatTest.kt | 3 +- .../com/company/template/SentryInitTest.kt | 1 - .../template/auth/AuthViewModelTest.kt | 180 ++++++++------- .../template/data/network/UserApiTest.kt | 61 ++--- .../fcm/FcmRegistrationPayloadTest.kt | 1 - .../template/home/HomeViewModelTest.kt | 111 +++++++++ .../template/navigation/AppViewModelTest.kt | 118 +++++----- .../onboarding/OnboardingViewModelTest.kt | 27 +-- .../template/storage/UploadRepositoryTest.kt | 132 +++++------ .../company/template/websocket/FakeOkHttp.kt | 33 ++- .../websocket/WebSocketManagerTest.kt | 4 +- .../websocket/WebSocketViewModelTest.kt | 12 +- mobile/build.gradle.kts | 8 + mobile/docs/_index.md | 1 + mobile/docs/architecture.md | 47 ++++ mobile/docs/compose-conventions.md | 73 +++++- mobile/docs/ui-states.md | 210 ++++++++++++++++++ mobile/gradle/libs.versions.toml | 2 + renovate.json | 31 +++ setup.ps1 | 166 ++++++++++++++ setup.sh | 159 +++++++++++++ web/app/demo/DemoTable.tsx | 43 ++++ web/app/demo/page.tsx | 16 ++ web/components/data-table/DataTable.tsx | 186 ++++++++++++++++ .../data-table/__tests__/DataTable.test.tsx | 147 ++++++++++++ web/components/data-table/columns.ts | 40 ++++ web/components/data-table/index.ts | 2 + web/docs/_index.md | 1 + web/docs/data-table.md | 154 +++++++++++++ web/package.json | 1 + web/pnpm-lock.yaml | 22 ++ 74 files changed, 2726 insertions(+), 645 deletions(-) create mode 100644 TEMPLATE_STATUS.md create mode 100644 backend/.golangci.yml create mode 100644 dev.ps1 create mode 100644 dev.sh create mode 100644 mobile/.editorconfig create mode 100644 mobile/app/src/androidTest/java/com/company/template/ui/components/UiStateContentTest.kt create mode 100644 mobile/app/src/main/java/com/company/template/home/HomeViewModel.kt create mode 100644 mobile/app/src/main/java/com/company/template/ui/components/UiStateContent.kt create mode 100644 mobile/app/src/main/java/com/company/template/ui/state/UiState.kt create mode 100644 mobile/app/src/test/java/com/company/template/home/HomeViewModelTest.kt create mode 100644 mobile/docs/ui-states.md create mode 100644 renovate.json create mode 100644 setup.ps1 create mode 100644 setup.sh create mode 100644 web/app/demo/DemoTable.tsx create mode 100644 web/app/demo/page.tsx create mode 100644 web/components/data-table/DataTable.tsx create mode 100644 web/components/data-table/__tests__/DataTable.test.tsx create mode 100644 web/components/data-table/columns.ts create mode 100644 web/components/data-table/index.ts create mode 100644 web/docs/data-table.md diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 0f2b8ac..b1edfec 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -29,6 +29,11 @@ jobs: - name: Install dependencies run: go mod download + - name: Check Swagger docs are up to date + run: | + make swagger + git diff --exit-code docs/swagger/ + - name: Vet run: go vet ./... diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index 0cf282f..d956897 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -38,5 +38,8 @@ jobs: - name: Lint run: ./gradlew lint + - name: Check formatting (ktlint via Spotless) + run: ./gradlew spotlessCheck + - name: Run unit tests run: ./gradlew test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d615e26..e2e718c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,13 +64,19 @@ Feature suggestions are welcome. Open an issue with: ```bash # Backend + cd backend && go vet ./... + cd backend && make lint # golangci-lint cd backend && make test # Web - cd web && pnpm lint && pnpm build + cd web && pnpm lint + cd web && pnpm test + cd web && pnpm build # Mobile - cd mobile && ./gradlew lint && ./gradlew test + cd mobile && ./gradlew lint + cd mobile && ./gradlew spotlessCheck # ktlint formatting + cd mobile && ./gradlew test ``` 6. **Open your pull request** against `main`. Fill in the PR template including: @@ -82,21 +88,47 @@ Feature suggestions are welcome. Open an issue with: ## Development Setup -See [README.md — Getting Started](README.md#getting-started) for the full setup guide. +### Prerequisites -Quick summary: +| Tool | Minimum version | Check | +|---|---|---| +| Go | 1.25 | `go version` | +| Node.js | 22 | `node --version` | +| pnpm | any | `pnpm --version` | +| Docker Desktop | running | `docker info` | +| JDK | 17 | `java -version` | +| Android SDK | API 35 | `$ANDROID_HOME` set | + +### First-time setup + +Run the setup script once after cloning. It checks prerequisites, installs dependencies, copies `.env` files, and runs database migrations: + +```bash +# macOS / Linux +./setup.sh + +# Windows +./setup.ps1 +``` + +### Starting all services ```bash -# Start the database -cd backend && make docker-run +# macOS / Linux — starts Postgres, backend, and web in one terminal +./dev.sh -# Backend (hot reload) -cd backend && make watch +# Windows — opens three separate PowerShell windows +./dev.ps1 +``` -# Web (hot reload) -cd web && pnpm dev +Or start services individually: + +```bash +cd backend && make docker-run # Postgres +cd backend && make watch # backend hot reload → :8080 +cd web && pnpm dev # web → :3000 -# Mobile — open mobile/ in Android Studio, or build via Gradle +# Mobile — open mobile/ in Android Studio, or: cd mobile && ./gradlew assembleDebug ``` @@ -119,7 +151,9 @@ This project uses topic-based documentation in `backend/docs/` and `web/docs/` t | Component patterns or TypeScript conventions | `web/docs/components.md` | | Composable conventions, theme, or Material3 usage | `mobile/docs/compose-conventions.md` | | Activity setup, lifecycle, or ViewModel pattern | `mobile/docs/architecture.md` | +| UiState, UiStateContent, or loading/error patterns | `mobile/docs/ui-states.md` | | Test setup or testing patterns (mobile) | `mobile/docs/testing.md` | +| Data table component or TanStack Table usage | `web/docs/data-table.md` | ### How to update a doc diff --git a/TEMPLATE_STATUS.md b/TEMPLATE_STATUS.md new file mode 100644 index 0000000..74b23b1 --- /dev/null +++ b/TEMPLATE_STATUS.md @@ -0,0 +1,67 @@ +# Template Readiness Status + +Tracks all identified gaps from the June 2026 template analysis. Issues live in the [fullstack-template GitHub project](https://github.com/users/GRACENOBLE/projects/8). + +--- + +## Priority: Critical — Day 1 blockers + +| # | Issue | Status | +|---|-------|--------| +| [#44](https://github.com/GRACENOBLE/fullstack-template/issues/44) | `backend` CORS origin hardcoded to localhost:3000 → make config-driven | ✅ Done (merged) | +| [#45](https://github.com/GRACENOBLE/fullstack-template/issues/45) | `web` `.env.example` missing BACKEND_URL, SENTRY_ORG, SENTRY_PROJECT | ✅ Done (merged) | +| [#46](https://github.com/GRACENOBLE/fullstack-template/issues/46) | `backend` Standardized API response envelope (`JSON`, `JSONError` helpers) | ✅ Done (merged) | +| [#47](https://github.com/GRACENOBLE/fullstack-template/issues/47) | `backend` Request ID middleware (`X-Request-ID`, propagated to logs) | ✅ Done (merged) | +| [#48](https://github.com/GRACENOBLE/fullstack-template/issues/48) | `web` Error pages: `not-found.tsx`, `error.tsx`, `global-error.tsx` | ✅ Done (merged) | +| [#49](https://github.com/GRACENOBLE/fullstack-template/issues/49) | `mobile` HTTP client for backend API calls (OkHttp + Firebase token interceptor) | ✅ Done (merged) | + +--- + +## Priority: High — First-week friction + +| # | Issue | Status | +|---|-------|--------| +| [#50](https://github.com/GRACENOBLE/fullstack-template/issues/50) | `infra` Root dev script to start all services with one command | 🔁 In review | +| [#51](https://github.com/GRACENOBLE/fullstack-template/issues/51) | `backend` Add `.golangci.yml` linter config | 🔁 In review | +| [#52](https://github.com/GRACENOBLE/fullstack-template/issues/52) | `mobile` Add ktlint and integrate into CI | 🔁 In review | +| [#53](https://github.com/GRACENOBLE/fullstack-template/issues/53) | `infra` Renovate / Dependabot for automated dependency updates | 🔁 In review | +| [#54](https://github.com/GRACENOBLE/fullstack-template/issues/54) | `infra` First-run setup script for new contributors | 🔁 In review | +| [#55](https://github.com/GRACENOBLE/fullstack-template/issues/55) | `backend` Swagger generation check in CI (fail if stale) | 🔁 In review | +| [#56](https://github.com/GRACENOBLE/fullstack-template/issues/56) | `mobile` Loading state and skeleton screen pattern | 🔁 In review | +| [#57](https://github.com/GRACENOBLE/fullstack-template/issues/57) | `mobile` Error state and retry UI pattern (`UiState` sealed class) | 🔁 In review | +| [#58](https://github.com/GRACENOBLE/fullstack-template/issues/58) | `web` Data table with sorting, filtering, and pagination (TanStack Table) | 🔁 In review | + +--- + +## Priority: Medium — Polish + +| # | Issue | Status | +|---|-------|--------| +| [#59](https://github.com/GRACENOBLE/fullstack-template/issues/59) | `mobile` Settings screen: show user profile + sign-out button | ⬜ Open | +| [#60](https://github.com/GRACENOBLE/fullstack-template/issues/60) | `web` Dashboard page: fetch and display `/api/v1/me` | ⬜ Open | +| [#61](https://github.com/GRACENOBLE/fullstack-template/issues/61) | `backend` Redis stream consumers: wire with feature flag or document as opt-in | ⬜ Open | +| [#62](https://github.com/GRACENOBLE/fullstack-template/issues/62) | `backend` pprof endpoints for runtime profiling (gated to internal network) | ⬜ Open | + +--- + +## Priority: Low — Nice-to-have + +| # | Issue | Status | +|---|-------|--------| +| [#63](https://github.com/GRACENOBLE/fullstack-template/issues/63) | `infra` Architecture Decision Records (ADRs) for key technology choices | ⬜ Open | +| [#64](https://github.com/GRACENOBLE/fullstack-template/issues/64) | `infra` Deployment runbook for staging and production | ⬜ Open | + +--- + +## Legend + +| Symbol | Meaning | +|--------|---------| +| ✅ Done | Merged to main | +| 🔁 In review | PR open, pending merge | +| ⬜ Open | Not started | +| 🚧 In progress | Branch exists, work ongoing | + +--- + +_Last updated: 2026-06-25 — #50–#58 implemented (first-week friction), PR pending._ diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 0000000..b2b4884 --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,30 @@ +version: "2" + +linters: + default: none + enable: + - errcheck # check errors are handled + - gosimple # simplify code + - govet # go vet checks + - ineffassign # detect ineffectual assignments + - staticcheck # staticcheck suite + - unused # unused code + - gofmt # formatting + - goimports # import ordering + - misspell # spelling mistakes + - revive # drop-in golint replacement + - bodyclose # HTTP response body close check + - noctx # HTTP requests without context + - exhaustive # exhaustive switch on enums (skip if too noisy — comment it out) + +linters-settings: + revive: + rules: + - name: exported + disabled: true # don't require godoc on all exports + +issues: + exclude-rules: + - path: _test\.go + linters: + - errcheck # test code can ignore errors freely diff --git a/backend/Makefile b/backend/Makefile index d3dd3dd..8a4ff52 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -39,6 +39,11 @@ swagger: @echo "Generating Swagger docs..." @go run github.com/swaggo/swag/cmd/swag@v1.16.6 init -g cmd/api/main.go -o docs/swagger +# Lint the application +lint: + @echo "Linting..." + @golangci-lint run ./... + # Live Reload watch: @powershell -ExecutionPolicy Bypass -Command "if (Get-Command air -ErrorAction SilentlyContinue) { \ @@ -77,6 +82,6 @@ migrate-version: migrate-create: @go run ./cmd/migrate create $(name) -.PHONY: all build run test clean watch docker-run docker-down itest swagger \ +.PHONY: all build run test clean watch lint docker-run docker-down itest swagger \ migrate-up migrate-up-one migrate-down migrate-down-to migrate-reset \ migrate-status migrate-version migrate-create diff --git a/backend/docs/environment.md b/backend/docs/environment.md index d7684b1..24463c4 100644 --- a/backend/docs/environment.md +++ b/backend/docs/environment.md @@ -7,6 +7,8 @@ sources: - internal/bootstrap/bootstrap.go - internal/infrastructure/database/postgres/db.go - pkg/firebase/admin.go + - .golangci.yml + - Makefile --- # Environment Variables @@ -58,6 +60,17 @@ The `.gitignore` in `backend/` excludes `.env` (verify before committing). Docker Compose reads the same `.env` file to configure the Postgres container, so the values must be consistent between the app and Docker. +## Quality commands + +| Command | What it runs | +|---|---| +| `make test` | `go test ./... -v` | +| `make itest` | `go test ./internal/infrastructure/... -v` (requires Docker) | +| `make lint` | `golangci-lint run ./...` | +| `make swagger` | regenerates `docs/swagger/` from swaggo annotations | + +`make lint` uses the config in `backend/.golangci.yml`. Enabled linters: `errcheck`, `gosimple`, `govet`, `ineffassign`, `staticcheck`, `unused`, `gofmt`, `goimports`, `misspell`, `revive`, `bodyclose`, `noctx`, `exhaustive`. The `revive` `exported` rule is disabled. Run `make lint` locally before pushing; CI also runs it. + ## Adding a new environment variable 1. Add to `backend/.env` with a descriptive name. 2. Read it in `internal/bootstrap/bootstrap.go` inside `loadConfig()` and store it on `Config`. diff --git a/dev.ps1 b/dev.ps1 new file mode 100644 index 0000000..0117bad --- /dev/null +++ b/dev.ps1 @@ -0,0 +1,25 @@ +# dev.ps1 — start all services in separate PowerShell windows (Windows equivalent of dev.sh) +# Usage: .\dev.ps1 +# Close each terminal window manually when done. + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition + +Write-Host "[postgres] Starting Docker Compose (Postgres)..." -ForegroundColor Blue +Start-Process powershell -ArgumentList "-NoExit", "-Command", "Set-Location '$ScriptDir\backend'; make docker-run" ` + -WindowStyle Normal + +Write-Host "[backend] Waiting 5s for Postgres, then starting Go backend on :8080..." -ForegroundColor Green +Start-Process powershell -ArgumentList "-NoExit", "-Command", "Set-Location '$ScriptDir\backend'; Start-Sleep 5; make watch" ` + -WindowStyle Normal + +Write-Host "[web] Starting Next.js dev server on :3000..." -ForegroundColor Yellow +Start-Process powershell -ArgumentList "-NoExit", "-Command", "Set-Location '$ScriptDir\web'; pnpm dev" ` + -WindowStyle Normal + +Write-Host "" +Write-Host "All three services are starting in separate windows:" -ForegroundColor Cyan +Write-Host " - Postgres (blue window)" -ForegroundColor Blue +Write-Host " - Go backend (green window) → http://localhost:8080" -ForegroundColor Green +Write-Host " - Next.js web (yellow window) → http://localhost:3000" -ForegroundColor Yellow +Write-Host "" +Write-Host "Close each window individually when you are done." -ForegroundColor Cyan diff --git a/dev.sh b/dev.sh new file mode 100644 index 0000000..0751ccb --- /dev/null +++ b/dev.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# dev.sh — start all services in parallel in a single terminal +# Usage: ./dev.sh +# Press Ctrl+C to stop everything. + +set -euo pipefail + +BLUE='\033[1;34m' +GREEN='\033[1;32m' +YELLOW='\033[1;33m' +RESET='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Kill all background jobs when the script exits (Ctrl+C or error) +trap 'echo ""; echo "Stopping all services..."; kill $(jobs -p) 2>/dev/null; wait' EXIT + +# ── Postgres (Docker Compose) ────────────────────────────────────────────────── +echo -e "${BLUE}[postgres] Starting Docker Compose (Postgres)...${RESET}" +(cd "$SCRIPT_DIR/backend" && make docker-run) & + +# ── Backend (Air hot-reload) — wait for Postgres to be ready ────────────────── +( + echo -e "${GREEN}[backend] Waiting 5s for Postgres to be healthy...${RESET}" + sleep 5 + echo -e "${GREEN}[backend] Starting Go backend with Air (hot-reload) on :8080...${RESET}" + cd "$SCRIPT_DIR/backend" && make watch +) & + +# ── Web (Next.js) ───────────────────────────────────────────────────────────── +echo -e "${YELLOW}[web] Starting Next.js dev server on :3000...${RESET}" +(cd "$SCRIPT_DIR/web" && pnpm dev) & + +# Block until Ctrl+C triggers the trap above +wait diff --git a/mobile/.editorconfig b/mobile/.editorconfig new file mode 100644 index 0000000..a74990f --- /dev/null +++ b/mobile/.editorconfig @@ -0,0 +1,4 @@ +[*.kt] +ktlint_standard_function-naming = disabled +ktlint_standard_backing-property-naming = disabled +ktlint_standard_no-wildcard-imports = disabled diff --git a/mobile/app/build.gradle.kts b/mobile/app/build.gradle.kts index bc95f2f..4f4a7c3 100644 --- a/mobile/app/build.gradle.kts +++ b/mobile/app/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.google.services) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.spotless) } val localProps = Properties().apply { diff --git a/mobile/app/src/androidTest/java/com/company/template/GreetingTest.kt b/mobile/app/src/androidTest/java/com/company/template/GreetingTest.kt index 4111b61..26e91d0 100644 --- a/mobile/app/src/androidTest/java/com/company/template/GreetingTest.kt +++ b/mobile/app/src/androidTest/java/com/company/template/GreetingTest.kt @@ -11,7 +11,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class GreetingTest { - @get:Rule val composeTestRule = createComposeRule() diff --git a/mobile/app/src/androidTest/java/com/company/template/auth/LoginScreenTest.kt b/mobile/app/src/androidTest/java/com/company/template/auth/LoginScreenTest.kt index 5849035..e63f661 100644 --- a/mobile/app/src/androidTest/java/com/company/template/auth/LoginScreenTest.kt +++ b/mobile/app/src/androidTest/java/com/company/template/auth/LoginScreenTest.kt @@ -15,7 +15,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class LoginScreenTest { - @get:Rule val composeTestRule = createComposeRule() diff --git a/mobile/app/src/androidTest/java/com/company/template/auth/RegisterScreenTest.kt b/mobile/app/src/androidTest/java/com/company/template/auth/RegisterScreenTest.kt index c7e44b5..01dc23e 100644 --- a/mobile/app/src/androidTest/java/com/company/template/auth/RegisterScreenTest.kt +++ b/mobile/app/src/androidTest/java/com/company/template/auth/RegisterScreenTest.kt @@ -14,7 +14,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class RegisterScreenTest { - @get:Rule val composeTestRule = createComposeRule() @@ -81,12 +80,13 @@ class RegisterScreenTest { fun registerScreen_clickCreateAccount_invokesCallback() { var registerCalled = false setContent( - registerForm = RegisterFormState( - name = "Alice", - email = "alice@example.com", - password = "pass", - confirmPassword = "pass", - ), + registerForm = + RegisterFormState( + name = "Alice", + email = "alice@example.com", + password = "pass", + confirmPassword = "pass", + ), onRegister = { registerCalled = true }, ) // Use the test tag to target the button specifically, not the heading diff --git a/mobile/app/src/androidTest/java/com/company/template/home/HomeScreenTest.kt b/mobile/app/src/androidTest/java/com/company/template/home/HomeScreenTest.kt index 0d897c4..24155b4 100644 --- a/mobile/app/src/androidTest/java/com/company/template/home/HomeScreenTest.kt +++ b/mobile/app/src/androidTest/java/com/company/template/home/HomeScreenTest.kt @@ -14,7 +14,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class HomeScreenTest { - @get:Rule val composeTestRule = createComposeRule() diff --git a/mobile/app/src/androidTest/java/com/company/template/onboarding/OnboardingScreenTest.kt b/mobile/app/src/androidTest/java/com/company/template/onboarding/OnboardingScreenTest.kt index e4fada6..247ee46 100644 --- a/mobile/app/src/androidTest/java/com/company/template/onboarding/OnboardingScreenTest.kt +++ b/mobile/app/src/androidTest/java/com/company/template/onboarding/OnboardingScreenTest.kt @@ -13,7 +13,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class OnboardingScreenTest { - @get:Rule val composeTestRule = createComposeRule() diff --git a/mobile/app/src/androidTest/java/com/company/template/ui/components/UiStateContentTest.kt b/mobile/app/src/androidTest/java/com/company/template/ui/components/UiStateContentTest.kt new file mode 100644 index 0000000..5c422c1 --- /dev/null +++ b/mobile/app/src/androidTest/java/com/company/template/ui/components/UiStateContentTest.kt @@ -0,0 +1,113 @@ +package com.company.template.ui.components + +import androidx.compose.material3.Text +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.company.template.ui.state.UiState +import com.company.template.ui.theme.TemplateTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UiStateContentTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun loadingState_showsCircularProgressIndicator() { + composeTestRule.setContent { + TemplateTheme { + UiStateContent( + state = UiState.Loading, + content = { _: Unit -> }, + ) + } + } + + composeTestRule.onNodeWithTag("loading_indicator").assertIsDisplayed() + } + + @Test + fun errorState_showsErrorMessage() { + composeTestRule.setContent { + TemplateTheme { + UiStateContent( + state = UiState.Error("Something went wrong"), + content = { _: Unit -> }, + ) + } + } + + composeTestRule.onNodeWithText("Something went wrong").assertIsDisplayed() + } + + @Test + fun errorState_withRetry_showsRetryButton() { + var retryClicked = false + + composeTestRule.setContent { + TemplateTheme { + UiStateContent( + state = UiState.Error("Network error"), + onRetry = { retryClicked = true }, + content = { _: Unit -> }, + ) + } + } + + composeTestRule.onNodeWithText("Retry").assertIsDisplayed() + composeTestRule.onNodeWithText("Retry").performClick() + assertTrue(retryClicked) + } + + @Test + fun errorState_withoutRetry_doesNotShowRetryButton() { + composeTestRule.setContent { + TemplateTheme { + UiStateContent( + state = UiState.Error("Network error"), + onRetry = null, + content = { _: Unit -> }, + ) + } + } + + composeTestRule.onNodeWithText("Retry").assertDoesNotExist() + } + + @Test + fun successState_rendersContentLambda() { + composeTestRule.setContent { + TemplateTheme { + UiStateContent( + state = UiState.Success("Hello from success"), + ) { data -> + Text(text = data) + } + } + } + + composeTestRule.onNodeWithText("Hello from success").assertIsDisplayed() + } + + @Test + fun idleState_rendersNothing() { + composeTestRule.setContent { + TemplateTheme { + UiStateContent( + state = UiState.Idle, + content = { _: Unit -> Text(text = "should not appear") }, + ) + } + } + + composeTestRule.onNodeWithText("should not appear").assertDoesNotExist() + composeTestRule.onNodeWithTag("loading_indicator").assertDoesNotExist() + } +} diff --git a/mobile/app/src/main/java/com/company/template/MainActivity.kt b/mobile/app/src/main/java/com/company/template/MainActivity.kt index 7da30f7..0687815 100644 --- a/mobile/app/src/main/java/com/company/template/MainActivity.kt +++ b/mobile/app/src/main/java/com/company/template/MainActivity.kt @@ -33,7 +33,6 @@ import io.sentry.android.core.SentryAndroid fun shouldInitSentry(dsn: String): Boolean = dsn.isNotBlank() class MainActivity : ComponentActivity() { - // activity-compose 1.8.0 transitively pulls in Fragment 1.6+; lint can't detect this @SuppressLint("InvalidFragmentVersionForActivityResult") private val requestNotificationPermission = @@ -70,7 +69,7 @@ class MainActivity : ComponentActivity() { AppNavGraph( appViewModel = appViewModel, authViewModel = authViewModel, - modifier = Modifier.padding(innerPadding) + modifier = Modifier.padding(innerPadding), ) } } @@ -87,9 +86,12 @@ fun AppPreview() { } @Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { +fun Greeting( + name: String, + modifier: Modifier = Modifier, +) { androidx.compose.material3.Text( text = "Hello $name!", - modifier = modifier + modifier = modifier, ) } diff --git a/mobile/app/src/main/java/com/company/template/auth/AuthRepository.kt b/mobile/app/src/main/java/com/company/template/auth/AuthRepository.kt index 31b9ae5..a969412 100644 --- a/mobile/app/src/main/java/com/company/template/auth/AuthRepository.kt +++ b/mobile/app/src/main/java/com/company/template/auth/AuthRepository.kt @@ -4,8 +4,19 @@ import kotlinx.coroutines.flow.StateFlow interface AuthRepository { val authStateFlow: StateFlow - suspend fun signInWithEmail(email: String, password: String): Result - suspend fun registerWithEmail(name: String, email: String, password: String): Result + + suspend fun signInWithEmail( + email: String, + password: String, + ): Result + + suspend fun registerWithEmail( + name: String, + email: String, + password: String, + ): Result + suspend fun signInWithGoogle(googleIdToken: String): Result + suspend fun signOut() } diff --git a/mobile/app/src/main/java/com/company/template/auth/AuthViewModel.kt b/mobile/app/src/main/java/com/company/template/auth/AuthViewModel.kt index 80eb6ac..ac82f26 100644 --- a/mobile/app/src/main/java/com/company/template/auth/AuthViewModel.kt +++ b/mobile/app/src/main/java/com/company/template/auth/AuthViewModel.kt @@ -24,9 +24,14 @@ import kotlinx.coroutines.launch sealed class AuthUiState { data object Idle : AuthUiState() + data object Loading : AuthUiState() + data object Success : AuthUiState() - data class Error(val message: String) : AuthUiState() + + data class Error( + val message: String, + ) : AuthUiState() } data class LoginFormState( @@ -41,8 +46,9 @@ data class RegisterFormState( val confirmPassword: String = "", ) -class AuthViewModel(private val repo: AuthRepository) : ViewModel() { - +class AuthViewModel( + private val repo: AuthRepository, +) : ViewModel() { private val _uiState = MutableStateFlow(AuthUiState.Idle) val uiState: StateFlow = _uiState.asStateFlow() @@ -90,7 +96,8 @@ class AuthViewModel(private val repo: AuthRepository) : ViewModel() { val (email, password) = _loginForm.value viewModelScope.launch { _uiState.value = AuthUiState.Loading - repo.signInWithEmail(email, password) + repo + .signInWithEmail(email, password) .onSuccess { _uiState.value = AuthUiState.Success } .onFailure { _uiState.value = AuthUiState.Error(it.message ?: "Sign in failed") } } @@ -104,7 +111,8 @@ class AuthViewModel(private val repo: AuthRepository) : ViewModel() { } viewModelScope.launch { _uiState.value = AuthUiState.Loading - repo.registerWithEmail(name, email, password) + repo + .registerWithEmail(name, email, password) .onSuccess { _uiState.value = AuthUiState.Success } .onFailure { _uiState.value = AuthUiState.Error(it.message ?: "Registration failed") } } @@ -115,11 +123,11 @@ class AuthViewModel(private val repo: AuthRepository) : ViewModel() { _uiState.value = AuthUiState.Loading fetchGoogleIdToken(activity) .onSuccess { token -> - repo.signInWithGoogle(token) + repo + .signInWithGoogle(token) .onSuccess { _uiState.value = AuthUiState.Success } .onFailure { _uiState.value = AuthUiState.Error(it.message ?: "Sign in failed") } - } - .onFailure { _uiState.value = AuthUiState.Error(it.message ?: "Google sign in failed") } + }.onFailure { _uiState.value = AuthUiState.Error(it.message ?: "Google sign in failed") } } } @@ -134,41 +142,46 @@ class AuthViewModel(private val repo: AuthRepository) : ViewModel() { if (_uiState.value is AuthUiState.Error) _uiState.value = AuthUiState.Idle } - private suspend fun fetchGoogleIdToken(activity: Activity): Result = runCatching { - val webClientId = resolveWebClientId(activity) - check(webClientId.isNotEmpty()) { - "Google Sign-In not configured. Enable Google Sign-In in Firebase Console and " + - "re-download google-services.json, or add GOOGLE_WEB_CLIENT_ID to local.properties." - } - - val credentialManager = CredentialManager.create(activity) - val option = GetSignInWithGoogleOption.Builder(webClientId).build() - val request = GetCredentialRequest.Builder().addCredentialOption(option).build() - - val result = try { - credentialManager.getCredential(activity, request) - } catch (e: NoCredentialException) { - activity.startActivity( - Intent(Settings.ACTION_ADD_ACCOUNT).apply { - putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google")) + private suspend fun fetchGoogleIdToken(activity: Activity): Result = + runCatching { + val webClientId = resolveWebClientId(activity) + check(webClientId.isNotEmpty()) { + "Google Sign-In not configured. Enable Google Sign-In in Firebase Console and " + + "re-download google-services.json, or add GOOGLE_WEB_CLIENT_ID to local.properties." + } + + val credentialManager = CredentialManager.create(activity) + val option = GetSignInWithGoogleOption.Builder(webClientId).build() + val request = GetCredentialRequest.Builder().addCredentialOption(option).build() + + val result = + try { + credentialManager.getCredential(activity, request) + } catch (e: NoCredentialException) { + activity.startActivity( + Intent(Settings.ACTION_ADD_ACCOUNT).apply { + putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google")) + }, + ) + error("No Google account found on device. Please add an account and try again.") } - ) - error("No Google account found on device. Please add an account and try again.") - } - val credential = result.credential - check( - credential is CustomCredential && - credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL - ) { "Unexpected credential type: ${credential.type}" } + val credential = result.credential + check( + credential is CustomCredential && + credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL, + ) { "Unexpected credential type: ${credential.type}" } - GoogleIdTokenCredential.createFrom(credential.data).idToken - } + GoogleIdTokenCredential.createFrom(credential.data).idToken + } private fun resolveWebClientId(context: Context): String { - val resId = context.resources.getIdentifier( - "default_web_client_id", "string", context.packageName - ) + val resId = + context.resources.getIdentifier( + "default_web_client_id", + "string", + context.packageName, + ) if (resId != 0) { val fromResource = context.getString(resId) if (fromResource.isNotEmpty()) return fromResource @@ -177,8 +190,9 @@ class AuthViewModel(private val repo: AuthRepository) : ViewModel() { } companion object { - fun factory(repo: AuthRepository): ViewModelProvider.Factory = viewModelFactory { - initializer { AuthViewModel(repo) } - } + fun factory(repo: AuthRepository): ViewModelProvider.Factory = + viewModelFactory { + initializer { AuthViewModel(repo) } + } } } diff --git a/mobile/app/src/main/java/com/company/template/auth/FirebaseAuthRepository.kt b/mobile/app/src/main/java/com/company/template/auth/FirebaseAuthRepository.kt index f3a7e05..8fd4fda 100644 --- a/mobile/app/src/main/java/com/company/template/auth/FirebaseAuthRepository.kt +++ b/mobile/app/src/main/java/com/company/template/auth/FirebaseAuthRepository.kt @@ -22,22 +22,24 @@ class FirebaseAuthRepository( private val context: Context, private val auth: FirebaseAuth = FirebaseAuth.getInstance(), ) : AuthRepository { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - override val authStateFlow: StateFlow = callbackFlow { - val listener = FirebaseAuth.AuthStateListener { trySend(it.currentUser) } - auth.addAuthStateListener(listener) - awaitClose { auth.removeAuthStateListener(listener) } - } - .map { it?.toDomain() } - .stateIn( - scope = scope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = auth.currentUser?.toDomain(), - ) + override val authStateFlow: StateFlow = + callbackFlow { + val listener = FirebaseAuth.AuthStateListener { trySend(it.currentUser) } + auth.addAuthStateListener(listener) + awaitClose { auth.removeAuthStateListener(listener) } + }.map { it?.toDomain() } + .stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = auth.currentUser?.toDomain(), + ) - override suspend fun signInWithEmail(email: String, password: String): Result = + override suspend fun signInWithEmail( + email: String, + password: String, + ): Result = runCatching { auth.signInWithEmailAndPassword(email, password).await() Unit @@ -47,31 +49,36 @@ class FirebaseAuthRepository( name: String, email: String, password: String, - ): Result = runCatching { - val result = auth.createUserWithEmailAndPassword(email, password).await() - result.user?.updateProfile( - UserProfileChangeRequest.Builder().setDisplayName(name).build() - )?.await() - Unit - } + ): Result = + runCatching { + val result = auth.createUserWithEmailAndPassword(email, password).await() + result.user + ?.updateProfile( + UserProfileChangeRequest.Builder().setDisplayName(name).build(), + )?.await() + Unit + } - override suspend fun signInWithGoogle(googleIdToken: String): Result = runCatching { - val credential = GoogleAuthProvider.getCredential(googleIdToken, null) - auth.signInWithCredential(credential).await() - Unit - } + override suspend fun signInWithGoogle(googleIdToken: String): Result = + runCatching { + val credential = GoogleAuthProvider.getCredential(googleIdToken, null) + auth.signInWithCredential(credential).await() + Unit + } override suspend fun signOut() { auth.signOut() // Clear saved Google credential so the next sign-in shows the account picker - CredentialManager.create(context) + CredentialManager + .create(context) .clearCredentialState(ClearCredentialStateRequest()) } - private fun FirebaseUser.toDomain() = User( - uid = uid, - email = email, - displayName = displayName, - photoUrl = photoUrl?.toString(), - ) + private fun FirebaseUser.toDomain() = + User( + uid = uid, + email = email, + displayName = displayName, + photoUrl = photoUrl?.toString(), + ) } diff --git a/mobile/app/src/main/java/com/company/template/auth/LoginScreen.kt b/mobile/app/src/main/java/com/company/template/auth/LoginScreen.kt index 2f6fc18..02efe87 100644 --- a/mobile/app/src/main/java/com/company/template/auth/LoginScreen.kt +++ b/mobile/app/src/main/java/com/company/template/auth/LoginScreen.kt @@ -38,38 +38,61 @@ internal object LoginTestTags { } private val GoogleIcon: ImageVector - get() = ImageVector.Builder( - name = "GoogleIcon", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ).apply { - path(fill = SolidColor(Color(0xFF4285F4))) { - moveTo(22.56f, 12.25f); curveToRelative(0f, -0.78f, -0.07f, -1.53f, -0.2f, -2.25f) - horizontalLineTo(12f); verticalLineToRelative(4.26f); horizontalLineToRelative(5.92f) - curveToRelative(-0.26f, 1.37f, -1.04f, 2.53f, -2.21f, 3.31f); verticalLineToRelative(2.77f) - horizontalLineToRelative(3.57f); curveToRelative(2.08f, -1.92f, 3.28f, -4.74f, 3.28f, -8.09f); close() - } - path(fill = SolidColor(Color(0xFF34A853))) { - moveTo(12f, 23f); curveToRelative(2.97f, 0f, 5.46f, -0.98f, 7.28f, -2.66f) - lineToRelative(-3.57f, -2.77f); curveToRelative(-0.98f, 0.66f, -2.23f, 1.06f, -3.71f, 1.06f) - curveToRelative(-2.86f, 0f, -5.29f, -1.93f, -6.16f, -4.53f); horizontalLineTo(2.18f) - verticalLineToRelative(2.84f); curveTo(3.99f, 20.53f, 7.7f, 23f, 12f, 23f); close() - } - path(fill = SolidColor(Color(0xFFFBBC05))) { - moveTo(5.84f, 14.09f); curveToRelative(-0.22f, -0.66f, -0.35f, -1.36f, -0.35f, -2.09f) - reflectiveCurveToRelative(0.13f, -1.43f, 0.35f, -2.09f); verticalLineTo(7.07f); horizontalLineTo(2.18f) - curveTo(1.43f, 8.55f, 1f, 10.22f, 1f, 12f); reflectiveCurveToRelative(0.43f, 3.45f, 1.18f, 4.93f) - lineToRelative(2.85f, -2.22f); lineToRelative(0.81f, -0.62f); close() - } - path(fill = SolidColor(Color(0xFFEA4335))) { - moveTo(12f, 5.38f); curveToRelative(1.62f, 0f, 3.06f, 0.56f, 4.21f, 1.64f) - lineToRelative(3.15f, -3.15f); curveTo(17.45f, 2.09f, 14.97f, 1f, 12f, 1f) - curveTo(7.7f, 1f, 3.99f, 3.47f, 2.18f, 7.07f); lineToRelative(3.66f, 2.84f) - curveToRelative(0.87f, -2.6f, 3.3f, -4.53f, 6.16f, -4.53f); close() - } - }.build() + get() = + ImageVector + .Builder( + name = "GoogleIcon", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path(fill = SolidColor(Color(0xFF4285F4))) { + moveTo(22.56f, 12.25f) + curveToRelative(0f, -0.78f, -0.07f, -1.53f, -0.2f, -2.25f) + horizontalLineTo(12f) + verticalLineToRelative(4.26f) + horizontalLineToRelative(5.92f) + curveToRelative(-0.26f, 1.37f, -1.04f, 2.53f, -2.21f, 3.31f) + verticalLineToRelative(2.77f) + horizontalLineToRelative(3.57f) + curveToRelative(2.08f, -1.92f, 3.28f, -4.74f, 3.28f, -8.09f) + close() + } + path(fill = SolidColor(Color(0xFF34A853))) { + moveTo(12f, 23f) + curveToRelative(2.97f, 0f, 5.46f, -0.98f, 7.28f, -2.66f) + lineToRelative(-3.57f, -2.77f) + curveToRelative(-0.98f, 0.66f, -2.23f, 1.06f, -3.71f, 1.06f) + curveToRelative(-2.86f, 0f, -5.29f, -1.93f, -6.16f, -4.53f) + horizontalLineTo(2.18f) + verticalLineToRelative(2.84f) + curveTo(3.99f, 20.53f, 7.7f, 23f, 12f, 23f) + close() + } + path(fill = SolidColor(Color(0xFFFBBC05))) { + moveTo(5.84f, 14.09f) + curveToRelative(-0.22f, -0.66f, -0.35f, -1.36f, -0.35f, -2.09f) + reflectiveCurveToRelative(0.13f, -1.43f, 0.35f, -2.09f) + verticalLineTo(7.07f) + horizontalLineTo(2.18f) + curveTo(1.43f, 8.55f, 1f, 10.22f, 1f, 12f) + reflectiveCurveToRelative(0.43f, 3.45f, 1.18f, 4.93f) + lineToRelative(2.85f, -2.22f) + lineToRelative(0.81f, -0.62f) + close() + } + path(fill = SolidColor(Color(0xFFEA4335))) { + moveTo(12f, 5.38f) + curveToRelative(1.62f, 0f, 3.06f, 0.56f, 4.21f, 1.64f) + lineToRelative(3.15f, -3.15f) + curveTo(17.45f, 2.09f, 14.97f, 1f, 12f, 1f) + curveTo(7.7f, 1f, 3.99f, 3.47f, 2.18f, 7.07f) + lineToRelative(3.66f, 2.84f) + curveToRelative(0.87f, -2.6f, 3.3f, -4.53f, 6.16f, -4.53f) + close() + } + }.build() @Composable fun LoginScreen( @@ -85,9 +108,10 @@ fun LoginScreen( val isLoading = uiState is AuthUiState.Loading Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 32.dp), + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 32.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -151,9 +175,10 @@ fun LoginScreen( Button( onClick = onSignIn, enabled = loginForm.email.isNotBlank() && loginForm.password.isNotBlank(), - modifier = Modifier - .fillMaxWidth() - .testTag(LoginTestTags.SIGN_IN_BUTTON), + modifier = + Modifier + .fillMaxWidth() + .testTag(LoginTestTags.SIGN_IN_BUTTON), ) { Text(text = "Sign In") } diff --git a/mobile/app/src/main/java/com/company/template/auth/RegisterScreen.kt b/mobile/app/src/main/java/com/company/template/auth/RegisterScreen.kt index cfd5810..2ba9df8 100644 --- a/mobile/app/src/main/java/com/company/template/auth/RegisterScreen.kt +++ b/mobile/app/src/main/java/com/company/template/auth/RegisterScreen.kt @@ -44,10 +44,11 @@ fun RegisterScreen( modifier: Modifier = Modifier, ) { Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 32.dp, vertical = 48.dp), + modifier = + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp, vertical = 48.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -107,11 +108,15 @@ fun RegisterScreen( } else { Button( onClick = onRegister, - enabled = registerForm.name.isNotBlank() && registerForm.email.isNotBlank() && - registerForm.password.isNotBlank() && registerForm.confirmPassword.isNotBlank(), - modifier = Modifier - .fillMaxWidth() - .testTag(RegisterTestTags.CREATE_ACCOUNT_BUTTON), + enabled = + registerForm.name.isNotBlank() && + registerForm.email.isNotBlank() && + registerForm.password.isNotBlank() && + registerForm.confirmPassword.isNotBlank(), + modifier = + Modifier + .fillMaxWidth() + .testTag(RegisterTestTags.CREATE_ACCOUNT_BUTTON), ) { Text(text = "Create Account") } diff --git a/mobile/app/src/main/java/com/company/template/data/network/ApiClient.kt b/mobile/app/src/main/java/com/company/template/data/network/ApiClient.kt index 3495a05..e9aabb5 100644 --- a/mobile/app/src/main/java/com/company/template/data/network/ApiClient.kt +++ b/mobile/app/src/main/java/com/company/template/data/network/ApiClient.kt @@ -12,28 +12,36 @@ object ApiClient { // getIdToken().result (synchronous) is safe — do NOT use await() here. private class AuthInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val token = runCatching { - // getIdToken(false) returns the cached token if still valid - FirebaseAuth.getInstance().currentUser - ?.getIdToken(false) - ?.result - ?.token - }.getOrNull() + val token = + runCatching { + // getIdToken(false) returns the cached token if still valid + FirebaseAuth + .getInstance() + .currentUser + ?.getIdToken(false) + ?.result + ?.token + }.getOrNull() - val request = if (token != null) { - chain.request().newBuilder() - .header("Authorization", "Bearer $token") - .build() - } else { - chain.request() - } + val request = + if (token != null) { + chain + .request() + .newBuilder() + .header("Authorization", "Bearer $token") + .build() + } else { + chain.request() + } return chain.proceed(request) } } - val httpClient: OkHttpClient = OkHttpClient.Builder() - .addInterceptor(AuthInterceptor()) - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() + val httpClient: OkHttpClient = + OkHttpClient + .Builder() + .addInterceptor(AuthInterceptor()) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() } diff --git a/mobile/app/src/main/java/com/company/template/data/network/ApiResponse.kt b/mobile/app/src/main/java/com/company/template/data/network/ApiResponse.kt index beaed8b..71a466d 100644 --- a/mobile/app/src/main/java/com/company/template/data/network/ApiResponse.kt +++ b/mobile/app/src/main/java/com/company/template/data/network/ApiResponse.kt @@ -3,10 +3,17 @@ package com.company.template.data.network import kotlinx.serialization.Serializable @Serializable -data class ApiResponse(val data: T) +data class ApiResponse( + val data: T, +) @Serializable -data class ApiErrorDetail(val code: String, val message: String) +data class ApiErrorDetail( + val code: String, + val message: String, +) @Serializable -data class ApiErrorResponse(val error: ApiErrorDetail) +data class ApiErrorResponse( + val error: ApiErrorDetail, +) diff --git a/mobile/app/src/main/java/com/company/template/data/network/UserApi.kt b/mobile/app/src/main/java/com/company/template/data/network/UserApi.kt index c34968a..154ad64 100644 --- a/mobile/app/src/main/java/com/company/template/data/network/UserApi.kt +++ b/mobile/app/src/main/java/com/company/template/data/network/UserApi.kt @@ -19,19 +19,22 @@ object UserApi { suspend fun getMe( baseUrl: String = BuildConfig.BACKEND_URL, client: OkHttpClient = ApiClient.httpClient, - ): Result = runCatching { - val request = Request.Builder() - .url("$baseUrl/api/v1/me") - .get() - .build() + ): Result = + runCatching { + val request = + Request + .Builder() + .url("$baseUrl/api/v1/me") + .get() + .build() - client.newCall(request).execute().use { response -> - val body = response.body?.string() ?: error("empty body") - if (!response.isSuccessful) { - val err = json.decodeFromString(body) - error(err.error.message) + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: error("empty body") + if (!response.isSuccessful) { + val err = json.decodeFromString(body) + error(err.error.message) + } + json.decodeFromString>(body).data } - json.decodeFromString>(body).data } - } } diff --git a/mobile/app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt b/mobile/app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt index 45dd344..5ae8a48 100644 --- a/mobile/app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt +++ b/mobile/app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt @@ -18,7 +18,6 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody class MyFirebaseMessagingService : FirebaseMessagingService() { - private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val httpClient = OkHttpClient() @@ -35,40 +34,54 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { } private fun registerTokenWithBackend(token: String) { - val backendUrl = getString( - applicationContext.resources.getIdentifier( - "backend_base_url", "string", packageName - ).takeIf { it != 0 } ?: return - ) + val backendUrl = + getString( + applicationContext.resources + .getIdentifier( + "backend_base_url", + "string", + packageName, + ).takeIf { it != 0 } ?: return, + ) val payload = FcmRegistrationPayload(token = token) - val body = Json.encodeToString(FcmRegistrationPayload.serializer(), payload) - .toRequestBody("application/json".toMediaType()) - val request = Request.Builder() - .url("$backendUrl/api/v1/fcm/register") - .post(body) - .build() + val body = + Json + .encodeToString(FcmRegistrationPayload.serializer(), payload) + .toRequestBody("application/json".toMediaType()) + val request = + Request + .Builder() + .url("$backendUrl/api/v1/fcm/register") + .post(body) + .build() runCatching { httpClient.newCall(request).execute().close() } } - private fun showNotification(title: String, body: String) { + private fun showNotification( + title: String, + body: String, + ) { val channelId = "fcm_default" val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - channelId, - "Push Notifications", - NotificationManager.IMPORTANCE_DEFAULT, - ) + val channel = + NotificationChannel( + channelId, + "Push Notifications", + NotificationManager.IMPORTANCE_DEFAULT, + ) manager.createNotificationChannel(channel) } - val notification = NotificationCompat.Builder(this, channelId) - .setSmallIcon(android.R.drawable.ic_dialog_info) - .setContentTitle(title) - .setContentText(body) - .setAutoCancel(true) - .build() + val notification = + NotificationCompat + .Builder(this, channelId) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .build() manager.notify(System.currentTimeMillis().toInt(), notification) } } diff --git a/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt b/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt index aa7ec52..33bf4f5 100644 --- a/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt +++ b/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt @@ -12,115 +12,135 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.company.template.data.network.UserApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.company.template.data.network.UserProfile +import com.company.template.ui.components.UiStateContent import com.company.template.ui.theme.TemplateTheme -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext @Composable fun HomeScreen( displayName: String, onSignOut: () -> Unit, - modifier: Modifier = Modifier + viewModel: HomeViewModel = viewModel(factory = HomeViewModel.factory()), + modifier: Modifier = Modifier, ) { - var profile by remember { mutableStateOf(null) } - var profileError by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - val result = withContext(Dispatchers.IO) { UserApi.getMe() } - result - .onSuccess { profile = it } - .onFailure { profileError = it.message } - } + val profileState by viewModel.profileState.collectAsStateWithLifecycle() Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 32.dp), + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 32.dp), verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = "Welcome back!", style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(16.dp)) if (displayName.isNotBlank()) { Text( text = displayName, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(8.dp)) } - profile?.let { p -> - p.displayName?.let { name -> - Text( - text = name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - p.email?.let { email -> - Text( - text = email, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Spacer(modifier = Modifier.height(8.dp)) - } - profileError?.let { err -> - Text( - text = "Profile error: $err", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.height(8.dp)) + UiStateContent( + state = profileState, + onRetry = viewModel::refresh, + ) { profile -> + ProfileContent(profile = profile) } Spacer(modifier = Modifier.height(40.dp)) Button( onClick = onSignOut, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ), - modifier = Modifier.fillMaxWidth() + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = Modifier.fillMaxWidth(), ) { Text(text = "Sign Out") } } } -@Preview(showBackground = true) @Composable -fun HomeScreenPreview() { - TemplateTheme { - HomeScreen( - displayName = "Alice", - onSignOut = {} - ) +private fun ProfileContent( + profile: UserProfile, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + profile.displayName?.let { name -> + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + profile.email?.let { email -> + Text( + text = email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) } } @Preview(showBackground = true) @Composable -fun HomeScreenNoNamePreview() { +fun HomeScreenPreview() { TemplateTheme { - HomeScreen( - displayName = "", - onSignOut = {} - ) + // Preview cannot instantiate HomeViewModel (needs BuildConfig), + // so we show a static layout only for the outer shell. + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Welcome back!", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Alice", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + ProfileContent(profile = UserProfile(uid = "u1", email = "alice@example.com", displayName = "Alice")) + Spacer(modifier = Modifier.height(40.dp)) + Button( + onClick = {}, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "Sign Out") + } + } } } diff --git a/mobile/app/src/main/java/com/company/template/home/HomeViewModel.kt b/mobile/app/src/main/java/com/company/template/home/HomeViewModel.kt new file mode 100644 index 0000000..e0ecfb5 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/home/HomeViewModel.kt @@ -0,0 +1,58 @@ +package com.company.template.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.company.template.BuildConfig +import com.company.template.data.network.ApiClient +import com.company.template.data.network.UserApi +import com.company.template.data.network.UserProfile +import com.company.template.ui.state.UiState +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient + +class HomeViewModel( + private val baseUrl: String, + private val httpClient: OkHttpClient, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + private val _profileState = MutableStateFlow>(UiState.Idle) + val profileState: StateFlow> = _profileState.asStateFlow() + + init { + fetchProfile() + } + + fun refresh() { + fetchProfile() + } + + private fun fetchProfile() { + viewModelScope.launch(ioDispatcher) { + _profileState.value = UiState.Loading + UserApi + .getMe(baseUrl = baseUrl, client = httpClient) + .onSuccess { _profileState.value = UiState.Success(it) } + .onFailure { _profileState.value = UiState.Error(it.message ?: "Failed to load profile") } + } + } + + companion object { + fun factory(): ViewModelProvider.Factory = + viewModelFactory { + initializer { + HomeViewModel( + baseUrl = BuildConfig.BACKEND_URL, + httpClient = ApiClient.httpClient, + ) + } + } + } +} diff --git a/mobile/app/src/main/java/com/company/template/navigation/AppNavGraph.kt b/mobile/app/src/main/java/com/company/template/navigation/AppNavGraph.kt index b352a7f..4b99ceb 100644 --- a/mobile/app/src/main/java/com/company/template/navigation/AppNavGraph.kt +++ b/mobile/app/src/main/java/com/company/template/navigation/AppNavGraph.kt @@ -13,9 +13,9 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.company.template.auth.AuthUiState import com.company.template.auth.AuthViewModel -import com.company.template.auth.User import com.company.template.auth.LoginScreen import com.company.template.auth.RegisterScreen +import com.company.template.auth.User import com.company.template.home.HomeScreen import com.company.template.onboarding.OnboardingScreen @@ -47,12 +47,13 @@ fun AppNavGraph( } } - val resolvedStart = when (startDestination) { - StartDestination.ONBOARDING -> ROUTE_ONBOARDING - StartDestination.LOGIN -> ROUTE_LOGIN - StartDestination.HOME -> ROUTE_HOME - null -> return // wait until resolved - } + val resolvedStart = + when (startDestination) { + StartDestination.ONBOARDING -> ROUTE_ONBOARDING + StartDestination.LOGIN -> ROUTE_LOGIN + StartDestination.HOME -> ROUTE_HOME + null -> return // wait until resolved + } NavHost( navController = navController, @@ -66,7 +67,7 @@ fun AppNavGraph( navController.navigate(ROUTE_LOGIN) { popUpTo(ROUTE_ONBOARDING) { inclusive = true } } - } + }, ) } composable(ROUTE_LOGIN) { diff --git a/mobile/app/src/main/java/com/company/template/navigation/AppViewModel.kt b/mobile/app/src/main/java/com/company/template/navigation/AppViewModel.kt index 63dd9f8..08be34d 100644 --- a/mobile/app/src/main/java/com/company/template/navigation/AppViewModel.kt +++ b/mobile/app/src/main/java/com/company/template/navigation/AppViewModel.kt @@ -16,14 +16,13 @@ import kotlinx.coroutines.launch enum class StartDestination { ONBOARDING, LOGIN, - HOME + HOME, } class AppViewModel( authRepository: AuthRepository, private val onboardingRepository: OnboardingRepository, ) : ViewModel() { - val startDestination: StateFlow = combine( onboardingRepository.hasSeenOnboarding(), @@ -50,8 +49,9 @@ class AppViewModel( fun factory( authRepository: AuthRepository, onboardingRepository: OnboardingRepository, - ): ViewModelProvider.Factory = viewModelFactory { - initializer { AppViewModel(authRepository, onboardingRepository) } - } + ): ViewModelProvider.Factory = + viewModelFactory { + initializer { AppViewModel(authRepository, onboardingRepository) } + } } } diff --git a/mobile/app/src/main/java/com/company/template/onboarding/DataStoreOnboardingRepository.kt b/mobile/app/src/main/java/com/company/template/onboarding/DataStoreOnboardingRepository.kt index e498a09..ec50ba5 100644 --- a/mobile/app/src/main/java/com/company/template/onboarding/DataStoreOnboardingRepository.kt +++ b/mobile/app/src/main/java/com/company/template/onboarding/DataStoreOnboardingRepository.kt @@ -14,16 +14,16 @@ import java.io.IOException private val Context.dataStore: DataStore by preferencesDataStore(name = "onboarding_prefs") -class DataStoreOnboardingRepository(private val context: Context) : OnboardingRepository { - +class DataStoreOnboardingRepository( + private val context: Context, +) : OnboardingRepository { private val hasSeenKey = booleanPreferencesKey("has_seen_onboarding") override fun hasSeenOnboarding(): Flow = context.dataStore.data .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e - } - .map { prefs -> prefs[hasSeenKey] ?: false } + }.map { prefs -> prefs[hasSeenKey] ?: false } override suspend fun markSeen() { context.dataStore.edit { prefs -> prefs[hasSeenKey] = true } diff --git a/mobile/app/src/main/java/com/company/template/onboarding/OnboardingRepository.kt b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingRepository.kt index fe5c0e2..fad5bf5 100644 --- a/mobile/app/src/main/java/com/company/template/onboarding/OnboardingRepository.kt +++ b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingRepository.kt @@ -4,5 +4,6 @@ import kotlinx.coroutines.flow.Flow interface OnboardingRepository { fun hasSeenOnboarding(): Flow + suspend fun markSeen() } diff --git a/mobile/app/src/main/java/com/company/template/onboarding/OnboardingScreen.kt b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingScreen.kt index 576529b..accee55 100644 --- a/mobile/app/src/main/java/com/company/template/onboarding/OnboardingScreen.kt +++ b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingScreen.kt @@ -21,32 +21,33 @@ import com.company.template.ui.theme.TemplateTheme @Composable fun OnboardingScreen( onGetStarted: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 32.dp), + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 32.dp), verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = "Welcome", style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "Get started with your account to access all features.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(48.dp)) Button( onClick = onGetStarted, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Text(text = "Get Started") } diff --git a/mobile/app/src/main/java/com/company/template/onboarding/OnboardingViewModel.kt b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingViewModel.kt index 8d48fc0..32466e0 100644 --- a/mobile/app/src/main/java/com/company/template/onboarding/OnboardingViewModel.kt +++ b/mobile/app/src/main/java/com/company/template/onboarding/OnboardingViewModel.kt @@ -8,11 +8,15 @@ import androidx.lifecycle.viewmodel.viewModelFactory import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch -class OnboardingViewModel(private val repo: OnboardingRepository) : ViewModel() { - +class OnboardingViewModel( + private val repo: OnboardingRepository, +) : ViewModel() { fun hasSeenOnboarding(): Flow = repo.hasSeenOnboarding() - fun markSeen(onComplete: () -> Unit = {}, onError: (Throwable) -> Unit = {}) { + fun markSeen( + onComplete: () -> Unit = {}, + onError: (Throwable) -> Unit = {}, + ) { viewModelScope.launch { runCatching { repo.markSeen() } .onSuccess { onComplete() } @@ -21,8 +25,9 @@ class OnboardingViewModel(private val repo: OnboardingRepository) : ViewModel() } companion object { - fun factory(repo: OnboardingRepository): ViewModelProvider.Factory = viewModelFactory { - initializer { OnboardingViewModel(repo) } - } + fun factory(repo: OnboardingRepository): ViewModelProvider.Factory = + viewModelFactory { + initializer { OnboardingViewModel(repo) } + } } } diff --git a/mobile/app/src/main/java/com/company/template/storage/UploadRepository.kt b/mobile/app/src/main/java/com/company/template/storage/UploadRepository.kt index 8fb1143..7cbd987 100644 --- a/mobile/app/src/main/java/com/company/template/storage/UploadRepository.kt +++ b/mobile/app/src/main/java/com/company/template/storage/UploadRepository.kt @@ -16,14 +16,13 @@ interface UploadRepository { contentType: String, fileBytes: ByteArray, idToken: String, - ): Result // returns public URL + ): Result // returns public URL } class R2UploadRepository( private val backendBaseUrl: String, private val httpClient: OkHttpClient = OkHttpClient(), ) : UploadRepository { - @Serializable private data class PresignRequest( val filename: String, @@ -43,32 +42,45 @@ class R2UploadRepository( contentType: String, fileBytes: ByteArray, idToken: String, - ): Result = withContext(Dispatchers.IO) { - runCatching { - val presignResponse = presign(filename, contentType, idToken) - uploadToR2(presignResponse.uploadUrl, fileBytes, contentType) - presignResponse.publicUrl + ): Result = + withContext(Dispatchers.IO) { + runCatching { + val presignResponse = presign(filename, contentType, idToken) + uploadToR2(presignResponse.uploadUrl, fileBytes, contentType) + presignResponse.publicUrl + } } - } - private fun presign(filename: String, contentType: String, idToken: String): PresignResponse { + private fun presign( + filename: String, + contentType: String, + idToken: String, + ): PresignResponse { val payload = json.encodeToString(PresignRequest.serializer(), PresignRequest(filename, contentType)) - val request = Request.Builder() - .url("$backendBaseUrl/api/v1/storage/presign") - .post(payload.toRequestBody("application/json".toMediaType())) - .header("Authorization", "Bearer $idToken") - .build() + val request = + Request + .Builder() + .url("$backendBaseUrl/api/v1/storage/presign") + .post(payload.toRequestBody("application/json".toMediaType())) + .header("Authorization", "Bearer $idToken") + .build() val response = httpClient.newCall(request).execute() check(response.isSuccessful) { "presign failed: ${response.code}" } val body = checkNotNull(response.body?.string()) { "presign: empty body" } return json.decodeFromString(PresignResponse.serializer(), body) } - private fun uploadToR2(uploadUrl: String, fileBytes: ByteArray, contentType: String) { - val request = Request.Builder() - .url(uploadUrl) - .put(fileBytes.toRequestBody(contentType.toMediaType())) - .build() + private fun uploadToR2( + uploadUrl: String, + fileBytes: ByteArray, + contentType: String, + ) { + val request = + Request + .Builder() + .url(uploadUrl) + .put(fileBytes.toRequestBody(contentType.toMediaType())) + .build() val response = httpClient.newCall(request).execute() check(response.isSuccessful) { "R2 upload failed: ${response.code}" } } diff --git a/mobile/app/src/main/java/com/company/template/ui/components/UiStateContent.kt b/mobile/app/src/main/java/com/company/template/ui/components/UiStateContent.kt new file mode 100644 index 0000000..23ee3cf --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/ui/components/UiStateContent.kt @@ -0,0 +1,56 @@ +package com.company.template.ui.components + +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.material3.CircularProgressIndicator +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.platform.testTag +import androidx.compose.ui.unit.dp +import com.company.template.ui.state.UiState + +@Composable +fun UiStateContent( + state: UiState, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, + content: @Composable (T) -> Unit, +) { + when (state) { + is UiState.Idle -> Unit + is UiState.Loading -> + Box( + modifier = modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.testTag("loading_indicator")) + } + is UiState.Error -> + Box( + modifier = modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + if (onRetry != null) { + TextButton(onClick = onRetry) { + Text("Retry") + } + } + } + } + is UiState.Success -> content(state.data) + } +} diff --git a/mobile/app/src/main/java/com/company/template/ui/state/UiState.kt b/mobile/app/src/main/java/com/company/template/ui/state/UiState.kt new file mode 100644 index 0000000..a50ecbf --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/ui/state/UiState.kt @@ -0,0 +1,15 @@ +package com.company.template.ui.state + +sealed class UiState { + data object Idle : UiState() + + data object Loading : UiState() + + data class Success( + val data: T, + ) : UiState() + + data class Error( + val message: String, + ) : UiState() +} diff --git a/mobile/app/src/main/java/com/company/template/ui/theme/Color.kt b/mobile/app/src/main/java/com/company/template/ui/theme/Color.kt index 0bc27da..5374ebb 100644 --- a/mobile/app/src/main/java/com/company/template/ui/theme/Color.kt +++ b/mobile/app/src/main/java/com/company/template/ui/theme/Color.kt @@ -8,4 +8,4 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) diff --git a/mobile/app/src/main/java/com/company/template/ui/theme/Theme.kt b/mobile/app/src/main/java/com/company/template/ui/theme/Theme.kt index 10477b6..7ae51cf 100644 --- a/mobile/app/src/main/java/com/company/template/ui/theme/Theme.kt +++ b/mobile/app/src/main/java/com/company/template/ui/theme/Theme.kt @@ -1,6 +1,5 @@ package com.company.template.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -11,17 +10,18 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 +private val DarkColorScheme = + darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + ) +private val LightColorScheme = + lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, /* Other default colors to override background = Color(0xFFFFFBFE), surface = Color(0xFFFFFBFE), @@ -30,29 +30,30 @@ private val LightColorScheme = lightColorScheme( onTertiary = Color.White, onBackground = Color(0xFF1C1B1F), onSurface = Color(0xFF1C1B1F), - */ -) + */ + ) @Composable fun TemplateTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } - darkTheme -> DarkColorScheme - else -> LightColorScheme - } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } MaterialTheme( colorScheme = colorScheme, typography = Typography, - content = content + content = content, ) -} \ No newline at end of file +} diff --git a/mobile/app/src/main/java/com/company/template/ui/theme/Type.kt b/mobile/app/src/main/java/com/company/template/ui/theme/Type.kt index ab9657b..4f638e2 100644 --- a/mobile/app/src/main/java/com/company/template/ui/theme/Type.kt +++ b/mobile/app/src/main/java/com/company/template/ui/theme/Type.kt @@ -7,14 +7,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), /* Other default text styles to override titleLarge = TextStyle( fontFamily = FontFamily.Default, @@ -30,5 +32,5 @@ val Typography = Typography( lineHeight = 16.sp, letterSpacing = 0.5.sp ) - */ -) \ No newline at end of file + */ + ) diff --git a/mobile/app/src/main/java/com/company/template/websocket/WebSocketManager.kt b/mobile/app/src/main/java/com/company/template/websocket/WebSocketManager.kt index f249f90..11f987b 100644 --- a/mobile/app/src/main/java/com/company/template/websocket/WebSocketManager.kt +++ b/mobile/app/src/main/java/com/company/template/websocket/WebSocketManager.kt @@ -12,7 +12,10 @@ import okhttp3.WebSocketListener * Factory abstraction over [OkHttpClient.newWebSocket] — injectable for unit tests. */ fun interface WebSocketFactory { - fun newWebSocket(request: Request, listener: WebSocketListener): WebSocket + fun newWebSocket( + request: Request, + listener: WebSocketListener, + ): WebSocket } /** @@ -67,38 +70,55 @@ class WebSocketManager( val urlBuilder = StringBuilder(serverUrl) currentToken?.let { urlBuilder.append("?token=").append(it) } - val request = Request.Builder() - .url(urlBuilder.toString()) - .build() + val request = + Request + .Builder() + .url(urlBuilder.toString()) + .build() socket = factory.newWebSocket(request, listener) } - private val listener = object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { - retryCount = 0 - onOpen?.invoke() - } - - override fun onMessage(webSocket: WebSocket, text: String) { - parseEnvelope(text)?.let { this@WebSocketManager.onMessage?.invoke(it) } - } + private val listener = + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + retryCount = 0 + onOpen?.invoke() + } - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - onClose?.invoke() - } + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + parseEnvelope(text)?.let { this@WebSocketManager.onMessage?.invoke(it) } + } - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - onError?.invoke(t) - if (active && retryCount < maxRetries) { - val delay = minOf(1000L * (1L shl retryCount), 30_000L) - retryCount++ - reconnectScheduler(delay) { if (active) openSocket() } - } else { + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { onClose?.invoke() } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + onError?.invoke(t) + if (active && retryCount < maxRetries) { + val delay = minOf(1000L * (1L shl retryCount), 30_000L) + retryCount++ + reconnectScheduler(delay) { if (active) openSocket() } + } else { + onClose?.invoke() + } + } } - } private fun parseEnvelope(text: String): WsEnvelope? = try { diff --git a/mobile/app/src/main/java/com/company/template/websocket/WebSocketViewModel.kt b/mobile/app/src/main/java/com/company/template/websocket/WebSocketViewModel.kt index 7f55485..df40e9b 100644 --- a/mobile/app/src/main/java/com/company/template/websocket/WebSocketViewModel.kt +++ b/mobile/app/src/main/java/com/company/template/websocket/WebSocketViewModel.kt @@ -14,7 +14,6 @@ data class WsState( class WebSocketViewModel( private val manager: WebSocketManager, ) : ViewModel() { - private val _state = MutableStateFlow(WsState()) val state: StateFlow = _state.asStateFlow() diff --git a/mobile/app/src/test/java/com/company/template/GreetingFormatTest.kt b/mobile/app/src/test/java/com/company/template/GreetingFormatTest.kt index a9c0d39..95f6143 100644 --- a/mobile/app/src/test/java/com/company/template/GreetingFormatTest.kt +++ b/mobile/app/src/test/java/com/company/template/GreetingFormatTest.kt @@ -1,10 +1,9 @@ package com.company.template -import org.junit.Test import org.junit.Assert.* +import org.junit.Test class GreetingFormatTest { - @Test fun greeting_text_contains_name() { val name = "World" diff --git a/mobile/app/src/test/java/com/company/template/SentryInitTest.kt b/mobile/app/src/test/java/com/company/template/SentryInitTest.kt index a95c23f..3515f11 100644 --- a/mobile/app/src/test/java/com/company/template/SentryInitTest.kt +++ b/mobile/app/src/test/java/com/company/template/SentryInitTest.kt @@ -12,7 +12,6 @@ import org.junit.Test * [shouldInitSentry], which IS pure Kotlin and testable here. */ class SentryInitTest { - @Test fun `shouldInitSentry returns false when DSN is empty`() { assertFalse(shouldInitSentry("")) diff --git a/mobile/app/src/test/java/com/company/template/auth/AuthViewModelTest.kt b/mobile/app/src/test/java/com/company/template/auth/AuthViewModelTest.kt index 6511a84..d59a831 100644 --- a/mobile/app/src/test/java/com/company/template/auth/AuthViewModelTest.kt +++ b/mobile/app/src/test/java/com/company/template/auth/AuthViewModelTest.kt @@ -25,13 +25,20 @@ class FakeAuthRepository : AuthRepository { var googleSignInResult: Result = Result.success(Unit) var signOutCalled = false - fun setUser(user: User?) { _authStateFlow.value = user } + fun setUser(user: User?) { + _authStateFlow.value = user + } - override suspend fun signInWithEmail(email: String, password: String): Result = - signInResult + override suspend fun signInWithEmail( + email: String, + password: String, + ): Result = signInResult - override suspend fun registerWithEmail(name: String, email: String, password: String): Result = - registerResult + override suspend fun registerWithEmail( + name: String, + email: String, + password: String, + ): Result = registerResult override suspend fun signInWithGoogle(googleIdToken: String): Result = googleSignInResult @@ -45,7 +52,6 @@ class FakeAuthRepository : AuthRepository { @OptIn(ExperimentalCoroutinesApi::class) class AuthViewModelTest { - private lateinit var fakeRepo: FakeAuthRepository private lateinit var viewModel: AuthViewModel @@ -67,97 +73,105 @@ class AuthViewModelTest { } @Test - fun `signIn emits Success on repository success`() = runTest { - fakeRepo.signInResult = Result.success(Unit) - viewModel.updateLoginEmail("test@example.com") - viewModel.updateLoginPassword("password") - viewModel.signIn() - assertEquals(AuthUiState.Success, viewModel.uiState.value) - } + fun `signIn emits Success on repository success`() = + runTest { + fakeRepo.signInResult = Result.success(Unit) + viewModel.updateLoginEmail("test@example.com") + viewModel.updateLoginPassword("password") + viewModel.signIn() + assertEquals(AuthUiState.Success, viewModel.uiState.value) + } @Test - fun `signIn emits Error on repository failure`() = runTest { - fakeRepo.signInResult = Result.failure(Exception("Invalid credentials")) - viewModel.updateLoginEmail("test@example.com") - viewModel.updateLoginPassword("wrong") - viewModel.signIn() - val state = viewModel.uiState.value - assertTrue(state is AuthUiState.Error) - assertEquals("Invalid credentials", (state as AuthUiState.Error).message) - } + fun `signIn emits Error on repository failure`() = + runTest { + fakeRepo.signInResult = Result.failure(Exception("Invalid credentials")) + viewModel.updateLoginEmail("test@example.com") + viewModel.updateLoginPassword("wrong") + viewModel.signIn() + val state = viewModel.uiState.value + assertTrue(state is AuthUiState.Error) + assertEquals("Invalid credentials", (state as AuthUiState.Error).message) + } @Test - fun `register emits Success on repository success`() = runTest { - fakeRepo.registerResult = Result.success(Unit) - viewModel.updateRegisterName("Alice") - viewModel.updateRegisterEmail("alice@example.com") - viewModel.updateRegisterPassword("password1") - viewModel.updateRegisterConfirmPassword("password1") - viewModel.register() - assertEquals(AuthUiState.Success, viewModel.uiState.value) - } + fun `register emits Success on repository success`() = + runTest { + fakeRepo.registerResult = Result.success(Unit) + viewModel.updateRegisterName("Alice") + viewModel.updateRegisterEmail("alice@example.com") + viewModel.updateRegisterPassword("password1") + viewModel.updateRegisterConfirmPassword("password1") + viewModel.register() + assertEquals(AuthUiState.Success, viewModel.uiState.value) + } @Test - fun `register emits Error when passwords do not match`() = runTest { - viewModel.updateRegisterName("Alice") - viewModel.updateRegisterEmail("alice@example.com") - viewModel.updateRegisterPassword("password1") - viewModel.updateRegisterConfirmPassword("password2") - viewModel.register() - val state = viewModel.uiState.value - assertTrue(state is AuthUiState.Error) - assertEquals("Passwords do not match", (state as AuthUiState.Error).message) - } + fun `register emits Error when passwords do not match`() = + runTest { + viewModel.updateRegisterName("Alice") + viewModel.updateRegisterEmail("alice@example.com") + viewModel.updateRegisterPassword("password1") + viewModel.updateRegisterConfirmPassword("password2") + viewModel.register() + val state = viewModel.uiState.value + assertTrue(state is AuthUiState.Error) + assertEquals("Passwords do not match", (state as AuthUiState.Error).message) + } @Test - fun `register emits Error on repository failure`() = runTest { - fakeRepo.registerResult = Result.failure(Exception("Email already in use")) - viewModel.updateRegisterName("Alice") - viewModel.updateRegisterEmail("alice@example.com") - viewModel.updateRegisterPassword("password1") - viewModel.updateRegisterConfirmPassword("password1") - viewModel.register() - val state = viewModel.uiState.value - assertTrue(state is AuthUiState.Error) - assertEquals("Email already in use", (state as AuthUiState.Error).message) - } + fun `register emits Error on repository failure`() = + runTest { + fakeRepo.registerResult = Result.failure(Exception("Email already in use")) + viewModel.updateRegisterName("Alice") + viewModel.updateRegisterEmail("alice@example.com") + viewModel.updateRegisterPassword("password1") + viewModel.updateRegisterConfirmPassword("password1") + viewModel.register() + val state = viewModel.uiState.value + assertTrue(state is AuthUiState.Error) + assertEquals("Email already in use", (state as AuthUiState.Error).message) + } @Test - fun `signOut calls repository signOut and resets uiState to Idle`() = runTest { - fakeRepo.signInResult = Result.success(Unit) - viewModel.updateLoginEmail("test@example.com") - viewModel.updateLoginPassword("pw") - viewModel.signIn() - assertEquals(AuthUiState.Success, viewModel.uiState.value) - - viewModel.signOut() - assertTrue(fakeRepo.signOutCalled) - assertEquals(AuthUiState.Idle, viewModel.uiState.value) - } + fun `signOut calls repository signOut and resets uiState to Idle`() = + runTest { + fakeRepo.signInResult = Result.success(Unit) + viewModel.updateLoginEmail("test@example.com") + viewModel.updateLoginPassword("pw") + viewModel.signIn() + assertEquals(AuthUiState.Success, viewModel.uiState.value) + + viewModel.signOut() + assertTrue(fakeRepo.signOutCalled) + assertEquals(AuthUiState.Idle, viewModel.uiState.value) + } @Test - fun `clearError resets Error state to Idle`() = runTest { - fakeRepo.signInResult = Result.failure(Exception("Bad")) - viewModel.updateLoginEmail("x@y.com") - viewModel.updateLoginPassword("pw") - viewModel.signIn() - assertTrue(viewModel.uiState.value is AuthUiState.Error) - - viewModel.clearError() - assertEquals(AuthUiState.Idle, viewModel.uiState.value) - } + fun `clearError resets Error state to Idle`() = + runTest { + fakeRepo.signInResult = Result.failure(Exception("Bad")) + viewModel.updateLoginEmail("x@y.com") + viewModel.updateLoginPassword("pw") + viewModel.signIn() + assertTrue(viewModel.uiState.value is AuthUiState.Error) + + viewModel.clearError() + assertEquals(AuthUiState.Idle, viewModel.uiState.value) + } @Test - fun `updating login field clears existing error`() = runTest { - fakeRepo.signInResult = Result.failure(Exception("Bad")) - viewModel.updateLoginEmail("x@y.com") - viewModel.updateLoginPassword("pw") - viewModel.signIn() - assertTrue(viewModel.uiState.value is AuthUiState.Error) - - viewModel.updateLoginEmail("new@example.com") - assertEquals(AuthUiState.Idle, viewModel.uiState.value) - } + fun `updating login field clears existing error`() = + runTest { + fakeRepo.signInResult = Result.failure(Exception("Bad")) + viewModel.updateLoginEmail("x@y.com") + viewModel.updateLoginPassword("pw") + viewModel.signIn() + assertTrue(viewModel.uiState.value is AuthUiState.Error) + + viewModel.updateLoginEmail("new@example.com") + assertEquals(AuthUiState.Idle, viewModel.uiState.value) + } @Test fun `loginForm reflects field updates`() { diff --git a/mobile/app/src/test/java/com/company/template/data/network/UserApiTest.kt b/mobile/app/src/test/java/com/company/template/data/network/UserApiTest.kt index 44b8bd8..c9c97e5 100644 --- a/mobile/app/src/test/java/com/company/template/data/network/UserApiTest.kt +++ b/mobile/app/src/test/java/com/company/template/data/network/UserApiTest.kt @@ -11,7 +11,6 @@ import org.junit.Before import org.junit.Test class UserApiTest { - private lateinit var server: MockWebServer // A plain OkHttpClient with no auth interceptor — tests don't need Firebase @@ -29,38 +28,42 @@ class UserApiTest { } @Test - fun `getMe returns UserProfile on successful response`() = runTest { - server.enqueue( - MockResponse() - .setResponseCode(200) - .setBody("""{"data":{"uid":"u1","email":"a@b.com"}}""") - ) + fun `getMe returns UserProfile on successful response`() = + runTest { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{"data":{"uid":"u1","email":"a@b.com"}}"""), + ) - val result = UserApi.getMe( - baseUrl = server.url("/").toString().trimEnd('/'), - client = testClient, - ) + val result = + UserApi.getMe( + baseUrl = server.url("/").toString().trimEnd('/'), + client = testClient, + ) - assertTrue(result.isSuccess) - val profile = result.getOrThrow() - assertEquals("u1", profile.uid) - assertEquals("a@b.com", profile.email) - } + assertTrue(result.isSuccess) + val profile = result.getOrThrow() + assertEquals("u1", profile.uid) + assertEquals("a@b.com", profile.email) + } @Test - fun `getMe returns failure with backend message on error response`() = runTest { - server.enqueue( - MockResponse() - .setResponseCode(401) - .setBody("""{"error":{"code":"UNAUTHENTICATED","message":"no token"}}""") - ) + fun `getMe returns failure with backend message on error response`() = + runTest { + server.enqueue( + MockResponse() + .setResponseCode(401) + .setBody("""{"error":{"code":"UNAUTHENTICATED","message":"no token"}}"""), + ) - val result = UserApi.getMe( - baseUrl = server.url("/").toString().trimEnd('/'), - client = testClient, - ) + val result = + UserApi.getMe( + baseUrl = server.url("/").toString().trimEnd('/'), + client = testClient, + ) - assertTrue(result.isFailure) - assertEquals("no token", result.exceptionOrNull()?.message) - } + assertTrue(result.isFailure) + assertEquals("no token", result.exceptionOrNull()?.message) + } } diff --git a/mobile/app/src/test/java/com/company/template/fcm/FcmRegistrationPayloadTest.kt b/mobile/app/src/test/java/com/company/template/fcm/FcmRegistrationPayloadTest.kt index de0da15..1143ee7 100644 --- a/mobile/app/src/test/java/com/company/template/fcm/FcmRegistrationPayloadTest.kt +++ b/mobile/app/src/test/java/com/company/template/fcm/FcmRegistrationPayloadTest.kt @@ -6,7 +6,6 @@ import org.junit.Assert.assertTrue import org.junit.Test class FcmRegistrationPayloadTest { - private val json = Json { encodeDefaults = true } @Test diff --git a/mobile/app/src/test/java/com/company/template/home/HomeViewModelTest.kt b/mobile/app/src/test/java/com/company/template/home/HomeViewModelTest.kt new file mode 100644 index 0000000..998202f --- /dev/null +++ b/mobile/app/src/test/java/com/company/template/home/HomeViewModelTest.kt @@ -0,0 +1,111 @@ +package com.company.template.home + +import com.company.template.data.network.UserProfile +import com.company.template.ui.state.UiState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModelTest { + private lateinit var server: MockWebServer + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + server = MockWebServer() + server.start() + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + server.shutdown() + Dispatchers.resetMain() + } + + private fun buildViewModel(): HomeViewModel = + HomeViewModel( + baseUrl = server.url("/").toString().trimEnd('/'), + httpClient = OkHttpClient(), + ioDispatcher = testDispatcher, + ) + + @Test + fun `init - success response transitions state to Success with correct data`() = + runTest { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"data":{"uid":"u1","email":"a@b.com","displayName":"Test"}}"""), + ) + + val viewModel = buildViewModel() + + val state = viewModel.profileState.value + assertTrue("Expected Success but was $state", state is UiState.Success<*>) + val profile = (state as UiState.Success<*>).data as UserProfile + assertEquals("u1", profile.uid) + assertEquals("a@b.com", profile.email) + assertEquals("Test", profile.displayName) + } + + @Test + fun `init - 401 response transitions state to Error`() = + runTest { + server.enqueue( + MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .setBody("""{"error":{"message":"unauthorized"}}"""), + ) + + val viewModel = buildViewModel() + + val state = viewModel.profileState.value + assertTrue("Expected Error but was $state", state is UiState.Error) + assertTrue((state as UiState.Error).message.isNotEmpty()) + } + + @Test + fun `refresh - re-fetches and updates state`() = + runTest { + // First call (from init) — error + server.enqueue( + MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .setBody("""{"error":{"message":"unauthorized"}}"""), + ) + // Second call (from refresh) — success + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"data":{"uid":"u2","email":"b@c.com","displayName":"Bob"}}"""), + ) + + val viewModel = buildViewModel() + assertTrue(viewModel.profileState.value is UiState.Error) + + viewModel.refresh() + + val state = viewModel.profileState.value + assertTrue("Expected Success but was $state", state is UiState.Success<*>) + val profile = (state as UiState.Success<*>).data as UserProfile + assertEquals("u2", profile.uid) + assertEquals("Bob", profile.displayName) + } +} diff --git a/mobile/app/src/test/java/com/company/template/navigation/AppViewModelTest.kt b/mobile/app/src/test/java/com/company/template/navigation/AppViewModelTest.kt index e245974..0fbe730 100644 --- a/mobile/app/src/test/java/com/company/template/navigation/AppViewModelTest.kt +++ b/mobile/app/src/test/java/com/company/template/navigation/AppViewModelTest.kt @@ -28,11 +28,16 @@ class FakeAuthRepositoryForNav : AuthRepository { _authStateFlow.value = user } - override suspend fun signInWithEmail(email: String, password: String): Result = - Result.success(Unit) + override suspend fun signInWithEmail( + email: String, + password: String, + ): Result = Result.success(Unit) - override suspend fun registerWithEmail(name: String, email: String, password: String): Result = - Result.success(Unit) + override suspend fun registerWithEmail( + name: String, + email: String, + password: String, + ): Result = Result.success(Unit) override suspend fun signInWithGoogle(googleIdToken: String): Result = Result.success(Unit) @@ -59,7 +64,6 @@ class FakeOnboardingRepositoryForNav : OnboardingRepository { @OptIn(ExperimentalCoroutinesApi::class) class AppViewModelTest { - private lateinit var fakeAuth: FakeAuthRepositoryForNav private lateinit var fakeOnboarding: FakeOnboardingRepositoryForNav private lateinit var viewModel: AppViewModel @@ -81,72 +85,78 @@ class AppViewModelTest { } @Test - fun `startDestination is Onboarding when not seen and not signed in`() = runTest { - fakeOnboarding.setSeen(false) - fakeAuth.setUser(null) - createViewModel() + fun `startDestination is Onboarding when not seen and not signed in`() = + runTest { + fakeOnboarding.setSeen(false) + fakeAuth.setUser(null) + createViewModel() - val dest = viewModel.startDestination.first { it != null } - assertEquals(StartDestination.ONBOARDING, dest) - } + val dest = viewModel.startDestination.first { it != null } + assertEquals(StartDestination.ONBOARDING, dest) + } @Test - fun `startDestination is Login when onboarding seen and not signed in`() = runTest { - fakeOnboarding.setSeen(true) - fakeAuth.setUser(null) - createViewModel() + fun `startDestination is Login when onboarding seen and not signed in`() = + runTest { + fakeOnboarding.setSeen(true) + fakeAuth.setUser(null) + createViewModel() - val dest = viewModel.startDestination.first { it != null } - assertEquals(StartDestination.LOGIN, dest) - } + val dest = viewModel.startDestination.first { it != null } + assertEquals(StartDestination.LOGIN, dest) + } @Test - fun `startDestination is Home when user is signed in`() = runTest { - fakeOnboarding.setSeen(true) - fakeAuth.setUser(User(uid = "uid123", email = "a@b.com", displayName = "Alice", photoUrl = null)) - createViewModel() + fun `startDestination is Home when user is signed in`() = + runTest { + fakeOnboarding.setSeen(true) + fakeAuth.setUser(User(uid = "uid123", email = "a@b.com", displayName = "Alice", photoUrl = null)) + createViewModel() - val dest = viewModel.startDestination.first { it != null } - assertEquals(StartDestination.HOME, dest) - } + val dest = viewModel.startDestination.first { it != null } + assertEquals(StartDestination.HOME, dest) + } @Test - fun `startDestination transitions to Login after onboarding is marked seen`() = runTest { - fakeOnboarding.setSeen(false) - fakeAuth.setUser(null) - createViewModel() + fun `startDestination transitions to Login after onboarding is marked seen`() = + runTest { + fakeOnboarding.setSeen(false) + fakeAuth.setUser(null) + createViewModel() - val firstDest = viewModel.startDestination.first { it != null } - assertEquals(StartDestination.ONBOARDING, firstDest) + val firstDest = viewModel.startDestination.first { it != null } + assertEquals(StartDestination.ONBOARDING, firstDest) - fakeOnboarding.setSeen(true) - val secondDest = viewModel.startDestination.first { it == StartDestination.LOGIN } - assertEquals(StartDestination.LOGIN, secondDest) - } + fakeOnboarding.setSeen(true) + val secondDest = viewModel.startDestination.first { it == StartDestination.LOGIN } + assertEquals(StartDestination.LOGIN, secondDest) + } @Test - fun `startDestination transitions to Login after sign out`() = runTest { - fakeOnboarding.setSeen(true) - fakeAuth.setUser(User(uid = "uid123", email = "a@b.com", displayName = "Alice", photoUrl = null)) - createViewModel() + fun `startDestination transitions to Login after sign out`() = + runTest { + fakeOnboarding.setSeen(true) + fakeAuth.setUser(User(uid = "uid123", email = "a@b.com", displayName = "Alice", photoUrl = null)) + createViewModel() - val homeDest = viewModel.startDestination.first { it == StartDestination.HOME } - assertEquals(StartDestination.HOME, homeDest) + val homeDest = viewModel.startDestination.first { it == StartDestination.HOME } + assertEquals(StartDestination.HOME, homeDest) - fakeAuth.signOut() - val loginDest = viewModel.startDestination.first { it == StartDestination.LOGIN } - assertEquals(StartDestination.LOGIN, loginDest) - } + fakeAuth.signOut() + val loginDest = viewModel.startDestination.first { it == StartDestination.LOGIN } + assertEquals(StartDestination.LOGIN, loginDest) + } @Test - fun `markOnboardingSeen persists the flag`() = runTest { - fakeOnboarding.setSeen(false) - fakeAuth.setUser(null) - createViewModel() + fun `markOnboardingSeen persists the flag`() = + runTest { + fakeOnboarding.setSeen(false) + fakeAuth.setUser(null) + createViewModel() - viewModel.markOnboardingSeen() + viewModel.markOnboardingSeen() - val seen = fakeOnboarding.hasSeenOnboarding().first() - assertEquals(true, seen) - } + val seen = fakeOnboarding.hasSeenOnboarding().first() + assertEquals(true, seen) + } } diff --git a/mobile/app/src/test/java/com/company/template/onboarding/OnboardingViewModelTest.kt b/mobile/app/src/test/java/com/company/template/onboarding/OnboardingViewModelTest.kt index 0d0786e..edd5b41 100644 --- a/mobile/app/src/test/java/com/company/template/onboarding/OnboardingViewModelTest.kt +++ b/mobile/app/src/test/java/com/company/template/onboarding/OnboardingViewModelTest.kt @@ -33,7 +33,6 @@ class FakeOnboardingRepository : OnboardingRepository { @OptIn(ExperimentalCoroutinesApi::class) class OnboardingViewModelTest { - private lateinit var fakeRepo: FakeOnboardingRepository private lateinit var viewModel: OnboardingViewModel @@ -50,19 +49,21 @@ class OnboardingViewModelTest { } @Test - fun `hasSeenOnboarding emits false initially`() = runTest { - val value = viewModel.hasSeenOnboarding().first() - assertEquals(false, value) - } + fun `hasSeenOnboarding emits false initially`() = + runTest { + val value = viewModel.hasSeenOnboarding().first() + assertEquals(false, value) + } @Test - fun `markSeen calls repository and flow emits true`() = runTest { - var callbackInvoked = false - viewModel.markSeen(onComplete = { callbackInvoked = true }) + fun `markSeen calls repository and flow emits true`() = + runTest { + var callbackInvoked = false + viewModel.markSeen(onComplete = { callbackInvoked = true }) - assertTrue(fakeRepo.markSeenCalled) - assertTrue(callbackInvoked) - val value = viewModel.hasSeenOnboarding().first() - assertEquals(true, value) - } + assertTrue(fakeRepo.markSeenCalled) + assertTrue(callbackInvoked) + val value = viewModel.hasSeenOnboarding().first() + assertEquals(true, value) + } } diff --git a/mobile/app/src/test/java/com/company/template/storage/UploadRepositoryTest.kt b/mobile/app/src/test/java/com/company/template/storage/UploadRepositoryTest.kt index 8137904..3621ea2 100644 --- a/mobile/app/src/test/java/com/company/template/storage/UploadRepositoryTest.kt +++ b/mobile/app/src/test/java/com/company/template/storage/UploadRepositoryTest.kt @@ -11,7 +11,6 @@ import org.junit.Before import org.junit.Test class UploadRepositoryTest { - private lateinit var backendServer: MockWebServer private lateinit var r2Server: MockWebServer private lateinit var repository: R2UploadRepository @@ -22,10 +21,11 @@ class UploadRepositoryTest { r2Server = MockWebServer() backendServer.start() r2Server.start() - repository = R2UploadRepository( - backendBaseUrl = backendServer.url("").toString().trimEnd('/'), - httpClient = OkHttpClient(), - ) + repository = + R2UploadRepository( + backendBaseUrl = backendServer.url("").toString().trimEnd('/'), + httpClient = OkHttpClient(), + ) } @After @@ -35,68 +35,74 @@ class UploadRepositoryTest { } @Test - fun `upload returns public URL on success`() = runTest { - val r2Url = r2Server.url("/test-key").toString() - backendServer.enqueue( - MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("""{"upload_url":"$r2Url","public_url":"https://pub.r2.dev/test-key"}"""), - ) - r2Server.enqueue(MockResponse().setResponseCode(200)) - - val result = repository.upload( - filename = "test.jpg", - contentType = "image/jpeg", - fileBytes = byteArrayOf(1, 2, 3), - idToken = "fake-token", - ) - - assertTrue(result.isSuccess) - assertEquals("https://pub.r2.dev/test-key", result.getOrNull()) - - val presignReq = backendServer.takeRequest() - assertEquals("POST", presignReq.method) - assertEquals("/api/v1/storage/presign", presignReq.path) - assertEquals("Bearer fake-token", presignReq.getHeader("Authorization")) - - val r2Req = r2Server.takeRequest() - assertEquals("PUT", r2Req.method) - assertEquals("image/jpeg", r2Req.getHeader("Content-Type")) - } + fun `upload returns public URL on success`() = + runTest { + val r2Url = r2Server.url("/test-key").toString() + backendServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"upload_url":"$r2Url","public_url":"https://pub.r2.dev/test-key"}"""), + ) + r2Server.enqueue(MockResponse().setResponseCode(200)) + + val result = + repository.upload( + filename = "test.jpg", + contentType = "image/jpeg", + fileBytes = byteArrayOf(1, 2, 3), + idToken = "fake-token", + ) + + assertTrue(result.isSuccess) + assertEquals("https://pub.r2.dev/test-key", result.getOrNull()) + + val presignReq = backendServer.takeRequest() + assertEquals("POST", presignReq.method) + assertEquals("/api/v1/storage/presign", presignReq.path) + assertEquals("Bearer fake-token", presignReq.getHeader("Authorization")) + + val r2Req = r2Server.takeRequest() + assertEquals("PUT", r2Req.method) + assertEquals("image/jpeg", r2Req.getHeader("Content-Type")) + } @Test - fun `upload returns failure when presign fails`() = runTest { - backendServer.enqueue(MockResponse().setResponseCode(401)) + fun `upload returns failure when presign fails`() = + runTest { + backendServer.enqueue(MockResponse().setResponseCode(401)) - val result = repository.upload( - filename = "test.jpg", - contentType = "image/jpeg", - fileBytes = byteArrayOf(), - idToken = "bad-token", - ) + val result = + repository.upload( + filename = "test.jpg", + contentType = "image/jpeg", + fileBytes = byteArrayOf(), + idToken = "bad-token", + ) - assertTrue(result.isFailure) - } + assertTrue(result.isFailure) + } @Test - fun `upload returns failure when R2 PUT fails`() = runTest { - val r2Url = r2Server.url("/test-key").toString() - backendServer.enqueue( - MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("""{"upload_url":"$r2Url","public_url":"https://pub.r2.dev/test-key"}"""), - ) - r2Server.enqueue(MockResponse().setResponseCode(403)) - - val result = repository.upload( - filename = "test.jpg", - contentType = "image/jpeg", - fileBytes = byteArrayOf(1, 2, 3), - idToken = "token", - ) - - assertTrue(result.isFailure) - } + fun `upload returns failure when R2 PUT fails`() = + runTest { + val r2Url = r2Server.url("/test-key").toString() + backendServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"upload_url":"$r2Url","public_url":"https://pub.r2.dev/test-key"}"""), + ) + r2Server.enqueue(MockResponse().setResponseCode(403)) + + val result = + repository.upload( + filename = "test.jpg", + contentType = "image/jpeg", + fileBytes = byteArrayOf(1, 2, 3), + idToken = "token", + ) + + assertTrue(result.isFailure) + } } diff --git a/mobile/app/src/test/java/com/company/template/websocket/FakeOkHttp.kt b/mobile/app/src/test/java/com/company/template/websocket/FakeOkHttp.kt index 0f22268..936071d 100644 --- a/mobile/app/src/test/java/com/company/template/websocket/FakeOkHttp.kt +++ b/mobile/app/src/test/java/com/company/template/websocket/FakeOkHttp.kt @@ -15,22 +15,41 @@ class FakeWebSocketFactory : WebSocketFactory { val lastSocket: FakeWebSocket get() = sockets.last() - override fun newWebSocket(request: Request, listener: WebSocketListener): WebSocket { + override fun newWebSocket( + request: Request, + listener: WebSocketListener, + ): WebSocket { lastListener = listener return FakeWebSocket(request).also { sockets.add(it) } } } -class FakeWebSocket(private val req: Request = Request.Builder().url("ws://localhost/ws").build()) : - WebSocket { - +class FakeWebSocket( + private val req: Request = Request.Builder().url("ws://localhost/ws").build(), +) : WebSocket { val sentMessages = mutableListOf() var closed = false override fun request(): Request = req + override fun queueSize(): Long = 0L - override fun send(text: String): Boolean { sentMessages.add(text); return true } + + override fun send(text: String): Boolean { + sentMessages.add(text) + return true + } + override fun send(bytes: ByteString): Boolean = false - override fun close(code: Int, reason: String?): Boolean { closed = true; return true } - override fun cancel() { closed = true } + + override fun close( + code: Int, + reason: String?, + ): Boolean { + closed = true + return true + } + + override fun cancel() { + closed = true + } } diff --git a/mobile/app/src/test/java/com/company/template/websocket/WebSocketManagerTest.kt b/mobile/app/src/test/java/com/company/template/websocket/WebSocketManagerTest.kt index 463cc18..647ff8b 100644 --- a/mobile/app/src/test/java/com/company/template/websocket/WebSocketManagerTest.kt +++ b/mobile/app/src/test/java/com/company/template/websocket/WebSocketManagerTest.kt @@ -11,7 +11,6 @@ import org.junit.Before import org.junit.Test class WebSocketManagerTest { - private lateinit var factory: FakeWebSocketFactory private val scheduledActions = mutableListOf<() -> Unit>() @@ -24,7 +23,8 @@ class WebSocketManagerTest { ) private fun fakeResponse(): Response = - Response.Builder() + Response + .Builder() .request(Request.Builder().url("ws://localhost:8080/ws").build()) .protocol(Protocol.HTTP_1_1) .code(101) diff --git a/mobile/app/src/test/java/com/company/template/websocket/WebSocketViewModelTest.kt b/mobile/app/src/test/java/com/company/template/websocket/WebSocketViewModelTest.kt index ed683c8..1688b85 100644 --- a/mobile/app/src/test/java/com/company/template/websocket/WebSocketViewModelTest.kt +++ b/mobile/app/src/test/java/com/company/template/websocket/WebSocketViewModelTest.kt @@ -15,7 +15,6 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class WebSocketViewModelTest { - private lateinit var factory: FakeWebSocketFactory private lateinit var manager: WebSocketManager private lateinit var viewModel: WebSocketViewModel @@ -24,11 +23,12 @@ class WebSocketViewModelTest { fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) factory = FakeWebSocketFactory() - manager = WebSocketManager( - serverUrl = "ws://localhost:8080/ws", - factory = factory, - reconnectScheduler = { _, _ -> }, - ) + manager = + WebSocketManager( + serverUrl = "ws://localhost:8080/ws", + factory = factory, + reconnectScheduler = { _, _ -> }, + ) viewModel = WebSocketViewModel(manager) } diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index 851628b..90218f7 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -3,4 +3,12 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.google.services) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.spotless) +} + +spotless { + kotlin { + target("**/*.kt") + ktlint() + } } \ No newline at end of file diff --git a/mobile/docs/_index.md b/mobile/docs/_index.md index 2b87492..cdf514d 100644 --- a/mobile/docs/_index.md +++ b/mobile/docs/_index.md @@ -16,3 +16,4 @@ Topic-based documentation for the Android app. Each file is kept in sync with th | Firebase Cloud Messaging — service, token registration, background notifications | `fcm.md` | `app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt`, `app/src/main/java/com/company/template/fcm/FcmRegistrationPayload.kt`, `app/src/main/AndroidManifest.xml`, `gradle/libs.versions.toml` | | Object storage (Cloudflare R2) — UploadRepository interface, R2UploadRepository, presign + PUT flow | `storage.md` | `app/src/main/java/com/company/template/storage/UploadRepository.kt` | | HTTP client and API layer — ApiClient, envelope types, UserApi, MockWebServer testing, BACKEND_URL | `http-client.md` | `app/src/main/java/com/company/template/data/network/ApiClient.kt`, `ApiResponse.kt`, `UserApi.kt`, `app/src/test/java/com/company/template/data/network/UserApiTest.kt` | +| Generic UI state — UiState\ sealed class, UiStateContent composable, ViewModel wiring, testing | `ui-states.md` | `app/src/main/java/com/company/template/ui/state/UiState.kt`, `ui/components/UiStateContent.kt`, `home/HomeViewModel.kt`, `home/HomeScreen.kt` | diff --git a/mobile/docs/architecture.md b/mobile/docs/architecture.md index 4be10c0..f2c79ef 100644 --- a/mobile/docs/architecture.md +++ b/mobile/docs/architecture.md @@ -8,6 +8,14 @@ sources: - app/src/main/java/com/company/template/data/network/ApiClient.kt - app/src/main/java/com/company/template/data/network/ApiResponse.kt - app/src/main/java/com/company/template/data/network/UserApi.kt + - app/src/main/java/com/company/template/auth/AuthViewModel.kt + - app/src/main/java/com/company/template/auth/LoginScreen.kt + - app/src/main/java/com/company/template/auth/RegisterScreen.kt + - app/src/main/java/com/company/template/home/HomeScreen.kt + - app/src/main/java/com/company/template/home/HomeViewModel.kt + - app/src/main/java/com/company/template/navigation/AppNavGraph.kt + - app/src/main/java/com/company/template/ui/state/UiState.kt + - app/src/main/java/com/company/template/ui/components/UiStateContent.kt --- # Activity and Compose architecture @@ -81,6 +89,45 @@ Key calls: Both repositories are instantiated lazily with `applicationContext` to avoid Activity leaks. +## Feature-scoped ViewModel state: AuthUiState + +`AuthViewModel` exposes its operation state as a `StateFlow` where `AuthUiState` is a sealed class defined in `auth/AuthViewModel.kt`: + +```kotlin +sealed class AuthUiState { + data object Idle : AuthUiState() + data object Loading : AuthUiState() + data object Success : AuthUiState() + data class Error(val message: String) : AuthUiState() +} +``` + +- `Idle` — no operation in progress; initial state and the state restored after sign-out or error dismissal. +- `Loading` — an async auth operation (sign-in, register, Google sign-in) is in flight. +- `Success` — the operation completed successfully; `AppNavGraph` observes this to navigate to the home screen. +- `Error(message)` — the operation failed; `message` is displayed inline in the auth screen. + +`AuthViewModel` also exposes two separate form state flows — `loginForm: StateFlow` and `registerForm: StateFlow` — so each screen receives only its own form fields. Any field update clears an active `Error` state via `clearError()`. + +This is a feature-scoped sealed class suited to auth operations where `Success` carries no payload. For ViewModels that load data and expose it to the UI, use the generic `UiState` sealed class instead — see `mobile/docs/ui-states.md`. + +## Generic UI state: UiState\ + +`ui/state/UiState.kt` defines a generic sealed class for ViewModels that load typed data: + +```kotlin +sealed class UiState { + data object Idle : UiState() + data object Loading : UiState() + data class Success(val data: T) : UiState() + data class Error(val message: String) : UiState() +} +``` + +`HomeViewModel` uses this pattern: it exposes `profileState: StateFlow>` and provides a `refresh()` method that re-runs the fetch. `HomeScreen` collects the flow with `collectAsStateWithLifecycle()` and delegates rendering to `UiStateContent`. + +See `mobile/docs/ui-states.md` for the full pattern, wiring instructions, and testing approach. + ## Single-Activity pattern There is no `Fragment` stack. All navigation between screens happens inside the Compose composition via Navigation Compose. Do not create additional Activities or Fragments. diff --git a/mobile/docs/compose-conventions.md b/mobile/docs/compose-conventions.md index 0675ff4..68caeb7 100644 --- a/mobile/docs/compose-conventions.md +++ b/mobile/docs/compose-conventions.md @@ -1,11 +1,18 @@ --- topic: Jetpack Compose UI conventions -last_verified: 2026-06-14 +last_verified: 2026-06-25 sources: - app/src/main/java/com/company/template/MainActivity.kt - app/src/main/java/com/company/template/ui/theme/Theme.kt - app/src/main/java/com/company/template/ui/theme/Color.kt - app/src/main/java/com/company/template/ui/theme/Type.kt + - app/src/main/java/com/company/template/auth/LoginScreen.kt + - app/src/main/java/com/company/template/auth/RegisterScreen.kt + - app/src/main/java/com/company/template/home/HomeScreen.kt + - app/src/main/java/com/company/template/home/HomeViewModel.kt + - app/src/main/java/com/company/template/navigation/AppNavGraph.kt + - app/src/main/java/com/company/template/onboarding/OnboardingScreen.kt + - app/src/main/java/com/company/template/ui/components/UiStateContent.kt --- # Jetpack Compose UI conventions @@ -19,7 +26,11 @@ All UI is wrapped in `TemplateTheme` exactly once — at the `setContent` call i setContent { TemplateTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - MyScreen(modifier = Modifier.padding(innerPadding)) + AppNavGraph( + appViewModel = appViewModel, + authViewModel = authViewModel, + modifier = Modifier.padding(innerPadding) + ) } } } @@ -80,7 +91,7 @@ fun Greeting(name: String, modifier: Modifier = Modifier) { ``` ### Screen vs component -- **Screens** — top-level Composables called from MainActivity or a nav host. File name: `Screen.kt`. +- **Screens** — top-level Composables called from `AppNavGraph`. File name: `Screen.kt`. - **Components** — reusable pieces. Place in `ui/components/`. Accept a `modifier` parameter; use Material3 primitives. ### Previews @@ -98,6 +109,62 @@ fun GreetingPreview() { Always wrap previews in `TemplateTheme` so colors and typography resolve correctly. +## Loading/error rendering pattern + +### Auth screens (AuthUiState) + +`LoginScreen` and `RegisterScreen` are driven by `AuthUiState`, a feature-scoped sealed class in `auth/AuthViewModel.kt`. + +**Loading state** — the submit button is replaced by a `CircularProgressIndicator`: + +```kotlin +if (isLoading) { + CircularProgressIndicator() +} else { + Button(onClick = onSignIn, ...) { Text("Sign In") } +} +``` + +`LoginScreen` derives `isLoading` as `val isLoading = uiState is AuthUiState.Loading` and also disables the Google sign-in button with `enabled = !isLoading`. + +**Error state** — an inline `Text` with `colorScheme.error` is inserted above the submit button area: + +```kotlin +if (uiState is AuthUiState.Error) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = uiState.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) +} +``` + +Both screens receive `uiState: AuthUiState` as a parameter and contain no logic — all state transitions happen in `AuthViewModel`. + +### Data-loading screens (UiState\ + UiStateContent) + +For screens that load typed data from a ViewModel, use the generic `UiState` sealed class and the `UiStateContent` composable from `ui/components/`. This is the canonical approach for new screens. + +`HomeScreen` demonstrates the pattern: + +```kotlin +val profileState by viewModel.profileState.collectAsStateWithLifecycle() + +UiStateContent( + state = profileState, + onRetry = viewModel::refresh, +) { profile -> + ProfileContent(profile = profile) +} +``` + +- `UiStateContent` handles `Idle` (renders nothing), `Loading` (centered `CircularProgressIndicator`), `Error` (error text + optional Retry button), and `Success` (calls the trailing `content` lambda with the typed data). +- Pass `onRetry = viewModel::refresh` to show a Retry button in the error state; omit it or pass `null` to suppress the button. +- The ViewModel exposes `profileState: StateFlow>` and a `refresh()` method; the screen contains no data-fetch logic. + +See `mobile/docs/ui-states.md` for the full `UiState` reference, ViewModel wiring, and testing patterns. + ## Scaffold Use `Scaffold` as the root layout for screens that need a top bar, bottom bar, or FAB: diff --git a/mobile/docs/ui-states.md b/mobile/docs/ui-states.md new file mode 100644 index 0000000..e9e77c8 --- /dev/null +++ b/mobile/docs/ui-states.md @@ -0,0 +1,210 @@ +--- +topic: Generic UI state (UiState and UiStateContent) +last_verified: 2026-06-25 +sources: + - app/src/main/java/com/company/template/ui/state/UiState.kt + - app/src/main/java/com/company/template/ui/components/UiStateContent.kt + - app/src/main/java/com/company/template/home/HomeViewModel.kt + - app/src/main/java/com/company/template/home/HomeScreen.kt + - app/src/test/java/com/company/template/home/HomeViewModelTest.kt + - app/src/androidTest/java/com/company/template/ui/components/UiStateContentTest.kt +--- + +# Generic UI state: UiState\ and UiStateContent + +## UiState\ sealed class + +`ui/state/UiState.kt` defines a covariant sealed class for ViewModels that load typed data: + +```kotlin +sealed class UiState { + data object Idle : UiState() + data object Loading : UiState() + data class Success(val data: T) : UiState() + data class Error(val message: String) : UiState() +} +``` + +| Variant | Meaning | Carries | +|---|---|---| +| `Idle` | No operation started yet; rendered as nothing | — | +| `Loading` | Fetch in progress | — | +| `Success` | Fetch completed; data is ready | `data: T` | +| `Error` | Fetch failed | `message: String` | + +Use `UiState` for any ViewModel that loads a specific type from the network or a repository. For auth operations where `Success` has no payload, use the feature-scoped `AuthUiState` in `auth/AuthViewModel.kt` instead. + +## UiStateContent composable + +`ui/components/UiStateContent.kt` is the canonical rendering component for `UiState`: + +```kotlin +@Composable +fun UiStateContent( + state: UiState, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, + content: @Composable (T) -> Unit, +) +``` + +| Parameter | Type | Default | Purpose | +|---|---|---|---| +| `state` | `UiState` | — | State to render | +| `modifier` | `Modifier` | `Modifier` | Applied to the container `Box` for Loading/Error states | +| `onRetry` | `(() -> Unit)?` | `null` | When non-null, shows a "Retry" `TextButton` in the Error state | +| `content` | `@Composable (T) -> Unit` | — | Trailing lambda called with `state.data` when state is `Success` | + +Rendering per variant: + +- `Idle` — renders nothing (`Unit`). +- `Loading` — centered `CircularProgressIndicator` inside a full-width `Box` with `32.dp` padding. Test tag: `"loading_indicator"`. +- `Error` — centered error `Text` (color `MaterialTheme.colorScheme.error`, style `bodyMedium`) inside a full-width `Box` with `16.dp` padding. If `onRetry != null`, a `TextButton("Retry")` appears below the message. +- `Success` — calls `content(state.data)`; the component itself renders nothing else. + +## Wiring in a ViewModel + +`HomeViewModel` demonstrates the full pattern: + +```kotlin +class HomeViewModel( + private val baseUrl: String, + private val httpClient: OkHttpClient, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + + private val _profileState = MutableStateFlow>(UiState.Idle) + val profileState: StateFlow> = _profileState.asStateFlow() + + init { fetchProfile() } + + fun refresh() { fetchProfile() } + + private fun fetchProfile() { + viewModelScope.launch(ioDispatcher) { + _profileState.value = UiState.Loading + UserApi.getMe(baseUrl = baseUrl, client = httpClient) + .onSuccess { _profileState.value = UiState.Success(it) } + .onFailure { _profileState.value = UiState.Error(it.message ?: "Failed to load profile") } + } + } + + companion object { + fun factory(): ViewModelProvider.Factory = viewModelFactory { + initializer { + HomeViewModel( + baseUrl = BuildConfig.BACKEND_URL, + httpClient = ApiClient.httpClient, + ) + } + } + } +} +``` + +Key points: +- Initial state is `Idle`; `fetchProfile()` is called in `init` so the fetch starts immediately. +- `ioDispatcher` is injected (defaults to `Dispatchers.IO`) so unit tests can substitute `UnconfinedTestDispatcher`. +- `baseUrl` and `httpClient` are constructor parameters so `MockWebServer` can be injected in tests. +- `refresh()` is a public method that re-runs the same fetch (sets state back to `Loading` first). + +The factory uses the `viewModelFactory { initializer { … } }` DSL and reads `BuildConfig.BACKEND_URL` and `ApiClient.httpClient` for production use. + +## Collecting in a screen + +```kotlin +@Composable +fun HomeScreen( + displayName: String, + onSignOut: () -> Unit, + viewModel: HomeViewModel = viewModel(factory = HomeViewModel.factory()), + modifier: Modifier = Modifier, +) { + val profileState by viewModel.profileState.collectAsStateWithLifecycle() + + UiStateContent( + state = profileState, + onRetry = viewModel::refresh, + ) { profile -> + ProfileContent(profile = profile) + } +} +``` + +- Collect with `collectAsStateWithLifecycle()` (from `lifecycle-runtime-compose`), not `collectAsState()`. +- Pass `viewModel::refresh` as `onRetry` so the Retry button re-triggers the fetch. +- The screen contains no data-fetch logic — all transitions happen in the ViewModel. + +## Testing + +### ViewModel unit tests (JVM, `src/test/`) + +`HomeViewModelTest` uses `MockWebServer` + `UnconfinedTestDispatcher`: + +```kotlin +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModelTest { + private lateinit var server: MockWebServer + private val testDispatcher = UnconfinedTestDispatcher() + + @Before fun setUp() { + server = MockWebServer() + server.start() + Dispatchers.setMain(testDispatcher) + } + + @After fun tearDown() { + server.shutdown() + Dispatchers.resetMain() + } + + private fun buildViewModel() = HomeViewModel( + baseUrl = server.url("/").toString().trimEnd('/'), + httpClient = OkHttpClient(), + ioDispatcher = testDispatcher, + ) +} +``` + +- Enqueue a `MockResponse` before constructing the ViewModel; `init` fires immediately with `UnconfinedTestDispatcher`. +- Assert on `viewModel.profileState.value` directly — no `runTest` `advanceUntilIdle()` required when using `UnconfinedTestDispatcher` with `runTest`. +- Test `refresh()` by enqueuing two responses: the first is consumed by `init`, the second by `refresh()`. + +### UiStateContent instrumented tests (`src/androidTest/`) + +`UiStateContentTest` uses `createComposeRule()` and sets each `UiState` variant in `setContent`: + +```kotlin +@RunWith(AndroidJUnit4::class) +class UiStateContentTest { + @get:Rule val composeTestRule = createComposeRule() + + @Test fun loadingState_showsCircularProgressIndicator() { + composeTestRule.setContent { + TemplateTheme { + UiStateContent(state = UiState.Loading, content = { _: Unit -> }) + } + } + composeTestRule.onNodeWithTag("loading_indicator").assertIsDisplayed() + } + + @Test fun errorState_withRetry_showsRetryButton() { + var retryClicked = false + composeTestRule.setContent { + TemplateTheme { + UiStateContent( + state = UiState.Error("Network error"), + onRetry = { retryClicked = true }, + content = { _: Unit -> }, + ) + } + } + composeTestRule.onNodeWithText("Retry").performClick() + assertTrue(retryClicked) + } +} +``` + +- Wrap content in `TemplateTheme` so color tokens resolve. +- Use `onNodeWithTag("loading_indicator")` for the spinner (the tag is set in `UiStateContent`). +- Use `onNodeWithText(…)` for error messages and the Retry button label. diff --git a/mobile/gradle/libs.versions.toml b/mobile/gradle/libs.versions.toml index 0c3eee8..32dfb7a 100644 --- a/mobile/gradle/libs.versions.toml +++ b/mobile/gradle/libs.versions.toml @@ -20,6 +20,7 @@ googleid = "1.1.1" datastore = "1.1.7" coil = "2.7.0" lifecycleViewmodelCompose = "2.9.0" +spotless = "7.0.4" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -60,4 +61,5 @@ android-application = { id = "com.android.application", version.ref = "agp" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..0469979 --- /dev/null +++ b/renovate.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "schedule": ["before 9am on Monday"], + "timezone": "UTC", + "packageRules": [ + { + "matchUpdateTypes": ["minor", "patch"], + "automerge": true, + "automergeType": "pr", + "platformAutomerge": true + }, + { + "matchPackageNames": ["go"], + "matchManagers": ["gomod"], + "groupName": "Go runtime" + }, + { + "matchManagers": ["npm"], + "groupName": "Web npm dependencies" + }, + { + "matchManagers": ["gradle", "gradle-wrapper"], + "groupName": "Android Gradle dependencies" + } + ], + "ignorePaths": [ + "**/testdata/**", + "**/.github/workflows/**" + ] +} diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..3655a54 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,166 @@ +# setup.ps1 — first-run setup for new contributors (Windows / PowerShell) +# Usage: .\setup.ps1 +# Run once after cloning the repo. + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$Errors = 0 + +# ── Banner ───────────────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Blue +Write-Host "║ Fullstack Template — First-Run Setup ║" -ForegroundColor Blue +Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Blue +Write-Host "" + +# ── Helpers ──────────────────────────────────────────────────────────────────── +function Pass { param($msg) Write-Host " ✓ $msg" -ForegroundColor Green } +function Fail { param($msg) Write-Host " ✗ $msg" -ForegroundColor Red; $script:Errors++ } +function Info { param($msg) Write-Host " → $msg" -ForegroundColor Yellow } +function Header { param($msg) Write-Host "`n$msg" -ForegroundColor White } + +# ── 1. Prerequisite checks ───────────────────────────────────────────────────── +Header "Checking prerequisites..." + +# go ≥ 1.25 +try { + $goVersion = (go version 2>&1) -replace '.*go(\d+\.\d+).*','$1' + $parts = $goVersion -split '\.' + $major = [int]$parts[0]; $minor = [int]$parts[1] + if ($major -gt 1 -or ($major -eq 1 -and $minor -ge 25)) { + Pass "go $goVersion (≥ 1.25)" + } else { + Fail "go $goVersion found — need ≥ 1.25. Install from https://go.dev/dl/" + } +} catch { + Fail "go not found. Install from https://go.dev/dl/" +} + +# node ≥ 22 +try { + $nodeVersion = (node --version 2>&1).TrimStart('v') + $nodeMajor = [int]($nodeVersion -split '\.')[0] + if ($nodeMajor -ge 22) { + Pass "node v$nodeVersion (≥ 22)" + } else { + Fail "node v$nodeVersion found — need ≥ 22. Install from https://nodejs.org/" + } +} catch { + Fail "node not found. Install from https://nodejs.org/" +} + +# pnpm (any version) +try { + $pnpmVersion = (pnpm --version 2>&1) + Pass "pnpm $pnpmVersion" +} catch { + Fail "pnpm not found. Install with: npm install -g pnpm" +} + +# docker (running) +try { + $null = docker info 2>&1 + if ($LASTEXITCODE -eq 0) { + Pass "docker (running)" + } else { + Fail "docker is installed but not running. Start Docker Desktop and retry." + } +} catch { + Fail "docker not found. Install Docker Desktop from https://www.docker.com/products/docker-desktop/" +} + +# java ≥ 17 +try { + $javaOut = (java -version 2>&1) | Select-Object -First 1 + if ($javaOut -match '"(\d+)') { + $javaMajor = [int]$Matches[1] + if ($javaMajor -ge 17) { + Pass "java $javaMajor (≥ 17)" + } else { + Fail "java $javaMajor found — need ≥ 17. Install from https://adoptium.net/" + } + } else { + Fail "Could not parse java version. Install from https://adoptium.net/" + } +} catch { + Fail "java not found. Install from https://adoptium.net/" +} + +# Android SDK (ANDROID_HOME) +$androidHome = $env:ANDROID_HOME +if ($androidHome -and (Test-Path $androidHome)) { + Pass "Android SDK at ANDROID_HOME=$androidHome" +} else { + Fail "ANDROID_HOME is not set or points to a missing directory." + Info "Install Android Studio and add ANDROID_HOME to your user environment variables." + Info "Typical path: C:\Users\\AppData\Local\Android\Sdk" +} + +# Abort on failures +if ($Errors -gt 0) { + Write-Host "" + Write-Host "$Errors prerequisite(s) failed. Fix the issues above and re-run .\setup.ps1." -ForegroundColor Red + Write-Host "" + exit 1 +} + +# ── 2. Install web dependencies ──────────────────────────────────────────────── +Header "Installing web dependencies (pnpm install)..." +Set-Location "$ScriptDir\web" +pnpm install +Set-Location $ScriptDir +Pass "web dependencies installed" + +# ── 3. Copy env files (if not already present) ──────────────────────────────── +Header "Copying environment files..." + +$backendEnv = "$ScriptDir\backend\.env" +$backendExample = "$ScriptDir\backend\.env.example" +if (Test-Path $backendEnv) { + Info "backend\.env already exists — skipping" +} else { + Copy-Item $backendExample $backendEnv + Pass "backend\.env.example → backend\.env" +} + +$webEnvLocal = "$ScriptDir\web\.env.local" +$webExample = "$ScriptDir\web\.env.example" +if (Test-Path $webEnvLocal) { + Info "web\.env.local already exists — skipping" +} else { + Copy-Item $webExample $webEnvLocal + Pass "web\.env.example → web\.env.local" +} + +# ── 4. Start Postgres and run migrations ────────────────────────────────────── +Header "Starting Postgres (Docker Compose) in a background window..." +Start-Process powershell -ArgumentList "-NoExit", "-Command", "Set-Location '$ScriptDir\backend'; make docker-run" ` + -WindowStyle Minimized + +Info "Waiting 5s for Postgres to be healthy..." +Start-Sleep 5 + +Header "Running database migrations..." +Set-Location "$ScriptDir\backend" +make migrate-up +Set-Location $ScriptDir +Pass "Migrations applied" + +# ── 5. Ready summary ────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Green +Write-Host "║ You're all set! Run these commands to start developing: ║" -ForegroundColor Green +Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Green +Write-Host "" +Write-Host " Quickstart (all services in separate windows):" -ForegroundColor White +Write-Host " .\dev.ps1" -ForegroundColor Yellow +Write-Host "" +Write-Host " Or start each service individually:" -ForegroundColor White +Write-Host " cd backend; make docker-run # Postgres" -ForegroundColor Blue +Write-Host " cd backend; make watch # Go backend → http://localhost:8080" -ForegroundColor Green +Write-Host " cd web; pnpm dev # Next.js web → http://localhost:3000" -ForegroundColor Yellow +Write-Host "" +Write-Host " Edit your env files before starting:" -ForegroundColor White +Write-Host " backend\.env — add DB credentials, Firebase config, etc." +Write-Host " web\.env.local — add Firebase client config, AUTH_SECRET, etc." +Write-Host "" diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..585866a --- /dev/null +++ b/setup.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# setup.sh — first-run setup for new contributors +# Usage: ./setup.sh +# Run once after cloning the repo. + +set -euo pipefail + +BLUE='\033[1;34m' +GREEN='\033[1;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BOLD='\033[1m' +RESET='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ERRORS=0 + +# ── Banner ───────────────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}${BLUE}╔═══════════════════════════════════════════════════════════╗${RESET}" +echo -e "${BOLD}${BLUE}║ Fullstack Template — First-Run Setup ║${RESET}" +echo -e "${BOLD}${BLUE}╚═══════════════════════════════════════════════════════════╝${RESET}" +echo "" + +# ── Helper: pass / fail printing ────────────────────────────────────────────── +pass() { echo -e " ${GREEN}✓${RESET} $*"; } +fail() { echo -e " ${RED}✗${RESET} $*"; ERRORS=$((ERRORS + 1)); } +info() { echo -e " ${YELLOW}→${RESET} $*"; } +header() { echo -e "\n${BOLD}$*${RESET}"; } + +# ── 1. Prerequisite checks ───────────────────────────────────────────────────── +header "Checking prerequisites..." + +# go ≥ 1.25 +if command -v go &>/dev/null; then + GO_VERSION=$(go version | grep -oP '\d+\.\d+' | head -1) + GO_MAJOR=$(echo "$GO_VERSION" | cut -d. -f1) + GO_MINOR=$(echo "$GO_VERSION" | cut -d. -f2) + if [[ "$GO_MAJOR" -gt 1 ]] || [[ "$GO_MAJOR" -eq 1 && "$GO_MINOR" -ge 25 ]]; then + pass "go $GO_VERSION (≥ 1.25)" + else + fail "go $GO_VERSION found — need ≥ 1.25. Install from https://go.dev/dl/" + fi +else + fail "go not found. Install from https://go.dev/dl/" +fi + +# node ≥ 22 +if command -v node &>/dev/null; then + NODE_VERSION=$(node --version | sed 's/v//') + NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1) + if [[ "$NODE_MAJOR" -ge 22 ]]; then + pass "node v$NODE_VERSION (≥ 22)" + else + fail "node v$NODE_VERSION found — need ≥ 22. Install from https://nodejs.org/" + fi +else + fail "node not found. Install from https://nodejs.org/" +fi + +# pnpm (any version) +if command -v pnpm &>/dev/null; then + PNPM_VERSION=$(pnpm --version) + pass "pnpm $PNPM_VERSION" +else + fail "pnpm not found. Install with: npm install -g pnpm" +fi + +# docker (running) +if command -v docker &>/dev/null; then + if docker info &>/dev/null 2>&1; then + pass "docker (running)" + else + fail "docker is installed but not running. Start Docker Desktop and retry." + fi +else + fail "docker not found. Install Docker Desktop from https://www.docker.com/products/docker-desktop/" +fi + +# java ≥ 17 +if command -v java &>/dev/null; then + JAVA_VERSION=$(java -version 2>&1 | grep -oP '(?<=version ")\d+' | head -1) + if [[ "$JAVA_VERSION" -ge 17 ]]; then + pass "java $JAVA_VERSION (≥ 17)" + else + fail "java $JAVA_VERSION found — need ≥ 17. Install from https://adoptium.net/" + fi +else + fail "java not found. Install from https://adoptium.net/" +fi + +# Android SDK (ANDROID_HOME) +if [[ -n "${ANDROID_HOME:-}" ]] && [[ -d "$ANDROID_HOME" ]]; then + pass "Android SDK at \$ANDROID_HOME=$ANDROID_HOME" +else + fail "ANDROID_HOME is not set or points to a missing directory." + info "Install Android Studio and set ANDROID_HOME to your SDK path." + info "Typical path: \$HOME/Android/Sdk (Linux/macOS)" +fi + +# Abort if any hard prerequisite failed +if [[ "$ERRORS" -gt 0 ]]; then + echo "" + echo -e "${RED}${BOLD}$ERRORS prerequisite(s) failed. Fix the issues above and re-run ./setup.sh.${RESET}" + echo "" + exit 1 +fi + +# ── 2. Install web dependencies ──────────────────────────────────────────────── +header "Installing web dependencies (pnpm install)..." +(cd "$SCRIPT_DIR/web" && pnpm install) +pass "web dependencies installed" + +# ── 3. Copy env files (if not already present) ──────────────────────────────── +header "Copying environment files..." + +if [[ -f "$SCRIPT_DIR/backend/.env" ]]; then + info "backend/.env already exists — skipping" +else + cp "$SCRIPT_DIR/backend/.env.example" "$SCRIPT_DIR/backend/.env" + pass "backend/.env.example → backend/.env" +fi + +if [[ -f "$SCRIPT_DIR/web/.env.local" ]]; then + info "web/.env.local already exists — skipping" +else + cp "$SCRIPT_DIR/web/.env.example" "$SCRIPT_DIR/web/.env.local" + pass "web/.env.example → web/.env.local" +fi + +# ── 4. Start Postgres and run migrations ────────────────────────────────────── +header "Starting Postgres (Docker Compose)..." +(cd "$SCRIPT_DIR/backend" && make docker-run) & +DOCKER_PID=$! +info "Waiting 5s for Postgres to be healthy..." +sleep 5 + +header "Running database migrations..." +(cd "$SCRIPT_DIR/backend" && make migrate-up) +pass "Migrations applied" + +# ── 5. Ready summary ────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}${GREEN}╔═══════════════════════════════════════════════════════════╗${RESET}" +echo -e "${BOLD}${GREEN}║ You're all set! Run these commands to start developing: ║${RESET}" +echo -e "${BOLD}${GREEN}╚═══════════════════════════════════════════════════════════╝${RESET}" +echo "" +echo -e " ${BOLD}Quickstart (all services in one terminal):${RESET}" +echo -e " ${YELLOW}./dev.sh${RESET}" +echo "" +echo -e " ${BOLD}Or start each service individually:${RESET}" +echo -e " ${BLUE}cd backend && make docker-run${RESET} # Postgres" +echo -e " ${GREEN}cd backend && make watch${RESET} # Go backend → http://localhost:8080" +echo -e " ${YELLOW}cd web && pnpm dev${RESET} # Next.js web → http://localhost:3000" +echo "" +echo -e " ${BOLD}Edit your env files before starting:${RESET}" +echo -e " backend/.env — add DB credentials, Firebase config, etc." +echo -e " web/.env.local — add Firebase client config, AUTH_SECRET, etc." +echo "" diff --git a/web/app/demo/DemoTable.tsx b/web/app/demo/DemoTable.tsx new file mode 100644 index 0000000..a5c30a9 --- /dev/null +++ b/web/app/demo/DemoTable.tsx @@ -0,0 +1,43 @@ +"use client" + +import { DataTable } from "@/components/data-table" +import { userColumns, type User } from "@/components/data-table" + +const users: User[] = [ + { id: "u-001", name: "Alice Martin", email: "alice.martin@example.com", createdAt: "2024-01-05T10:00:00Z" }, + { id: "u-002", name: "Bob Chen", email: "bob.chen@example.com", createdAt: "2024-01-12T09:15:00Z" }, + { id: "u-003", name: "Clara Osei", email: "clara.osei@example.com", createdAt: "2024-01-20T14:30:00Z" }, + { id: "u-004", name: "David Kim", email: "david.kim@example.com", createdAt: "2024-02-03T08:45:00Z" }, + { id: "u-005", name: "Evelyn Brooks", email: "evelyn.brooks@example.com", createdAt: "2024-02-14T11:00:00Z" }, + { id: "u-006", name: "Frank Müller", email: "frank.muller@example.com", createdAt: "2024-02-28T16:20:00Z" }, + { id: "u-007", name: "Grace Nakamura", email: "grace.nakamura@example.com", createdAt: "2024-03-07T13:10:00Z" }, + { id: "u-008", name: "Henry Okafor", email: "henry.okafor@example.com", createdAt: "2024-03-15T10:55:00Z" }, + { id: "u-009", name: "Isabel Santos", email: "isabel.santos@example.com", createdAt: "2024-03-22T09:30:00Z" }, + { id: "u-010", name: "James Patel", email: "james.patel@example.com", createdAt: "2024-04-01T12:00:00Z" }, + { id: "u-011", name: "Karen Liu", email: "karen.liu@example.com", createdAt: "2024-04-10T15:45:00Z" }, + { id: "u-012", name: "Liam Johansson", email: "liam.johansson@example.com", createdAt: "2024-04-18T08:00:00Z" }, + { id: "u-013", name: "Maya Rossi", email: "maya.rossi@example.com", createdAt: "2024-04-25T17:30:00Z" }, + { id: "u-014", name: "Nolan Wright", email: "nolan.wright@example.com", createdAt: "2024-05-02T10:15:00Z" }, + { id: "u-015", name: "Olivia Diallo", email: "olivia.diallo@example.com", createdAt: "2024-05-09T11:40:00Z" }, + { id: "u-016", name: "Peter Andersen", email: "peter.andersen@example.com", createdAt: "2024-05-17T14:00:00Z" }, + { id: "u-017", name: "Quinn Yamamoto", email: "quinn.yamamoto@example.com", createdAt: "2024-05-24T09:00:00Z" }, + { id: "u-018", name: "Rachel Nkosi", email: "rachel.nkosi@example.com", createdAt: "2024-06-01T13:25:00Z" }, + { id: "u-019", name: "Samuel Torres", email: "samuel.torres@example.com", createdAt: "2024-06-10T10:50:00Z" }, + { id: "u-020", name: "Tanya Kowalski", email: "tanya.kowalski@example.com", createdAt: "2024-06-18T16:10:00Z" }, + { id: "u-021", name: "Uma Fitzgerald", email: "uma.fitzgerald@example.com", createdAt: "2024-06-25T08:30:00Z" }, + { id: "u-022", name: "Victor Mensah", email: "victor.mensah@example.com", createdAt: "2024-07-03T11:20:00Z" }, + { id: "u-023", name: "Wendy Larsson", email: "wendy.larsson@example.com", createdAt: "2024-07-11T14:45:00Z" }, + { id: "u-024", name: "Xavier Dubois", email: "xavier.dubois@example.com", createdAt: "2024-07-19T09:05:00Z" }, + { id: "u-025", name: "Yuki Tanaka", email: "yuki.tanaka@example.com", createdAt: "2024-07-28T12:35:00Z" }, +] + +export function DemoTable() { + return ( + + ) +} diff --git a/web/app/demo/page.tsx b/web/app/demo/page.tsx new file mode 100644 index 0000000..d9c7b91 --- /dev/null +++ b/web/app/demo/page.tsx @@ -0,0 +1,16 @@ +import { DemoTable } from "./DemoTable" + +export default function DemoPage() { + return ( +
+

+ Data Table Demo +

+

+ A reusable table with sorting, filtering, and pagination. Showing 25 + users — 10 per page. +

+ +
+ ) +} diff --git a/web/components/data-table/DataTable.tsx b/web/components/data-table/DataTable.tsx new file mode 100644 index 0000000..93e62e4 --- /dev/null +++ b/web/components/data-table/DataTable.tsx @@ -0,0 +1,186 @@ +"use client" + +import * as React from "react" +import { + useReactTable, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + getPaginationRowModel, + flexRender, + type ColumnDef, + type SortingState, + type ColumnFiltersState, +} from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + filterColumn?: string + filterPlaceholder?: string +} + +export function DataTable({ + columns, + data, + filterColumn, + filterPlaceholder = "Filter…", +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState([]) + + const table = useReactTable({ + data, + columns, + state: { sorting, columnFilters }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { pageSize: 10 }, + }, + }) + + const currentPage = table.getState().pagination.pageIndex + 1 + const totalPages = table.getPageCount() + + return ( +
+ {filterColumn && ( + + table.getColumn(filterColumn)?.setFilterValue(e.target.value) + } + className="max-w-sm" + aria-label={filterPlaceholder} + /> + )} + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isSortable = header.column.getCanSort() + const sortDir = header.column.getIsSorted() + + return ( + + ) + })} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )} + +
+ + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + {isSortable && ( + + )} + +
+ No results found. +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+ +
+

+ Page {currentPage} of {totalPages} +

+
+ + +
+
+
+ ) +} diff --git a/web/components/data-table/__tests__/DataTable.test.tsx b/web/components/data-table/__tests__/DataTable.test.tsx new file mode 100644 index 0000000..5c55491 --- /dev/null +++ b/web/components/data-table/__tests__/DataTable.test.tsx @@ -0,0 +1,147 @@ +import { describe, it, expect } from "vitest" +import { render, screen, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" + +import { DataTable } from "../DataTable" +import { userColumns, type User } from "../columns" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildUsers(count: number): User[] { + return Array.from({ length: count }, (_, i) => ({ + id: `u-${String(i + 1).padStart(3, "0")}`, + name: `User ${String(i + 1).padStart(3, "0")}`, + email: `user${i + 1}@example.com`, + createdAt: `2024-01-${String((i % 28) + 1).padStart(2, "0")}T00:00:00Z`, + })) +} + +// --------------------------------------------------------------------------- +// Test 1 — initial render shows exactly 10 rows for a 25-item dataset +// --------------------------------------------------------------------------- + +describe("DataTable — initial render", () => { + it("shows 10 data rows by default for a 25-item dataset", () => { + const data = buildUsers(25) + render() + + // tbody rows only (exclude header row) + const tbody = screen.getByRole("table").querySelector("tbody")! + const rows = within(tbody).getAllByRole("row") + expect(rows).toHaveLength(10) + }) +}) + +// --------------------------------------------------------------------------- +// Test 2 — filter input narrows visible rows +// --------------------------------------------------------------------------- + +describe("DataTable — filtering", () => { + it("shows only rows matching the filter value", async () => { + const user = userEvent.setup() + const data = buildUsers(25) + render( + + ) + + const input = screen.getByRole("textbox", { name: "Search by name…" }) + // "User 001" is the exact name; typing "001" should match only that row + await user.type(input, "001") + + const tbody = screen.getByRole("table").querySelector("tbody")! + const rows = within(tbody).getAllByRole("row") + expect(rows).toHaveLength(1) + expect(within(rows[0]).getByText("User 001")).toBeInTheDocument() + }) +}) + +// --------------------------------------------------------------------------- +// Test 3 — pagination advances pages; Previous is disabled on page 1 +// --------------------------------------------------------------------------- + +describe("DataTable — pagination", () => { + it("Previous button is disabled on page 1", () => { + const data = buildUsers(25) + render() + + const prevButton = screen.getByRole("button", { name: /previous page/i }) + expect(prevButton).toBeDisabled() + }) + + it("Next button advances to page 2 and Previous becomes enabled", async () => { + const user = userEvent.setup() + const data = buildUsers(25) + render() + + const nextButton = screen.getByRole("button", { name: /next page/i }) + await user.click(nextButton) + + expect(screen.getByText(/page 2 of/i)).toBeInTheDocument() + + const prevButton = screen.getByRole("button", { name: /previous page/i }) + expect(prevButton).not.toBeDisabled() + }) + + it("page 2 shows remaining rows (15 items → 5 on page 2)", async () => { + const user = userEvent.setup() + const data = buildUsers(15) + render() + + await user.click(screen.getByRole("button", { name: /next page/i })) + + const tbody = screen.getByRole("table").querySelector("tbody")! + const rows = within(tbody).getAllByRole("row") + expect(rows).toHaveLength(5) + }) +}) + +// --------------------------------------------------------------------------- +// Test 4 — sorting reorders rows when a sortable header is clicked +// --------------------------------------------------------------------------- + +describe("DataTable — sorting", () => { + it("sorts Name column ascending on first click", async () => { + const user = userEvent.setup() + // Build 5 users with names out of natural order to make sorting visible + const data: User[] = [ + { id: "1", name: "Zara Adams", email: "z@example.com", createdAt: "2024-01-01T00:00:00Z" }, + { id: "2", name: "Alice Brooks", email: "a@example.com", createdAt: "2024-01-02T00:00:00Z" }, + { id: "3", name: "Mike Chen", email: "m@example.com", createdAt: "2024-01-03T00:00:00Z" }, + ] + render() + + const nameHeader = screen.getByRole("columnheader", { name: /name/i }) + await user.click(nameHeader) + + const tbody = screen.getByRole("table").querySelector("tbody")! + const rows = within(tbody).getAllByRole("row") + const firstCellText = within(rows[0]).getAllByRole("cell")[1].textContent + expect(firstCellText).toBe("Alice Brooks") + }) + + it("sorts Name column descending on second click", async () => { + const user = userEvent.setup() + const data: User[] = [ + { id: "1", name: "Zara Adams", email: "z@example.com", createdAt: "2024-01-01T00:00:00Z" }, + { id: "2", name: "Alice Brooks", email: "a@example.com", createdAt: "2024-01-02T00:00:00Z" }, + { id: "3", name: "Mike Chen", email: "m@example.com", createdAt: "2024-01-03T00:00:00Z" }, + ] + render() + + const nameHeader = screen.getByRole("columnheader", { name: /name/i }) + await user.click(nameHeader) // asc + await user.click(nameHeader) // desc + + const tbody = screen.getByRole("table").querySelector("tbody")! + const rows = within(tbody).getAllByRole("row") + const firstCellText = within(rows[0]).getAllByRole("cell")[1].textContent + expect(firstCellText).toBe("Zara Adams") + }) +}) diff --git a/web/components/data-table/columns.ts b/web/components/data-table/columns.ts new file mode 100644 index 0000000..4be0031 --- /dev/null +++ b/web/components/data-table/columns.ts @@ -0,0 +1,40 @@ +import { type ColumnDef } from "@tanstack/react-table" + +export interface User { + id: string + name: string + email: string + createdAt: string +} + +export const userColumns: ColumnDef[] = [ + { + accessorKey: "id", + header: "ID", + enableSorting: false, + }, + { + accessorKey: "name", + header: "Name", + enableSorting: true, + }, + { + accessorKey: "email", + header: "Email", + enableSorting: true, + }, + { + accessorKey: "createdAt", + header: "Created At", + enableSorting: false, + cell: ({ getValue }) => { + const raw = getValue() + const date = new Date(raw) + if (isNaN(date.getTime())) return raw + const yyyy = date.getUTCFullYear() + const mm = String(date.getUTCMonth() + 1).padStart(2, "0") + const dd = String(date.getUTCDate()).padStart(2, "0") + return `${yyyy}-${mm}-${dd}` + }, + }, +] diff --git a/web/components/data-table/index.ts b/web/components/data-table/index.ts new file mode 100644 index 0000000..f92c5eb --- /dev/null +++ b/web/components/data-table/index.ts @@ -0,0 +1,2 @@ +export { DataTable } from "./DataTable" +export { userColumns, type User } from "./columns" diff --git a/web/docs/_index.md b/web/docs/_index.md index ef559b6..1ad0b22 100644 --- a/web/docs/_index.md +++ b/web/docs/_index.md @@ -16,3 +16,4 @@ The `docs` agent reads this index first to locate the right file. | Firebase Cloud Messaging — permission, token, service worker, useFCM hook | [fcm.md](fcm.md) | `lib/fcm.ts`, `lib/useFCM.ts`, `public/firebase-messaging-sw.js`, `lib/fcm.test.ts` | | Object storage (Cloudflare R2) — presign utility, uploadToR2, useUpload hook | [storage.md](storage.md) | `lib/storage.ts`, `lib/useUpload.ts` | | Authentication (NextAuth v5) — providers, session, proxy, forms, hooks | [auth.md](auth.md) | `auth.ts`, `proxy.ts`, `features/auth/` | +| DataTable component (TanStack Table v8) — sorting, filtering, pagination, column definitions | [data-table.md](data-table.md) | `components/data-table/DataTable.tsx`, `components/data-table/columns.ts`, `components/data-table/index.ts`, `app/demo/page.tsx`, `app/demo/DemoTable.tsx` | diff --git a/web/docs/data-table.md b/web/docs/data-table.md new file mode 100644 index 0000000..ef72257 --- /dev/null +++ b/web/docs/data-table.md @@ -0,0 +1,154 @@ +--- +topic: DataTable component (TanStack Table v8) +last_verified: 2026-06-25 +sources: + - components/data-table/DataTable.tsx + - components/data-table/columns.ts + - components/data-table/index.ts + - app/demo/page.tsx + - app/demo/DemoTable.tsx + - components/data-table/__tests__/DataTable.test.tsx +--- + +# DataTable component + +## When to use + +Use `DataTable` for any list that needs client-side sorting, column filtering, and pagination. It is a Client Component (`"use client"`) backed by TanStack Table v8 (`@tanstack/react-table`). + +## Props interface + +```tsx +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + filterColumn?: string + filterPlaceholder?: string +} +``` + +| Prop | Type | Default | Purpose | +|---|---|---|---| +| `columns` | `ColumnDef[]` | — | Column definitions (see below) | +| `data` | `TData[]` | — | Row data array | +| `filterColumn` | `string \| undefined` | `undefined` | When set, renders a text input that filters rows by this column's accessor key | +| `filterPlaceholder` | `string` | `"Filter…"` | Placeholder and `aria-label` for the filter input | + +Pagination defaults to **10 rows per page** (`initialState: { pagination: { pageSize: 10 } }`). The page size is not currently configurable via props. + +## Defining columns + +Columns are `ColumnDef[]` objects from `@tanstack/react-table`. Place column arrays in `components/data-table/columns.ts` (or a feature-specific file). + +```ts +import { type ColumnDef } from "@tanstack/react-table" + +export interface User { + id: string + name: string + email: string + createdAt: string +} + +export const userColumns: ColumnDef[] = [ + { + accessorKey: "id", + header: "ID", + enableSorting: false, + }, + { + accessorKey: "name", + header: "Name", + enableSorting: true, + }, + { + accessorKey: "createdAt", + header: "Created At", + enableSorting: false, + cell: ({ getValue }) => { + const raw = getValue() + const date = new Date(raw) + if (isNaN(date.getTime())) return raw + const yyyy = date.getUTCFullYear() + const mm = String(date.getUTCMonth() + 1).padStart(2, "0") + const dd = String(date.getUTCDate()).padStart(2, "0") + return `${yyyy}-${mm}-${dd}` + }, + }, +] +``` + +- `accessorKey` — maps to a field on `TData`. +- `enableSorting: true` — renders a sort toggle (↕ / ↑ / ↓) in the header; `false` disables it. +- `cell` — optional custom renderer; receives `{ getValue }` and must return a `React.ReactNode` (or a primitive). + +## Using the component + +```tsx +import { DataTable } from "@/components/data-table" +import { userColumns } from "@/components/data-table" + +export function DemoTable() { + return ( + + ) +} +``` + +`DemoTable` must be a Client Component (`"use client"`) because it passes state-affecting columns. The Server Component page (`app/demo/page.tsx`) imports `DemoTable` and renders it inside a static `
` layout. + +## Demo page + +A working demo is available at `/demo` (`app/demo/page.tsx` + `app/demo/DemoTable.tsx`). It renders 25 hardcoded `User` rows with `filterColumn="name"` and shows 10 rows per page. + +## Exports + +`components/data-table/index.ts` re-exports: + +```ts +export { DataTable } from "./DataTable" +export { userColumns, type User } from "./columns" +``` + +Import from `@/components/data-table` to pick up both. + +## Internals + +`DataTable` wires four TanStack Table row models: + +| Row model | Hook | +|---|---| +| Core | `getCoreRowModel()` | +| Sorting | `getSortedRowModel()` | +| Column filtering | `getFilteredRowModel()` | +| Pagination | `getPaginationRowModel()` | + +Sortable headers receive `onClick` → `getToggleSortingHandler()` and `aria-sort` attributes (`"ascending"` / `"descending"` / `"none"`). The filter input calls `column.setFilterValue()` on the `onChange` event. Pagination is controlled by `table.previousPage()` / `table.nextPage()` buttons with `aria-label="Previous page"` / `aria-label="Next page"`. + +## Testing + +Tests live in `components/data-table/__tests__/DataTable.test.tsx` and run with `pnpm test` (Vitest + `@testing-library/react`, jsdom environment). + +Coverage: + +| Test | What is asserted | +|---|---| +| Initial render | 10 rows visible for a 25-item dataset | +| Filtering | Typing in the filter input narrows rows to matching entries | +| Pagination — page 1 | Previous button is disabled | +| Pagination — advance | Next click shows page 2; Previous becomes enabled | +| Pagination — last page | Correct row count on the final page | +| Sorting ascending | Clicking a sortable header once sorts that column A→Z | +| Sorting descending | Clicking the same header a second time reverses the sort | + +Pattern for selecting rows (avoids picking up the header row): + +```tsx +const tbody = screen.getByRole("table").querySelector("tbody")! +const rows = within(tbody).getAllByRole("row") +``` diff --git a/web/package.json b/web/package.json index 42e2692..6888f82 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "@hookform/resolvers": "^5.4.0", "@sentry/nextjs": "^10.57.0", "@tanstack/react-query": "^5.101.1", + "@tanstack/react-table": "^8.21.3", "@trpc/client": "^11.18.0", "@trpc/react-query": "^11.18.0", "@trpc/server": "^11.18.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 0521d88..1df0162 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@tanstack/react-query': specifier: ^5.101.1 version: 5.101.1(react@19.2.4) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@trpc/client': specifier: ^11.18.0 version: 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3) @@ -2207,6 +2210,17 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -7805,6 +7819,14 @@ snapshots: '@tanstack/query-core': 5.101.1 react: 19.2.4 + '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7