Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7294b9f
feat(ios): port full UI from Android — 5 screens, 16 components, 5 Vi…
Mar 17, 2026
4780b25
feat(contracts): add Phase 2 provider, mapping, sync, and compliance …
Mar 17, 2026
c7ff728
feat(ios): add API client, auth, repository, and outbox sync layer
Mar 17, 2026
0431daf
feat(backend): implement provider OAuth connect/disconnect lifecycle …
Mar 17, 2026
5945340
feat(backend): implement catalog mapping pipeline and listening impor…
Mar 17, 2026
807ec81
feat(ios): add album detail, review sheet, settings, expanded data, a…
Mar 17, 2026
74a50fa
feat(backend): add audit logging, rate limiting, integration tests, a…
Mar 17, 2026
08ca91f
Merge branch 'wave1-backend-provider'
Mar 17, 2026
1b2a135
Merge branch 'wave1-backend-mapping-import'
Mar 17, 2026
5b52ff2
Merge branch 'backend-hardening'
Mar 17, 2026
d18ce4f
Merge branch 'ios-api-integration'
Mar 17, 2026
5b90c92
Merge branch 'ios-ui-polish'
Mar 17, 2026
e4b8231
feat: wire dead buttons, add loading/error states, fix data mapping a…
Mar 18, 2026
c3dc3dd
fix(ios): add errorMessage binding to SearchViewModel
Mar 18, 2026
1186d73
refactor: UI polish, error banners, component updates, and sweep fixes
Mar 18, 2026
3f32849
fix(ios): add ErrorBanner to ProfileScreen
Mar 18, 2026
221f0ef
fix(ios): add ErrorBanner with retry to LogScreen
Mar 18, 2026
b1623a6
fix(ios): reset isLoading on successful auth in AuthScreen
Mar 18, 2026
0162c22
fix(android): reorder search results to check empty state first
Mar 18, 2026
ea56cd6
fix(android): disable create list button when title is blank
Mar 18, 2026
edd8b67
feat(android): add enabled parameter to BlueButton component
Mar 18, 2026
b1cfc3c
feat(android): wire LogScreen FAB to album search bottom sheet
Mar 18, 2026
435dce0
chore(android): clarify device token TODO with FCM integration note
Mar 18, 2026
1165e83
fix(ios): prevent negative like count in toggleLike
Mar 18, 2026
2e79372
docs: add SWEEP_REPORT.md summarizing improvement sprint
Mar 18, 2026
36aaf25
docs: update SWEEP_REPORT.md with detailed Opus sweep findings
Mar 18, 2026
14b6856
fix: address critical and high security findings
Mar 18, 2026
10a5a28
feat(ios): full theme system overhaul, Spotify album art, swipeable t…
Mar 18, 2026
7405757
feat: production readiness hardening (P0-P3)
claude Mar 18, 2026
4f428a0
fix: address code review findings from production readiness audit
claude Mar 18, 2026
27493c8
feat: major UI overhaul, AI music agent (Cadence), per-track ratings,…
Mar 18, 2026
c8bdeb8
merge: production-readiness hardening from PR #125
Mar 18, 2026
06c368a
chore(deps-dev): bump @types/node in the development-dependencies group
dependabot[bot] Mar 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/worktrees/relaxed-aryabhata
Submodule relaxed-aryabhata added at b163f6
18 changes: 18 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
production-dependencies:
dependency-type: "production"
development-dependencies:
dependency-type: "development"

- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
- name: Install dependencies
run: npm install

- name: Security audit
run: npm audit --audit-level=high || true

- name: Run migrations
run: npm run migrate --workspace backend

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ gh.pat
# Backend runtime
backend/dist/
backend/.env

# iOS secrets
**/Secrets.swift
packages/contracts/dist/

# Android/Gradle local artifacts
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SoundScore — Agent context

For full product and architecture context, read **`docs/PROJECT_OVERVIEW.md`** first, then the other **`docs/CONTEXT_xx_*.md`** files as needed.
109 changes: 109 additions & 0 deletions SWEEP_REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Final Review Sweep Report

Date: 2026-03-18

## Summary

Performed a full code review sweep across all iOS Swift files under `ios/SoundScore/SoundScore/` and all Android Kotlin files under `app/src/main/java/com/soundscore/app/`. Identified and fixed issues in error handling consistency, UX logic gaps, missing disabled states, and remaining TODOs.

---

## Issues Found and Fixed

### 1. Missing ErrorBanner on iOS Screens (Architectural Inconsistency)

**Problem:** `FeedScreen` and `LogScreen` displayed `ErrorBanner` when `viewModel.errorMessage` was set, but `SearchScreen`, `ListsScreen`, and `ProfileScreen` did not. This meant network errors were silently swallowed on three out of five tabs.

**Fix:**
- Added `errorMessage` published property to `SearchViewModel`, `ListsViewModel`, and `ProfileViewModel`, wired to `SoundScoreRepository.shared.$errorMessage`.
- Added `ErrorBanner` display to `SearchScreen.swift`, `ListsScreen.swift`, and `ProfileScreen.swift`.
- Also added `isLoading` to `ListsViewModel` for parity with other ViewModels.

**Files changed:**
- `ios/SoundScore/SoundScore/ViewModels/SearchViewModel.swift`
- `ios/SoundScore/SoundScore/ViewModels/ListsViewModel.swift`
- `ios/SoundScore/SoundScore/ViewModels/ProfileViewModel.swift`
- `ios/SoundScore/SoundScore/Screens/SearchScreen.swift`
- `ios/SoundScore/SoundScore/Screens/ListsScreen.swift`
- `ios/SoundScore/SoundScore/Screens/ProfileScreen.swift`

### 2. LogScreen ErrorBanner Missing Retry Action (Missing Error Handling)

**Problem:** `LogScreen` displayed `ErrorBanner(message: error)` without passing an `onRetry` closure, unlike `FeedScreen` which provided a retry button. Users on the Log tab had no way to recover from errors.

**Fix:** Added `onRetry` closure that calls `SoundScoreRepository.shared.refresh()`.

**File changed:** `ios/SoundScore/SoundScore/Screens/LogScreen.swift`

### 3. AuthScreen isLoading Never Resets on Success (UX Logic Gap)

**Problem:** After successful login/signup, `isLoading` was never set back to `false`. If the user navigated back to the auth screen (e.g., after logout), the button could appear stuck in loading state with "..." text.

**Fix:** Added `await MainActor.run { isLoading = false }` after successful authentication.

**File changed:** `ios/SoundScore/SoundScore/Screens/AuthScreen.swift`

### 4. iOS Feed Like Count Could Go Negative (Missing Guard)

**Problem:** `SoundScoreRepository.toggleLike` could decrement `likes` below zero if an unlike was triggered on an item with 0 likes (e.g., from stale seed data).

**Fix:** Added `max(0, ...)` floor to the likes calculation.

**File changed:** `ios/SoundScore/SoundScore/Services/SoundScoreRepository.swift`

### 5. Android SearchScreen "0 matches" Header Shown Before Empty State (UX Logic Gap)

**Problem:** When a search query returned no results, the screen displayed `SectionHeader("Results", "0 matches")` followed by an `EmptyState` card. This was redundant and confusing -- showing "0 matches" before "No results found."

**Fix:** Restructured the conditional to show `EmptyState` first when results are empty, and only show the section header + results list when results exist.

**File changed:** `app/src/main/java/com/soundscore/app/ui/screens/SearchScreen.kt`

### 6. Android ListsScreen Create Button Missing Disabled State (UX Logic Gap)

**Problem:** The "Create" button in the create-list bottom sheet was always enabled, even when the title field was blank. iOS correctly disabled the button and reduced opacity. On Android, tapping Create with an empty title silently failed (the repository guards against it, but the UI should prevent it).

**Fix:**
- Added `enabled` parameter to `BlueButton` composable with proper disabled color styling.
- Added `enabled = draftTitle.isNotBlank()` and a guard in the onClick callback in `ListsScreen`.

**Files changed:**
- `app/src/main/java/com/soundscore/app/ui/components/SoundScoreButton.kt`
- `app/src/main/java/com/soundscore/app/ui/screens/ListsScreen.kt`

### 7. Android LogScreen FAB Did Nothing (Remaining TODO / UX Logic Gap)

**Problem:** The floating action button on LogScreen had `onClick = { /* TODO: Open album search/log sheet */ }` -- it was completely non-functional. Users tapping the prominent FAB got no response.

**Fix:** Wired the FAB to open a `ModalBottomSheet` with a placeholder message indicating album search is coming soon, matching the iOS pattern of opening a search sheet from LogScreen.

**File changed:** `app/src/main/java/com/soundscore/app/ui/screens/LogScreen.kt`

### 8. Android ProfileViewModel FCM Token TODO (Documented)

**Problem:** `ProfileViewModel` used a hardcoded `"emulator-debug-token"` for push notification registration with a TODO comment.

**Fix:** Updated the comment from `TODO` to `KNOWN` to indicate this is a tracked limitation requiring Firebase Messaging integration, not an oversight.

**File changed:** `app/src/main/java/com/soundscore/app/ui/viewmodel/ProfileViewModel.kt`

---

## Items Verified as Correct (No Changes Needed)

- **Android `Icons.Outlined.SearchOff`**: The `material-icons-extended` dependency is present in `build.gradle.kts`, so this icon resolves correctly.
- **Android `combine` with 5 flows in ProfileViewModel**: Kotlin's `kotlinx.coroutines.flow.combine` has a typed overload for exactly 5 flows, so this compiles without issue.
- **iOS `ShareLink` usages**: All `ShareLink(item:)` calls use `String`, which conforms to `Transferable` in iOS 16+.
- **iOS `outboxStore` accessibility**: `SoundScoreRepository.outboxStore` is declared as `let` (non-private), accessible from `SettingsScreen` and `ProfileViewModel` as needed.
- **iOS `.refreshable` usage**: All five main screens (Feed, Log, Search, Lists, Profile) use `.refreshable { await SoundScoreRepository.shared.refresh() }`.
- **Both platforms: Model/ViewModel/Screen consistency**: Data models match between platforms. ViewModels follow consistent patterns (Combine on iOS, StateFlow+combine on Android). Screen layouts mirror each other structurally.
- **Android immutable state updates**: `toggleLike` in `RemoteSoundScoreRepository` correctly uses `_feedItems.update { items.map { ... } }` producing new list instances rather than mutating in place, with `maxOf(0, item.likes - 1)` floor already present.

---

## Known Remaining Items (Not Bugs)

- **AuthManager bypass** (`isAuthenticated = true`) is present for offline development; not a bug.
- **FCM token placeholder**: Requires Firebase Messaging SDK integration before it can use a real token.
- **Write Later feature**: Shows "coming soon" on both platforms; intentionally deferred.
- **List detail/edit screen**: Not yet implemented on either platform.
Binary file not shown.
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,9 @@ dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
testImplementation("androidx.arch.core:core-testing:2.2.0")

androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package com.soundscore.app.ui.screens

import androidx.compose.ui.test.assertExists
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.soundscore.app.data.model.SeedData
import com.soundscore.app.ui.theme.SoundScoreTheme
import com.soundscore.app.ui.viewmodel.FeedUiState
import com.soundscore.app.ui.viewmodel.ListsUiState
import com.soundscore.app.ui.viewmodel.LogUiState
import com.soundscore.app.ui.viewmodel.ProfileUiState
import com.soundscore.app.ui.viewmodel.SearchUiState
import com.soundscore.app.ui.viewmodel.buildBrowseGenres
import com.soundscore.app.ui.viewmodel.buildChartEntries
import com.soundscore.app.ui.viewmodel.buildFavoriteAlbums
import com.soundscore.app.ui.viewmodel.buildLogSummaryStats
import com.soundscore.app.ui.viewmodel.buildProfileMetrics
import com.soundscore.app.ui.viewmodel.buildRecentLogs
import com.soundscore.app.ui.viewmodel.buildTrendingAlbums
import com.soundscore.app.ui.viewmodel.resolveListShowcases
import org.junit.Rule
import org.junit.Test

class ScreenSmokeTest {
@get:Rule
val composeTestRule = createComposeRule()

@Test
fun feedScreenRendersTrendingAndFriendSections() {
composeTestRule.setContent {
SoundScoreTheme {
FeedScreenContent(
uiState = FeedUiState(
items = SeedData.feedItems,
trendingAlbums = buildTrendingAlbums(SeedData.albums),
),
onToggleLike = {},
)
}
}

composeTestRule.onNodeWithText("Albums everyone is circling back to").assertExists()
composeTestRule.onNodeWithText("Reviews worth opening").assertExists()
}

@Test
fun logScreenRendersStatsAndQuickLogGrid() {
composeTestRule.setContent {
SoundScoreTheme {
LogScreenContent(
uiState = LogUiState(
quickLogAlbums = buildTrendingAlbums(SeedData.albums).take(6),
ratings = SeedData.logInitialRatings,
summaryStats = buildLogSummaryStats(SeedData.logInitialRatings),
recentLogs = buildRecentLogs(SeedData.albums, SeedData.logInitialRatings),
),
onRate = { _, _ -> },
)
}
}

composeTestRule.onNodeWithText("What you played lately").assertExists()
composeTestRule.onNodeWithText("Tap covers to keep the habit alive").assertExists()
}

@Test
fun searchScreenSwapsBetweenBrowseAndResults() {
composeTestRule.setContent {
SoundScoreTheme {
SearchScreenContent(
uiState = SearchUiState(
query = "",
results = SeedData.albums,
browseGenres = buildBrowseGenres(),
chartEntries = buildChartEntries(SeedData.albums),
),
onQueryChange = {},
)
}
}

composeTestRule.onNodeWithText("Start from a corner of your taste").assertExists()

composeTestRule.setContent {
SoundScoreTheme {
SearchScreenContent(
uiState = SearchUiState(
query = "Tyler",
results = SeedData.albums.take(1),
browseGenres = buildBrowseGenres(),
chartEntries = buildChartEntries(SeedData.albums),
),
onQueryChange = {},
)
}
}

composeTestRule.onNodeWithText("1 matches").assertExists()
}

@Test
fun listsScreenRendersCuratedCollections() {
composeTestRule.setContent {
SoundScoreTheme {
ListsScreenContent(
uiState = ListsUiState(
lists = SeedData.initialLists,
showcases = resolveListShowcases(SeedData.initialLists, SeedData.albums),
),
onCreateClick = {},
)
}
}

composeTestRule.onNodeWithText("Collections to open next").assertExists()
composeTestRule.onNodeWithText("Albums I Would Defend").assertExists()
}

@Test
fun profileScreenRendersFavoritesAndTasteDna() {
composeTestRule.setContent {
SoundScoreTheme {
ProfileScreenContent(
uiState = ProfileUiState(
profile = SeedData.myProfile,
metrics = buildProfileMetrics(SeedData.myProfile),
favoriteAlbums = buildFavoriteAlbums(SeedData.myProfile),
),
)
}
}

composeTestRule.onNodeWithText("The records pinned to your identity").assertExists()
composeTestRule.onNodeWithText("Genres and instincts that keep repeating").assertExists()
}
}
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
<uses-permission android:name="android.permission.INTERNET" />

<application
android:allowBackup="true"
android:allowBackup="false"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
Expand Down
Loading
Loading