diff --git a/.claude/worktrees/relaxed-aryabhata b/.claude/worktrees/relaxed-aryabhata
new file mode 160000
index 0000000..b163f64
--- /dev/null
+++ b/.claude/worktrees/relaxed-aryabhata
@@ -0,0 +1 @@
+Subproject commit b163f64ebc1840c61f6a7f44aa5c61467626afca
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..c4322ac
--- /dev/null
+++ b/.github/dependabot.yml
@@ -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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f936326..ea13d7a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
@@ -66,7 +69,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Java
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
diff --git a/.gitignore b/.gitignore
index 67aee54..720fff7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,9 @@ gh.pat
# Backend runtime
backend/dist/
backend/.env
+
+# iOS secrets
+**/Secrets.swift
packages/contracts/dist/
# Android/Gradle local artifacts
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..4e8be86
--- /dev/null
+++ b/AGENTS.md
@@ -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.
diff --git a/SWEEP_REPORT.md b/SWEEP_REPORT.md
new file mode 100644
index 0000000..d24cf1a
--- /dev/null
+++ b/SWEEP_REPORT.md
@@ -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.
diff --git a/SoundScore_V1_Mobile_Architecture_Report_Madhav_Chauhan.pdf b/SoundScore_V1_Mobile_Architecture_Report_Madhav_Chauhan.pdf
new file mode 100644
index 0000000..43ca3d8
Binary files /dev/null and b/SoundScore_V1_Mobile_Architecture_Report_Madhav_Chauhan.pdf differ
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 882008a..6309417 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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")
}
diff --git a/app/src/androidTest/java/com/soundscore/app/ui/screens/ScreenSmokeTest.kt b/app/src/androidTest/java/com/soundscore/app/ui/screens/ScreenSmokeTest.kt
new file mode 100644
index 0000000..7b65215
--- /dev/null
+++ b/app/src/androidTest/java/com/soundscore/app/ui/screens/ScreenSmokeTest.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 91efe56..89d7632 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,7 +5,8 @@
{
- Box(Modifier.padding(innerPadding)) { FeedScreen() }
+ composable {
+ Box(Modifier.padding(innerPadding)) { FeedScreen() }
}
- composable {
- Box(Modifier.padding(innerPadding)) { LogScreen() }
+ composable {
+ Box(Modifier.padding(innerPadding)) { LogScreen() }
}
- composable {
- Box(Modifier.padding(innerPadding)) { SearchScreen() }
+ composable {
+ Box(Modifier.padding(innerPadding)) { SearchScreen() }
}
- composable {
- Box(Modifier.padding(innerPadding)) { ListsScreen() }
+ composable {
+ Box(Modifier.padding(innerPadding)) { ListsScreen() }
}
- composable {
- Box(Modifier.padding(innerPadding)) { ProfileScreen() }
+ composable {
+ Box(Modifier.padding(innerPadding)) { ProfileScreen() }
}
}
}
@@ -110,72 +147,89 @@ fun SoundScoreApp(startDeepLink: String? = null) {
@Composable
fun FloatingNavigationBar(
screens: List,
- currentDestination: androidx.navigation.NavDestination?,
- onNavigate: (Screen) -> Unit
+ currentDestination: NavDestination?,
+ onNavigate: (Screen) -> Unit,
) {
+ val haptic = LocalHapticFeedback.current
+ val shape = RoundedCornerShape(28.dp)
+
Box(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 20.dp)
- .padding(bottom = 28.dp),
- contentAlignment = Alignment.BottomCenter
+ .navigationBarsPadding()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 12.dp),
+ contentAlignment = Alignment.BottomCenter,
) {
- GlassCard(
- cornerRadius = 35.dp,
- modifier = Modifier.height(76.dp),
- borderColor = Color.White.copy(alpha = 0.12f)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .clip(shape)
+ .background(
+ Brush.verticalGradient(
+ listOf(GlassFrosted, GlassFrosted.copy(alpha = 0.22f))
+ )
+ )
+ .border(0.5.dp, GlassBorder, shape)
+ .padding(horizontal = 8.dp),
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceEvenly,
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
) {
screens.forEach { screen ->
val selected = currentDestination?.hierarchy?.any {
it.hasRoute(screen::class)
} == true
- val animatedSize by animateDpAsState(
- targetValue = if (selected) 30.dp else 24.dp,
- animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
- label = "iconSize"
+ val iconAlpha by animateFloatAsState(
+ targetValue = if (selected) 1f else 0.45f,
+ animationSpec = tween(250),
+ label = "iconAlpha"
)
- val animatedAlpha by animateFloatAsState(
- targetValue = if (selected) 1f else 0.4f,
- animationSpec = tween(300),
- label = "iconAlpha"
+ val iconSize by animateDpAsState(
+ targetValue = if (selected) 26.dp else 22.dp,
+ animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f),
+ label = "iconSize"
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
- .fillMaxHeight()
+ .fillMaxHeight(),
) {
- // Subtle selection glow
- if (selected) {
- Box(
- modifier = Modifier
- .size(45.dp)
- .background(
- brush = androidx.compose.ui.graphics.Brush.radialGradient(
- listOf(ElectricBlue.copy(alpha = 0.15f), Color.Transparent)
- ),
- shape = CircleShape
- )
- )
- }
-
IconButton(
- onClick = { onNavigate(screen) }
+ onClick = {
+ if (!selected) {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+ onNavigate(screen)
+ }
) {
- Icon(
- imageVector = if (selected) screen.iconFilled else screen.iconOutlined,
- contentDescription = screen.label,
- tint = (if (selected) ElectricBlue else Color.White).copy(alpha = animatedAlpha),
- modifier = Modifier.size(animatedSize)
- )
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Icon(
+ imageVector = if (selected) screen.iconFilled else screen.iconOutlined,
+ contentDescription = screen.label,
+ tint = (if (selected) AccentGreen else Color.White).copy(alpha = iconAlpha),
+ modifier = Modifier.size(iconSize),
+ )
+ Box(
+ modifier = Modifier
+ .size(4.dp)
+ .clip(CircleShape)
+ .graphicsLayer {
+ alpha = if (selected) 1f else 0f
+ }
+ .background(AccentGreen),
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/soundscore/app/data/api/ApiClient.kt b/app/src/main/java/com/soundscore/app/data/api/ApiClient.kt
index 54e7848..a752e61 100644
--- a/app/src/main/java/com/soundscore/app/data/api/ApiClient.kt
+++ b/app/src/main/java/com/soundscore/app/data/api/ApiClient.kt
@@ -16,11 +16,15 @@ object ApiClient {
}
private val httpClient = OkHttpClient.Builder()
- .addInterceptor(
- HttpLoggingInterceptor().apply {
- level = HttpLoggingInterceptor.Level.BODY
- },
- )
+ .apply {
+ if (com.soundscore.app.BuildConfig.DEBUG) {
+ addInterceptor(
+ HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ },
+ )
+ }
+ }
.build()
fun create(baseUrl: String = DEFAULT_BASE_URL): SoundScoreApi {
diff --git a/app/src/main/java/com/soundscore/app/data/model/DummyData.kt b/app/src/main/java/com/soundscore/app/data/model/DummyData.kt
index f9801c4..5e68fe5 100644
--- a/app/src/main/java/com/soundscore/app/data/model/DummyData.kt
+++ b/app/src/main/java/com/soundscore/app/data/model/DummyData.kt
@@ -3,17 +3,13 @@ package com.soundscore.app.data.model
import androidx.compose.ui.graphics.Color
import com.soundscore.app.ui.theme.AlbumColors
-/**
- * Lightweight data classes + static dummy data.
- * Replace with real Room / network models later.
- */
-
data class Album(
val id: String,
val title: String,
val artist: String,
val year: Int,
- val artColors: List, // gradient placeholder — swap for image URL later
+ val artColors: List,
+ val artworkUrl: String? = null,
val avgRating: Float = 0f,
val logCount: Int = 0,
)
@@ -21,7 +17,7 @@ data class Album(
data class FeedItem(
val id: String,
val username: String,
- val action: String, // "logged an album", "rated", "added to list"
+ val action: String,
val album: Album,
val rating: Float,
val reviewSnippet: String? = null,
@@ -37,9 +33,13 @@ data class UserProfile(
val logCount: Int,
val reviewCount: Int,
val listCount: Int,
- val topAlbums: List>, // album + user rating
+ val topAlbums: List>,
val genres: List,
val avgRating: Float,
+ val albumsCount: Int = logCount,
+ val followingCount: Int = 0,
+ val followersCount: Int = 0,
+ val favoriteAlbums: List = topAlbums.map { it.first },
)
data class UserList(
@@ -47,6 +47,8 @@ data class UserList(
val title: String,
val note: String? = null,
val albumIds: List = emptyList(),
+ val curatorHandle: String = "@madhav",
+ val saves: Int = 0,
)
data class NotificationPreferences(
@@ -68,55 +70,121 @@ data class WeeklyRecap(
val deepLink: String,
)
-
-// ── Static seed data ──────────────────────────────────────────
-
object SeedData {
+ private fun hiResArtwork(url: String) = url
+ .replace("100x100bb.jpg", "600x600bb.jpg")
+ .replace("100x100bb.png", "600x600bb.png")
val albums = listOf(
- Album("alb_1", "CHROMAKOPIA", "Tyler, the Creator", 2024, AlbumColors.purple, 4.3f, 2100),
- Album("alb_2", "GNX", "Kendrick Lamar", 2024, AlbumColors.indigo, 4.1f, 1800),
- Album("alb_3", "Short n' Sweet", "Sabrina Carpenter", 2024, AlbumColors.teal, 3.8f, 950),
- Album("alb_4", "Brat", "Charli XCX", 2024, AlbumColors.pink, 4.0f, 3200),
- Album("alb_5", "Manning Fireside", "Mk.gee", 2024, AlbumColors.blue, 3.9f, 620),
- Album("alb_6", "The Great Impersonator", "Halsey", 2024, AlbumColors.gold, 3.5f, 430),
+ Album(
+ id = "alb_1",
+ title = "CHROMAKOPIA",
+ artist = "Tyler, the Creator",
+ year = 2024,
+ artColors = AlbumColors.forest,
+ artworkUrl = hiResArtwork("https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/b6/ef/ee/b6efeefa-fc99-37d1-ad21-0d769b2a4958/196872796971.jpg/100x100bb.jpg"),
+ avgRating = 4.3f,
+ logCount = 2100,
+ ),
+ Album(
+ id = "alb_2",
+ title = "GNX",
+ artist = "Kendrick Lamar",
+ year = 2024,
+ artColors = AlbumColors.midnight,
+ artworkUrl = hiResArtwork("https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/54/28/14/54281424-eece-0935-299d-fdd2ab403f92/24UM1IM28978.rgb.jpg/100x100bb.jpg"),
+ avgRating = 4.1f,
+ logCount = 1800,
+ ),
+ Album(
+ id = "alb_3",
+ title = "Short n' Sweet",
+ artist = "Sabrina Carpenter",
+ year = 2024,
+ artColors = AlbumColors.lime,
+ artworkUrl = hiResArtwork("https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/a1/1c/ca/a11ccab6-7d4c-e041-d028-998bcebeb709/24UMGIM61704.rgb.jpg/100x100bb.jpg"),
+ avgRating = 3.8f,
+ logCount = 950,
+ ),
+ Album(
+ id = "alb_4",
+ title = "Brat",
+ artist = "Charli XCX",
+ year = 2024,
+ artColors = AlbumColors.rose,
+ artworkUrl = null,
+ avgRating = 4.0f,
+ logCount = 3200,
+ ),
+ Album(
+ id = "alb_5",
+ title = "Manning Fireside",
+ artist = "Mk.gee",
+ year = 2024,
+ artColors = AlbumColors.lagoon,
+ artworkUrl = null,
+ avgRating = 3.9f,
+ logCount = 620,
+ ),
+ Album(
+ id = "alb_6",
+ title = "The Great Impersonator",
+ artist = "Halsey",
+ year = 2024,
+ artColors = AlbumColors.ember,
+ artworkUrl = null,
+ avgRating = 3.5f,
+ logCount = 430,
+ ),
)
val feedItems = listOf(
FeedItem(
id = "f1",
username = "rohan",
- action = "logged an album",
+ action = "logged a perfect score",
album = albums[0],
rating = 5.0f,
- reviewSnippet = "Tyler peaked. Every track is a statement.",
- likes = 12, comments = 3, timeAgo = "2h",
+ reviewSnippet = "Tyler made a world, not just a tracklist.",
+ likes = 12,
+ comments = 3,
+ timeAgo = "2h",
isLiked = true,
),
FeedItem(
id = "f2",
username = "priya",
- action = "rated",
+ action = "left a glowing review",
album = albums[2],
rating = 4.0f,
- likes = 8, comments = 1, timeAgo = "5h",
+ reviewSnippet = "Hooks for days, but the production is what sticks.",
+ likes = 8,
+ comments = 1,
+ timeAgo = "5h",
),
FeedItem(
id = "f3",
username = "kai",
- action = "added to list",
+ action = "added this to a late-night list",
album = albums[3],
- rating = 4.0f,
- likes = 24, comments = 7, timeAgo = "8h",
+ rating = 4.5f,
+ reviewSnippet = "The whole thing feels fluorescent and slightly dangerous.",
+ likes = 24,
+ comments = 7,
+ timeAgo = "8h",
),
)
- // Initial ratings for the Log screen (matches mockup visual state)
- val logInitialRatings = mapOf("alb_1" to 5f, "alb_2" to 4f, "alb_3" to 3f)
+ val logInitialRatings = mapOf(
+ "alb_1" to 5f,
+ "alb_2" to 4.5f,
+ "alb_3" to 4f,
+ "alb_4" to 4.5f,
+ )
val myProfile = UserProfile(
handle = "@madhav",
- bio = "Taste Journal · music nerd",
+ bio = "Taste journal for records worth replaying at 1 a.m.",
logCount = 142,
reviewCount = 38,
listCount = 24,
@@ -128,16 +196,45 @@ object SeedData {
albums[5] to 4.0f,
albums[1] to 3.5f,
),
- genres = listOf("Indie", "Rap", "Electronic", "Alt R&B", "2010s", "Avg 3.9 ★"),
- avgRating = 3.9f,
+ genres = listOf("Indie Sleaze", "Alt Rap", "Digital Pop", "Neo-Soul", "Late Night", "Avg 4.1 ★"),
+ avgRating = 4.1f,
+ albumsCount = 142,
+ followingCount = 186,
+ followersCount = 248,
+ favoriteAlbums = listOf(
+ albums[0],
+ albums[3],
+ albums[2],
+ albums[1],
+ albums[4],
+ albums[5],
+ ),
)
val initialLists = listOf(
UserList(
id = "l1",
title = "Albums I Would Defend",
- note = "All gas, no skips.",
- albumIds = listOf("alb_1", "alb_4"),
+ note = "Chaotic, immediate, impossible to half-love.",
+ albumIds = listOf("alb_4", "alb_1", "alb_2", "alb_3"),
+ curatorHandle = "@madhav",
+ saves = 128,
+ ),
+ UserList(
+ id = "l2",
+ title = "Midnight Headphones",
+ note = "For the train ride home when the city still feels loud.",
+ albumIds = listOf("alb_5", "alb_6", "alb_1", "alb_2"),
+ curatorHandle = "@priya",
+ saves = 84,
+ ),
+ UserList(
+ id = "l3",
+ title = "2024 Pop Mutations",
+ note = "Big hooks, weird textures, zero safe choices.",
+ albumIds = listOf("alb_3", "alb_4", "alb_2", "alb_1"),
+ curatorHandle = "@kai",
+ saves = 67,
),
)
diff --git a/app/src/main/java/com/soundscore/app/data/repository/SoundScoreRepository.kt b/app/src/main/java/com/soundscore/app/data/repository/SoundScoreRepository.kt
index 6955e32..7fe8667 100644
--- a/app/src/main/java/com/soundscore/app/data/repository/SoundScoreRepository.kt
+++ b/app/src/main/java/com/soundscore/app/data/repository/SoundScoreRepository.kt
@@ -120,6 +120,7 @@ class RemoteSoundScoreRepository : SoundScoreRepository {
reviewCount = remoteProfile.reviewCount,
listCount = remoteProfile.listCount,
avgRating = remoteProfile.avgRating,
+ albumsCount = remoteProfile.logCount,
)
val remoteFeed = api.feed(token).items
@@ -392,9 +393,9 @@ class RemoteSoundScoreRepository : SoundScoreRepository {
return
}
- val email = "phase1b@local.soundscore.app"
- val password = "soundscore-dev-pass"
- val handle = "madhav"
+ val email = System.getenv("SOUNDSCORE_DEV_EMAIL") ?: "phase1b@local.soundscore.app"
+ val password = System.getenv("SOUNDSCORE_DEV_PASSWORD") ?: "soundscore-dev-pass"
+ val handle = System.getenv("SOUNDSCORE_DEV_HANDLE") ?: "madhav"
val auth = runCatching {
api.login(AuthRequest(email = email, password = password))
@@ -411,18 +412,7 @@ class RemoteSoundScoreRepository : SoundScoreRepository {
}
private fun mapAlbum(dto: AlbumDto): Album {
- val colors = SeedData.albums.find { it.id == dto.id }?.artColors
- ?: SeedData.albums.random().artColors
-
- return Album(
- id = dto.id,
- title = dto.title,
- artist = dto.artist,
- year = dto.year,
- artColors = colors,
- avgRating = dto.avgRating,
- logCount = dto.logCount,
- )
+ return mapAlbumDto(dto)
}
private fun mapFeedItem(event: ActivityEventDto): FeedItem {
@@ -458,3 +448,23 @@ object AppContainer {
RemoteSoundScoreRepository()
}
}
+
+internal fun mapAlbumDto(
+ dto: AlbumDto,
+ seedAlbums: List = SeedData.albums,
+): Album {
+ val colors = seedAlbums.find { it.id == dto.id }?.artColors
+ ?: seedAlbums.firstOrNull()?.artColors
+ ?: SeedData.albums.first().artColors
+
+ return Album(
+ id = dto.id,
+ title = dto.title,
+ artist = dto.artist,
+ year = dto.year,
+ artColors = colors,
+ artworkUrl = dto.artworkUrl,
+ avgRating = dto.avgRating,
+ logCount = dto.logCount,
+ )
+}
diff --git a/app/src/main/java/com/soundscore/app/ui/components/AlbumArtPlaceholder.kt b/app/src/main/java/com/soundscore/app/ui/components/AlbumArtPlaceholder.kt
index 4d9f90c..537ff37 100644
--- a/app/src/main/java/com/soundscore/app/ui/components/AlbumArtPlaceholder.kt
+++ b/app/src/main/java/com/soundscore/app/ui/components/AlbumArtPlaceholder.kt
@@ -1,8 +1,13 @@
package com.soundscore.app.ui.components
-import androidx.compose.animation.core.*
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -10,47 +15,89 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
-/**
- * Gradient placeholder with a subtle "shimmer" and depth for the liquid glass look.
- */
@Composable
-fun AlbumArtPlaceholder(
+fun AlbumArtwork(
+ artworkUrl: String?,
colors: List,
modifier: Modifier = Modifier,
- cornerRadius: Dp = 10.dp,
+ cornerRadius: Dp = 12.dp,
) {
- val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
+ val infiniteTransition = rememberInfiniteTransition(label = "artShimmer")
val shimmerShift by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
- animation = tween(3000, easing = LinearEasing),
- repeatMode = RepeatMode.Restart
+ animation = tween(3200, easing = LinearEasing),
),
- label = "shimmerShift"
+ label = "artShimmerShift",
)
+ val shape = if (cornerRadius == 0.dp) RectangleShape else RoundedCornerShape(cornerRadius)
Box(
modifier = modifier
- .clip(RoundedCornerShape(cornerRadius))
- .background(
- brush = Brush.linearGradient(
- colors = colors,
- )
+ .clip(shape)
+ .background(Brush.linearGradient(colors)),
+ ) {
+ if (!artworkUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(artworkUrl)
+ .crossfade(true)
+ .build(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
)
- .background(
- brush = Brush.linearGradient(
- colors = listOf(
- Color.White.copy(alpha = 0.0f),
- Color.White.copy(alpha = 0.05f),
- Color.White.copy(alpha = 0.0f),
- ),
- start = androidx.compose.ui.geometry.Offset(shimmerShift, shimmerShift),
- end = androidx.compose.ui.geometry.Offset(shimmerShift + 200f, shimmerShift + 200f)
- )
- ),
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ brush = Brush.linearGradient(
+ colors = listOf(
+ Color.White.copy(alpha = 0f),
+ Color.White.copy(alpha = 0.08f),
+ Color.White.copy(alpha = 0f),
+ ),
+ start = androidx.compose.ui.geometry.Offset(shimmerShift, shimmerShift),
+ end = androidx.compose.ui.geometry.Offset(shimmerShift + 260f, shimmerShift + 260f),
+ )
+ ),
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ Color.Black.copy(alpha = 0.06f),
+ Color.Black.copy(alpha = 0.22f),
+ )
+ )
+ ),
+ )
+ }
+}
+
+@Composable
+fun AlbumArtPlaceholder(
+ colors: List,
+ modifier: Modifier = Modifier,
+ cornerRadius: Dp = 12.dp,
+) {
+ AlbumArtwork(
+ artworkUrl = null,
+ colors = colors,
+ modifier = modifier,
+ cornerRadius = cornerRadius,
)
}
diff --git a/app/src/main/java/com/soundscore/app/ui/components/AppBackdrop.kt b/app/src/main/java/com/soundscore/app/ui/components/AppBackdrop.kt
new file mode 100644
index 0000000..4122169
--- /dev/null
+++ b/app/src/main/java/com/soundscore/app/ui/components/AppBackdrop.kt
@@ -0,0 +1,41 @@
+package com.soundscore.app.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import com.soundscore.app.ui.theme.AccentGreen
+import com.soundscore.app.ui.theme.AccentViolet
+import com.soundscore.app.ui.theme.DarkBase
+import com.soundscore.app.ui.theme.DarkElevated
+
+@Composable
+fun AppBackdrop(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(DarkElevated, DarkBase, DarkBase)
+ )
+ )
+ .background(
+ Brush.radialGradient(
+ colors = listOf(AccentGreen.copy(alpha = 0.12f), Color.Transparent),
+ center = Offset(120f, -80f),
+ radius = 800f,
+ )
+ )
+ .background(
+ Brush.radialGradient(
+ colors = listOf(AccentViolet.copy(alpha = 0.06f), Color.Transparent),
+ center = Offset(900f, 300f),
+ radius = 600f,
+ )
+ ),
+ )
+}
diff --git a/app/src/main/java/com/soundscore/app/ui/components/GlassCard.kt b/app/src/main/java/com/soundscore/app/ui/components/GlassCard.kt
index 94c30bf..4f00aed 100644
--- a/app/src/main/java/com/soundscore/app/ui/components/GlassCard.kt
+++ b/app/src/main/java/com/soundscore/app/ui/components/GlassCard.kt
@@ -7,13 +7,17 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.runtime.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -22,84 +26,79 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.soundscore.app.ui.theme.GlassBg
import com.soundscore.app.ui.theme.GlassBorder
+import com.soundscore.app.ui.theme.GlassHighlight
-/**
- * Liquid glass card with Apple-like dynamic movement and haptic-style scaling.
- *
- * @param tintColor Optional color that "bleeds" through the glass.
- * @param cornerRadius Corner rounding. Default 16dp.
- * @param borderColor Border color. Default GlassBorder (9% white).
- */
@Composable
fun GlassCard(
modifier: Modifier = Modifier,
tintColor: Color? = null,
- cornerRadius: Dp = 16.dp,
+ cornerRadius: Dp = 20.dp,
borderColor: Color = GlassBorder,
+ contentPadding: PaddingValues = PaddingValues(horizontal = 14.dp, vertical = 14.dp),
+ fillMaxWidth: Boolean = true,
+ frosted: Boolean = false,
onClick: (() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit,
) {
var isPressed by remember { mutableStateOf(false) }
-
- // Smooth spring animation for the "liquid" scale effect
val scale by animateFloatAsState(
- targetValue = if (isPressed) 0.96f else 1f,
- animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f),
- label = "scale"
+ targetValue = if (isPressed) 0.97f else 1f,
+ animationSpec = spring(dampingRatio = 0.65f, stiffness = 500f),
+ label = "glassScale"
)
val shape = RoundedCornerShape(cornerRadius)
- val bgModifier = if (tintColor != null) {
- Modifier.background(
- brush = Brush.linearGradient(
- colors = listOf(
- tintColor.copy(alpha = 0.18f),
- GlassBg.copy(alpha = 0.4f),
- tintColor.copy(alpha = 0.05f),
- )
- ),
- shape = shape,
+ val bgBrush = if (tintColor != null) {
+ Brush.linearGradient(
+ colors = listOf(
+ tintColor.copy(alpha = 0.14f),
+ GlassBg.copy(alpha = 0.60f),
+ tintColor.copy(alpha = 0.04f),
+ )
)
} else {
- Modifier.background(
- brush = Brush.verticalGradient(
- colors = listOf(
- Color.White.copy(alpha = 0.08f),
- GlassBg,
- )
- ),
- shape = shape
+ Brush.verticalGradient(
+ colors = listOf(
+ GlassHighlight.copy(alpha = if (frosted) 0.28f else 0.20f),
+ GlassBg,
+ )
)
}
+ val interactionModifier = if (onClick != null) {
+ Modifier.pointerInput(onClick) {
+ detectTapGestures(
+ onPress = {
+ isPressed = true
+ tryAwaitRelease()
+ isPressed = false
+ },
+ onTap = { onClick() },
+ )
+ }
+ } else {
+ Modifier
+ }
+
Box(
modifier = modifier
- .fillMaxWidth()
+ .then(if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier)
.graphicsLayer {
this.scaleX = scale
this.scaleY = scale
}
.clip(shape)
- .then(bgModifier)
+ .background(brush = bgBrush, shape = shape)
.border(
- width = 0.5.dp,
+ width = 0.5.dp,
brush = Brush.verticalGradient(
- listOf(Color.White.copy(alpha = 0.15f), borderColor)
- ),
- shape = shape
+ listOf(Color.White.copy(alpha = 0.14f), borderColor)
+ ),
+ shape = shape,
)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed = true
- tryAwaitRelease()
- isPressed = false
- },
- onTap = { onClick?.invoke() }
- )
- }
- .padding(12.dp),
+ .then(interactionModifier)
+ .padding(contentPadding),
content = content,
)
}
diff --git a/app/src/main/java/com/soundscore/app/ui/components/NewComponents.kt b/app/src/main/java/com/soundscore/app/ui/components/NewComponents.kt
new file mode 100644
index 0000000..4baebef
--- /dev/null
+++ b/app/src/main/java/com/soundscore/app/ui/components/NewComponents.kt
@@ -0,0 +1,320 @@
+package com.soundscore.app.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.CloudOff
+import androidx.compose.material.icons.outlined.Mic
+import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.soundscore.app.ui.theme.AccentAmber
+import com.soundscore.app.ui.theme.AccentGreen
+import com.soundscore.app.ui.theme.AccentGreenDim
+import com.soundscore.app.ui.theme.ChromeDim
+import com.soundscore.app.ui.theme.ChromeFaint
+import com.soundscore.app.ui.theme.ChromeLight
+import com.soundscore.app.ui.theme.DarkBase
+import com.soundscore.app.ui.theme.FeedItemBorder
+import com.soundscore.app.ui.theme.GlassBg
+import com.soundscore.app.ui.theme.GlassBorder
+import com.soundscore.app.ui.theme.GlassFrosted
+import com.soundscore.app.ui.theme.GlassSheet
+import com.soundscore.app.ui.theme.TextPrimary
+import com.soundscore.app.ui.theme.TextSecondary
+import com.soundscore.app.ui.theme.TextTertiary
+
+@Composable
+fun AvatarCircle(
+ initials: String,
+ gradientColors: List,
+ modifier: Modifier = Modifier,
+ size: Dp = 44.dp,
+) {
+ Box(
+ modifier = modifier
+ .size(size)
+ .clip(CircleShape)
+ .background(Brush.linearGradient(gradientColors))
+ .border(1.dp, Color.White.copy(alpha = 0.12f), CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = initials.take(2).uppercase(),
+ style = MaterialTheme.typography.labelLarge,
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ fontSize = (size.value * 0.36f).sp,
+ )
+ }
+}
+
+@Composable
+fun PillSearchBar(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ placeholder: String = "Albums, artists, friends...",
+) {
+ val shape = RoundedCornerShape(28.dp)
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(shape)
+ .background(GlassFrosted)
+ .border(0.5.dp, GlassBorder, shape)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ Icons.Outlined.Search,
+ contentDescription = null,
+ tint = ChromeDim,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(Modifier.width(12.dp))
+ Box(modifier = Modifier.weight(1f)) {
+ if (query.isEmpty()) {
+ Text(
+ text = placeholder,
+ style = MaterialTheme.typography.bodyLarge,
+ color = TextTertiary,
+ )
+ }
+ BasicTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ textStyle = MaterialTheme.typography.bodyLarge.copy(color = TextPrimary),
+ singleLine = true,
+ cursorBrush = SolidColor(AccentGreen),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ Spacer(Modifier.width(8.dp))
+ Icon(
+ Icons.Outlined.Mic,
+ contentDescription = "Voice search",
+ tint = ChromeFaint,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+}
+
+@Composable
+fun SyncBanner(
+ message: String?,
+ modifier: Modifier = Modifier,
+) {
+ AnimatedVisibility(
+ visible = message != null,
+ enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
+ exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(),
+ modifier = modifier,
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(AccentAmber.copy(alpha = 0.12f))
+ .border(0.5.dp, AccentAmber.copy(alpha = 0.3f), RoundedCornerShape(0.dp))
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Outlined.CloudOff,
+ contentDescription = null,
+ tint = AccentAmber,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = message ?: "",
+ style = MaterialTheme.typography.bodySmall,
+ color = AccentAmber,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun EmptyState(
+ title: String,
+ subtitle: String,
+ modifier: Modifier = Modifier,
+ icon: ImageVector? = null,
+ actionLabel: String? = null,
+ onAction: (() -> Unit)? = null,
+) {
+ GlassCard(
+ modifier = modifier,
+ cornerRadius = 24.dp,
+ borderColor = FeedItemBorder,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (icon != null) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(GlassSheet),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ tint = ChromeDim,
+ modifier = Modifier.size(28.dp),
+ )
+ }
+ Spacer(Modifier.height(16.dp))
+ }
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = ChromeLight,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = TextSecondary,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ if (actionLabel != null && onAction != null) {
+ Spacer(Modifier.height(20.dp))
+ BlueButton(text = actionLabel, onClick = onAction)
+ }
+ }
+ }
+}
+
+@Composable
+fun TimelineEntry(
+ dateLabel: String,
+ timeLabel: String,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.width(48.dp),
+ ) {
+ Text(
+ text = dateLabel.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = AccentGreen,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = timeLabel,
+ style = MaterialTheme.typography.labelSmall,
+ color = TextTertiary,
+ )
+ Spacer(Modifier.height(8.dp))
+ Box(
+ modifier = Modifier
+ .width(1.dp)
+ .height(40.dp)
+ .background(FeedItemBorder),
+ )
+ }
+ Box(modifier = Modifier.weight(1f)) {
+ content()
+ }
+ }
+}
+
+@Composable
+fun GlassIconButton(
+ icon: ImageVector,
+ label: String,
+ modifier: Modifier = Modifier,
+ tint: Color = ChromeLight,
+ onClick: () -> Unit = {},
+) {
+ Column(
+ modifier = modifier
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onClick,
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(GlassBg)
+ .border(0.5.dp, GlassBorder, CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(22.dp))
+ }
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = TextSecondary,
+ )
+ }
+}
diff --git a/app/src/main/java/com/soundscore/app/ui/components/PremiumComponents.kt b/app/src/main/java/com/soundscore/app/ui/components/PremiumComponents.kt
new file mode 100644
index 0000000..bd0f85c
--- /dev/null
+++ b/app/src/main/java/com/soundscore/app/ui/components/PremiumComponents.kt
@@ -0,0 +1,301 @@
+package com.soundscore.app.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.TrendingUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.soundscore.app.data.model.Album
+import com.soundscore.app.ui.theme.AccentGreen
+import com.soundscore.app.ui.theme.AccentGreenDim
+import com.soundscore.app.ui.theme.ChromeLight
+import com.soundscore.app.ui.theme.FeedItemBorder
+import com.soundscore.app.ui.theme.GlassBg
+import com.soundscore.app.ui.theme.GlassBorder
+import com.soundscore.app.ui.theme.TextSecondary
+import com.soundscore.app.ui.theme.TextTertiary
+import com.soundscore.app.ui.viewmodel.ChartEntry
+
+@Composable
+fun ScreenHeader(
+ title: String,
+ subtitle: String,
+ modifier: Modifier = Modifier,
+ actionLabel: String? = null,
+ onActionClick: (() -> Unit)? = null,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Top,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.displaySmall,
+ color = ChromeLight,
+ )
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = TextSecondary,
+ )
+ }
+
+ if (actionLabel != null) {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(AccentGreenDim)
+ .border(0.5.dp, AccentGreen.copy(alpha = 0.28f), RoundedCornerShape(20.dp))
+ .then(if (onActionClick != null) Modifier.clickable(onClick = onActionClick) else Modifier)
+ .padding(horizontal = 14.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = actionLabel,
+ style = MaterialTheme.typography.labelMedium,
+ color = AccentGreen,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun SectionHeader(
+ eyebrow: String,
+ title: String,
+ modifier: Modifier = Modifier,
+ trailing: String? = null,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ Column {
+ Text(
+ text = eyebrow.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = TextTertiary,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = ChromeLight,
+ )
+ }
+ if (trailing != null) {
+ Text(
+ text = trailing,
+ style = MaterialTheme.typography.labelMedium,
+ color = AccentGreen,
+ )
+ }
+ }
+}
+
+@Composable
+fun StatPill(
+ value: String,
+ label: String,
+ modifier: Modifier = Modifier,
+ highlight: Boolean = false,
+ accentColor: androidx.compose.ui.graphics.Color = AccentGreen,
+) {
+ val backgroundColor = if (highlight) accentColor.copy(alpha = 0.10f) else GlassBg
+ val borderColor = if (highlight) accentColor.copy(alpha = 0.24f) else FeedItemBorder
+
+ Column(
+ modifier = modifier
+ .clip(RoundedCornerShape(18.dp))
+ .background(backgroundColor)
+ .border(0.5.dp, borderColor, RoundedCornerShape(18.dp))
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ ) {
+ Text(
+ text = value,
+ style = MaterialTheme.typography.titleLarge,
+ color = if (highlight) accentColor else ChromeLight,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = label.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = if (highlight) accentColor.copy(alpha = 0.8f) else TextTertiary,
+ )
+ }
+}
+
+@Composable
+fun ActionChip(
+ text: String,
+ icon: ImageVector,
+ modifier: Modifier = Modifier,
+ active: Boolean = false,
+ onClick: (() -> Unit)? = null,
+) {
+ Row(
+ modifier = modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(if (active) AccentGreenDim else GlassBg)
+ .border(
+ 0.5.dp,
+ if (active) AccentGreen.copy(alpha = 0.24f) else GlassBorder,
+ RoundedCornerShape(20.dp),
+ )
+ .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = if (active) AccentGreen else TextSecondary,
+ modifier = Modifier.size(14.dp),
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium,
+ color = if (active) AccentGreen else TextSecondary,
+ )
+ }
+}
+
+@Composable
+fun MosaicCover(
+ albums: List,
+ modifier: Modifier = Modifier,
+ cornerRadius: Dp = 18.dp,
+) {
+ val coverSlots = albums.take(4).map { it as Album? } + List((4 - albums.take(4).size).coerceAtLeast(0)) { null }
+
+ GlassCard(
+ modifier = modifier,
+ fillMaxWidth = false,
+ cornerRadius = cornerRadius,
+ contentPadding = PaddingValues(5.dp),
+ borderColor = FeedItemBorder,
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ coverSlots.chunked(2).forEach { row ->
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ row.forEach { album ->
+ if (album != null) {
+ AlbumArtwork(
+ artworkUrl = album.artworkUrl,
+ colors = album.artColors,
+ modifier = Modifier.size(52.dp),
+ cornerRadius = 10.dp,
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .size(52.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(GlassBg),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun TrendChartRow(
+ entry: ChartEntry,
+ modifier: Modifier = Modifier,
+) {
+ GlassCard(
+ modifier = modifier,
+ cornerRadius = 20.dp,
+ borderColor = FeedItemBorder,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(AccentGreenDim),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = entry.rank.toString(),
+ style = MaterialTheme.typography.labelLarge,
+ color = AccentGreen,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ AlbumArtwork(
+ artworkUrl = entry.album.artworkUrl,
+ colors = entry.album.artColors,
+ modifier = Modifier.size(48.dp),
+ cornerRadius = 14.dp,
+ )
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(entry.album.title, style = MaterialTheme.typography.titleMedium)
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = "${entry.album.artist} · ${entry.album.logCount} logs",
+ style = MaterialTheme.typography.bodySmall,
+ color = TextSecondary,
+ )
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.TrendingUp,
+ contentDescription = null,
+ tint = AccentGreen,
+ modifier = Modifier.size(14.dp),
+ )
+ Text(
+ text = entry.movementLabel,
+ style = MaterialTheme.typography.labelMedium,
+ color = AccentGreen,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/soundscore/app/ui/components/SoundScoreButton.kt b/app/src/main/java/com/soundscore/app/ui/components/SoundScoreButton.kt
index 12dd360..584cb1e 100644
--- a/app/src/main/java/com/soundscore/app/ui/components/SoundScoreButton.kt
+++ b/app/src/main/java/com/soundscore/app/ui/components/SoundScoreButton.kt
@@ -12,38 +12,40 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import com.soundscore.app.ui.theme.*
+import com.soundscore.app.ui.theme.AccentGreen
+import com.soundscore.app.ui.theme.DarkBase
+import com.soundscore.app.ui.theme.GlassBg
+import com.soundscore.app.ui.theme.GlassBorder
+import com.soundscore.app.ui.theme.TextPrimary
-/**
- * Primary CTA — Electric Blue with glow shadow.
- */
@Composable
fun BlueButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
+ enabled: Boolean = true,
) {
Button(
onClick = onClick,
modifier = modifier,
- shape = RoundedCornerShape(12.dp),
+ enabled = enabled,
+ shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
- containerColor = ElectricBlue,
+ containerColor = AccentGreen,
contentColor = DarkBase,
+ disabledContainerColor = AccentGreen.copy(alpha = 0.38f),
+ disabledContentColor = DarkBase.copy(alpha = 0.5f),
),
- contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp),
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp),
) {
Text(
text = text,
- fontWeight = FontWeight.SemiBold,
- fontSize = 13.sp,
+ fontWeight = FontWeight.Bold,
+ fontSize = 14.sp,
)
}
}
-/**
- * Ghost / secondary button — dark glass with chrome border.
- */
@Composable
fun GhostButton(
text: String,
@@ -53,18 +55,18 @@ fun GhostButton(
OutlinedButton(
onClick = onClick,
modifier = modifier,
- shape = RoundedCornerShape(10.dp),
- border = BorderStroke(1.dp, GlassBorder),
+ shape = RoundedCornerShape(20.dp),
+ border = BorderStroke(0.5.dp, GlassBorder),
colors = ButtonDefaults.outlinedButtonColors(
- containerColor = GlassBg,
- contentColor = ChromeMedium,
+ containerColor = GlassBg.copy(alpha = 0.6f),
+ contentColor = TextPrimary,
),
- contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp),
+ contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp),
) {
Text(
text = text,
fontWeight = FontWeight.Medium,
- fontSize = 12.sp,
+ fontSize = 13.sp,
)
}
}
diff --git a/app/src/main/java/com/soundscore/app/ui/components/StarRating.kt b/app/src/main/java/com/soundscore/app/ui/components/StarRating.kt
index 916ea7d..170a925 100644
--- a/app/src/main/java/com/soundscore/app/ui/components/StarRating.kt
+++ b/app/src/main/java/com/soundscore/app/ui/components/StarRating.kt
@@ -20,51 +20,37 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import com.soundscore.app.ui.theme.AccentAmber
import com.soundscore.app.ui.theme.ChromeFaint
-import com.soundscore.app.ui.theme.ElectricBlue
-/**
- * Half-star rating component.
- *
- * Chrome (empty) by default → fills to Electric Blue when selected.
- *
- * @param rating Current rating (0.0 – 5.0, half-star increments).
- * @param onRate Called with the new rating when a star is tapped.
- * Pass null to make the component read-only.
- * @param starSize Icon size. Default 24dp.
- */
@Composable
fun StarRating(
rating: Float,
modifier: Modifier = Modifier,
onRate: ((Float) -> Unit)? = null,
- starSize: Dp = 24.dp,
+ starSize: Dp = 22.dp,
maxStars: Int = 5,
) {
val haptic = LocalHapticFeedback.current
Row(
modifier = modifier,
- horizontalArrangement = Arrangement.spacedBy(2.dp),
+ horizontalArrangement = Arrangement.spacedBy(1.dp),
) {
for (i in 1..maxStars) {
val starValue = i.toFloat()
val isFilled = rating >= starValue - 0.5f
-
+
val icon = when {
rating >= starValue -> Icons.Filled.Star
rating >= starValue - 0.5f -> Icons.Filled.StarHalf
else -> Icons.Outlined.StarOutline
}
- val tint = if (isFilled) ElectricBlue else ChromeFaint
+ val tint = if (isFilled) AccentAmber else ChromeFaint
- // Star rating spring bounce
val scale by animateFloatAsState(
- targetValue = if (isFilled) 1f else 0.8f,
- animationSpec = spring(
- dampingRatio = 0.4f,
- stiffness = 600f
- ),
+ targetValue = if (isFilled) 1f else 0.85f,
+ animationSpec = spring(dampingRatio = 0.45f, stiffness = 600f),
label = "starBounce"
)
diff --git a/app/src/main/java/com/soundscore/app/ui/screens/FeedScreen.kt b/app/src/main/java/com/soundscore/app/ui/screens/FeedScreen.kt
index 8626b1b..b0d8e37 100644
--- a/app/src/main/java/com/soundscore/app/ui/screens/FeedScreen.kt
+++ b/app/src/main/java/com/soundscore/app/ui/screens/FeedScreen.kt
@@ -1,36 +1,72 @@
package com.soundscore.app.ui.screens
-import androidx.compose.animation.*
-import androidx.compose.animation.core.*
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AddCircleOutline
+import androidx.compose.material.icons.outlined.ChatBubbleOutline
+import androidx.compose.material.icons.outlined.FavoriteBorder
+import androidx.compose.material.icons.outlined.People
+import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
-import androidx.compose.runtime.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.soundscore.app.data.model.Album
import com.soundscore.app.data.model.FeedItem
-import com.soundscore.app.ui.components.AlbumArtPlaceholder
+import com.soundscore.app.ui.components.ActionChip
+import com.soundscore.app.ui.components.AlbumArtwork
+import com.soundscore.app.ui.components.AvatarCircle
+import com.soundscore.app.ui.components.EmptyState
import com.soundscore.app.ui.components.GlassCard
+import com.soundscore.app.ui.components.ScreenHeader
+import com.soundscore.app.ui.components.SectionHeader
import com.soundscore.app.ui.components.StarRating
-import com.soundscore.app.ui.theme.*
+import com.soundscore.app.ui.components.SyncBanner
+import com.soundscore.app.ui.theme.AccentCoral
+import com.soundscore.app.ui.theme.AccentGreen
+import com.soundscore.app.ui.theme.AlbumColors
+import com.soundscore.app.ui.theme.ChromeLight
+import com.soundscore.app.ui.theme.FeedItemBorder
+import com.soundscore.app.ui.theme.TextSecondary
+import com.soundscore.app.ui.theme.TextTertiary
+import com.soundscore.app.ui.viewmodel.FeedUiState
import com.soundscore.app.ui.viewmodel.FeedViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.delay
@Composable
@@ -39,178 +75,260 @@ fun FeedScreen(
feedViewModel: FeedViewModel = viewModel(),
) {
val uiState by feedViewModel.uiState.collectAsStateWithLifecycle()
+ FeedScreenContent(
+ uiState = uiState,
+ modifier = modifier,
+ onToggleLike = feedViewModel::toggleLike,
+ )
+}
+@Composable
+fun FeedScreenContent(
+ uiState: FeedUiState,
+ onToggleLike: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
LazyColumn(
modifier = modifier.fillMaxSize(),
- contentPadding = PaddingValues(bottom = 16.dp),
+ contentPadding = PaddingValues(start = 20.dp, top = 16.dp, end = 20.dp, bottom = 120.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
) {
- // ── Page title ──
item {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 18.dp, vertical = 8.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text("Feed", style = MaterialTheme.typography.headlineMedium)
- // Avatar circle matching mockup
- Box(
- modifier = Modifier
- .size(30.dp)
- .clip(CircleShape)
- .background(
- androidx.compose.ui.graphics.Brush.linearGradient(
- listOf(ElectricBlue, AlbumColors.purple.last())
- )
- )
- .border(1.5.dp, ElectricBlue.copy(alpha = 0.4f), CircleShape),
- )
- }
+ SyncBanner(message = uiState.syncMessage)
}
- // ── Friends strip ──
item {
- val names = listOf("you", "rohan", "priya", "kai", "ananya")
- val colors = listOf(
- listOf(ElectricBlue, AlbumColors.purple.last()),
- AlbumColors.teal,
- AlbumColors.pink,
- AlbumColors.blue,
- AlbumColors.gold,
+ ScreenHeader(
+ title = "Feed",
+ subtitle = "What your people are logging right now.",
)
- LazyRow(
- contentPadding = PaddingValues(horizontal = 14.dp),
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- modifier = Modifier.padding(bottom = 12.dp),
- ) {
- items(names.size) { i ->
- val isMe = i == 0
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Box(
- modifier = Modifier
- .size(38.dp)
- .clip(CircleShape)
- .background(
- androidx.compose.ui.graphics.Brush.linearGradient(colors[i])
- )
- .border(
- width = if (isMe) 1.5.dp else 1.dp,
- color = if (isMe) ElectricBlue else ChromeFaint.copy(alpha = 0.12f),
- shape = CircleShape,
- ),
- )
- Spacer(Modifier.height(3.dp))
- Text(
- names[i],
- style = MaterialTheme.typography.labelSmall,
- color = TextTertiary,
- )
+ }
+
+ if (uiState.trendingAlbums.isNotEmpty()) {
+ item {
+ SectionHeader(eyebrow = "Trending", title = "Hot this week")
+ }
+
+ item {
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ contentPadding = PaddingValues(end = 8.dp),
+ ) {
+ items(uiState.trendingAlbums, key = { it.id }) { album ->
+ TrendingHeroCard(album = album)
}
}
}
}
- // ── Feed items ──
- itemsIndexed(uiState.items, key = { _, item -> item.id }) { index, item ->
- // Staggered list entrance
- var visible by remember { mutableStateOf(false) }
- LaunchedEffect(Unit) {
- delay(index * 80L)
- visible = true
- }
-
- AnimatedVisibility(
- visible = visible,
- enter = fadeIn(animationSpec = tween(400)) +
- slideInVertically(initialOffsetY = { 40 }, animationSpec = tween(400)),
- ) {
- FeedCard(
- item = item,
- onToggleLike = { feedViewModel.toggleLike(item.id) },
+ if (uiState.items.isEmpty()) {
+ item {
+ EmptyState(
+ title = "Your feed is quiet",
+ subtitle = "Follow friends to see their ratings, reviews, and lists here.",
+ icon = Icons.Outlined.People,
)
}
+ } else {
+ item {
+ SectionHeader(eyebrow = "Activity", title = "From your circle")
+ }
+
+ itemsIndexed(uiState.items, key = { _, it -> it.id }) { index, item ->
+ var visible by remember(item.id) { mutableStateOf(false) }
+ LaunchedEffect(item.id) {
+ delay((index * 40).toLong())
+ visible = true
+ }
+
+ AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn(animationSpec = tween(300)) + slideInVertically(
+ initialOffsetY = { 30 },
+ animationSpec = tween(300),
+ ),
+ ) {
+ FeedActivityCard(
+ item = item,
+ onToggleLike = { onToggleLike(item.id) },
+ )
+ }
+ }
}
}
}
@Composable
-private fun FeedCard(
- item: FeedItem,
- onToggleLike: () -> Unit,
-) {
- // Pulsing glow on liked heart
- val infiniteTransition = rememberInfiniteTransition(label = "pulse")
- val pulseAlpha by infiniteTransition.animateFloat(
- initialValue = 0.6f,
- targetValue = 1.0f,
- animationSpec = infiniteRepeatable(
- animation = tween(1500, easing = LinearOutSlowInEasing),
- repeatMode = RepeatMode.Reverse
- ),
- label = "pulseAlpha"
- )
-
+private fun TrendingHeroCard(album: Album) {
GlassCard(
- modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
- tintColor = item.album.artColors.firstOrNull(),
- cornerRadius = 16.dp,
+ modifier = Modifier.size(width = 200.dp, height = 260.dp),
+ fillMaxWidth = false,
+ cornerRadius = 24.dp,
borderColor = FeedItemBorder,
+ contentPadding = PaddingValues(0.dp),
) {
- Row(modifier = Modifier.fillMaxWidth()) {
- AlbumArtPlaceholder(
- colors = item.album.artColors,
- modifier = Modifier.size(48.dp),
- cornerRadius = 9.dp,
+ Box(modifier = Modifier.fillMaxSize()) {
+ AlbumArtwork(
+ artworkUrl = album.artworkUrl,
+ colors = album.artColors,
+ modifier = Modifier.fillMaxSize(),
+ cornerRadius = 0.dp,
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.7f)),
+ startY = 100f,
+ )
+ ),
)
- Spacer(Modifier.width(10.dp))
- Column(modifier = Modifier.weight(1f)) {
- // @username highlighted + action text
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(14.dp),
+ ) {
Text(
- buildAnnotatedString {
- withStyle(SpanStyle(color = TextPrimary, fontWeight = FontWeight.SemiBold)) {
- append("@${item.username}")
- }
- withStyle(SpanStyle(color = ChromeDim)) {
- append(" ${item.action}")
- }
- },
- style = MaterialTheme.typography.bodySmall,
+ text = album.title,
+ style = MaterialTheme.typography.titleLarge,
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ maxLines = 2,
)
- Text(item.album.title, style = MaterialTheme.typography.titleSmall)
+ Spacer(Modifier.height(2.dp))
Text(
- "${item.album.artist} · ${item.album.year}",
+ text = album.artist,
style = MaterialTheme.typography.bodySmall,
- color = TextSecondary,
+ color = Color.White.copy(alpha = 0.8f),
)
- Spacer(Modifier.height(3.dp))
- StarRating(rating = item.rating, starSize = 12.dp)
-
- if (!item.reviewSnippet.isNullOrBlank()) {
- Spacer(Modifier.height(3.dp))
+ Spacer(Modifier.height(6.dp))
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ StarRating(rating = album.avgRating, starSize = 12.dp)
Text(
- "\"${item.reviewSnippet}\"",
- style = MaterialTheme.typography.bodySmall,
- fontStyle = FontStyle.Italic,
- color = ChromeDim,
+ text = "${album.logCount}",
+ style = MaterialTheme.typography.labelSmall,
+ color = AccentGreen,
)
}
+ }
+ }
+ }
+}
+
+@Composable
+private fun FeedActivityCard(
+ item: FeedItem,
+ onToggleLike: () -> Unit,
+) {
+ GlassCard(
+ cornerRadius = 22.dp,
+ borderColor = FeedItemBorder,
+ contentPadding = PaddingValues(12.dp),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AvatarCircle(
+ initials = item.username.take(2),
+ gradientColors = avatarColors(item.username),
+ size = 38.dp,
+ )
+ Column {
+ Text(
+ text = "@${item.username}",
+ style = MaterialTheme.typography.titleMedium,
+ color = ChromeLight,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ text = item.action,
+ style = MaterialTheme.typography.bodySmall,
+ color = TextSecondary,
+ )
+ }
+ }
+ Text(
+ text = item.timeAgo,
+ style = MaterialTheme.typography.labelSmall,
+ color = TextTertiary,
+ )
+ }
- Spacer(Modifier.height(5.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ AlbumArtwork(
+ artworkUrl = item.album.artworkUrl,
+ colors = item.album.artColors,
+ modifier = Modifier.size(72.dp),
+ cornerRadius = 16.dp,
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
Text(
- "♥ ${item.likes}",
- style = MaterialTheme.typography.labelSmall,
- color = if (item.isLiked) ElectricBlue else TextTertiary,
- modifier = Modifier.graphicsLayer {
- alpha = if (item.isLiked) pulseAlpha else 1f
- }.clickable { onToggleLike() }
+ text = item.album.title,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = "${item.album.artist} · ${item.album.year}",
+ style = MaterialTheme.typography.bodySmall,
+ color = TextSecondary,
)
- Text("💬 ${item.comments}", style = MaterialTheme.typography.labelSmall, color = TextTertiary)
- Text("+ Log", style = MaterialTheme.typography.labelSmall, color = TextTertiary)
+ StarRating(rating = item.rating, starSize = 14.dp)
}
}
- Text(item.timeAgo, style = MaterialTheme.typography.labelSmall, color = TextTertiary)
+
+ if (!item.reviewSnippet.isNullOrBlank()) {
+ Text(
+ text = "\"${item.reviewSnippet}\"",
+ style = MaterialTheme.typography.bodyMedium,
+ color = ChromeLight.copy(alpha = 0.9f),
+ fontStyle = FontStyle.Italic,
+ )
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ ActionChip(
+ text = "${item.likes}",
+ icon = Icons.Outlined.FavoriteBorder,
+ active = item.isLiked,
+ onClick = onToggleLike,
+ )
+ ActionChip(
+ text = "${item.comments}",
+ icon = Icons.Outlined.ChatBubbleOutline,
+ )
+ ActionChip(
+ text = "Share",
+ icon = Icons.Outlined.Share,
+ )
+ }
}
}
}
+
+private fun avatarColors(username: String): List {
+ val palettes = listOf(
+ AlbumColors.forest, AlbumColors.rose, AlbumColors.orchid,
+ AlbumColors.lagoon, AlbumColors.amber, AlbumColors.midnight,
+ AlbumColors.lime, AlbumColors.ember, AlbumColors.coral,
+ AlbumColors.slate,
+ )
+ return palettes[kotlin.math.abs(username.hashCode()) % palettes.count()]
+}
diff --git a/app/src/main/java/com/soundscore/app/ui/screens/ListsScreen.kt b/app/src/main/java/com/soundscore/app/ui/screens/ListsScreen.kt
index 7082bef..6789bd5 100644
--- a/app/src/main/java/com/soundscore/app/ui/screens/ListsScreen.kt
+++ b/app/src/main/java/com/soundscore/app/ui/screens/ListsScreen.kt
@@ -1,20 +1,28 @@
package com.soundscore.app.ui.screens
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.AlertDialog
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.PlaylistAdd
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TextField
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -22,143 +30,235 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
-import com.soundscore.app.data.model.UserList
+import com.soundscore.app.ui.components.AlbumArtwork
import com.soundscore.app.ui.components.BlueButton
+import com.soundscore.app.ui.components.EmptyState
import com.soundscore.app.ui.components.GlassCard
+import com.soundscore.app.ui.components.MosaicCover
+import com.soundscore.app.ui.components.PillSearchBar
+import com.soundscore.app.ui.components.ScreenHeader
+import com.soundscore.app.ui.components.SectionHeader
+import com.soundscore.app.ui.components.SyncBanner
+import com.soundscore.app.ui.theme.AccentGreen
import com.soundscore.app.ui.theme.ChromeLight
+import com.soundscore.app.ui.theme.DarkElevated
+import com.soundscore.app.ui.theme.FeedItemBorder
+import com.soundscore.app.ui.theme.GlassBg
+import com.soundscore.app.ui.theme.GlassBorder
import com.soundscore.app.ui.theme.TextSecondary
import com.soundscore.app.ui.theme.TextTertiary
+import com.soundscore.app.ui.viewmodel.ListShowcase
+import com.soundscore.app.ui.viewmodel.ListsUiState
import com.soundscore.app.ui.viewmodel.ListsViewModel
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListsScreen(
modifier: Modifier = Modifier,
listsViewModel: ListsViewModel = viewModel(),
) {
val uiState by listsViewModel.uiState.collectAsStateWithLifecycle()
- var showCreateDialog by remember { mutableStateOf(false) }
+ var showCreateSheet by remember { mutableStateOf(false) }
var draftTitle by remember { mutableStateOf("") }
- if (showCreateDialog) {
- AlertDialog(
- onDismissRequest = { showCreateDialog = false },
- title = { Text("Create list") },
- text = {
- TextField(
- value = draftTitle,
- onValueChange = { draftTitle = it },
- placeholder = { Text("Albums I Would Defend") },
- singleLine = true,
+ if (showCreateSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { showCreateSheet = false },
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ containerColor = DarkElevated,
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ ) {
+ Text(
+ text = "Create a list",
+ style = MaterialTheme.typography.headlineSmall,
+ color = ChromeLight,
)
- },
- confirmButton = {
- TextButton(onClick = {
- listsViewModel.createList(draftTitle)
- draftTitle = ""
- showCreateDialog = false
- }) {
- Text("Create")
- }
- },
- dismissButton = {
- TextButton(onClick = {
- showCreateDialog = false
- draftTitle = ""
- }) {
- Text("Cancel")
- }
- },
- )
+ Spacer(Modifier.height(16.dp))
+ PillSearchBar(
+ query = draftTitle,
+ onQueryChange = { draftTitle = it },
+ placeholder = "Albums I Would Defend...",
+ )
+ Spacer(Modifier.height(20.dp))
+ BlueButton(
+ text = "Create",
+ onClick = {
+ if (draftTitle.isNotBlank()) {
+ listsViewModel.createList(draftTitle)
+ draftTitle = ""
+ showCreateSheet = false
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = draftTitle.isNotBlank(),
+ )
+ Spacer(Modifier.height(24.dp))
+ }
+ }
}
- Column(
+ ListsScreenContent(
+ uiState = uiState,
+ modifier = modifier,
+ onCreateClick = { showCreateSheet = true },
+ )
+}
+
+@Composable
+fun ListsScreenContent(
+ uiState: ListsUiState,
+ onCreateClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyColumn(
modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(start = 20.dp, top = 16.dp, end = 20.dp, bottom = 120.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
) {
- Text(
- "Lists",
- style = MaterialTheme.typography.headlineMedium,
- modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
- )
+ item {
+ SyncBanner(message = uiState.syncMessage)
+ }
- if (uiState.lists.isEmpty()) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(horizontal = 24.dp),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- GlassCard(cornerRadius = 20.dp) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 24.dp),
- ) {
- Text(
- "Curate your taste",
- style = MaterialTheme.typography.titleLarge,
- color = ChromeLight,
- )
- Spacer(Modifier.height(6.dp))
- Text(
- "Create ranked lists, share them\nas cards, discover what friends list.",
- style = MaterialTheme.typography.bodyMedium,
- color = TextSecondary,
- modifier = Modifier.padding(horizontal = 16.dp),
- )
- Spacer(Modifier.height(18.dp))
- BlueButton(
- text = "Create your first list",
- onClick = { showCreateDialog = true },
- )
+ item {
+ ScreenHeader(
+ title = "Lists",
+ subtitle = "Curated collections worth sharing.",
+ actionLabel = "Create",
+ onActionClick = onCreateClick,
+ )
+ }
+
+ if (uiState.showcases.isNotEmpty()) {
+ item {
+ FeaturedListHero(showcase = uiState.showcases.first())
+ }
+ }
+
+ if (uiState.showcases.size > 1) {
+ item {
+ SectionHeader(eyebrow = "Your lists", title = "Collections")
+ }
+
+ item {
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ contentPadding = PaddingValues(end = 8.dp),
+ ) {
+ items(uiState.showcases.drop(1), key = { it.list.id }) { showcase ->
+ CompactListCard(showcase = showcase)
}
}
}
- } else {
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- item {
- BlueButton(
- text = "Create list",
- onClick = { showCreateDialog = true },
- modifier = Modifier.fillMaxWidth(),
- )
- }
- items(uiState.lists, key = { it.id }) { list ->
- ListCard(list)
- }
+ }
+
+ if (uiState.showcases.isEmpty()) {
+ item {
+ EmptyState(
+ title = "Build your first collection",
+ subtitle = "Arrange records into ranked moods, eras, or arguments worth sharing.",
+ icon = Icons.Outlined.PlaylistAdd,
+ actionLabel = "Create a list",
+ onAction = onCreateClick,
+ )
}
}
+
}
}
@Composable
-private fun ListCard(list: UserList) {
- GlassCard(cornerRadius = 14.dp) {
- Column(modifier = Modifier.fillMaxWidth()) {
- Text(
- text = list.title,
- style = MaterialTheme.typography.titleMedium,
+private fun FeaturedListHero(showcase: ListShowcase) {
+ GlassCard(
+ cornerRadius = 24.dp,
+ borderColor = FeedItemBorder,
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Box(modifier = Modifier.fillMaxWidth().height(180.dp)) {
+ val coverAlbum = showcase.coverAlbums.firstOrNull()
+ if (coverAlbum != null) {
+ AlbumArtwork(
+ artworkUrl = coverAlbum.artworkUrl,
+ colors = coverAlbum.artColors,
+ modifier = Modifier.fillMaxSize(),
+ cornerRadius = 0.dp,
+ )
+ }
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.75f)),
+ startY = 40f,
+ )
+ ),
)
- if (!list.note.isNullOrBlank()) {
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(16.dp),
+ ) {
+ Text(
+ text = "FEATURED",
+ style = MaterialTheme.typography.labelSmall,
+ color = AccentGreen,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = showcase.list.title,
+ style = MaterialTheme.typography.headlineMedium,
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ )
Spacer(Modifier.height(4.dp))
Text(
- text = list.note,
+ text = "${showcase.list.curatorHandle} · ${showcase.list.albumIds.size} albums · ${showcase.list.saves} saves",
style = MaterialTheme.typography.bodySmall,
- color = TextSecondary,
+ color = Color.White.copy(alpha = 0.7f),
)
}
- Spacer(Modifier.height(6.dp))
+ }
+ }
+}
+
+@Composable
+private fun CompactListCard(showcase: ListShowcase) {
+ GlassCard(
+ modifier = Modifier.width(180.dp),
+ fillMaxWidth = false,
+ cornerRadius = 20.dp,
+ borderColor = FeedItemBorder,
+ contentPadding = PaddingValues(10.dp),
+ onClick = { },
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ MosaicCover(
+ albums = showcase.coverAlbums,
+ cornerRadius = 14.dp,
+ )
+ Text(
+ text = showcase.list.title,
+ style = MaterialTheme.typography.titleMedium,
+ color = ChromeLight,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ )
Text(
- text = "${list.albumIds.size} items",
- style = MaterialTheme.typography.labelSmall,
+ text = "${showcase.list.albumIds.size} albums",
+ style = MaterialTheme.typography.bodySmall,
color = TextTertiary,
)
}
diff --git a/app/src/main/java/com/soundscore/app/ui/screens/LogScreen.kt b/app/src/main/java/com/soundscore/app/ui/screens/LogScreen.kt
index 6a9feed..18d7bac 100644
--- a/app/src/main/java/com/soundscore/app/ui/screens/LogScreen.kt
+++ b/app/src/main/java/com/soundscore/app/ui/screens/LogScreen.kt
@@ -1,201 +1,352 @@
package com.soundscore.app.ui.screens
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
import com.soundscore.app.data.model.Album
-import com.soundscore.app.ui.components.AlbumArtPlaceholder
+import com.soundscore.app.ui.components.AlbumArtwork
+import com.soundscore.app.ui.components.GlassCard
+import com.soundscore.app.ui.components.ScreenHeader
+import com.soundscore.app.ui.components.SectionHeader
import com.soundscore.app.ui.components.StarRating
-import com.soundscore.app.ui.theme.*
+import com.soundscore.app.ui.components.StatPill
+import com.soundscore.app.ui.components.SyncBanner
+import com.soundscore.app.ui.components.TimelineEntry
+import com.soundscore.app.ui.theme.AccentAmber
+import com.soundscore.app.ui.theme.AccentGreen
+import com.soundscore.app.ui.theme.AccentGreenDim
+import com.soundscore.app.ui.theme.ChromeLight
+import com.soundscore.app.ui.theme.DarkBase
+import com.soundscore.app.ui.theme.DarkElevated
+import com.soundscore.app.ui.theme.FeedItemBorder
+import com.soundscore.app.ui.theme.GlassBg
+import com.soundscore.app.ui.theme.GlassBorder
+import com.soundscore.app.ui.theme.TextSecondary
+import com.soundscore.app.ui.theme.TextTertiary
+import com.soundscore.app.ui.viewmodel.LogUiState
import com.soundscore.app.ui.viewmodel.LogViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.lifecycle.viewmodel.compose.viewModel
+import com.soundscore.app.ui.viewmodel.RecentLogEntry
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogScreen(
modifier: Modifier = Modifier,
logViewModel: LogViewModel = viewModel(),
) {
val uiState by logViewModel.uiState.collectAsStateWithLifecycle()
+ var showSearchSheet by remember { mutableStateOf(false) }
- Column(
- modifier = modifier
- .fillMaxSize()
- .verticalScroll(rememberScrollState()),
- ) {
- // ── Header ──
- Row(
+ if (showSearchSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { showSearchSheet = false },
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ containerColor = DarkElevated,
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ ) {
+ Text(
+ text = "Log an album",
+ style = MaterialTheme.typography.headlineSmall,
+ color = ChromeLight,
+ )
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = "Album search coming soon. Use the Quick Rate cards below to log albums.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = TextSecondary,
+ )
+ Spacer(Modifier.height(24.dp))
+ }
+ }
+ }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ LogScreenContent(
+ uiState = uiState,
+ onRate = logViewModel::updateRating,
+ )
+
+ FloatingActionButton(
+ onClick = { showSearchSheet = true },
modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 18.dp, vertical = 8.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
+ .align(Alignment.BottomEnd)
+ .padding(end = 20.dp, bottom = 24.dp),
+ shape = CircleShape,
+ containerColor = AccentGreen,
+ contentColor = DarkBase,
) {
- Text("Log", style = MaterialTheme.typography.headlineMedium)
- Text("+ Manual", style = MaterialTheme.typography.labelLarge, color = ElectricBlue)
+ Icon(Icons.Filled.Add, contentDescription = "Log Album")
}
+ }
+}
- // ── Recently played ──
- SectionLabel("Recently played")
+@Composable
+fun LogScreenContent(
+ uiState: LogUiState,
+ onRate: (String, Float) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(start = 20.dp, top = 16.dp, end = 20.dp, bottom = 120.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ item {
+ SyncBanner(message = uiState.syncMessage)
+ }
- // 3-column grid via chunked rows
- uiState.albums.chunked(3).forEach { rowAlbums ->
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp)
- .padding(bottom = 7.dp),
- horizontalArrangement = Arrangement.spacedBy(7.dp),
+ item {
+ ScreenHeader(
+ title = "Diary",
+ subtitle = "Your listening journal. Rate, log, repeat.",
+ )
+ }
+
+ item {
+ GlassCard(
+ cornerRadius = 22.dp,
+ borderColor = FeedItemBorder,
+ frosted = true,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ uiState.summaryStats.forEach { stat ->
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = stat.value,
+ style = MaterialTheme.typography.headlineMedium,
+ color = if (stat.label == "This week") AccentGreen else ChromeLight,
+ fontWeight = FontWeight.Black,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = stat.label.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = TextTertiary,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ item {
+ SectionHeader(
+ eyebrow = "Quick rate",
+ title = "Tap to rate",
+ )
+ }
+
+ item {
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ contentPadding = PaddingValues(end = 8.dp),
) {
- rowAlbums.forEach { album ->
- AlbumTile(
+ items(uiState.quickLogAlbums, key = { it.id }) { album ->
+ QuickRateCard(
album = album,
rating = uiState.ratings[album.id] ?: 0f,
- onRate = { logViewModel.updateRating(album.id, it) },
- modifier = Modifier.weight(1f),
+ onRate = { onRate(album.id, it) },
)
}
- // Fill remaining slots if row is not full
- repeat(3 - rowAlbums.size) {
- Spacer(Modifier.weight(1f))
- }
}
}
- // ── Write later queue ──
- Spacer(Modifier.height(7.dp))
- SectionLabel("Write later queue")
+ if (uiState.recentLogs.isNotEmpty()) {
+ item {
+ SectionHeader(
+ eyebrow = "Recent",
+ title = "Your diary entries",
+ )
+ }
- uiState.writeLaterQueue.forEach { album ->
- QueueItem(album = album)
+ items(uiState.recentLogs, key = { "${it.album.id}-${it.timeLabel}" }) { entry ->
+ TimelineEntry(
+ dateLabel = entry.dateLabel,
+ timeLabel = entry.timeLabel,
+ ) {
+ DiaryEntryCard(entry = entry)
+ }
+ }
}
- Spacer(Modifier.height(16.dp))
+ item {
+ GlassCard(
+ cornerRadius = 20.dp,
+ borderColor = FeedItemBorder,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = "Write Later",
+ style = MaterialTheme.typography.titleMedium,
+ color = ChromeLight,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = "Queue albums for later review — coming soon",
+ style = MaterialTheme.typography.bodySmall,
+ color = TextTertiary,
+ )
+ }
+ }
+ }
}
}
@Composable
-private fun AlbumTile(
+private fun QuickRateCard(
album: Album,
rating: Float,
onRate: (Float) -> Unit,
- modifier: Modifier = Modifier,
) {
- var isPressed by remember { mutableStateOf(false) }
-
- // Album tile press ripple effect
- val scale by animateFloatAsState(
- targetValue = if (isPressed) 0.94f else 1f,
- animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f),
- label = "albumTileScale"
- )
-
- val shape = RoundedCornerShape(11.dp)
- Box(
- modifier = modifier
- .graphicsLayer {
- scaleX = scale
- scaleY = scale
- }
- .clip(shape)
- .background(Color(0x0AFFFFFF))
- .border(1.dp, FeedItemBorder, shape)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed = true
- tryAwaitRelease()
- isPressed = false
- }
- )
- },
+ GlassCard(
+ modifier = Modifier.width(140.dp),
+ cornerRadius = 20.dp,
+ fillMaxWidth = false,
+ borderColor = FeedItemBorder,
+ contentPadding = PaddingValues(8.dp),
) {
- Column {
- AlbumArtPlaceholder(
- colors = album.artColors,
- modifier = Modifier
- .fillMaxWidth()
- .aspectRatio(1f),
- cornerRadius = 0.dp,
- )
- Column(modifier = Modifier.padding(horizontal = 6.dp, vertical = 5.dp)) {
- Text(
- album.title,
- style = MaterialTheme.typography.labelSmall,
- color = ChromeMedium,
- maxLines = 1,
- )
- Spacer(Modifier.height(3.dp))
- StarRating(
- rating = rating,
- starSize = 10.dp,
- onRate = onRate,
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Box {
+ AlbumArtwork(
+ artworkUrl = album.artworkUrl,
+ colors = album.artColors,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(130.dp),
+ cornerRadius = 14.dp,
)
+ if (rating > 0f) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(6.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(DarkBase.copy(alpha = 0.7f))
+ .padding(horizontal = 6.dp, vertical = 3.dp),
+ ) {
+ Text(
+ text = String.format("%.1f", rating),
+ style = MaterialTheme.typography.labelSmall,
+ color = AccentAmber,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
}
+ Text(
+ text = album.title,
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 1,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = album.artist,
+ style = MaterialTheme.typography.bodySmall,
+ color = TextSecondary,
+ maxLines = 1,
+ )
+ StarRating(
+ rating = rating,
+ onRate = onRate,
+ starSize = 14.dp,
+ )
}
}
}
@Composable
-private fun QueueItem(album: Album) {
- val shape = RoundedCornerShape(10.dp)
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 3.dp)
- .clip(shape)
- .background(Color(0x0AFFFFFF))
- .border(1.dp, FeedItemBorder, shape)
- .padding(horizontal = 11.dp, vertical = 9.dp),
- verticalAlignment = Alignment.CenterVertically,
+private fun DiaryEntryCard(entry: RecentLogEntry) {
+ GlassCard(
+ cornerRadius = 18.dp,
+ borderColor = FeedItemBorder,
+ contentPadding = PaddingValues(10.dp),
) {
- AlbumArtPlaceholder(
- colors = album.artColors,
- modifier = Modifier.size(30.dp),
- cornerRadius = 6.dp,
- )
- Spacer(Modifier.width(9.dp))
- Text(
- album.title,
- style = MaterialTheme.typography.bodyMedium,
- color = ChromeMedium,
- modifier = Modifier.weight(1f),
- maxLines = 1,
- )
- Text(
- "Write →",
- style = MaterialTheme.typography.labelSmall,
- color = ElectricBlue,
- )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AlbumArtwork(
+ artworkUrl = entry.album.artworkUrl,
+ colors = entry.album.artColors,
+ modifier = Modifier.size(56.dp),
+ cornerRadius = 14.dp,
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = entry.album.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = entry.album.artist,
+ style = MaterialTheme.typography.bodySmall,
+ color = TextSecondary,
+ )
+ if (entry.caption.isNotBlank()) {
+ Text(
+ text = entry.caption,
+ style = MaterialTheme.typography.bodySmall,
+ color = TextTertiary,
+ maxLines = 1,
+ )
+ }
+ }
+ StarRating(rating = entry.rating, starSize = 12.dp)
+ }
}
}
-@Composable
-private fun SectionLabel(text: String) {
- Text(
- text.uppercase(),
- style = MaterialTheme.typography.labelMedium,
- color = TextTertiary,
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
- )
-}
+
diff --git a/app/src/main/java/com/soundscore/app/ui/screens/ProfileScreen.kt b/app/src/main/java/com/soundscore/app/ui/screens/ProfileScreen.kt
index 2f45d9e..6514935 100644
--- a/app/src/main/java/com/soundscore/app/ui/screens/ProfileScreen.kt
+++ b/app/src/main/java/com/soundscore/app/ui/screens/ProfileScreen.kt
@@ -1,16 +1,10 @@
package com.soundscore.app.ui.screens
-import android.content.Intent
-import android.widget.Toast
-import androidx.compose.animation.core.animateIntAsState
-import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -19,48 +13,52 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.grid.GridCells
-import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
-import androidx.compose.foundation.lazy.grid.items
-import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Download
+import androidx.compose.material.icons.outlined.History
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Switch
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
-import com.soundscore.app.data.model.NotificationPreferences
-import com.soundscore.app.ui.components.AlbumArtPlaceholder
+import androidx.compose.foundation.lazy.items
+import com.soundscore.app.data.model.Album
+import com.soundscore.app.data.model.UserProfile
+import com.soundscore.app.ui.components.AlbumArtwork
+import com.soundscore.app.ui.components.AvatarCircle
import com.soundscore.app.ui.components.BlueButton
-import com.soundscore.app.ui.components.GhostButton
+import com.soundscore.app.ui.components.EmptyState
import com.soundscore.app.ui.components.GlassCard
+import com.soundscore.app.ui.components.GlassIconButton
+import com.soundscore.app.ui.components.ScreenHeader
+import com.soundscore.app.ui.components.SectionHeader
+import com.soundscore.app.ui.components.StatPill
+import com.soundscore.app.ui.components.SyncBanner
+import com.soundscore.app.ui.theme.AccentAmber
+import com.soundscore.app.ui.theme.AccentGreen
+import com.soundscore.app.ui.theme.AccentViolet
import com.soundscore.app.ui.theme.AlbumColors
-import com.soundscore.app.ui.theme.ChromeDim
-import com.soundscore.app.ui.theme.ChromeFaint
import com.soundscore.app.ui.theme.ChromeLight
-import com.soundscore.app.ui.theme.DarkBase
-import com.soundscore.app.ui.theme.ElectricBlue
-import com.soundscore.app.ui.theme.ElectricBlueDim
-import com.soundscore.app.ui.theme.GlassBorder
+import com.soundscore.app.ui.theme.FeedItemBorder
import com.soundscore.app.ui.theme.GlassBg
+import com.soundscore.app.ui.theme.GlassBorder
import com.soundscore.app.ui.theme.TextSecondary
import com.soundscore.app.ui.theme.TextTertiary
+import com.soundscore.app.ui.viewmodel.ProfileUiState
import com.soundscore.app.ui.viewmodel.ProfileViewModel
@Composable
@@ -69,253 +67,171 @@ fun ProfileScreen(
profileViewModel: ProfileViewModel = viewModel(),
) {
val uiState by profileViewModel.uiState.collectAsStateWithLifecycle()
+ ProfileScreenContent(
+ uiState = uiState,
+ modifier = modifier,
+ )
+}
+
+@Composable
+fun ProfileScreenContent(
+ uiState: ProfileUiState,
+ modifier: Modifier = Modifier,
+) {
val profile = uiState.profile
if (profile == null) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text("Loading profile…", color = TextSecondary)
+ Text("Loading profile...", color = TextSecondary)
}
return
}
- val context = LocalContext.current
- val clipboard = LocalClipboardManager.current
-
- var startAnimation by remember { mutableStateOf(false) }
- LaunchedEffect(Unit) {
- startAnimation = true
- }
-
- val logCountAnimate by animateIntAsState(
- targetValue = if (startAnimation) profile.logCount else 0,
- animationSpec = tween(durationMillis = 800),
- label = "logCount",
- )
- val reviewCountAnimate by animateIntAsState(
- targetValue = if (startAnimation) profile.reviewCount else 0,
- animationSpec = tween(durationMillis = 800),
- label = "reviewCount",
- )
- val listCountAnimate by animateIntAsState(
- targetValue = if (startAnimation) profile.listCount else 0,
- animationSpec = tween(durationMillis = 800),
- label = "listCount",
- )
-
LazyColumn(
modifier = modifier.fillMaxSize(),
- contentPadding = PaddingValues(bottom = 24.dp),
+ contentPadding = PaddingValues(start = 20.dp, top = 16.dp, end = 20.dp, bottom = 120.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
) {
+ item {
+ SyncBanner(message = uiState.syncMessage)
+ }
+
+ item {
+ ProfileHeader(profile = profile)
+ }
+
item {
Row(
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 18.dp, vertical = 8.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
- Text("Profile", style = MaterialTheme.typography.headlineMedium)
- Text("⚙", style = MaterialTheme.typography.titleLarge, color = ChromeFaint)
+ StatPill(
+ value = "${profile.albumsCount}",
+ label = "Albums",
+ modifier = Modifier.weight(1f),
+ highlight = true,
+ )
+ StatPill(
+ value = "${profile.reviewCount}",
+ label = "Reviews",
+ modifier = Modifier.weight(1f),
+ )
+ StatPill(
+ value = "${profile.listCount}",
+ label = "Lists",
+ modifier = Modifier.weight(1f),
+ )
+ StatPill(
+ value = String.format("%.1f", profile.avgRating),
+ label = "Avg",
+ modifier = Modifier.weight(1f),
+ highlight = true,
+ accentColor = AccentAmber,
+ )
}
}
item {
Row(
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 4.dp),
- verticalAlignment = Alignment.Top,
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
) {
- Box(
- modifier = Modifier
- .size(56.dp)
- .clip(CircleShape)
- .background(
- Brush.linearGradient(listOf(ElectricBlue, AlbumColors.purple.last())),
- )
- .border(2.dp, ElectricBlue.copy(alpha = 0.4f), CircleShape),
+ GlassIconButton(
+ icon = Icons.Outlined.Share,
+ label = "Share",
+ tint = AccentGreen,
+ )
+ GlassIconButton(
+ icon = Icons.Outlined.Download,
+ label = "Export",
+ )
+ GlassIconButton(
+ icon = Icons.Outlined.Settings,
+ label = "Settings",
)
- Spacer(Modifier.size(14.dp))
- Column {
- Text(profile.handle, style = MaterialTheme.typography.titleLarge)
- Text(profile.bio, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
- Spacer(Modifier.size(10.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
- StatChip("$logCountAnimate", "Logs")
- StatChip("$reviewCountAnimate", "Reviews")
- StatChip("$listCountAnimate", "Lists")
- }
- }
}
}
- item {
- Spacer(Modifier.height(16.dp))
- Text(
- "TOP ALBUMS",
- style = MaterialTheme.typography.labelMedium,
- color = TextTertiary,
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp),
- )
- }
- item {
- LazyVerticalGrid(
- columns = GridCells.Fixed(3),
- contentPadding = PaddingValues(horizontal = 12.dp),
- horizontalArrangement = Arrangement.spacedBy(5.dp),
- verticalArrangement = Arrangement.spacedBy(5.dp),
- modifier = Modifier
- .fillMaxWidth()
- .height(200.dp),
- userScrollEnabled = false,
- ) {
- items(profile.topAlbums) { (album, rating) ->
- Box(
- modifier = Modifier
- .clip(RoundedCornerShape(9.dp))
- .border(1.dp, GlassBorder, RoundedCornerShape(9.dp)),
- ) {
- AlbumArtPlaceholder(
- colors = album.artColors,
- cornerRadius = 9.dp,
- modifier = Modifier.fillMaxSize(),
- )
- Box(
- modifier = Modifier
- .align(Alignment.BottomStart)
- .fillMaxWidth()
- .background(
- Brush.verticalGradient(
- listOf(
- DarkBase.copy(alpha = 0f),
- DarkBase.copy(alpha = 0.75f),
- ),
- ),
- )
- .padding(4.dp),
- ) {
- Text(
- "$rating",
- style = MaterialTheme.typography.labelSmall,
- color = ElectricBlue,
- )
- }
- }
- }
+ if (uiState.favoriteAlbums.isNotEmpty()) {
+ item {
+ SectionHeader(eyebrow = "Favorites", title = "Pinned to your identity")
+ }
+
+ item {
+ FavoriteGrid(albums = uiState.favoriteAlbums)
}
}
item {
- Spacer(Modifier.height(14.dp))
- Text(
- "TASTE DNA",
- style = MaterialTheme.typography.labelMedium,
- color = TextTertiary,
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp),
- )
+ SectionHeader(eyebrow = "Taste DNA", title = "Genres on repeat")
}
- @OptIn(ExperimentalLayoutApi::class)
+
item {
- FlowRow(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp),
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- verticalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- val highlighted = setOf("Indie", "Rap", "Avg 3.9 ★")
- profile.genres.forEach { genre ->
- val isHighlighted = genre in highlighted
- Box(
- modifier = Modifier
- .clip(RoundedCornerShape(20.dp))
- .background(if (isHighlighted) ElectricBlueDim else GlassBg)
- .border(
- 1.dp,
- if (isHighlighted) ElectricBlue.copy(alpha = 0.3f) else GlassBorder,
- RoundedCornerShape(20.dp),
- )
- .padding(horizontal = 10.dp, vertical = 5.dp),
- ) {
- Text(
- genre,
- style = MaterialTheme.typography.labelSmall,
- color = if (isHighlighted) ElectricBlue else ChromeDim,
- )
- }
- }
- }
+ TasteTags(tags = profile.genres)
}
- item {
- Spacer(Modifier.height(16.dp))
- Text(
- "NOTIFICATIONS",
- style = MaterialTheme.typography.labelMedium,
- color = TextTertiary,
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp),
- )
- NotificationPreferencesCard(
- preferences = uiState.notificationPreferences,
- onPreferencesChange = profileViewModel::updateNotificationPreferences,
- )
+ if (uiState.latestRecap != null) {
+ item {
+ SectionHeader(eyebrow = "Weekly recap", title = "Your week in music")
+ }
+
+ item {
+ RecapCard(
+ totalLogs = uiState.latestRecap!!.totalLogs,
+ avgRating = uiState.latestRecap!!.averageRating,
+ shareText = uiState.latestRecap!!.shareText,
+ )
+ }
}
item {
- Spacer(Modifier.height(12.dp))
- Text(
- "WEEKLY RECAP",
- style = MaterialTheme.typography.labelMedium,
- color = TextTertiary,
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp),
- )
- RecapCard(
- summary = uiState.latestRecap?.shareText ?: "No recap yet",
- onGenerate = profileViewModel::generateRecap,
- )
+ SectionHeader(eyebrow = "Activity", title = "Recent ratings")
}
- item {
- Spacer(Modifier.height(16.dp))
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
+ items(uiState.recentActivity) { item ->
+ GlassCard(
+ cornerRadius = 16.dp,
+ borderColor = FeedItemBorder,
+ contentPadding = PaddingValues(10.dp),
) {
- BlueButton(
- "Share profile card",
- onClick = {
- val text = profileViewModel.buildShareText()
- val intent = Intent(Intent.ACTION_SEND).apply {
- type = "text/plain"
- putExtra(Intent.EXTRA_TEXT, text)
- }
- context.startActivity(Intent.createChooser(intent, "Share profile"))
- },
- modifier = Modifier.weight(1.2f),
- )
- GhostButton(
- "Export data",
- onClick = {
- profileViewModel.exportDataSnapshot { snapshot ->
- clipboard.setText(AnnotatedString(snapshot))
- Toast.makeText(context, "Export snapshot copied", Toast.LENGTH_SHORT).show()
- }
- },
- modifier = Modifier.weight(1f),
- )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AlbumArtwork(
+ artworkUrl = item.album.artworkUrl,
+ colors = item.album.artColors,
+ modifier = Modifier.size(44.dp),
+ cornerRadius = 12.dp,
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = item.album.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = item.action,
+ style = MaterialTheme.typography.bodySmall,
+ color = TextSecondary,
+ )
+ }
+ Text(
+ text = item.timeAgo,
+ style = MaterialTheme.typography.labelSmall,
+ color = TextTertiary,
+ )
+ }
}
}
- if (!uiState.syncMessage.isNullOrBlank()) {
+ if (uiState.recentActivity.isEmpty()) {
item {
- Spacer(Modifier.height(10.dp))
- Text(
- text = uiState.syncMessage ?: "",
- style = MaterialTheme.typography.bodySmall,
- color = TextSecondary,
- modifier = Modifier.padding(horizontal = 14.dp),
+ EmptyState(
+ title = "Recent activity",
+ subtitle = "Your latest ratings and reviews will appear here.",
+ icon = Icons.Outlined.History,
)
}
}
@@ -323,95 +239,197 @@ fun ProfileScreen(
}
@Composable
-private fun NotificationPreferencesCard(
- preferences: NotificationPreferences,
- onPreferencesChange: (NotificationPreferences) -> Unit,
-) {
- GlassCard(cornerRadius = 14.dp, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) {
- PreferenceRow(
- label = "Social activity",
- enabled = preferences.socialEnabled,
- onToggle = { onPreferencesChange(preferences.copy(socialEnabled = it)) },
- )
- PreferenceRow(
- label = "Recap ready",
- enabled = preferences.recapEnabled,
- onToggle = { onPreferencesChange(preferences.copy(recapEnabled = it)) },
- )
- PreferenceRow(
- label = "Comments",
- enabled = preferences.commentEnabled,
- onToggle = { onPreferencesChange(preferences.copy(commentEnabled = it)) },
- )
- PreferenceRow(
- label = "Reactions",
- enabled = preferences.reactionEnabled,
- onToggle = { onPreferencesChange(preferences.copy(reactionEnabled = it)) },
- )
- Row(
+private fun ProfileHeader(profile: UserProfile) {
+ GlassCard(
+ cornerRadius = 26.dp,
+ borderColor = FeedItemBorder,
+ frosted = true,
+ ) {
+ Column(
modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
+ horizontalAlignment = Alignment.CenterHorizontally,
) {
+ AvatarCircle(
+ initials = profile.handle.removePrefix("@").take(2),
+ gradientColors = listOf(AccentGreen, AccentViolet),
+ size = 80.dp,
+ )
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = profile.handle,
+ style = MaterialTheme.typography.headlineMedium,
+ color = ChromeLight,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.height(4.dp))
Text(
- text = "Quiet hours: ${preferences.quietHoursStart}:00–${preferences.quietHoursEnd}:00",
- style = MaterialTheme.typography.bodySmall,
+ text = profile.bio,
+ style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
- modifier = Modifier.weight(1f),
+ textAlign = TextAlign.Center,
)
- TextButton(onClick = {
- val nextStart = if (preferences.quietHoursStart == 0) 23 else preferences.quietHoursStart - 1
- onPreferencesChange(preferences.copy(quietHoursStart = nextStart))
- }) {
- Text("-1h")
- }
- TextButton(onClick = {
- val nextStart = (preferences.quietHoursStart + 1) % 24
- onPreferencesChange(preferences.copy(quietHoursStart = nextStart))
- }) {
- Text("+1h")
+ Spacer(Modifier.height(12.dp))
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ ProfileCount(value = "${profile.followingCount}", label = "Following")
+ ProfileCount(value = "${profile.followersCount}", label = "Followers")
}
}
}
}
@Composable
-private fun PreferenceRow(
- label: String,
- enabled: Boolean,
- onToggle: (Boolean) -> Unit,
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
+private fun ProfileCount(value: String, label: String) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
- text = label,
- style = MaterialTheme.typography.bodyMedium,
+ text = value,
+ style = MaterialTheme.typography.titleLarge,
color = ChromeLight,
- modifier = Modifier.weight(1f),
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = TextTertiary,
)
- Switch(checked = enabled, onCheckedChange = onToggle)
}
}
@Composable
-private fun RecapCard(
- summary: String,
- onGenerate: () -> Unit,
-) {
- GlassCard(cornerRadius = 14.dp, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) {
- Text(summary, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
- Spacer(Modifier.height(8.dp))
- BlueButton(text = "Generate latest recap", onClick = onGenerate)
+private fun FavoriteGrid(albums: List) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ albums.chunked(3).forEach { rowAlbums ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ rowAlbums.forEach { album ->
+ GlassCard(
+ modifier = Modifier.weight(1f),
+ fillMaxWidth = true,
+ cornerRadius = 18.dp,
+ borderColor = FeedItemBorder,
+ contentPadding = PaddingValues(6.dp),
+ onClick = { },
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ AlbumArtwork(
+ artworkUrl = album.artworkUrl,
+ colors = album.artColors,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp),
+ cornerRadius = 14.dp,
+ )
+ Text(
+ text = album.title,
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 1,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ }
+ repeat(3 - rowAlbums.size) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
}
}
@Composable
-private fun StatChip(value: String, label: String) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Text(value, style = MaterialTheme.typography.titleMedium, color = ChromeLight)
- Text(label.uppercase(), style = MaterialTheme.typography.labelSmall, color = TextTertiary)
+private fun TasteTags(tags: List) {
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(tags.size) { index ->
+ val tag = tags[index]
+ val tagColors = when (index % 4) {
+ 0 -> AlbumColors.orchid
+ 1 -> AlbumColors.lagoon
+ 2 -> AlbumColors.ember
+ else -> AlbumColors.rose
+ }
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(
+ Brush.linearGradient(
+ listOf(tagColors.first().copy(alpha = 0.4f), tagColors.last().copy(alpha = 0.15f))
+ )
+ )
+ .border(0.5.dp, tagColors.last().copy(alpha = 0.3f), RoundedCornerShape(20.dp))
+ .padding(horizontal = 14.dp, vertical = 8.dp),
+ ) {
+ Text(
+ text = tag,
+ style = MaterialTheme.typography.labelMedium,
+ color = ChromeLight,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun RecapCard(
+ totalLogs: Int,
+ avgRating: Float,
+ shareText: String,
+) {
+ GlassCard(
+ cornerRadius = 22.dp,
+ tintColor = AccentGreen,
+ borderColor = AccentGreen.copy(alpha = 0.2f),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column {
+ Text(
+ text = "$totalLogs",
+ style = MaterialTheme.typography.headlineMedium,
+ color = AccentGreen,
+ fontWeight = FontWeight.Black,
+ )
+ Text(
+ text = "ALBUMS LOGGED",
+ style = MaterialTheme.typography.labelSmall,
+ color = TextTertiary,
+ )
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ text = String.format("%.1f", avgRating),
+ style = MaterialTheme.typography.headlineMedium,
+ color = AccentAmber,
+ fontWeight = FontWeight.Black,
+ )
+ Text(
+ text = "AVG RATING",
+ style = MaterialTheme.typography.labelSmall,
+ color = TextTertiary,
+ )
+ }
+ }
+ Text(
+ text = shareText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = TextSecondary,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ BlueButton(text = "View Recap", onClick = { })
+ GlassIconButton(
+ icon = Icons.Outlined.Share,
+ label = "Share",
+ tint = AccentGreen,
+ )
+ }
+ }
}
}
diff --git a/app/src/main/java/com/soundscore/app/ui/screens/SearchScreen.kt b/app/src/main/java/com/soundscore/app/ui/screens/SearchScreen.kt
index 0645443..75b14aa 100644
--- a/app/src/main/java/com/soundscore/app/ui/screens/SearchScreen.kt
+++ b/app/src/main/java/com/soundscore/app/ui/screens/SearchScreen.kt
@@ -1,23 +1,55 @@
package com.soundscore.app.ui.screens
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Search
-import androidx.compose.material3.*
+import androidx.compose.material.icons.outlined.People
+import androidx.compose.material.icons.outlined.SearchOff
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import com.soundscore.app.ui.components.AlbumArtPlaceholder
-import com.soundscore.app.ui.components.GlassCard
-import com.soundscore.app.ui.theme.*
-import com.soundscore.app.ui.viewmodel.SearchViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
+import com.soundscore.app.data.model.Album
+import com.soundscore.app.ui.components.AlbumArtwork
+import com.soundscore.app.ui.components.EmptyState
+import com.soundscore.app.ui.components.GlassCard
+import com.soundscore.app.ui.components.PillSearchBar
+import com.soundscore.app.ui.components.ScreenHeader
+import com.soundscore.app.ui.components.SectionHeader
+import com.soundscore.app.ui.components.StarRating
+import com.soundscore.app.ui.components.SyncBanner
+import com.soundscore.app.ui.components.TrendChartRow
+import com.soundscore.app.ui.theme.AccentGreen
+import com.soundscore.app.ui.theme.FeedItemBorder
+import com.soundscore.app.ui.theme.TextSecondary
+import com.soundscore.app.ui.theme.TextTertiary
+import com.soundscore.app.ui.viewmodel.BrowseGenre
+import com.soundscore.app.ui.viewmodel.SearchUiState
+import com.soundscore.app.ui.viewmodel.SearchViewModel
@Composable
fun SearchScreen(
@@ -25,83 +57,263 @@ fun SearchScreen(
searchViewModel: SearchViewModel = viewModel(),
) {
val uiState by searchViewModel.uiState.collectAsStateWithLifecycle()
+ SearchScreenContent(
+ uiState = uiState,
+ modifier = modifier,
+ onQueryChange = searchViewModel::updateQuery,
+ )
+}
- Column(modifier = modifier.fillMaxSize()) {
- // ── Header ──
- Text(
- "Search",
- style = MaterialTheme.typography.headlineMedium,
- modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
- )
-
- // ── Search bar ──
- OutlinedTextField(
- value = uiState.query,
- onValueChange = { searchViewModel.updateQuery(it) },
- placeholder = {
- Text("Albums, artists, friends…", color = ChromeFaint)
- },
- leadingIcon = {
- Icon(Icons.Default.Search, contentDescription = null, tint = ChromeDim)
- },
- singleLine = true,
- shape = RoundedCornerShape(14.dp),
- colors = OutlinedTextFieldDefaults.colors(
- focusedContainerColor = GlassBg,
- unfocusedContainerColor = GlassBg,
- focusedBorderColor = ElectricBlue,
- unfocusedBorderColor = GlassBorder,
- cursorColor = ElectricBlue,
- focusedTextColor = TextPrimary,
- unfocusedTextColor = TextPrimary,
- ),
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 14.dp, vertical = 4.dp),
- )
-
- Spacer(Modifier.height(12.dp))
-
- // ── Results / browse ──
- Text(
- if (uiState.query.isBlank()) "TRENDING" else "RESULTS",
- style = MaterialTheme.typography.labelMedium,
- color = TextTertiary,
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp),
- )
-
- LazyColumn(
- contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
- verticalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- items(uiState.results, key = { it.id }) { album ->
- GlassCard(cornerRadius = 12.dp) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth(),
+@Composable
+fun SearchScreenContent(
+ uiState: SearchUiState,
+ onQueryChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(start = 20.dp, top = 16.dp, end = 20.dp, bottom = 120.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ item {
+ SyncBanner(message = uiState.syncMessage)
+ }
+
+ item {
+ ScreenHeader(
+ title = "Discover",
+ subtitle = "Browse by mood, genre, or find the record in your head.",
+ )
+ }
+
+ item {
+ PillSearchBar(
+ query = uiState.query,
+ onQueryChange = onQueryChange,
+ )
+ }
+
+ if (uiState.query.isBlank()) {
+ if (uiState.chartEntries.isNotEmpty()) {
+ item {
+ SectionHeader(eyebrow = "Trending now", title = "Most logged this week")
+ }
+
+ item {
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ contentPadding = PaddingValues(end = 8.dp),
) {
- AlbumArtPlaceholder(
- colors = album.artColors,
- modifier = Modifier.size(44.dp),
- cornerRadius = 8.dp,
- )
- Spacer(Modifier.width(10.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(album.title, style = MaterialTheme.typography.titleSmall)
- Text(
- "${album.artist} · ${album.year}",
- style = MaterialTheme.typography.bodySmall,
- color = TextSecondary,
+ items(uiState.chartEntries.take(4), key = { it.album.id }) { entry ->
+ TrendingSearchCard(
+ album = entry.album,
+ rank = entry.rank,
)
}
- Text(
- "${album.avgRating}",
- style = MaterialTheme.typography.titleSmall,
- color = ElectricBlue,
- )
}
}
}
+
+ item {
+ SectionHeader(eyebrow = "Browse", title = "Explore by genre")
+ }
+
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ uiState.browseGenres.chunked(2).forEach { rowGenres ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ rowGenres.forEach { genre ->
+ GenreCard(genre = genre, modifier = Modifier.weight(1f))
+ }
+ if (rowGenres.size == 1) {
+ Spacer(Modifier.weight(1f))
+ }
+ }
+ }
+ }
+ }
+
+ item {
+ SectionHeader(eyebrow = "Charts", title = "What SoundScore is logging")
+ }
+
+ items(uiState.chartEntries, key = { it.album.id }) { entry ->
+ TrendChartRow(entry = entry)
+ }
+
+ } else {
+ if (uiState.results.isEmpty()) {
+ item {
+ EmptyState(
+ title = "No results found",
+ subtitle = "Try a different search term or check your spelling.",
+ icon = Icons.Outlined.SearchOff,
+ )
+ }
+ } else {
+ item {
+ SectionHeader(
+ eyebrow = "Results",
+ title = "${uiState.results.size} matches",
+ )
+ }
+
+ items(uiState.results, key = { it.id }) { album ->
+ SearchResultCard(album = album)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TrendingSearchCard(
+ album: Album,
+ rank: Int,
+) {
+ GlassCard(
+ modifier = Modifier.size(width = 160.dp, height = 200.dp),
+ fillMaxWidth = false,
+ cornerRadius = 20.dp,
+ borderColor = FeedItemBorder,
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ AlbumArtwork(
+ artworkUrl = album.artworkUrl,
+ colors = album.artColors,
+ modifier = Modifier.fillMaxSize(),
+ cornerRadius = 0.dp,
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.65f)),
+ startY = 80f,
+ )
+ ),
+ )
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(8.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(AccentGreen.copy(alpha = 0.9f))
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ ) {
+ Text(
+ text = "#$rank",
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.Black,
+ fontWeight = FontWeight.Black,
+ )
+ }
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(10.dp),
+ ) {
+ Text(
+ text = album.title,
+ style = MaterialTheme.typography.titleSmall,
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ )
+ Text(
+ text = album.artist,
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.White.copy(alpha = 0.7f),
+ maxLines = 1,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun GenreCard(
+ genre: BrowseGenre,
+ modifier: Modifier = Modifier,
+) {
+ GlassCard(
+ modifier = modifier.height(120.dp),
+ fillMaxWidth = true,
+ cornerRadius = 20.dp,
+ tintColor = genre.colors.last(),
+ borderColor = FeedItemBorder,
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(Brush.linearGradient(genre.colors)),
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = genre.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ text = genre.caption,
+ style = MaterialTheme.typography.bodySmall,
+ color = TextSecondary,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SearchResultCard(album: Album) {
+ GlassCard(
+ cornerRadius = 18.dp,
+ borderColor = FeedItemBorder,
+ contentPadding = PaddingValues(10.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AlbumArtwork(
+ artworkUrl = album.artworkUrl,
+ colors = album.artColors,
+ modifier = Modifier.size(64.dp),
+ cornerRadius = 16.dp,
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = album.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = "${album.artist} · ${album.year}",
+ style = MaterialTheme.typography.bodySmall,
+ color = TextSecondary,
+ )
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ StarRating(rating = album.avgRating, starSize = 12.dp)
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = "${album.logCount} logs",
+ style = MaterialTheme.typography.labelSmall,
+ color = TextTertiary,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/soundscore/app/ui/theme/Color.kt b/app/src/main/java/com/soundscore/app/ui/theme/Color.kt
index c5ab85d..c731553 100644
--- a/app/src/main/java/com/soundscore/app/ui/theme/Color.kt
+++ b/app/src/main/java/com/soundscore/app/ui/theme/Color.kt
@@ -3,31 +3,55 @@ package com.soundscore.app.ui.theme
import androidx.compose.ui.graphics.Color
// ── Dark Base ──
-val DarkBase = Color(0xFF0A0A0A) // near-black, slightly warm
-val DarkSurface = Color(0xFF111111) // cards sit on this
-val DarkElevated = Color(0xFF1A1A1A) // modals, sheets
+val DarkBase = Color(0xFF040506)
+val DarkSurface = Color(0xFF0A0D0F)
+val DarkElevated = Color(0xFF111618)
// ── Liquid Glass ──
-val GlassBg = Color(0x0DFFFFFF) // rgba(255,255,255,0.05)
-val GlassBorder = Color(0x17FFFFFF) // rgba(255,255,255,0.09)
-val GlassHeavy = Color(0x14FFFFFF) // rgba(255,255,255,0.08) — pressed state
-val FeedItemBorder = Color(0x12FFFFFF) // rgba(255,255,255,0.07) — feed cards
+val GlassBg = Color(0x12FFFFFF)
+val GlassBorder = Color(0x24FFFFFF)
+val GlassHeavy = Color(0x1CFFFFFF)
+val GlassHighlight = Color(0x30FFFFFF)
+val GlassUltraLight = Color(0x0AFFFFFF)
+val GlassSheet = Color(0x1AFFFFFF)
+val GlassFrosted = Color(0x28FFFFFF)
+val FeedItemBorder = Color(0x14FFFFFF)
+
+// ── Semantic Surfaces ──
+val SurfaceCard = Color(0x0EFFFFFF)
+val SurfaceModal = Color(0x1EFFFFFF)
+val SurfaceOverlay = Color(0xCC000000)
// ── Chrome ──
-val ChromeLight = Color(0xE6FFFFFF) // rgba(255,255,255,0.90) — headlines
-val ChromeMedium = Color(0x99FFFFFF) // rgba(255,255,255,0.60) — icons, dividers
-val ChromeDim = Color(0x66FFFFFF) // rgba(255,255,255,0.40) — secondary text
-val ChromeFaint = Color(0x33FFFFFF) // rgba(255,255,255,0.20) — disabled
+val ChromeLight = Color(0xF0FFFFFF)
+val ChromeMedium = Color(0xB3FFFFFF)
+val ChromeDim = Color(0x70FFFFFF)
+val ChromeFaint = Color(0x3DFFFFFF)
+
+// ── Primary Accent (Green) ──
+val AccentGreen = Color(0xFF1ED760)
+val AccentGreenStrong = Color(0xFF19B24F)
+val AccentGreenGlow = Color(0x661ED760)
+val AccentGreenDim = Color(0x201ED760)
+val AccentGreenMuted = Color(0x101ED760)
-// ── Electric Blue ──
-val ElectricBlue = Color(0xFF4D9FFF) // primary accent
-val ElectricBlueGlow = Color(0x4D4D9FFF) // 0.3 opacity — glow behind CTAs
-val ElectricBlueDim = Color(0x1F4D9FFF) // 0.12 opacity — tag backgrounds
+// ── Secondary Accents ──
+val AccentAmber = Color(0xFFFFA726)
+val AccentAmberDim = Color(0x20FFA726)
+val AccentCoral = Color(0xFFFF6B6B)
+val AccentCoralDim = Color(0x20FF6B6B)
+val AccentViolet = Color(0xFFB388FF)
+val AccentVioletDim = Color(0x20B388FF)
+
+// Back-compat aliases
+val ElectricBlue = AccentGreen
+val ElectricBlueGlow = AccentGreenGlow
+val ElectricBlueDim = AccentGreenDim
// ── Text ──
-val TextPrimary = Color(0xE6FFFFFF) // white @ 90% — body text
-val TextSecondary = Color(0x59FFFFFF) // white @ 35% — captions
-val TextTertiary = Color(0x33FFFFFF) // white @ 20% — timestamps
+val TextPrimary = Color(0xF2FFFFFF)
+val TextSecondary = Color(0xB8FFFFFF)
+val TextTertiary = Color(0x6EFFFFFF)
// ── Semantic ──
val Destructive = Color(0xFFFF4D4D)
@@ -35,10 +59,21 @@ val Success = Color(0xFF38EF7D)
// ── Album art placeholder gradients (start, end) ──
object AlbumColors {
- val purple = listOf(Color(0xFF1A0533), Color(0xFF533483))
- val teal = listOf(Color(0xFF0D3B34), Color(0xFF38EF7D))
- val pink = listOf(Color(0xFF4A0028), Color(0xFFB91D73))
- val blue = listOf(Color(0xFF0A1628), Color(0xFF8E54E9))
- val gold = listOf(Color(0xFF2A1500), Color(0xFFFFD200))
- val indigo = listOf(Color(0xFF0D0D2B), Color(0xFF24243E))
+ val forest = listOf(Color(0xFF09130E), Color(0xFF1E7A4E))
+ val lime = listOf(Color(0xFF102915), Color(0xFF1ED760))
+ val ember = listOf(Color(0xFF2B110C), Color(0xFFCC6A2C))
+ val orchid = listOf(Color(0xFF1B102C), Color(0xFF7550D8))
+ val lagoon = listOf(Color(0xFF061B26), Color(0xFF2FC0B8))
+ val rose = listOf(Color(0xFF2A0E1A), Color(0xFFC4548B))
+ val midnight = listOf(Color(0xFF09111A), Color(0xFF2D4A67))
+ val slate = listOf(Color(0xFF0E1114), Color(0xFF4A5568))
+ val coral = listOf(Color(0xFF1A0A0A), Color(0xFFFF6B6B))
+ val amber = listOf(Color(0xFF1A1208), Color(0xFFFFA726))
+
+ val purple = orchid
+ val teal = lagoon
+ val pink = rose
+ val blue = midnight
+ val gold = ember
+ val indigo = forest
}
diff --git a/app/src/main/java/com/soundscore/app/ui/theme/Theme.kt b/app/src/main/java/com/soundscore/app/ui/theme/Theme.kt
index 780e721..4c377a7 100644
--- a/app/src/main/java/com/soundscore/app/ui/theme/Theme.kt
+++ b/app/src/main/java/com/soundscore/app/ui/theme/Theme.kt
@@ -1,34 +1,37 @@
package com.soundscore.app.ui.theme
import android.app.Activity
+import android.os.Build
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val SoundScoreColorScheme = darkColorScheme(
- // Primary = Electric Blue
- primary = ElectricBlue,
+ primary = AccentGreen,
onPrimary = DarkBase,
- primaryContainer = ElectricBlueDim,
- onPrimaryContainer = ElectricBlue,
-
- // Surface = Dark layers
+ primaryContainer = AccentGreenDim,
+ onPrimaryContainer = AccentGreen,
+ secondary = AccentAmber,
+ onSecondary = DarkBase,
+ secondaryContainer = AccentAmberDim,
+ onSecondaryContainer = AccentAmber,
+ tertiary = AccentCoral,
+ onTertiary = DarkBase,
+ tertiaryContainer = AccentCoralDim,
+ onTertiaryContainer = AccentCoral,
background = DarkBase,
onBackground = TextPrimary,
surface = DarkSurface,
onSurface = TextPrimary,
surfaceVariant = DarkElevated,
onSurfaceVariant = TextSecondary,
-
- // Outlines
outline = GlassBorder,
outlineVariant = ChromeFaint,
-
- // Error
error = Destructive,
onError = DarkBase,
)
@@ -39,8 +42,11 @@ fun SoundScoreTheme(content: @Composable () -> Unit) {
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
- window.statusBarColor = DarkBase.toArgb()
- window.navigationBarColor = DarkBase.toArgb()
+ @Suppress("DEPRECATION")
+ window.statusBarColor = Color.Transparent.toArgb()
+ @Suppress("DEPRECATION")
+ window.navigationBarColor = Color.Transparent.toArgb()
+ WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = false
diff --git a/app/src/main/java/com/soundscore/app/ui/theme/Type.kt b/app/src/main/java/com/soundscore/app/ui/theme/Type.kt
index c275f1f..2353ad2 100644
--- a/app/src/main/java/com/soundscore/app/ui/theme/Type.kt
+++ b/app/src/main/java/com/soundscore/app/ui/theme/Type.kt
@@ -6,39 +6,48 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val SoundScoreTypography = Typography(
-
- // ── Display ──
displayLarge = TextStyle(
- fontWeight = FontWeight.Bold,
- fontSize = 34.sp,
+ fontWeight = FontWeight.Black,
+ fontSize = 44.sp,
+ lineHeight = 48.sp,
+ letterSpacing = (-1.2).sp,
+ color = ChromeLight,
+ ),
+ displayMedium = TextStyle(
+ fontWeight = FontWeight.ExtraBold,
+ fontSize = 36.sp,
lineHeight = 40.sp,
- letterSpacing = (-0.5).sp,
+ letterSpacing = (-0.8).sp,
+ color = ChromeLight,
+ ),
+ displaySmall = TextStyle(
+ fontWeight = FontWeight.Bold,
+ fontSize = 28.sp,
+ lineHeight = 32.sp,
+ letterSpacing = (-0.6).sp,
color = ChromeLight,
),
-
- // ── Headlines ──
headlineLarge = TextStyle(
fontWeight = FontWeight.Bold,
- fontSize = 26.sp,
+ fontSize = 28.sp,
lineHeight = 32.sp,
- letterSpacing = (-0.3).sp,
+ letterSpacing = (-0.5).sp,
color = ChromeLight,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
- lineHeight = 28.sp,
- letterSpacing = (-0.2).sp,
+ lineHeight = 26.sp,
+ letterSpacing = (-0.3).sp,
color = ChromeLight,
),
headlineSmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 24.sp,
+ letterSpacing = (-0.1).sp,
color = ChromeLight,
),
-
- // ── Titles ──
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
@@ -52,52 +61,48 @@ val SoundScoreTypography = Typography(
color = TextPrimary,
),
titleSmall = TextStyle(
- fontWeight = FontWeight.SemiBold,
- fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ fontSize = 13.sp,
lineHeight = 16.sp,
color = TextPrimary,
),
-
- // ── Body ──
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
- fontSize = 14.sp,
- lineHeight = 20.sp,
+ fontSize = 15.sp,
+ lineHeight = 22.sp,
color = TextPrimary,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
- fontSize = 12.sp,
+ fontSize = 13.sp,
lineHeight = 18.sp,
- color = TextPrimary,
+ color = TextSecondary,
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
- fontSize = 11.sp,
+ fontSize = 12.sp,
lineHeight = 16.sp,
color = TextSecondary,
),
-
- // ── Labels ──
labelLarge = TextStyle(
- fontWeight = FontWeight.SemiBold,
- fontSize = 13.sp,
+ fontWeight = FontWeight.Bold,
+ fontSize = 14.sp,
lineHeight = 18.sp,
- letterSpacing = 0.3.sp,
+ letterSpacing = 0.2.sp,
color = TextPrimary,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
- fontSize = 11.sp,
+ fontSize = 12.sp,
lineHeight = 14.sp,
- letterSpacing = 0.5.sp,
+ letterSpacing = 0.4.sp,
color = TextSecondary,
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
- fontSize = 9.sp,
+ fontSize = 10.sp,
lineHeight = 12.sp,
- letterSpacing = 0.8.sp,
+ letterSpacing = 0.6.sp,
color = TextTertiary,
),
)
diff --git a/app/src/main/java/com/soundscore/app/ui/viewmodel/FeedViewModel.kt b/app/src/main/java/com/soundscore/app/ui/viewmodel/FeedViewModel.kt
index c2cce07..bea0577 100644
--- a/app/src/main/java/com/soundscore/app/ui/viewmodel/FeedViewModel.kt
+++ b/app/src/main/java/com/soundscore/app/ui/viewmodel/FeedViewModel.kt
@@ -2,6 +2,7 @@ package com.soundscore.app.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.soundscore.app.data.model.Album
import com.soundscore.app.data.model.FeedItem
import com.soundscore.app.data.repository.AppContainer
import kotlinx.coroutines.flow.SharingStarted
@@ -12,6 +13,7 @@ import kotlinx.coroutines.launch
data class FeedUiState(
val items: List = emptyList(),
+ val trendingAlbums: List = emptyList(),
val syncMessage: String? = null,
)
@@ -20,9 +22,14 @@ class FeedViewModel : ViewModel() {
val uiState: StateFlow = combine(
repository.feedItems,
+ repository.albums,
repository.syncMessage,
- ) { items, syncMessage ->
- FeedUiState(items = items, syncMessage = syncMessage)
+ ) { items, albums, syncMessage ->
+ FeedUiState(
+ items = items,
+ trendingAlbums = buildTrendingAlbums(albums),
+ syncMessage = syncMessage,
+ )
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
diff --git a/app/src/main/java/com/soundscore/app/ui/viewmodel/ListsViewModel.kt b/app/src/main/java/com/soundscore/app/ui/viewmodel/ListsViewModel.kt
index 3fcb0dd..babfc22 100644
--- a/app/src/main/java/com/soundscore/app/ui/viewmodel/ListsViewModel.kt
+++ b/app/src/main/java/com/soundscore/app/ui/viewmodel/ListsViewModel.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.launch
data class ListsUiState(
val lists: List = emptyList(),
+ val showcases: List = emptyList(),
val syncMessage: String? = null,
)
@@ -20,9 +21,14 @@ class ListsViewModel : ViewModel() {
val uiState: StateFlow = combine(
repository.lists,
+ repository.albums,
repository.syncMessage,
- ) { lists, syncMessage ->
- ListsUiState(lists = lists, syncMessage = syncMessage)
+ ) { lists, albums, syncMessage ->
+ ListsUiState(
+ lists = lists,
+ showcases = resolveListShowcases(lists, albums),
+ syncMessage = syncMessage,
+ )
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
diff --git a/app/src/main/java/com/soundscore/app/ui/viewmodel/LogViewModel.kt b/app/src/main/java/com/soundscore/app/ui/viewmodel/LogViewModel.kt
index bc9971d..9130461 100644
--- a/app/src/main/java/com/soundscore/app/ui/viewmodel/LogViewModel.kt
+++ b/app/src/main/java/com/soundscore/app/ui/viewmodel/LogViewModel.kt
@@ -11,9 +11,10 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class LogUiState(
- val albums: List = emptyList(),
+ val quickLogAlbums: List = emptyList(),
val ratings: Map = emptyMap(),
- val writeLaterQueue: List = emptyList(),
+ val summaryStats: List = emptyList(),
+ val recentLogs: List = emptyList(),
val syncMessage: String? = null,
)
@@ -26,9 +27,10 @@ class LogViewModel : ViewModel() {
repository.syncMessage,
) { albums, ratings, syncMessage ->
LogUiState(
- albums = albums,
+ quickLogAlbums = buildTrendingAlbums(albums).take(6),
ratings = ratings,
- writeLaterQueue = albums.take(3),
+ summaryStats = buildLogSummaryStats(ratings),
+ recentLogs = buildRecentLogs(albums, ratings),
syncMessage = syncMessage,
)
}.stateIn(
diff --git a/app/src/main/java/com/soundscore/app/ui/viewmodel/ProfileViewModel.kt b/app/src/main/java/com/soundscore/app/ui/viewmodel/ProfileViewModel.kt
index e191a53..a3f96db 100644
--- a/app/src/main/java/com/soundscore/app/ui/viewmodel/ProfileViewModel.kt
+++ b/app/src/main/java/com/soundscore/app/ui/viewmodel/ProfileViewModel.kt
@@ -2,6 +2,8 @@ package com.soundscore.app.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.soundscore.app.data.model.Album
+import com.soundscore.app.data.model.FeedItem
import com.soundscore.app.data.model.NotificationPreferences
import com.soundscore.app.data.model.UserProfile
import com.soundscore.app.data.model.WeeklyRecap
@@ -14,9 +16,12 @@ import kotlinx.coroutines.launch
data class ProfileUiState(
val profile: UserProfile? = null,
+ val metrics: List = emptyList(),
+ val favoriteAlbums: List = emptyList(),
val notificationPreferences: NotificationPreferences = NotificationPreferences(),
val latestRecap: WeeklyRecap? = null,
val syncMessage: String? = null,
+ val recentActivity: List = emptyList(),
)
class ProfileViewModel : ViewModel() {
@@ -27,12 +32,16 @@ class ProfileViewModel : ViewModel() {
repository.notificationPreferences,
repository.latestRecap,
repository.syncMessage,
- ) { profile, prefs, recap, syncMessage ->
+ repository.feedItems,
+ ) { profile, prefs, recap, syncMessage, feedItems ->
ProfileUiState(
profile = profile,
+ metrics = buildProfileMetrics(profile),
+ favoriteAlbums = buildFavoriteAlbums(profile),
notificationPreferences = prefs,
latestRecap = recap,
syncMessage = syncMessage,
+ recentActivity = feedItems.take(3),
)
}.stateIn(
scope = viewModelScope,
@@ -45,7 +54,7 @@ class ProfileViewModel : ViewModel() {
repository.refresh()
repository.registerDeviceToken(
platform = "android",
- token = "emulator-debug-token",
+ token = "emulator-debug-token", // KNOWN: Replace with FirebaseMessaging.getInstance().token once FCM is integrated
)
repository.loadLatestRecap()
}
diff --git a/app/src/main/java/com/soundscore/app/ui/viewmodel/ScreenPresentation.kt b/app/src/main/java/com/soundscore/app/ui/viewmodel/ScreenPresentation.kt
new file mode 100644
index 0000000..9a18715
--- /dev/null
+++ b/app/src/main/java/com/soundscore/app/ui/viewmodel/ScreenPresentation.kt
@@ -0,0 +1,118 @@
+package com.soundscore.app.ui.viewmodel
+
+import com.soundscore.app.data.model.Album
+import com.soundscore.app.data.model.UserList
+import com.soundscore.app.data.model.UserProfile
+import com.soundscore.app.ui.theme.AlbumColors
+
+data class LogSummaryStat(
+ val value: String,
+ val label: String,
+ val caption: String,
+)
+
+data class RecentLogEntry(
+ val album: Album,
+ val rating: Float,
+ val dateLabel: String,
+ val timeLabel: String,
+ val caption: String,
+)
+
+data class BrowseGenre(
+ val name: String,
+ val caption: String,
+ val colors: List,
+)
+
+data class ChartEntry(
+ val rank: Int,
+ val album: Album,
+ val movementLabel: String,
+)
+
+data class ListShowcase(
+ val list: UserList,
+ val coverAlbums: List,
+)
+
+data class ProfileMetric(
+ val value: String,
+ val label: String,
+)
+
+fun buildTrendingAlbums(albums: List): List =
+ albums.sortedByDescending { it.logCount }
+
+fun buildLogSummaryStats(ratings: Map): List {
+ val average = if (ratings.isEmpty()) 0f else ratings.values.average().toFloat()
+ val weekLogs = ratings.count()
+ val streak = (ratings.count() + 2).coerceAtMost(9)
+
+ return listOf(
+ LogSummaryStat(value = weekLogs.toString(), label = "This week", caption = "New logs"),
+ LogSummaryStat(value = String.format("%.1f★", average), label = "Average", caption = "Your current pace"),
+ LogSummaryStat(value = "$streak d", label = "Streak", caption = "Listening every day"),
+ )
+}
+
+fun buildRecentLogs(albums: List, ratings: Map): List {
+ val moments = listOf(
+ Triple("Today", "11:48 PM", "Late-night replay. Worth the full write-up."),
+ Triple("Yesterday", "7:12 PM", "Instant favorite chorus. Logged before dinner."),
+ Triple("Mar 11", "9:03 AM", "Sharp production details on the second listen."),
+ Triple("Mar 09", "6:41 PM", "Saved for the weekend drive and it landed."),
+ )
+
+ return albums
+ .sortedByDescending { ratings[it.id] ?: 0f }
+ .take(moments.size)
+ .mapIndexed { index, album ->
+ val (date, time, caption) = moments[index]
+ RecentLogEntry(
+ album = album,
+ rating = ratings[album.id] ?: album.avgRating,
+ dateLabel = date,
+ timeLabel = time,
+ caption = caption,
+ )
+ }
+}
+
+fun buildBrowseGenres(): List = listOf(
+ BrowseGenre("Alt Rap", "Dense bars, stranger palettes", AlbumColors.forest),
+ BrowseGenre("Night Pop", "Glossy hooks with a bite", AlbumColors.rose),
+ BrowseGenre("Leftfield R&B", "Warm low end, sharp edges", AlbumColors.lagoon),
+ BrowseGenre("Indie Mutations", "Guitars that still feel digital", AlbumColors.orchid),
+)
+
+fun resolveSearchResults(
+ query: String,
+ albums: List,
+ searchAlbums: (String) -> List,
+): List = if (query.isBlank()) albums else searchAlbums(query)
+
+fun buildChartEntries(albums: List): List {
+ val movementLabels = listOf("+18%", "+12%", "+9%", "+6%", "+4%")
+ return albums
+ .sortedByDescending { it.logCount }
+ .take(movementLabels.size)
+ .mapIndexed { index, album ->
+ ChartEntry(rank = index + 1, album = album, movementLabel = movementLabels[index])
+ }
+}
+
+fun resolveListShowcases(lists: List, albums: List): List =
+ lists.map { list ->
+ val coverAlbums = list.albumIds.mapNotNull { id -> albums.find { it.id == id } }.take(4)
+ ListShowcase(list = list, coverAlbums = coverAlbums)
+ }
+
+fun buildProfileMetrics(profile: UserProfile): List = listOf(
+ ProfileMetric(profile.albumsCount.toString(), "Albums"),
+ ProfileMetric(profile.listCount.toString(), "Lists"),
+ ProfileMetric(profile.followingCount.toString(), "Following"),
+ ProfileMetric(profile.followersCount.toString(), "Followers"),
+)
+
+fun buildFavoriteAlbums(profile: UserProfile): List = profile.favoriteAlbums.take(6)
diff --git a/app/src/main/java/com/soundscore/app/ui/viewmodel/SearchViewModel.kt b/app/src/main/java/com/soundscore/app/ui/viewmodel/SearchViewModel.kt
index 4273813..3953dc7 100644
--- a/app/src/main/java/com/soundscore/app/ui/viewmodel/SearchViewModel.kt
+++ b/app/src/main/java/com/soundscore/app/ui/viewmodel/SearchViewModel.kt
@@ -15,6 +15,8 @@ import kotlinx.coroutines.launch
data class SearchUiState(
val query: String = "",
val results: List = emptyList(),
+ val browseGenres: List = emptyList(),
+ val chartEntries: List = emptyList(),
val syncMessage: String? = null,
)
@@ -27,12 +29,14 @@ class SearchViewModel : ViewModel() {
repository.albums,
repository.syncMessage,
) { text, albums, syncMessage ->
- val results = if (text.isBlank()) {
- albums
- } else {
- repository.searchAlbums(text)
- }
- SearchUiState(query = text, results = results, syncMessage = syncMessage)
+ val results = resolveSearchResults(text, albums, repository::searchAlbums)
+ SearchUiState(
+ query = text,
+ results = results,
+ browseGenres = buildBrowseGenres(),
+ chartEntries = buildChartEntries(albums),
+ syncMessage = syncMessage,
+ )
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
diff --git a/app/src/test/java/com/soundscore/app/data/repository/SoundScoreRepositoryMappingTest.kt b/app/src/test/java/com/soundscore/app/data/repository/SoundScoreRepositoryMappingTest.kt
new file mode 100644
index 0000000..f2adef9
--- /dev/null
+++ b/app/src/test/java/com/soundscore/app/data/repository/SoundScoreRepositoryMappingTest.kt
@@ -0,0 +1,26 @@
+package com.soundscore.app.data.repository
+
+import com.soundscore.app.data.api.AlbumDto
+import com.soundscore.app.data.model.SeedData
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SoundScoreRepositoryMappingTest {
+ @Test
+ fun mapAlbumDtoPreservesArtworkUrl() {
+ val dto = AlbumDto(
+ id = "alb_1",
+ title = "CHROMAKOPIA",
+ artist = "Tyler, the Creator",
+ year = 2024,
+ artworkUrl = "https://example.com/cover.jpg",
+ avgRating = 4.6f,
+ logCount = 999,
+ )
+
+ val mapped = mapAlbumDto(dto, SeedData.albums)
+
+ assertEquals("https://example.com/cover.jpg", mapped.artworkUrl)
+ assertEquals(SeedData.albums.first().artColors, mapped.artColors)
+ }
+}
diff --git a/app/src/test/java/com/soundscore/app/ui/viewmodel/ScreenPresentationTest.kt b/app/src/test/java/com/soundscore/app/ui/viewmodel/ScreenPresentationTest.kt
new file mode 100644
index 0000000..0ecf137
--- /dev/null
+++ b/app/src/test/java/com/soundscore/app/ui/viewmodel/ScreenPresentationTest.kt
@@ -0,0 +1,52 @@
+package com.soundscore.app.ui.viewmodel
+
+import com.soundscore.app.data.model.SeedData
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ScreenPresentationTest {
+ @Test
+ fun resolveSearchResultsReturnsAllAlbumsWhenQueryBlank() {
+ val albums = SeedData.albums
+
+ val results = resolveSearchResults("", albums) { emptyList() }
+
+ assertSame(albums, results)
+ }
+
+ @Test
+ fun resolveSearchResultsDelegatesToSearchWhenQueryPresent() {
+ val albums = SeedData.albums
+ var delegatedQuery: String? = null
+
+ val results = resolveSearchResults("Tyler", albums) { query ->
+ delegatedQuery = query
+ albums.take(1)
+ }
+
+ assertEquals("Tyler", delegatedQuery)
+ assertEquals(1, results.size)
+ }
+
+ @Test
+ fun resolveListShowcasesBuildsFourAlbumMosaics() {
+ val showcases = resolveListShowcases(SeedData.initialLists, SeedData.albums)
+
+ assertEquals(SeedData.initialLists.size, showcases.size)
+ assertEquals(4, showcases.first().coverAlbums.size)
+ assertEquals("alb_4", showcases.first().coverAlbums.first().id)
+ }
+
+ @Test
+ fun buildProfileMetricsUsesAlbumsListsFollowingAndFollowers() {
+ val metrics = buildProfileMetrics(SeedData.myProfile)
+ val favorites = buildFavoriteAlbums(SeedData.myProfile)
+
+ assertEquals(listOf("Albums", "Lists", "Following", "Followers"), metrics.map { it.label })
+ assertEquals(6, favorites.size)
+ assertEquals(SeedData.myProfile.favoriteAlbums.first(), favorites.first())
+ assertTrue(metrics.any { it.value == SeedData.myProfile.followersCount.toString() })
+ }
+}
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..5bbcd42
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,10 @@
+node_modules
+dist
+.git
+*.md
+docker-compose*.yml
+.env
+.env.*
+src/tests
+coverage
+.DS_Store
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..3898709
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,40 @@
+# --- Builder stage ---
+FROM node:20-alpine AS builder
+WORKDIR /app
+
+# Copy workspace root for npm workspaces resolution
+COPY package.json package-lock.json ./
+COPY packages/contracts/package.json packages/contracts/
+COPY backend/package.json backend/
+
+RUN npm ci --workspace backend --workspace @soundscore/contracts
+
+COPY packages/contracts/ packages/contracts/
+COPY backend/ backend/
+
+RUN npm run build --workspace @soundscore/contracts && npm run build --workspace backend
+
+# --- Production stage ---
+FROM node:20-alpine AS production
+WORKDIR /app
+
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+
+COPY package.json package-lock.json ./
+COPY packages/contracts/package.json packages/contracts/
+COPY backend/package.json backend/
+
+RUN npm ci --workspace backend --workspace @soundscore/contracts --omit=dev
+
+COPY --from=builder /app/packages/contracts/dist packages/contracts/dist
+COPY --from=builder /app/backend/dist backend/dist
+COPY backend/src/db/schema backend/src/db/schema
+
+USER appuser
+
+EXPOSE 8080
+
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD wget -q --spider http://localhost:8080/health || exit 1
+
+CMD ["node", "backend/dist/index.js"]
diff --git a/backend/package.json b/backend/package.json
index 3a4a0b9..42a0fd4 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -3,6 +3,9 @@
"version": "0.1.0",
"private": true,
"type": "module",
+ "engines": {
+ "node": ">=20.0.0"
+ },
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
@@ -12,14 +15,18 @@
"test": "tsx --test src/tests/*.test.ts"
},
"dependencies": {
- "@fastify/rate-limit": "^10.3.0",
"@fastify/cors": "^10.0.1",
+ "@fastify/helmet": "^13.0.2",
+ "@fastify/rate-limit": "^10.3.0",
+ "@fastify/swagger": "^9.7.0",
+ "@fastify/swagger-ui": "^5.2.5",
"@soundscore/contracts": "0.1.0",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.7",
"fastify": "^5.2.0",
"ioredis": "^5.4.2",
- "pg": "^8.13.1"
+ "pg": "^8.13.1",
+ "zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts
index 0d2f817..c5bdd71 100644
--- a/backend/src/config/env.ts
+++ b/backend/src/config/env.ts
@@ -1,24 +1,71 @@
import dotenv from "dotenv";
+import { z } from "zod";
dotenv.config();
-const toNumber = (value: string | undefined, fallback: number) => {
- const parsed = Number(value);
- return Number.isFinite(parsed) ? parsed : fallback;
-};
+const DEV_DATABASE_URL = "postgresql://soundscore:soundscore@localhost:5432/soundscore";
+const DEV_REDIS_URL = "redis://localhost:6379";
+
+const EnvSchema = z.object({
+ PORT: z.coerce.number().default(8080),
+ HOST: z.string().default("0.0.0.0"),
+ DATABASE_URL: z.string().min(1).default(DEV_DATABASE_URL),
+ REDIS_URL: z.string().min(1).default(DEV_REDIS_URL),
+ AUTH_SALT_ROUNDS: z.coerce.number().default(10),
+ SPOTIFY_CLIENT_ID: z.string().optional().default(""),
+ SPOTIFY_CLIENT_SECRET: z.string().optional().default(""),
+ ALLOWED_ORIGINS: z.string().default("http://localhost:3000"),
+ NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
+ LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
+});
+
+const parsed = EnvSchema.safeParse(process.env);
+
+if (!parsed.success) {
+ console.error("Invalid environment variables:");
+ for (const issue of parsed.error.issues) {
+ console.error(` ${issue.path.join(".")}: ${issue.message}`);
+ }
+ process.exit(1);
+}
+
+const validated = parsed.data;
+
+// In production, require explicit DATABASE_URL and REDIS_URL (no dev defaults)
+if (validated.NODE_ENV === "production") {
+ if (!process.env.DATABASE_URL) {
+ console.error("DATABASE_URL must be explicitly set in production");
+ process.exit(1);
+ }
+ if (!process.env.REDIS_URL) {
+ console.error("REDIS_URL must be explicitly set in production");
+ process.exit(1);
+ }
+}
+
+if (!validated.SPOTIFY_CLIENT_ID || !validated.SPOTIFY_CLIENT_SECRET) {
+ console.warn("Warning: SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET not set — provider features will be unavailable");
+}
export const env = {
app: {
- port: toNumber(process.env.PORT, 8080),
- host: process.env.HOST ?? "0.0.0.0",
+ port: validated.PORT,
+ host: validated.HOST,
+ allowedOrigins: validated.ALLOWED_ORIGINS.split(",").map((s) => s.trim()),
+ nodeEnv: validated.NODE_ENV,
+ logLevel: validated.LOG_LEVEL,
},
postgres: {
- connectionString: process.env.DATABASE_URL ?? "postgresql://soundscore:soundscore@localhost:5432/soundscore",
+ connectionString: validated.DATABASE_URL,
},
redis: {
- url: process.env.REDIS_URL ?? "redis://localhost:6379",
+ url: validated.REDIS_URL,
},
auth: {
- saltRounds: toNumber(process.env.AUTH_SALT_ROUNDS, 10),
+ saltRounds: validated.AUTH_SALT_ROUNDS,
+ },
+ spotify: {
+ clientId: validated.SPOTIFY_CLIENT_ID,
+ clientSecret: validated.SPOTIFY_CLIENT_SECRET,
},
};
diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts
index 3279a6f..1b8faca 100644
--- a/backend/src/db/client.ts
+++ b/backend/src/db/client.ts
@@ -15,11 +15,13 @@ export type Db = {
export const createDb = (): Db => {
const pool = new Pool({
connectionString: env.postgres.connectionString,
+ connectionTimeoutMillis: 5_000,
});
const redis = new Redis(env.redis.url, {
maxRetriesPerRequest: 1,
lazyConnect: false,
+ retryStrategy: (times) => (times <= 3 ? Math.min(times * 200, 2000) : null),
});
return {
@@ -28,7 +30,10 @@ export const createDb = (): Db => {
query: (text: string, params?: unknown[]) =>
pool.query(text, params),
close: async () => {
- await Promise.all([pool.end(), redis.quit()]);
+ await Promise.all([
+ pool.end().catch(() => {}),
+ redis.quit().catch(() => { redis.disconnect(); }),
+ ]);
},
};
};
diff --git a/backend/src/db/schema/003_audit_dead_letter.sql b/backend/src/db/schema/003_audit_dead_letter.sql
new file mode 100644
index 0000000..455c7d1
--- /dev/null
+++ b/backend/src/db/schema/003_audit_dead_letter.sql
@@ -0,0 +1,25 @@
+-- Audit events for security and compliance logging.
+-- user_id intentionally has no FK to users: audit rows are retained after
+-- account deletion to support compliance investigations and breach forensics.
+CREATE TABLE IF NOT EXISTS audit_events (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ event_type TEXT NOT NULL,
+ details JSONB DEFAULT '{}',
+ ip_address TEXT,
+ user_agent TEXT,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_events(user_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_audit_type ON audit_events(event_type, created_at DESC);
+
+-- Dead letter queue for failed async operations
+CREATE TABLE IF NOT EXISTS dead_letter_events (
+ id TEXT PRIMARY KEY,
+ original_id TEXT,
+ event_type TEXT NOT NULL,
+ payload JSONB NOT NULL,
+ error TEXT NOT NULL,
+ attempt_count INT DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
diff --git a/backend/src/db/schema/004_canonical_mapping_sync.sql b/backend/src/db/schema/004_canonical_mapping_sync.sql
new file mode 100644
index 0000000..f7996f7
--- /dev/null
+++ b/backend/src/db/schema/004_canonical_mapping_sync.sql
@@ -0,0 +1,68 @@
+-- Canonical artists
+CREATE TABLE IF NOT EXISTS canonical_artists (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ normalized_name TEXT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_canonical_artist_norm ON canonical_artists(normalized_name);
+
+-- Canonical albums (SoundScore-owned IDs)
+CREATE TABLE IF NOT EXISTS canonical_albums (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ normalized_title TEXT NOT NULL,
+ artist_id TEXT REFERENCES canonical_artists(id),
+ year INT,
+ track_count INT,
+ artwork_url TEXT,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_canonical_album_norm ON canonical_albums(normalized_title, artist_id);
+
+-- Provider ID mappings
+CREATE TABLE IF NOT EXISTS provider_mappings (
+ id TEXT PRIMARY KEY,
+ canonical_id TEXT NOT NULL,
+ canonical_type TEXT NOT NULL CHECK (canonical_type IN ('artist', 'album', 'track')),
+ provider TEXT NOT NULL,
+ provider_id TEXT NOT NULL,
+ confidence REAL NOT NULL DEFAULT 0 CHECK (confidence >= 0 AND confidence <= 1),
+ provenance TEXT NOT NULL DEFAULT 'auto_match' CHECK (provenance IN ('auto_match', 'user_confirm', 'admin_override', 'provider_link')),
+ status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'pending', 'ambiguous', 'unmapped')),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ UNIQUE(provider, provider_id)
+);
+CREATE INDEX IF NOT EXISTS idx_mapping_canonical ON provider_mappings(canonical_id);
+CREATE INDEX IF NOT EXISTS idx_mapping_lookup ON provider_mappings(provider, provider_id);
+
+-- Sync cursors (resume point per user per provider)
+CREATE TABLE IF NOT EXISTS sync_cursors (
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ provider TEXT NOT NULL,
+ cursor_value TEXT,
+ last_sync_at TIMESTAMPTZ,
+ PRIMARY KEY(user_id, provider)
+);
+
+-- Sync jobs
+CREATE TABLE IF NOT EXISTS sync_jobs (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id),
+ provider TEXT NOT NULL,
+ sync_type TEXT NOT NULL CHECK (sync_type IN ('full', 'incremental')),
+ status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'completed', 'failed', 'cancelled')),
+ progress INT DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
+ items_processed INT DEFAULT 0,
+ items_total INT,
+ error TEXT,
+ started_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_sync_jobs_user ON sync_jobs(user_id, created_at DESC);
+
+-- Add dedup_key to listening_events for import deduplication
+ALTER TABLE listening_events ADD COLUMN IF NOT EXISTS dedup_key TEXT;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_listening_events_dedup ON listening_events(dedup_key) WHERE dedup_key IS NOT NULL;
diff --git a/backend/src/db/schema/004_tracks_and_track_ratings.sql b/backend/src/db/schema/004_tracks_and_track_ratings.sql
new file mode 100644
index 0000000..70969e5
--- /dev/null
+++ b/backend/src/db/schema/004_tracks_and_track_ratings.sql
@@ -0,0 +1,28 @@
+-- Phase 1: Tracks and Track Ratings
+-- Adds per-track data and per-track rating support
+
+CREATE TABLE IF NOT EXISTS tracks (
+ id TEXT PRIMARY KEY,
+ album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ track_number INTEGER NOT NULL,
+ duration_ms INTEGER,
+ spotify_id TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(album_id, track_number)
+);
+
+CREATE TABLE IF NOT EXISTS track_ratings (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ track_id TEXT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
+ album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
+ value REAL NOT NULL CHECK(value >= 0 AND value <= 6),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(user_id, track_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks(album_id);
+CREATE INDEX IF NOT EXISTS idx_track_ratings_user_track ON track_ratings(user_id, track_id);
+CREATE INDEX IF NOT EXISTS idx_track_ratings_album ON track_ratings(album_id);
diff --git a/backend/src/db/schema/005_provider_connections.sql b/backend/src/db/schema/005_provider_connections.sql
new file mode 100644
index 0000000..1f9c88e
--- /dev/null
+++ b/backend/src/db/schema/005_provider_connections.sql
@@ -0,0 +1,27 @@
+-- Provider connections table
+CREATE TABLE IF NOT EXISTS provider_connections (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ provider TEXT NOT NULL CHECK (provider IN ('spotify', 'apple_music', 'musicbrainz')),
+ access_token TEXT NOT NULL,
+ refresh_token TEXT,
+ token_expires_at TIMESTAMPTZ,
+ scopes TEXT[] DEFAULT '{}',
+ provider_user_id TEXT,
+ connected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ disconnected_at TIMESTAMPTZ,
+ UNIQUE(user_id, provider)
+);
+
+CREATE INDEX IF NOT EXISTS idx_provider_conn_user ON provider_connections(user_id);
+CREATE INDEX IF NOT EXISTS idx_provider_conn_provider ON provider_connections(provider, user_id);
+
+-- OAuth state table (for CSRF protection)
+CREATE TABLE IF NOT EXISTS oauth_states (
+ state TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ provider TEXT NOT NULL,
+ redirect_uri TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '10 minutes')
+);
diff --git a/backend/src/db/schema/006_session_expiry_and_indexes.sql b/backend/src/db/schema/006_session_expiry_and_indexes.sql
new file mode 100644
index 0000000..0e2c53f
--- /dev/null
+++ b/backend/src/db/schema/006_session_expiry_and_indexes.sql
@@ -0,0 +1,38 @@
+-- Add session expiration support
+ALTER TABLE sessions ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
+
+-- Backfill existing sessions with a 24-hour window from creation
+UPDATE sessions SET expires_at = created_at + INTERVAL '24 hours' WHERE expires_at IS NULL;
+
+-- Now enforce NOT NULL
+ALTER TABLE sessions ALTER COLUMN expires_at SET NOT NULL;
+
+-- Create index for expired session cleanup
+CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
+
+-- Missing indexes for common query patterns
+CREATE INDEX IF NOT EXISTS idx_ratings_album ON ratings(album_id);
+CREATE INDEX IF NOT EXISTS idx_reviews_album ON reviews(album_id);
+CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_listening_album ON listening_events(album_id);
+CREATE INDEX IF NOT EXISTS idx_notification_events_user ON notification_events(user_id, created_at DESC);
+
+-- Full-text search support for albums
+ALTER TABLE albums ADD COLUMN IF NOT EXISTS search_vector tsvector;
+
+UPDATE albums SET search_vector = to_tsvector('english', COALESCE(title, '') || ' ' || COALESCE(artist, '')) WHERE search_vector IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_albums_search ON albums USING GIN (search_vector);
+
+-- Trigger to keep search_vector updated on insert/update
+CREATE OR REPLACE FUNCTION albums_search_vector_update() RETURNS trigger AS $$
+BEGIN
+ NEW.search_vector := to_tsvector('english', COALESCE(NEW.title, '') || ' ' || COALESCE(NEW.artist, ''));
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_albums_search_vector ON albums;
+CREATE TRIGGER trg_albums_search_vector
+ BEFORE INSERT OR UPDATE OF title, artist ON albums
+ FOR EACH ROW EXECUTE FUNCTION albums_search_vector_update();
diff --git a/backend/src/index.ts b/backend/src/index.ts
index eb6a922..f8d309f 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -7,6 +7,15 @@ const run = async () => {
port: env.app.port,
host: env.app.host,
});
+
+ // Graceful shutdown: drain in-flight requests, close DB/Redis via onClose hook
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
+ process.on(signal, async () => {
+ app.log.info({ signal }, "shutting down gracefully");
+ await app.close();
+ process.exit(0);
+ });
+ }
};
run().catch((error) => {
diff --git a/backend/src/lib/audit.ts b/backend/src/lib/audit.ts
new file mode 100644
index 0000000..7c5cc6e
--- /dev/null
+++ b/backend/src/lib/audit.ts
@@ -0,0 +1,62 @@
+import type { Db } from "../db/client";
+import { uid } from "./util";
+
+const AUDIT_SENSITIVE_FIELDS = new Set([
+ "password",
+ "passwordHash",
+ "token",
+ "accessToken",
+ "refreshToken",
+ "email",
+ "deviceToken",
+]);
+
+const scrubDetails = (details: Record): Record =>
+ Object.fromEntries(
+ Object.entries(details).filter(([key]) => !AUDIT_SENSITIVE_FIELDS.has(key)),
+ );
+
+export type AuditEventType =
+ | "user.signup"
+ | "user.login"
+ | "user.logout"
+ | "provider.connect"
+ | "provider.disconnect"
+ | "account.export"
+ | "account.delete"
+ | "sync.start"
+ | "sync.complete"
+ | "sync.fail"
+ | "rating.create"
+ | "review.create"
+ | "review.update"
+ | "review.delete"
+ | "list.create"
+ | "admin.mapping_override";
+
+export async function logAuditEvent(
+ db: Db,
+ event: {
+ userId: string;
+ type: AuditEventType;
+ details?: Record;
+ ipAddress?: string;
+ userAgent?: string;
+ },
+): Promise {
+ const id = uid("aud");
+ await db.query(
+ `
+ INSERT INTO audit_events(id, user_id, event_type, details, ip_address, user_agent)
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6)
+ `,
+ [
+ id,
+ event.userId,
+ event.type,
+ JSON.stringify(event.details ? scrubDetails(event.details) : {}),
+ event.ipAddress ?? null,
+ event.userAgent ?? null,
+ ],
+ );
+}
diff --git a/backend/src/lib/dead-letter.ts b/backend/src/lib/dead-letter.ts
new file mode 100644
index 0000000..5b95571
--- /dev/null
+++ b/backend/src/lib/dead-letter.ts
@@ -0,0 +1,72 @@
+import type { Db } from "../db/client";
+import { uid } from "./util";
+
+export async function moveToDeadLetter(
+ db: Db,
+ event: {
+ originalId?: string;
+ eventType: string;
+ payload: Record;
+ error: string;
+ attemptCount?: number;
+ },
+): Promise {
+ await db.query(
+ `
+ INSERT INTO dead_letter_events(id, original_id, event_type, payload, error, attempt_count)
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6)
+ `,
+ [
+ uid("dle"),
+ event.originalId ?? null,
+ event.eventType,
+ JSON.stringify(event.payload),
+ event.error,
+ event.attemptCount ?? 0,
+ ],
+ );
+}
+
+export async function listRecentDeadLetters(
+ db: Db,
+ limit = 50,
+ maxLimit = 200,
+): Promise<
+ Array<{
+ id: string;
+ originalId: string | null;
+ eventType: string;
+ payload: Record;
+ error: string;
+ attemptCount: number;
+ createdAt: string;
+ }>
+> {
+ const result = await db.query<{
+ id: string;
+ original_id: string | null;
+ event_type: string;
+ payload: Record;
+ error: string;
+ attempt_count: number;
+ created_at: string;
+ }>(
+ `
+ SELECT id, original_id, event_type, payload, error, attempt_count, created_at
+ FROM dead_letter_events
+ ORDER BY created_at DESC
+ LIMIT $1
+ `,
+ [Math.min(Math.max(1, limit), maxLimit)],
+ );
+
+ return result.rows.map((row) => ({
+ id: row.id,
+ originalId: row.original_id,
+ eventType: row.event_type,
+ payload: row.payload,
+ error: row.error,
+ attemptCount: row.attempt_count,
+ createdAt: row.created_at,
+ }));
+}
diff --git a/backend/src/lib/normalize.ts b/backend/src/lib/normalize.ts
new file mode 100644
index 0000000..9dcc5eb
--- /dev/null
+++ b/backend/src/lib/normalize.ts
@@ -0,0 +1,8 @@
+export const normalizeText = (text: string): string =>
+ text
+ .toLowerCase()
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "") // strip accents
+ .replace(/[^a-z0-9\s]/g, "") // remove special chars
+ .replace(/\s+/g, " ") // collapse whitespace
+ .trim();
diff --git a/backend/src/lib/pagination.ts b/backend/src/lib/pagination.ts
new file mode 100644
index 0000000..d8e72b3
--- /dev/null
+++ b/backend/src/lib/pagination.ts
@@ -0,0 +1,33 @@
+import type { FastifyRequest } from "fastify";
+
+export type PaginationParams = {
+ cursor: string | null;
+ limit: number;
+};
+
+const DEFAULT_LIMIT = 30;
+const MAX_LIMIT = 100;
+const MAX_CURSOR_LENGTH = 128;
+
+export const parsePaginationParams = (request: FastifyRequest): PaginationParams => {
+ const query = request.query as { cursor?: string; limit?: string };
+ const rawLimit = Number(query.limit);
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0
+ ? Math.min(rawLimit, MAX_LIMIT)
+ : DEFAULT_LIMIT;
+
+ const raw = query.cursor?.trim() || null;
+ // Reject cursors that are too long or contain SQL-suspicious characters
+ const cursor = raw && raw.length <= MAX_CURSOR_LENGTH ? raw : null;
+
+ return { cursor, limit };
+};
+
+export const buildPaginatedResponse = (items: T[], limit: number, getCursor: (item: T) => string) => {
+ const hasMore = items.length > limit;
+ const trimmed = hasMore ? items.slice(0, limit) : items;
+ return {
+ items: trimmed,
+ nextCursor: hasMore && trimmed.length > 0 ? getCursor(trimmed[trimmed.length - 1]) : null,
+ };
+};
diff --git a/backend/src/lib/provider-adapter.ts b/backend/src/lib/provider-adapter.ts
new file mode 100644
index 0000000..a61e275
--- /dev/null
+++ b/backend/src/lib/provider-adapter.ts
@@ -0,0 +1,14 @@
+export interface TokenBundle {
+ access_token: string;
+ refresh_token?: string;
+ expires_in?: number;
+ scope?: string;
+}
+
+export interface ProviderAdapter {
+ readonly name: string;
+ getOAuthUrl(state: string, redirectUri: string, scopes?: string[]): string;
+ exchangeCode(code: string, redirectUri: string): Promise;
+ refreshToken(refreshToken: string): Promise;
+ revokeToken(accessToken: string): Promise;
+}
diff --git a/backend/src/lib/provider-registry.ts b/backend/src/lib/provider-registry.ts
new file mode 100644
index 0000000..11308eb
--- /dev/null
+++ b/backend/src/lib/provider-registry.ts
@@ -0,0 +1,11 @@
+import type { ProviderAdapter } from "./provider-adapter";
+import { SpotifyAdapter } from "./spotify-adapter";
+
+const adapters = new Map([
+ ["spotify", new SpotifyAdapter()],
+]);
+
+export const getAdapter = (provider: string): ProviderAdapter | null =>
+ adapters.get(provider) ?? null;
+
+export const SUPPORTED_PROVIDERS = [...adapters.keys()] as const;
diff --git a/backend/src/lib/rate-limit.ts b/backend/src/lib/rate-limit.ts
new file mode 100644
index 0000000..a393763
--- /dev/null
+++ b/backend/src/lib/rate-limit.ts
@@ -0,0 +1,43 @@
+import type { FastifyInstance } from "fastify";
+
+const AUTH_ROUTES = new Set([
+ "/v1/auth/signup",
+ "/v1/auth/login",
+ "/v1/auth/refresh",
+]);
+
+const SENSITIVE_ROUTES = new Set(["/v1/account/export", "/v1/account"]);
+
+const PROVIDER_PREFIX = "/v1/providers/";
+
+export const applyRouteRateLimits = (app: FastifyInstance): void => {
+ app.addHook("onRoute", (routeOptions) => {
+ const url = routeOptions.url;
+ if (!url.startsWith("/v1/")) return;
+
+ const existing = routeOptions.config as Record | undefined;
+ if (existing?.rateLimit !== undefined) return;
+
+ let rateLimit: { max: number; timeWindow: string } | undefined;
+
+ if (AUTH_ROUTES.has(url)) {
+ rateLimit = { max: 10, timeWindow: "1 minute" };
+ } else if (SENSITIVE_ROUTES.has(url)) {
+ rateLimit = { max: 3, timeWindow: "1 hour" };
+ } else if (url.startsWith(PROVIDER_PREFIX)) {
+ rateLimit = { max: 10, timeWindow: "1 minute" };
+ } else {
+ const methods = Array.isArray(routeOptions.method)
+ ? routeOptions.method
+ : [routeOptions.method];
+ const isWrite = methods.some((m) => m !== "GET" && m !== "HEAD");
+ if (isWrite) {
+ rateLimit = { max: 30, timeWindow: "1 minute" };
+ }
+ }
+
+ if (rateLimit) {
+ routeOptions.config = { ...existing, rateLimit };
+ }
+ });
+};
diff --git a/backend/src/lib/retry.ts b/backend/src/lib/retry.ts
new file mode 100644
index 0000000..5389733
--- /dev/null
+++ b/backend/src/lib/retry.ts
@@ -0,0 +1,30 @@
+export async function withRetry(
+ fn: () => Promise,
+ options?: {
+ maxAttempts?: number;
+ backoffMs?: number;
+ maxBackoffMs?: number;
+ onRetry?: (attempt: number, error: unknown) => void;
+ },
+): Promise {
+ const maxAttempts = options?.maxAttempts ?? 3;
+ const baseBackoff = options?.backoffMs ?? 1000;
+ const maxBackoff = options?.maxBackoffMs ?? 64000;
+
+ let lastError: unknown;
+
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ try {
+ return await fn();
+ } catch (error) {
+ lastError = error;
+ if (attempt < maxAttempts - 1) {
+ const delay = Math.min(baseBackoff * 2 ** attempt, maxBackoff);
+ options?.onRetry?.(attempt + 1, error);
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ }
+ }
+
+ throw lastError;
+}
diff --git a/backend/src/lib/sanitize.ts b/backend/src/lib/sanitize.ts
new file mode 100644
index 0000000..c5f6938
--- /dev/null
+++ b/backend/src/lib/sanitize.ts
@@ -0,0 +1,12 @@
+/**
+ * Sanitize user-submitted plain-text fields.
+ * SoundScore does not support rich text — all user content is plain text.
+ * Encodes HTML special characters to prevent XSS if content is ever
+ * rendered in a web context, and strips any remaining HTML tags.
+ */
+export const stripHtml = (text: string): string =>
+ text
+ .replace(/<[^>]*>/g, "")
+ .replace(//g, ">")
+ .trim();
diff --git a/backend/src/lib/spotify-adapter.ts b/backend/src/lib/spotify-adapter.ts
new file mode 100644
index 0000000..2f2dcae
--- /dev/null
+++ b/backend/src/lib/spotify-adapter.ts
@@ -0,0 +1,90 @@
+import { env } from "../config/env";
+import type { ProviderAdapter, TokenBundle } from "./provider-adapter";
+
+const SPOTIFY_AUTHORIZE_URL = "https://accounts.spotify.com/authorize";
+const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
+
+const DEFAULT_SCOPES = [
+ "user-read-recently-played",
+ "user-read-email",
+ "user-library-read",
+];
+
+export class SpotifyAdapter implements ProviderAdapter {
+ readonly name = "spotify";
+
+ getOAuthUrl(state: string, redirectUri: string, scopes?: string[]): string {
+ const params = new URLSearchParams({
+ client_id: env.spotify.clientId,
+ response_type: "code",
+ redirect_uri: redirectUri,
+ state,
+ scope: (scopes ?? DEFAULT_SCOPES).join(" "),
+ });
+ return `${SPOTIFY_AUTHORIZE_URL}?${params.toString()}`;
+ }
+
+ async exchangeCode(code: string, redirectUri: string): Promise {
+ const body = new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: redirectUri,
+ });
+
+ const response = await fetch(SPOTIFY_TOKEN_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Authorization: `Basic ${Buffer.from(`${env.spotify.clientId}:${env.spotify.clientSecret}`).toString("base64")}`,
+ },
+ body: body.toString(),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Spotify token exchange failed (${response.status}): ${text}`);
+ }
+
+ const data = (await response.json()) as TokenBundle;
+ return {
+ access_token: data.access_token,
+ refresh_token: data.refresh_token,
+ expires_in: data.expires_in,
+ scope: data.scope,
+ };
+ }
+
+ async refreshToken(refreshToken: string): Promise {
+ const body = new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: refreshToken,
+ });
+
+ const response = await fetch(SPOTIFY_TOKEN_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Authorization: `Basic ${Buffer.from(`${env.spotify.clientId}:${env.spotify.clientSecret}`).toString("base64")}`,
+ },
+ body: body.toString(),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Spotify token refresh failed (${response.status}): ${text}`);
+ }
+
+ const data = (await response.json()) as TokenBundle;
+ return {
+ access_token: data.access_token,
+ refresh_token: data.refresh_token ?? refreshToken,
+ expires_in: data.expires_in,
+ scope: data.scope,
+ };
+ }
+
+ async revokeToken(_accessToken: string): Promise {
+ // Spotify does not support token revocation via API.
+ // Connection cleanup is handled by disconnecting on our side.
+ }
+}
diff --git a/backend/src/lib/token-refresh.ts b/backend/src/lib/token-refresh.ts
new file mode 100644
index 0000000..608fc9e
--- /dev/null
+++ b/backend/src/lib/token-refresh.ts
@@ -0,0 +1,66 @@
+import type { Db } from "../db/client";
+import { getAdapter } from "./provider-registry";
+
+const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
+
+export const ensureFreshToken = async (
+ userId: string,
+ provider: string,
+ db: Db,
+): Promise => {
+ const result = await db.query<{
+ id: string;
+ access_token: string;
+ refresh_token: string | null;
+ token_expires_at: string | null;
+ }>(
+ `SELECT id, access_token, refresh_token, token_expires_at
+ FROM provider_connections
+ WHERE user_id = $1 AND provider = $2 AND disconnected_at IS NULL`,
+ [userId, provider],
+ );
+
+ if (!result.rowCount) {
+ throw new Error(`No active ${provider} connection for user ${userId}`);
+ }
+
+ const conn = result.rows[0];
+
+ // If no expiry or not yet close to expiring, return current token
+ if (conn.token_expires_at) {
+ const expiresAt = new Date(conn.token_expires_at).getTime();
+ const now = Date.now();
+
+ if (expiresAt - now > REFRESH_BUFFER_MS) {
+ return conn.access_token;
+ }
+
+ // Token is expired or about to expire — refresh it
+ if (!conn.refresh_token) {
+ throw new Error(`Token expired and no refresh token available for ${provider}`);
+ }
+
+ const adapter = getAdapter(provider);
+ if (!adapter) {
+ throw new Error(`No adapter for provider ${provider}`);
+ }
+
+ const tokens = await adapter.refreshToken(conn.refresh_token);
+ const newExpiresAt = tokens.expires_in
+ ? new Date(Date.now() + tokens.expires_in * 1000).toISOString()
+ : null;
+
+ await db.query(
+ `UPDATE provider_connections
+ SET access_token = $1,
+ refresh_token = COALESCE($2, refresh_token),
+ token_expires_at = $3
+ WHERE id = $4`,
+ [tokens.access_token, tokens.refresh_token, newExpiresAt, conn.id],
+ );
+
+ return tokens.access_token;
+ }
+
+ return conn.access_token;
+};
diff --git a/backend/src/modules/auth.ts b/backend/src/modules/auth.ts
index 5c29a68..83cf8e6 100644
--- a/backend/src/modules/auth.ts
+++ b/backend/src/modules/auth.ts
@@ -5,8 +5,9 @@ import {
RefreshRequestSchema,
SignUpRequestSchema,
} from "@soundscore/contracts";
-import { compare, hash } from "bcryptjs";
+import bcrypt from "bcryptjs";
import type { Db } from "../db/client";
+import { logAuditEvent } from "../lib/audit";
import { conflict, unauthorized } from "../lib/errors";
import { mapUserProfile } from "../lib/mappers";
import { nowIso, uid } from "../lib/util";
@@ -47,7 +48,7 @@ const writeProfileCache = async (db: Db, userId: string) => {
};
export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {
- app.post("/v1/auth/signup", async (request) => {
+ app.post("/v1/auth/signup", async (request, reply) => {
const payload = SignUpRequestSchema.parse(request.body);
const existing = await db.query<{ id: string }>(
"SELECT id FROM users WHERE email = $1",
@@ -61,7 +62,7 @@ export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {
const accessToken = uid("atk");
const refreshToken = uid("rtk");
const now = nowIso();
- const passwordHash = await hash(payload.password, env.auth.saltRounds);
+ const passwordHash = await bcrypt.hash(payload.password, env.auth.saltRounds);
await db.query(
`
@@ -81,8 +82,8 @@ export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {
await db.query(
`
- INSERT INTO sessions(access_token, user_id, created_at)
- VALUES($1, $2, $3)
+ INSERT INTO sessions(access_token, user_id, created_at, expires_at)
+ VALUES($1, $2, $3, NOW() + INTERVAL '24 hours')
`,
[accessToken, userId, now],
);
@@ -98,12 +99,20 @@ export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {
await writeProfileCache(db, userId);
- return buildAuthResponse(
+ logAuditEvent(db, {
+ userId,
+ type: "user.signup",
+ details: { handle: payload.handle.startsWith("@") ? payload.handle : `@${payload.handle}` },
+ ipAddress: request.ip,
+ userAgent: request.headers["user-agent"],
+ }).catch(() => {});
+
+ return reply.status(201).send(buildAuthResponse(
accessToken,
refreshToken,
userId,
payload.handle.startsWith("@") ? payload.handle : `@${payload.handle}`,
- );
+ ));
});
app.post("/v1/auth/login", async (request) => {
@@ -122,7 +131,7 @@ export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {
}
const user = userResult.rows[0];
- const matches = await compare(payload.password, user.password_hash);
+ const matches = await bcrypt.compare(payload.password, user.password_hash);
if (!matches) {
throw unauthorized("Invalid credentials");
}
@@ -136,10 +145,20 @@ export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {
[user.id, refreshToken],
);
await db.query(
- "INSERT INTO sessions(access_token, user_id, created_at) VALUES($1, $2, $3)",
+ "INSERT INTO sessions(access_token, user_id, created_at, expires_at) VALUES($1, $2, $3, NOW() + INTERVAL '24 hours')",
[accessToken, user.id, now],
);
+ // Clean up expired sessions for this user
+ await db.query("DELETE FROM sessions WHERE user_id = $1 AND expires_at < NOW()", [user.id]);
+
+ logAuditEvent(db, {
+ userId: user.id,
+ type: "user.login",
+ ipAddress: request.ip,
+ userAgent: request.headers["user-agent"],
+ }).catch(() => {});
+
return buildAuthResponse(accessToken, refreshToken, user.id, user.handle);
});
@@ -164,10 +183,13 @@ export const registerAuthRoutes = (app: FastifyInstance, db: Db) => {
[user.id, nextRefreshToken],
);
await db.query(
- "INSERT INTO sessions(access_token, user_id, created_at) VALUES($1, $2, $3)",
+ "INSERT INTO sessions(access_token, user_id, created_at, expires_at) VALUES($1, $2, $3, NOW() + INTERVAL '24 hours')",
[accessToken, user.id, now],
);
+ // Clean up expired sessions for this user
+ await db.query("DELETE FROM sessions WHERE user_id = $1 AND expires_at < NOW()", [user.id]);
+
return buildAuthResponse(accessToken, nextRefreshToken, user.id, user.handle);
});
diff --git a/backend/src/modules/catalog.ts b/backend/src/modules/catalog.ts
index d2187c7..882d995 100644
--- a/backend/src/modules/catalog.ts
+++ b/backend/src/modules/catalog.ts
@@ -1,59 +1,79 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "../db/client";
import { notFound } from "../lib/errors";
+import { parsePaginationParams, buildPaginatedResponse } from "../lib/pagination";
export const registerCatalogRoutes = (app: FastifyInstance, db: Db) => {
app.get("/v1/search", async (request) => {
const query = ((request.query as { q?: string }).q ?? "").trim().toLowerCase();
+ const { cursor, limit } = parsePaginationParams(request);
- const albums = query
- ? await db.query<{
- id: string;
- title: string;
- artist: string;
- year: number;
- artwork_url: string | null;
- avg_rating: number;
- log_count: number;
- }>(
- `
- SELECT id, title, artist, year, artwork_url, avg_rating, log_count
- FROM albums
- WHERE LOWER(title) LIKE $1 OR LOWER(artist) LIKE $1
- ORDER BY log_count DESC
- LIMIT 50
- `,
- [`%${query}%`],
- )
- : await db.query<{
- id: string;
- title: string;
- artist: string;
- year: number;
- artwork_url: string | null;
- avg_rating: number;
- log_count: number;
- }>(
- `
- SELECT id, title, artist, year, artwork_url, avg_rating, log_count
- FROM albums
- ORDER BY log_count DESC
- LIMIT 50
- `,
- );
+ const cursorClause = cursor ? "AND log_count < $2" : "";
+ const limitParam = cursor ? "$3" : "$2";
- return {
- items: albums.rows.map((album) => ({
- id: album.id,
- title: album.title,
- artist: album.artist,
- year: album.year,
- artworkUrl: album.artwork_url,
- avgRating: Number(album.avg_rating),
- logCount: album.log_count,
- })),
- nextCursor: null,
+ type AlbumRow = {
+ id: string;
+ title: string;
+ artist: string;
+ year: number;
+ artwork_url: string | null;
+ avg_rating: number;
+ log_count: number;
};
+
+ let albums;
+ if (query) {
+ // Use full-text search if search_vector column exists, fallback to LIKE
+ const baseParams: unknown[] = [query];
+ const cursorParams = cursor ? [cursor] : [];
+ const allParams = [...baseParams, ...cursorParams, limit + 1];
+
+ const cursorFilter = cursor ? `AND log_count < $${baseParams.length + 1}` : "";
+ const limitIdx = allParams.length;
+
+ albums = await db.query(
+ `
+ SELECT id, title, artist, year, artwork_url, avg_rating, log_count
+ FROM albums
+ WHERE (
+ search_vector @@ plainto_tsquery('english', $1)
+ OR LOWER(title) LIKE '%' || $1 || '%'
+ OR LOWER(artist) LIKE '%' || $1 || '%'
+ )
+ ${cursorFilter}
+ ORDER BY log_count DESC
+ LIMIT $${limitIdx}
+ `,
+ allParams,
+ );
+ } else {
+ const params: unknown[] = cursor ? [cursor, limit + 1] : [limit + 1];
+ const cursorFilter = cursor ? "WHERE log_count < $1" : "";
+ const limitIdx = params.length;
+
+ albums = await db.query(
+ `
+ SELECT id, title, artist, year, artwork_url, avg_rating, log_count
+ FROM albums
+ ${cursorFilter}
+ ORDER BY log_count DESC
+ LIMIT $${limitIdx}
+ `,
+ params,
+ );
+ }
+
+ const mapped = albums.rows.map((album) => ({
+ id: album.id,
+ title: album.title,
+ artist: album.artist,
+ year: album.year,
+ artworkUrl: album.artwork_url,
+ avgRating: Number(album.avg_rating),
+ logCount: album.log_count,
+ }));
+
+ return buildPaginatedResponse(mapped, limit, (item) => String(item.logCount));
});
app.get("/v1/albums/:id", async (request) => {
diff --git a/backend/src/modules/import.ts b/backend/src/modules/import.ts
new file mode 100644
index 0000000..d3731f9
--- /dev/null
+++ b/backend/src/modules/import.ts
@@ -0,0 +1,291 @@
+import type { FastifyInstance } from "fastify";
+import type { Db } from "../db/client";
+import { badRequest, conflict, notFound } from "../lib/errors";
+import { uid } from "../lib/util";
+import { resolveMapping } from "./mapping";
+
+// --- Provider adapter types (mock for V1) ---
+
+type RawListen = {
+ providerAlbumId: string;
+ title: string;
+ artist: string;
+ year?: number;
+ trackCount?: number;
+ playedAt: string;
+};
+
+// Mock provider fetch — will be replaced with real provider adapters
+const fetchRecentPlays = async (
+ _provider: string,
+ _userId: string,
+ _cursor?: string,
+): Promise<{ plays: RawListen[]; nextCursor: string | null }> => ({
+ plays: [
+ {
+ providerAlbumId: "spotify:album:6kZ42qRrzov54LcAk4onW9",
+ title: "CHROMAKOPIA",
+ artist: "Tyler, the Creator",
+ year: 2024,
+ playedAt: new Date().toISOString(),
+ },
+ {
+ providerAlbumId: "spotify:album:0hvT3yIEysuuvkK73vgdcW",
+ title: "GNX",
+ artist: "Kendrick Lamar",
+ year: 2024,
+ playedAt: new Date(Date.now() - 600_000).toISOString(),
+ },
+ ],
+ nextCursor: null,
+});
+
+// --- Dedup key generation ---
+
+export const generateDedupKey = (
+ userId: string,
+ canonicalAlbumId: string,
+ playedAt: Date,
+): string => {
+ const epochSeconds = Math.floor(playedAt.getTime() / 1000);
+ const bucket = Math.floor(epochSeconds / 600); // 10-minute buckets
+ return `${userId}:${canonicalAlbumId}:${bucket}`;
+};
+
+// --- Sync job row → API shape ---
+
+type SyncJobRow = {
+ id: string;
+ user_id: string;
+ provider: string;
+ sync_type: string;
+ status: string;
+ progress: number;
+ items_processed: number;
+ items_total: number | null;
+ error: string | null;
+ started_at: string | null;
+ completed_at: string | null;
+ created_at: string;
+};
+
+const mapSyncJob = (row: SyncJobRow) => ({
+ id: row.id,
+ userId: row.user_id,
+ provider: row.provider,
+ syncType: row.sync_type,
+ status: row.status,
+ progress: row.progress,
+ itemsProcessed: row.items_processed,
+ itemsTotal: row.items_total,
+ error: row.error,
+ startedAt: row.started_at,
+ completedAt: row.completed_at,
+ createdAt: row.created_at,
+});
+
+// --- Background sync worker ---
+
+const processSync = async (db: Db, syncJobId: string): Promise => {
+ try {
+ // 1. Mark running
+ await db.query(
+ "UPDATE sync_jobs SET status = 'running', started_at = NOW() WHERE id = $1",
+ [syncJobId],
+ );
+
+ const job = await db.query<{ user_id: string; provider: string }>(
+ "SELECT user_id, provider FROM sync_jobs WHERE id = $1",
+ [syncJobId],
+ );
+ if (!job.rowCount) return;
+
+ const { user_id: userId, provider } = job.rows[0];
+
+ // 2. Load sync cursor
+ const cursorResult = await db.query<{ cursor_value: string | null }>(
+ "SELECT cursor_value FROM sync_cursors WHERE user_id = $1 AND provider = $2",
+ [userId, provider],
+ );
+ const cursor = cursorResult.rows[0]?.cursor_value ?? undefined;
+
+ // 3. Fetch recent plays from provider
+ const { plays, nextCursor } = await fetchRecentPlays(provider, userId, cursor);
+
+ await db.query("UPDATE sync_jobs SET items_total = $2 WHERE id = $1", [
+ syncJobId,
+ plays.length,
+ ]);
+
+ // 4. Process each listening event
+ let processed = 0;
+ for (const play of plays) {
+ // Check cancellation
+ const current = await db.query<{ status: string }>(
+ "SELECT status FROM sync_jobs WHERE id = $1",
+ [syncJobId],
+ );
+ if (current.rows[0]?.status === "cancelled") return;
+
+ // a. Resolve mapping: provider album → canonical album
+ const resolved = await resolveMapping(db, provider, play.providerAlbumId, {
+ title: play.title,
+ artist: play.artist,
+ year: play.year,
+ trackCount: play.trackCount,
+ });
+
+ // b. Generate dedup key
+ const playedAt = new Date(play.playedAt);
+ const dedupKey = generateDedupKey(userId, resolved.canonicalAlbum.id, playedAt);
+
+ // c. Check for duplicate
+ const existing = await db.query(
+ "SELECT 1 FROM listening_events WHERE dedup_key = $1",
+ [dedupKey],
+ );
+
+ // d. Insert if not duplicate
+ if (!existing.rowCount) {
+ await db.query(
+ `INSERT INTO listening_events (id, user_id, album_id, played_at, source, source_ref, dedup_key)
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)`,
+ [
+ uid("lst"),
+ userId,
+ resolved.canonicalAlbum.id,
+ playedAt.toISOString(),
+ provider,
+ JSON.stringify({ provider_album_id: play.providerAlbumId }),
+ dedupKey,
+ ],
+ );
+ }
+
+ // e. Update progress
+ processed++;
+ const progress = plays.length > 0 ? Math.round((processed / plays.length) * 100) : 100;
+ await db.query(
+ "UPDATE sync_jobs SET items_processed = $2, progress = $3 WHERE id = $1",
+ [syncJobId, processed, progress],
+ );
+ }
+
+ // 5. Update sync cursor
+ await db.query(
+ `INSERT INTO sync_cursors (user_id, provider, cursor_value, last_sync_at)
+ VALUES ($1, $2, $3, NOW())
+ ON CONFLICT (user_id, provider)
+ DO UPDATE SET cursor_value = $3, last_sync_at = NOW()`,
+ [userId, provider, nextCursor],
+ );
+
+ // 6. Mark completed
+ await db.query(
+ "UPDATE sync_jobs SET status = 'completed', completed_at = NOW() WHERE id = $1",
+ [syncJobId],
+ );
+ } catch (error) {
+ // 7. On error: mark failed, preserve cursor
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+ await db.query(
+ "UPDATE sync_jobs SET status = 'failed', error = $2, completed_at = NOW() WHERE id = $1",
+ [syncJobId, errorMessage],
+ );
+ }
+};
+
+// --- Route registration ---
+
+export const registerImportRoutes = (app: FastifyInstance, db: Db) => {
+ // POST /v1/sync/start
+ app.post("/v1/sync/start", async (request) => {
+ const userId = await app.requireAuth(request);
+ const body = request.body as { provider?: string; sync_type?: string };
+
+ if (!body.provider) {
+ throw badRequest("MISSING_PROVIDER", "provider is required");
+ }
+
+ const provider = body.provider;
+ const syncType = body.sync_type === "full" ? "full" : "incremental";
+
+ // Check no running sync for this user+provider
+ const running = await db.query<{ id: string }>(
+ "SELECT id FROM sync_jobs WHERE user_id = $1 AND provider = $2 AND status IN ('queued', 'running')",
+ [userId, provider],
+ );
+
+ if (running.rowCount) {
+ throw conflict("SYNC_ALREADY_RUNNING", "A sync is already in progress for this provider");
+ }
+
+ // Create sync job
+ const jobId = uid("syj");
+ await db.query(
+ `INSERT INTO sync_jobs (id, user_id, provider, sync_type, status)
+ VALUES ($1, $2, $3, $4, 'queued')`,
+ [jobId, userId, provider, syncType],
+ );
+
+ // Kick off async (don't await)
+ processSync(db, jobId).catch((err) => {
+ app.log.error({ err, syncJobId: jobId }, "sync_process_error");
+ });
+
+ const created = await db.query(
+ "SELECT * FROM sync_jobs WHERE id = $1",
+ [jobId],
+ );
+
+ return { job: mapSyncJob(created.rows[0]) };
+ });
+
+ // GET /v1/sync/status/:sync_id
+ app.get("/v1/sync/status/:sync_id", async (request) => {
+ const userId = await app.requireAuth(request);
+ const syncId = (request.params as { sync_id: string }).sync_id;
+
+ const result = await db.query(
+ "SELECT * FROM sync_jobs WHERE id = $1 AND user_id = $2",
+ [syncId, userId],
+ );
+
+ if (!result.rowCount) {
+ throw notFound("Sync job");
+ }
+
+ return { job: mapSyncJob(result.rows[0]) };
+ });
+
+ // POST /v1/sync/cancel
+ app.post("/v1/sync/cancel", async (request) => {
+ const userId = await app.requireAuth(request);
+ const body = request.body as { sync_id?: string };
+
+ if (!body.sync_id) {
+ throw badRequest("MISSING_SYNC_ID", "sync_id is required");
+ }
+
+ const result = await db.query<{ id: string; status: string }>(
+ "SELECT id, status FROM sync_jobs WHERE id = $1 AND user_id = $2",
+ [body.sync_id, userId],
+ );
+
+ if (!result.rowCount) {
+ throw notFound("Sync job");
+ }
+
+ const job = result.rows[0];
+ if (job.status !== "queued" && job.status !== "running") {
+ throw badRequest("SYNC_NOT_CANCELLABLE", "Only queued or running syncs can be cancelled");
+ }
+
+ await db.query(
+ "UPDATE sync_jobs SET status = 'cancelled', completed_at = NOW() WHERE id = $1",
+ [body.sync_id],
+ );
+
+ return { cancelled: true };
+ });
+};
diff --git a/backend/src/modules/lists.ts b/backend/src/modules/lists.ts
index 453a80a..526f94d 100644
--- a/backend/src/modules/lists.ts
+++ b/backend/src/modules/lists.ts
@@ -1,10 +1,12 @@
import type { FastifyInstance } from "fastify";
import { AddListItemRequestSchema, CreateListRequestSchema } from "@soundscore/contracts";
import type { Db } from "../db/client";
+import { logAuditEvent } from "../lib/audit";
import { notFound } from "../lib/errors";
import { withIdempotency } from "../lib/idempotency";
import { invalidateFeedCacheForUserAndFollowers, queueFollowerNotifications } from "../lib/notifications";
import { nowIso, uid } from "../lib/util";
+import { stripHtml } from "../lib/sanitize";
const updateUserListCount = async (db: Db, userId: string) => {
await db.query(
@@ -25,7 +27,7 @@ const updateUserListCount = async (db: Db, userId: string) => {
};
export const registerListRoutes = (app: FastifyInstance, db: Db) => {
- app.post("/v1/lists", async (request) => {
+ app.post("/v1/lists", async (request, reply) => {
const userId = await app.requireAuth(request);
const payload = CreateListRequestSchema.parse(request.body);
@@ -38,7 +40,7 @@ export const registerListRoutes = (app: FastifyInstance, db: Db) => {
INSERT INTO lists(id, owner_id, title, note, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $5)
`,
- [listId, userId, payload.title, payload.note ?? null, now],
+ [listId, userId, stripHtml(payload.title), payload.note ? stripHtml(payload.note) : null, now],
);
await updateUserListCount(db, userId);
@@ -68,7 +70,15 @@ export const registerListRoutes = (app: FastifyInstance, db: Db) => {
},
);
- return {
+ logAuditEvent(db, {
+ userId,
+ type: "list.create",
+ details: { listId },
+ ipAddress: request.ip,
+ userAgent: request.headers["user-agent"],
+ }).catch(() => {});
+
+ return reply.status(201).send({
id: listId,
ownerId: userId,
title: payload.title,
@@ -76,11 +86,11 @@ export const registerListRoutes = (app: FastifyInstance, db: Db) => {
items: [],
createdAt: now,
updatedAt: now,
- };
+ });
});
});
- app.post("/v1/lists/:id/items", async (request) => {
+ app.post("/v1/lists/:id/items", async (request, reply) => {
const userId = await app.requireAuth(request);
const listId = (request.params as { id: string }).id;
const payload = AddListItemRequestSchema.parse(request.body);
@@ -156,7 +166,7 @@ export const registerListRoutes = (app: FastifyInstance, db: Db) => {
[listId],
);
- return {
+ return reply.status(201).send({
id: response.rows[0].id,
ownerId: response.rows[0].owner_id,
title: response.rows[0].title,
@@ -168,7 +178,7 @@ export const registerListRoutes = (app: FastifyInstance, db: Db) => {
})),
createdAt: response.rows[0].created_at,
updatedAt: response.rows[0].updated_at,
- };
+ });
});
});
diff --git a/backend/src/modules/mapping.ts b/backend/src/modules/mapping.ts
new file mode 100644
index 0000000..5041b51
--- /dev/null
+++ b/backend/src/modules/mapping.ts
@@ -0,0 +1,424 @@
+import type { FastifyInstance } from "fastify";
+import type { Db } from "../db/client";
+import { badRequest, notFound } from "../lib/errors";
+import { normalizeText } from "../lib/normalize";
+import { uid } from "../lib/util";
+
+type MappingMetadata = {
+ title: string;
+ artist: string;
+ year?: number;
+ trackCount?: number;
+ artworkUrl?: string;
+};
+
+type CanonicalAlbum = {
+ id: string;
+ title: string;
+ artistId: string;
+ artistName: string;
+ year: number | null;
+ trackCount: number | null;
+ artworkUrl: string | null;
+};
+
+type MappingRecord = {
+ id: string;
+ canonicalId: string;
+ provider: string;
+ providerId: string;
+ confidence: number;
+ status: string;
+};
+
+export type ResolveResult = {
+ canonicalAlbum: CanonicalAlbum;
+ mapping: MappingRecord;
+ isNew: boolean;
+};
+
+const findOrCreateArtist = async (
+ db: Db,
+ artistName: string,
+): Promise<{ id: string; name: string; normalizedName: string }> => {
+ const normalized = normalizeText(artistName);
+
+ const existing = await db.query<{ id: string; name: string; normalized_name: string }>(
+ "SELECT id, name, normalized_name FROM canonical_artists WHERE normalized_name = $1 LIMIT 1",
+ [normalized],
+ );
+
+ if (existing.rowCount) {
+ const row = existing.rows[0];
+ return { id: row.id, name: row.name, normalizedName: row.normalized_name };
+ }
+
+ const id = uid("cna");
+ await db.query(
+ "INSERT INTO canonical_artists (id, name, normalized_name) VALUES ($1, $2, $3)",
+ [id, artistName, normalized],
+ );
+ return { id, name: artistName, normalizedName: normalized };
+};
+
+export const scoreMatch = (
+ normalizedTitle: string,
+ normalizedArtist: string,
+ candidate: {
+ normalized_title: string;
+ artist_normalized_name: string;
+ year: number | null;
+ track_count: number | null;
+ },
+ metadata: MappingMetadata,
+): number => {
+ let score = 0;
+ if (candidate.normalized_title === normalizedTitle) score += 0.5;
+ if (candidate.artist_normalized_name === normalizedArtist) score += 0.3;
+ if (
+ metadata.year != null &&
+ candidate.year != null &&
+ Math.abs(metadata.year - candidate.year) <= 1
+ ) {
+ score += 0.1;
+ }
+ if (
+ metadata.trackCount != null &&
+ candidate.track_count != null &&
+ metadata.trackCount === candidate.track_count
+ ) {
+ score += 0.1;
+ }
+ return score;
+};
+
+const upsertMapping = async (
+ db: Db,
+ canonicalId: string,
+ provider: string,
+ providerId: string,
+ confidence: number,
+ status: "confirmed" | "pending",
+): Promise => {
+ const mappingId = uid("pmp");
+ const result = await db.query<{ id: string }>(
+ `INSERT INTO provider_mappings (id, canonical_id, canonical_type, provider, provider_id, confidence, provenance, status)
+ VALUES ($1, $2, 'album', $3, $4, $5, 'auto_match', $6)
+ ON CONFLICT (provider, provider_id)
+ DO UPDATE SET canonical_id = $2, confidence = $5, status = $6, updated_at = NOW()
+ RETURNING id`,
+ [mappingId, canonicalId, provider, providerId, confidence, status],
+ );
+ return result.rows[0].id;
+};
+
+const loadCanonicalAlbumWithArtist = async (
+ db: Db,
+ albumId: string,
+): Promise => {
+ const album = await db.query<{
+ id: string;
+ title: string;
+ artist_id: string;
+ year: number | null;
+ track_count: number | null;
+ artwork_url: string | null;
+ }>(
+ "SELECT id, title, artist_id, year, track_count, artwork_url FROM canonical_albums WHERE id = $1",
+ [albumId],
+ );
+
+ if (!album.rowCount) return null;
+
+ const row = album.rows[0];
+ const artist = await db.query<{ name: string }>(
+ "SELECT name FROM canonical_artists WHERE id = $1",
+ [row.artist_id],
+ );
+
+ return {
+ id: row.id,
+ title: row.title,
+ artistId: row.artist_id,
+ artistName: artist.rows[0]?.name ?? "",
+ year: row.year,
+ trackCount: row.track_count,
+ artworkUrl: row.artwork_url,
+ };
+};
+
+export const resolveMapping = async (
+ db: Db,
+ provider: string,
+ providerId: string,
+ metadata: MappingMetadata,
+): Promise => {
+ // 1. Check existing confirmed mapping
+ const existingMapping = await db.query<{
+ id: string;
+ canonical_id: string;
+ confidence: number;
+ status: string;
+ }>(
+ "SELECT id, canonical_id, confidence, status FROM provider_mappings WHERE provider = $1 AND provider_id = $2",
+ [provider, providerId],
+ );
+
+ if (existingMapping.rowCount && existingMapping.rows[0].status === "confirmed") {
+ const mapping = existingMapping.rows[0];
+ const canonicalAlbum = await loadCanonicalAlbumWithArtist(db, mapping.canonical_id);
+
+ if (canonicalAlbum) {
+ return {
+ canonicalAlbum,
+ mapping: {
+ id: mapping.id,
+ canonicalId: mapping.canonical_id,
+ provider,
+ providerId,
+ confidence: mapping.confidence,
+ status: mapping.status,
+ },
+ isNew: false,
+ };
+ }
+ }
+
+ // 2. Normalize title and artist
+ const normalizedTitle = normalizeText(metadata.title);
+ const normalizedArtist = normalizeText(metadata.artist);
+
+ // 3. Search canonical_albums for matches
+ const candidates = await db.query<{
+ id: string;
+ title: string;
+ normalized_title: string;
+ artist_id: string;
+ artist_name: string;
+ artist_normalized_name: string;
+ year: number | null;
+ track_count: number | null;
+ artwork_url: string | null;
+ }>(
+ `SELECT ca.id, ca.title, ca.normalized_title, ca.artist_id,
+ cart.name AS artist_name, cart.normalized_name AS artist_normalized_name,
+ ca.year, ca.track_count, ca.artwork_url
+ FROM canonical_albums ca
+ JOIN canonical_artists cart ON cart.id = ca.artist_id
+ WHERE ca.normalized_title = $1 AND cart.normalized_name = $2`,
+ [normalizedTitle, normalizedArtist],
+ );
+
+ // 4. Score candidates and find best match
+ let bestMatch: (typeof candidates.rows)[number] | null = null;
+ let bestScore = 0;
+
+ for (const candidate of candidates.rows) {
+ const score = scoreMatch(normalizedTitle, normalizedArtist, candidate, metadata);
+ if (score > bestScore) {
+ bestScore = score;
+ bestMatch = candidate;
+ }
+ }
+
+ // 5. High confidence (>= 0.7): confirmed mapping
+ if (bestMatch && bestScore >= 0.7) {
+ const mappingId = await upsertMapping(db, bestMatch.id, provider, providerId, bestScore, "confirmed");
+ return {
+ canonicalAlbum: {
+ id: bestMatch.id,
+ title: bestMatch.title,
+ artistId: bestMatch.artist_id,
+ artistName: bestMatch.artist_name,
+ year: bestMatch.year,
+ trackCount: bestMatch.track_count,
+ artworkUrl: bestMatch.artwork_url,
+ },
+ mapping: {
+ id: mappingId,
+ canonicalId: bestMatch.id,
+ provider,
+ providerId,
+ confidence: bestScore,
+ status: "confirmed",
+ },
+ isNew: false,
+ };
+ }
+
+ // 6. Medium confidence (0.4–0.7): pending mapping
+ if (bestMatch && bestScore >= 0.4) {
+ const mappingId = await upsertMapping(db, bestMatch.id, provider, providerId, bestScore, "pending");
+ return {
+ canonicalAlbum: {
+ id: bestMatch.id,
+ title: bestMatch.title,
+ artistId: bestMatch.artist_id,
+ artistName: bestMatch.artist_name,
+ year: bestMatch.year,
+ trackCount: bestMatch.track_count,
+ artworkUrl: bestMatch.artwork_url,
+ },
+ mapping: {
+ id: mappingId,
+ canonicalId: bestMatch.id,
+ provider,
+ providerId,
+ confidence: bestScore,
+ status: "pending",
+ },
+ isNew: false,
+ };
+ }
+
+ // 7. No match or low confidence: create new canonical album + artist, confirmed self-mapping
+ const artist = await findOrCreateArtist(db, metadata.artist);
+ const albumId = uid("cnb");
+
+ await db.query(
+ `INSERT INTO canonical_albums (id, title, normalized_title, artist_id, year, track_count, artwork_url)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
+ [
+ albumId,
+ metadata.title,
+ normalizedTitle,
+ artist.id,
+ metadata.year ?? null,
+ metadata.trackCount ?? null,
+ metadata.artworkUrl ?? null,
+ ],
+ );
+
+ // Also ensure album exists in the albums table for FK compatibility with listening_events
+ await db.query(
+ `INSERT INTO albums (id, title, artist, year, artwork_url, avg_rating, log_count)
+ VALUES ($1, $2, $3, $4, $5, 0, 0)
+ ON CONFLICT (id) DO NOTHING`,
+ [albumId, metadata.title, metadata.artist, metadata.year ?? 0, metadata.artworkUrl ?? null],
+ );
+
+ const mappingId = await upsertMapping(db, albumId, provider, providerId, 1.0, "confirmed");
+
+ return {
+ canonicalAlbum: {
+ id: albumId,
+ title: metadata.title,
+ artistId: artist.id,
+ artistName: artist.name,
+ year: metadata.year ?? null,
+ trackCount: metadata.trackCount ?? null,
+ artworkUrl: metadata.artworkUrl ?? null,
+ },
+ mapping: {
+ id: mappingId,
+ canonicalId: albumId,
+ provider,
+ providerId,
+ confidence: 1.0,
+ status: "confirmed",
+ },
+ isNew: true,
+ };
+};
+
+export const registerMappingRoutes = (app: FastifyInstance, db: Db) => {
+ // GET /v1/mappings/lookup?provider=spotify&provider_id=xxx
+ app.get("/v1/mappings/lookup", async (request) => {
+ const { provider, provider_id } = request.query as {
+ provider?: string;
+ provider_id?: string;
+ };
+
+ if (!provider || !provider_id) {
+ throw badRequest("MISSING_PARAMS", "provider and provider_id are required");
+ }
+
+ const mapping = await db.query<{
+ id: string;
+ canonical_id: string;
+ canonical_type: string;
+ confidence: number;
+ provenance: string;
+ status: string;
+ }>(
+ `SELECT id, canonical_id, canonical_type, confidence, provenance, status
+ FROM provider_mappings WHERE provider = $1 AND provider_id = $2`,
+ [provider, provider_id],
+ );
+
+ if (!mapping.rowCount) {
+ throw notFound("Mapping");
+ }
+
+ const canonicalId = mapping.rows[0].canonical_id;
+
+ const allMappings = await db.query<{
+ id: string;
+ provider: string;
+ provider_id: string;
+ confidence: number;
+ provenance: string;
+ status: string;
+ }>(
+ `SELECT id, provider, provider_id, confidence, provenance, status
+ FROM provider_mappings WHERE canonical_id = $1`,
+ [canonicalId],
+ );
+
+ const canonicalAlbum = await loadCanonicalAlbumWithArtist(db, canonicalId);
+
+ return {
+ canonical: canonicalAlbum
+ ? {
+ id: canonicalAlbum.id,
+ title: canonicalAlbum.title,
+ artistId: canonicalAlbum.artistId,
+ artistName: canonicalAlbum.artistName,
+ year: canonicalAlbum.year,
+ trackCount: canonicalAlbum.trackCount,
+ artworkUrl: canonicalAlbum.artworkUrl,
+ }
+ : null,
+ mappings: allMappings.rows.map((m) => ({
+ id: m.id,
+ provider: m.provider,
+ providerId: m.provider_id,
+ confidence: m.confidence,
+ provenance: m.provenance,
+ status: m.status,
+ })),
+ };
+ });
+
+ // POST /v1/mappings/resolve
+ app.post("/v1/mappings/resolve", async (request) => {
+ const body = request.body as {
+ provider?: string;
+ provider_id?: string;
+ title?: string;
+ artist?: string;
+ year?: number;
+ track_count?: number;
+ };
+
+ if (!body.provider || !body.provider_id || !body.title || !body.artist) {
+ throw badRequest(
+ "MISSING_PARAMS",
+ "provider, provider_id, title, and artist are required",
+ );
+ }
+
+ const result = await resolveMapping(db, body.provider, body.provider_id, {
+ title: body.title,
+ artist: body.artist,
+ year: body.year,
+ trackCount: body.track_count,
+ });
+
+ return {
+ canonicalAlbum: result.canonicalAlbum,
+ mapping: result.mapping,
+ isNew: result.isNew,
+ };
+ });
+};
diff --git a/backend/src/modules/opinions.ts b/backend/src/modules/opinions.ts
index 3cd4dd3..ecf7e40 100644
--- a/backend/src/modules/opinions.ts
+++ b/backend/src/modules/opinions.ts
@@ -5,6 +5,7 @@ import {
UpdateReviewRequestSchema,
} from "@soundscore/contracts";
import type { Db } from "../db/client";
+import { logAuditEvent } from "../lib/audit";
import { conflict, notFound } from "../lib/errors";
import { withIdempotency } from "../lib/idempotency";
import {
@@ -12,6 +13,8 @@ import {
queueFollowerNotifications,
} from "../lib/notifications";
import { nowIso, uid } from "../lib/util";
+import { parsePaginationParams, buildPaginatedResponse } from "../lib/pagination";
+import { stripHtml } from "../lib/sanitize";
const updateUserAndAlbumAggregates = async (db: Db, userId: string, albumId: string) => {
await db.query(
@@ -53,6 +56,13 @@ const updateUserAndAlbumAggregates = async (db: Db, userId: string, albumId: str
export const registerOpinionRoutes = (app: FastifyInstance, db: Db) => {
app.get("/v1/log/recently-played", async (request) => {
const userId = await app.requireAuth(request);
+ const { cursor, limit } = parsePaginationParams(request);
+
+ const cursorClause = cursor ? "AND played_at < $3" : "";
+ const params: unknown[] = [userId, limit + 1];
+ if (cursor) {
+ params.push(cursor);
+ }
const recentlyPlayed = await db.query<{
id: string;
@@ -65,27 +75,26 @@ export const registerOpinionRoutes = (app: FastifyInstance, db: Db) => {
`
SELECT id, user_id, album_id, played_at, source, source_ref
FROM listening_events
- WHERE user_id = $1
+ WHERE user_id = $1 ${cursorClause}
ORDER BY played_at DESC
- LIMIT 30
+ LIMIT $2
`,
- [userId],
+ params,
);
- return {
- items: recentlyPlayed.rows.map((row) => ({
- id: row.id,
- userId: row.user_id,
- albumId: row.album_id,
- playedAt: row.played_at,
- source: row.source,
- sourceRef: row.source_ref,
- })),
- nextCursor: null,
- };
+ const mapped = recentlyPlayed.rows.map((row) => ({
+ id: row.id,
+ userId: row.user_id,
+ albumId: row.album_id,
+ playedAt: row.played_at,
+ source: row.source,
+ sourceRef: row.source_ref,
+ }));
+
+ return buildPaginatedResponse(mapped, limit, (item) => item.playedAt);
});
- app.post("/v1/ratings", async (request) => {
+ app.post("/v1/ratings", async (request, reply) => {
const userId = await app.requireAuth(request);
const payload = CreateRatingRequestSchema.parse(request.body);
@@ -152,19 +161,27 @@ export const registerOpinionRoutes = (app: FastifyInstance, db: Db) => {
},
);
+ logAuditEvent(db, {
+ userId,
+ type: "rating.create",
+ details: { albumId: payload.albumId, value: payload.value },
+ ipAddress: request.ip,
+ userAgent: request.headers["user-agent"],
+ }).catch(() => {});
+
const rating = ratingResult.rows[0];
- return {
+ return reply.status(201).send({
id: rating.id,
userId: rating.user_id,
albumId: rating.album_id,
value: Number(rating.value),
createdAt: rating.created_at,
updatedAt: rating.updated_at,
- };
+ });
});
});
- app.post("/v1/reviews", async (request) => {
+ app.post("/v1/reviews", async (request, reply) => {
const userId = await app.requireAuth(request);
const payload = CreateReviewRequestSchema.parse(request.body);
@@ -191,7 +208,7 @@ export const registerOpinionRoutes = (app: FastifyInstance, db: Db) => {
VALUES ($1, $2, $3, $4, 0, $5, $5)
RETURNING id, user_id, album_id, body, revision, created_at, updated_at
`,
- [reviewId, userId, payload.albumId, payload.body, now],
+ [reviewId, userId, payload.albumId, stripHtml(payload.body), now],
);
const activityId = uid("act");
@@ -236,8 +253,16 @@ export const registerOpinionRoutes = (app: FastifyInstance, db: Db) => {
},
);
+ logAuditEvent(db, {
+ userId,
+ type: "review.create",
+ details: { albumId: payload.albumId, reviewId },
+ ipAddress: request.ip,
+ userAgent: request.headers["user-agent"],
+ }).catch(() => {});
+
const review = reviewResult.rows[0];
- return {
+ return reply.status(201).send({
id: review.id,
userId: review.user_id,
albumId: review.album_id,
@@ -245,7 +270,7 @@ export const registerOpinionRoutes = (app: FastifyInstance, db: Db) => {
revision: review.revision,
createdAt: review.created_at,
updatedAt: review.updated_at,
- };
+ });
});
});
@@ -296,9 +321,17 @@ export const registerOpinionRoutes = (app: FastifyInstance, db: Db) => {
WHERE id = $1
RETURNING id, user_id, album_id, body, revision, created_at, updated_at
`,
- [reviewId, payload.body],
+ [reviewId, stripHtml(payload.body)],
);
+ logAuditEvent(db, {
+ userId,
+ type: "review.update",
+ details: { reviewId, revision: updated.rows[0].revision },
+ ipAddress: request.ip,
+ userAgent: request.headers["user-agent"],
+ }).catch(() => {});
+
const row = updated.rows[0];
return {
id: row.id,
diff --git a/backend/src/modules/providers.ts b/backend/src/modules/providers.ts
new file mode 100644
index 0000000..e27d503
--- /dev/null
+++ b/backend/src/modules/providers.ts
@@ -0,0 +1,243 @@
+import crypto from "node:crypto";
+import type { FastifyInstance } from "fastify";
+import type { Db } from "../db/client";
+import { badRequest, conflict, notFound, unauthorized } from "../lib/errors";
+import { getAdapter, SUPPORTED_PROVIDERS } from "../lib/provider-registry";
+import { uid } from "../lib/util";
+
+const VALID_PROVIDERS = new Set(SUPPORTED_PROVIDERS);
+
+const validateProvider = (provider: string) => {
+ if (!VALID_PROVIDERS.has(provider)) {
+ throw badRequest("INVALID_PROVIDER", `Unsupported provider: ${provider}`);
+ }
+};
+
+export const registerProviderRoutes = (app: FastifyInstance, db: Db) => {
+ // --- POST /v1/providers/:provider/connect ---
+ app.post("/v1/providers/:provider/connect", async (request) => {
+ const userId = await app.requireAuth(request);
+ const { provider } = request.params as { provider: string };
+ validateProvider(provider);
+
+ const body = request.body as { redirect_uri?: string } | undefined;
+ const redirectUri = body?.redirect_uri;
+ if (!redirectUri) {
+ throw badRequest("MISSING_REDIRECT_URI", "redirect_uri is required");
+ }
+
+ // Check if already connected
+ const existing = await db.query<{ id: string; connected_at: string }>(
+ `SELECT id, connected_at FROM provider_connections
+ WHERE user_id = $1 AND provider = $2 AND disconnected_at IS NULL`,
+ [userId, provider],
+ );
+ if (existing.rowCount) {
+ throw conflict("ALREADY_CONNECTED", `Already connected to ${provider}`);
+ }
+
+ const adapter = getAdapter(provider);
+ if (!adapter) {
+ throw badRequest("INVALID_PROVIDER", `No adapter for provider: ${provider}`);
+ }
+
+ // Generate crypto-random state for CSRF protection
+ const state = crypto.randomBytes(32).toString("hex");
+ await db.query(
+ `INSERT INTO oauth_states(state, user_id, provider, redirect_uri)
+ VALUES($1, $2, $3, $4)`,
+ [state, userId, provider, redirectUri],
+ );
+
+ const oauthUrl = adapter.getOAuthUrl(state, redirectUri);
+
+ return { oauth_url: oauthUrl, state };
+ });
+
+ // --- POST /v1/providers/:provider/callback ---
+ app.post("/v1/providers/:provider/callback", async (request) => {
+ const userId = await app.requireAuth(request);
+ const { provider } = request.params as { provider: string };
+ validateProvider(provider);
+
+ const body = request.body as { code?: string; state?: string } | undefined;
+ const code = body?.code;
+ const state = body?.state;
+
+ if (!code || !state) {
+ throw badRequest("MISSING_PARAMS", "code and state are required");
+ }
+
+ // Validate state: exists, not expired, matches user
+ const stateResult = await db.query<{
+ user_id: string;
+ provider: string;
+ redirect_uri: string;
+ expires_at: string;
+ }>(
+ `SELECT user_id, provider, redirect_uri, expires_at
+ FROM oauth_states
+ WHERE state = $1`,
+ [state],
+ );
+
+ if (!stateResult.rowCount) {
+ throw unauthorized("Invalid or expired OAuth state");
+ }
+
+ const oauthState = stateResult.rows[0];
+
+ if (oauthState.user_id !== userId) {
+ throw unauthorized("OAuth state does not match authenticated user");
+ }
+ if (oauthState.provider !== provider) {
+ throw badRequest("STATE_PROVIDER_MISMATCH", "State provider does not match route");
+ }
+ if (new Date(oauthState.expires_at).getTime() < Date.now()) {
+ // Clean up expired state
+ await db.query("DELETE FROM oauth_states WHERE state = $1", [state]);
+ throw unauthorized("OAuth state has expired");
+ }
+
+ // Delete used state (one-time use)
+ await db.query("DELETE FROM oauth_states WHERE state = $1", [state]);
+
+ const adapter = getAdapter(provider);
+ if (!adapter) {
+ throw badRequest("INVALID_PROVIDER", `No adapter for provider: ${provider}`);
+ }
+
+ const tokens = await adapter.exchangeCode(code, oauthState.redirect_uri);
+ const connectionId = uid("prc");
+ const expiresAt = tokens.expires_in
+ ? new Date(Date.now() + tokens.expires_in * 1000).toISOString()
+ : null;
+ const scopes = tokens.scope ? tokens.scope.split(" ") : [];
+
+ // Upsert: if a disconnected connection exists for this user+provider, replace it
+ await db.query(
+ `INSERT INTO provider_connections(id, user_id, provider, access_token, refresh_token, token_expires_at, scopes, connected_at, disconnected_at)
+ VALUES($1, $2, $3, $4, $5, $6, $7, NOW(), NULL)
+ ON CONFLICT(user_id, provider) DO UPDATE SET
+ id = $1,
+ access_token = $4,
+ refresh_token = $5,
+ token_expires_at = $6,
+ scopes = $7,
+ connected_at = NOW(),
+ disconnected_at = NULL`,
+ [connectionId, userId, provider, tokens.access_token, tokens.refresh_token ?? null, expiresAt, scopes],
+ );
+
+ return {
+ connection: {
+ id: connectionId,
+ provider,
+ connected: true,
+ connected_at: new Date().toISOString(),
+ scopes,
+ },
+ };
+ });
+
+ // --- GET /v1/providers/:provider/status ---
+ app.get("/v1/providers/:provider/status", async (request) => {
+ const userId = await app.requireAuth(request);
+ const { provider } = request.params as { provider: string };
+ validateProvider(provider);
+
+ const result = await db.query<{
+ id: string;
+ provider: string;
+ connected_at: string;
+ scopes: string[];
+ }>(
+ `SELECT id, provider, connected_at, scopes
+ FROM provider_connections
+ WHERE user_id = $1 AND provider = $2 AND disconnected_at IS NULL`,
+ [userId, provider],
+ );
+
+ if (!result.rowCount) {
+ return { connection: null };
+ }
+
+ const conn = result.rows[0];
+ return {
+ connection: {
+ id: conn.id,
+ provider: conn.provider,
+ connected: true,
+ connected_at: conn.connected_at,
+ scopes: conn.scopes,
+ },
+ };
+ });
+
+ // --- POST /v1/providers/:provider/disconnect ---
+ app.post("/v1/providers/:provider/disconnect", async (request) => {
+ const userId = await app.requireAuth(request);
+ const { provider } = request.params as { provider: string };
+ validateProvider(provider);
+
+ const connResult = await db.query<{
+ id: string;
+ access_token: string;
+ }>(
+ `SELECT id, access_token FROM provider_connections
+ WHERE user_id = $1 AND provider = $2 AND disconnected_at IS NULL`,
+ [userId, provider],
+ );
+
+ if (!connResult.rowCount) {
+ throw notFound("Provider connection");
+ }
+
+ const conn = connResult.rows[0];
+
+ // Best-effort token revocation
+ const adapter = getAdapter(provider);
+ if (adapter) {
+ try {
+ await adapter.revokeToken(conn.access_token);
+ } catch {
+ // Revocation is best-effort; log but don't fail
+ }
+ }
+
+ // Soft-disconnect: set disconnected_at
+ await db.query(
+ `UPDATE provider_connections SET disconnected_at = NOW() WHERE id = $1`,
+ [conn.id],
+ );
+
+ // Purge associated data if requested
+ const body = request.body as { purge_data?: boolean } | undefined;
+ if (body?.purge_data) {
+ await db.query(
+ `DELETE FROM listening_events WHERE user_id = $1 AND source = $2`,
+ [userId, provider],
+ );
+ // sync_cursors and sync_jobs tables may not exist yet in this phase;
+ // wrap in try/catch to be forward-compatible
+ try {
+ await db.query(
+ `DELETE FROM sync_cursors WHERE user_id = $1 AND provider = $2`,
+ [userId, provider],
+ );
+ } catch {
+ // Table may not exist yet
+ }
+ try {
+ await db.query(
+ `DELETE FROM sync_jobs WHERE user_id = $1 AND provider = $2`,
+ [userId, provider],
+ );
+ } catch {
+ // Table may not exist yet
+ }
+ }
+
+ return { disconnected: true };
+ });
+};
diff --git a/backend/src/modules/social.ts b/backend/src/modules/social.ts
index a724570..63eb6f8 100644
--- a/backend/src/modules/social.ts
+++ b/backend/src/modules/social.ts
@@ -4,11 +4,12 @@ import type { Db } from "../db/client";
import { notFound } from "../lib/errors";
import { withIdempotency } from "../lib/idempotency";
import { invalidateFeedCacheForUserAndFollowers, queueNotification } from "../lib/notifications";
+import { parsePaginationParams, buildPaginatedResponse } from "../lib/pagination";
const FEED_CACHE_TTL_SECONDS = 90;
export const registerSocialRoutes = (app: FastifyInstance, db: Db) => {
- app.post("/v1/follow/:userId", async (request) => {
+ app.post("/v1/follow/:userId", async (request, reply) => {
const actorId = await app.requireAuth(request);
const targetUserId = (request.params as { userId: string }).userId;
@@ -28,7 +29,7 @@ export const registerSocialRoutes = (app: FastifyInstance, db: Db) => {
);
await db.redis.del(`feed:${actorId}:page1`);
- return { followingUserId: targetUserId, following: true };
+ return reply.status(201).send({ followingUserId: targetUserId, following: true });
});
});
@@ -52,10 +53,15 @@ export const registerSocialRoutes = (app: FastifyInstance, db: Db) => {
app.get("/v1/feed", async (request) => {
const actorId = await app.requireAuth(request);
- const cacheKey = `feed:${actorId}:page1`;
- const cached = await db.redis.get(cacheKey);
- if (cached) {
- return JSON.parse(cached) as unknown;
+ const { cursor, limit } = parsePaginationParams(request);
+
+ // Only use cache for the first page (no cursor)
+ if (!cursor) {
+ const cacheKey = `feed:${actorId}:page1`;
+ const cached = await db.redis.get(cacheKey);
+ if (cached) {
+ return JSON.parse(cached) as unknown;
+ }
}
const follows = await db.query<{ followee_id: string }>(
@@ -64,6 +70,13 @@ export const registerSocialRoutes = (app: FastifyInstance, db: Db) => {
);
const actorIds = [actorId, ...follows.rows.map((row) => row.followee_id)];
+
+ const cursorClause = cursor ? "AND created_at < $3" : "";
+ const params: unknown[] = [actorIds, limit + 1];
+ if (cursor) {
+ params.push(cursor);
+ }
+
const feed = await db.query<{
id: string;
actor_id: string;
@@ -78,35 +91,37 @@ export const registerSocialRoutes = (app: FastifyInstance, db: Db) => {
`
SELECT id, actor_id, type, object_type, object_id, created_at, payload, reactions, comments
FROM activity_events
- WHERE actor_id = ANY($1::text[])
+ WHERE actor_id = ANY($1::text[]) ${cursorClause}
ORDER BY created_at DESC
- LIMIT 40
+ LIMIT $2
`,
- [actorIds],
+ params,
);
- const response = {
- items: feed.rows.map((row) => ({
- id: row.id,
- actorId: row.actor_id,
- type: row.type,
- object: {
- type: row.object_type,
- id: row.object_id,
- },
- createdAt: row.created_at,
- payload: row.payload,
- reactions: row.reactions,
- comments: row.comments,
- })),
- nextCursor: null,
- };
-
- await db.redis.setex(cacheKey, FEED_CACHE_TTL_SECONDS, JSON.stringify(response));
+ const mapped = feed.rows.map((row) => ({
+ id: row.id,
+ actorId: row.actor_id,
+ type: row.type,
+ object: {
+ type: row.object_type,
+ id: row.object_id,
+ },
+ createdAt: row.created_at,
+ payload: row.payload,
+ reactions: row.reactions,
+ comments: row.comments,
+ }));
+
+ const response = buildPaginatedResponse(mapped, limit, (item) => item.createdAt);
+
+ // Cache first page only
+ if (!cursor) {
+ await db.redis.setex(`feed:${actorId}:page1`, FEED_CACHE_TTL_SECONDS, JSON.stringify(response));
+ }
return response;
});
- app.post("/v1/activity/:id/react", async (request) => {
+ app.post("/v1/activity/:id/react", async (request, reply) => {
const actorId = await app.requireAuth(request);
const activityId = (request.params as { id: string }).id;
ReactActivityRequestSchema.parse(request.body);
@@ -151,11 +166,11 @@ export const registerSocialRoutes = (app: FastifyInstance, db: Db) => {
}
await invalidateFeedCacheForUserAndFollowers(db, ownerId);
- return { activityId, reactions: event.rows[0].reactions };
+ return reply.status(201).send({ activityId, reactions: event.rows[0].reactions });
});
});
- app.post("/v1/activity/:id/comment", async (request) => {
+ app.post("/v1/activity/:id/comment", async (request, reply) => {
const actorId = await app.requireAuth(request);
const activityId = (request.params as { id: string }).id;
CommentActivityRequestSchema.parse(request.body);
@@ -200,7 +215,7 @@ export const registerSocialRoutes = (app: FastifyInstance, db: Db) => {
}
await invalidateFeedCacheForUserAndFollowers(db, ownerId);
- return { activityId, comments: event.rows[0].comments };
+ return reply.status(201).send({ activityId, comments: event.rows[0].comments });
});
});
};
diff --git a/backend/src/modules/trust.ts b/backend/src/modules/trust.ts
index 023ac9e..ad97f37 100644
--- a/backend/src/modules/trust.ts
+++ b/backend/src/modules/trust.ts
@@ -1,5 +1,6 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "../db/client";
+import { logAuditEvent } from "../lib/audit";
export const registerTrustRoutes = (app: FastifyInstance, db: Db) => {
app.post("/v1/account/export", async (request) => {
@@ -125,6 +126,13 @@ export const registerTrustRoutes = (app: FastifyInstance, db: Db) => {
[userId],
);
+ logAuditEvent(db, {
+ userId,
+ type: "account.export",
+ ipAddress: request.ip,
+ userAgent: request.headers["user-agent"],
+ }).catch(() => {});
+
return {
generatedAt: new Date().toISOString(),
profile: profile.rowCount
@@ -198,6 +206,13 @@ export const registerTrustRoutes = (app: FastifyInstance, db: Db) => {
await db.query("DELETE FROM users WHERE id = $1", [userId]);
+ logAuditEvent(db, {
+ userId,
+ type: "account.delete",
+ ipAddress: request.ip,
+ userAgent: request.headers["user-agent"],
+ }).catch(() => {});
+
await db.redis.del(
`profile:${userId}`,
`feed:${userId}:page1`,
@@ -206,17 +221,4 @@ export const registerTrustRoutes = (app: FastifyInstance, db: Db) => {
reply.code(204).send();
});
- app.post("/v1/providers/:provider/connect", async (_request, reply) => {
- return reply.code(501).send({
- code: "PROVIDER_NOT_ENABLED",
- message: "Provider connections are out of scope for provider-free phase 1",
- });
- });
-
- app.post("/v1/providers/:provider/disconnect", async (_request, reply) => {
- return reply.code(501).send({
- code: "PROVIDER_NOT_ENABLED",
- message: "Provider connections are out of scope for provider-free phase 1",
- });
- });
};
diff --git a/backend/src/server.ts b/backend/src/server.ts
index 5cb2a43..f5fe435 100644
--- a/backend/src/server.ts
+++ b/backend/src/server.ts
@@ -1,7 +1,11 @@
import Fastify, { type FastifyRequest } from "fastify";
import cors from "@fastify/cors";
+import helmet from "@fastify/helmet";
import rateLimit from "@fastify/rate-limit";
+import swagger from "@fastify/swagger";
+import swaggerUi from "@fastify/swagger-ui";
import { ApiError, unauthorized } from "./lib/errors";
+import { applyRouteRateLimits } from "./lib/rate-limit";
import { registerAuthRoutes } from "./modules/auth";
import { registerCatalogRoutes } from "./modules/catalog";
import { registerOpinionRoutes } from "./modules/opinions";
@@ -10,8 +14,13 @@ import { registerListRoutes } from "./modules/lists";
import { registerTrustRoutes } from "./modules/trust";
import { registerRecapRoutes } from "./modules/recaps";
import { registerPushRoutes } from "./modules/push";
+import { registerProviderRoutes } from "./modules/providers";
+import { registerMappingRoutes } from "./modules/mapping";
+import { registerImportRoutes } from "./modules/import";
import { createDb, type Db } from "./db/client";
import { runMigrations } from "./db/runMigrations";
+import { env } from "./config/env";
+import { uid } from "./lib/util";
declare module "fastify" {
interface FastifyInstance {
@@ -28,7 +37,7 @@ const resolveUserIdFromRequest = async (request: FastifyRequest, db: Db): Promis
const token = authHeader.replace("Bearer ", "").trim();
const session = await db.query<{ user_id: string }>(
- "SELECT user_id FROM sessions WHERE access_token = $1",
+ "SELECT user_id FROM sessions WHERE access_token = $1 AND expires_at > NOW()",
[token],
);
@@ -39,18 +48,75 @@ const resolveUserIdFromRequest = async (request: FastifyRequest, db: Db): Promis
};
export const buildServer = async () => {
- const app = Fastify({ logger: true });
+ const app = Fastify({
+ logger: {
+ level: env.app.logLevel,
+ serializers: {
+ req: (req) => ({
+ method: req.method,
+ url: req.url,
+ remoteAddress: req.ip,
+ }),
+ },
+ },
+ requestIdHeader: "x-request-id",
+ genReqId: () => uid("req"),
+ });
+
const db = createDb();
- await runMigrations(db);
+ try {
+ await runMigrations(db);
+ } catch (error) {
+ await db.close();
+ throw error;
+ }
app.decorate("db", db);
app.decorate("requireAuth", (request: FastifyRequest) => resolveUserIdFromRequest(request, db));
- app.register(cors, { origin: true });
+ // OpenAPI documentation
+ await app.register(swagger, {
+ openapi: {
+ info: {
+ title: "SoundScore API",
+ description: "Music logging & social discovery platform",
+ version: "0.1.0",
+ },
+ servers: [{ url: `http://localhost:${env.app.port}` }],
+ components: {
+ securitySchemes: {
+ bearerAuth: {
+ type: "http",
+ scheme: "bearer",
+ },
+ },
+ },
+ },
+ });
+ await app.register(swaggerUi, { routePrefix: "/docs" });
+
+ // Security headers (API-only, no CSP needed)
+ app.register(helmet, {
+ contentSecurityPolicy: false,
+ crossOriginResourcePolicy: { policy: "cross-origin" },
+ });
+
+ // CORS with explicit origin allowlist
+ app.register(cors, {
+ origin: env.app.allowedOrigins,
+ credentials: true,
+ });
+
app.register(rateLimit, {
global: true,
max: 100,
timeWindow: "1 minute",
+ addHeaders: {
+ "x-ratelimit-limit": true,
+ "x-ratelimit-remaining": true,
+ "x-ratelimit-reset": true,
+ "retry-after": true,
+ },
addHeadersOnExceeding: {
"x-ratelimit-limit": true,
"x-ratelimit-remaining": true,
@@ -58,14 +124,25 @@ export const buildServer = async () => {
},
});
- app.get("/health", async () => ({
- status: "ok",
- service: "soundscore-backend",
- checks: {
- postgres: "up",
- redis: db.redis.status,
- },
- }));
+ applyRouteRateLimits(app);
+
+ // Health check with actual DB/Redis connectivity probes
+ app.get("/health", async (_request, reply) => {
+ const pgStatus = await db.query("SELECT 1").then(() => "up" as const).catch(() => "down" as const);
+ const redisStatus = db.redis.status === "ready" ? "up" as const : "down" as const;
+ const allUp = pgStatus === "up" && redisStatus === "up";
+
+ const payload = {
+ status: allUp ? "ok" : "degraded",
+ service: "soundscore-backend",
+ checks: {
+ postgres: pgStatus,
+ redis: redisStatus,
+ },
+ };
+
+ return allUp ? payload : reply.status(503).send(payload);
+ });
registerAuthRoutes(app, db);
registerCatalogRoutes(app, db);
@@ -75,6 +152,9 @@ export const buildServer = async () => {
registerTrustRoutes(app, db);
registerPushRoutes(app, db);
registerRecapRoutes(app, db);
+ registerProviderRoutes(app, db);
+ registerMappingRoutes(app, db);
+ registerImportRoutes(app, db);
app.addHook("onRequest", (request, _reply, done) => {
// Attach start timestamp for latency logging.
diff --git a/backend/src/tests/audit.test.ts b/backend/src/tests/audit.test.ts
new file mode 100644
index 0000000..d71f337
--- /dev/null
+++ b/backend/src/tests/audit.test.ts
@@ -0,0 +1,110 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { logAuditEvent, type AuditEventType } from "../lib/audit";
+import type { Db } from "../db/client";
+
+const createMockDb = () => {
+ const queries: Array<{ text: string; params: unknown[] }> = [];
+
+ const mockDb: Db = {
+ pool: {} as Db["pool"],
+ redis: {} as Db["redis"],
+ query: async (text: string, params?: unknown[]) => {
+ queries.push({ text, params: params ?? [] });
+ return { rows: [], rowCount: 0, command: "INSERT", oid: 0, fields: [] };
+ },
+ close: async () => {},
+ };
+
+ return { db: mockDb, queries };
+};
+
+test("logAuditEvent inserts with correct parameters", async () => {
+ const { db, queries } = createMockDb();
+
+ await logAuditEvent(db, {
+ userId: "usr_123",
+ type: "user.signup",
+ details: { handle: "@testuser" },
+ ipAddress: "127.0.0.1",
+ userAgent: "TestAgent/1.0",
+ });
+
+ assert.equal(queries.length, 1);
+ const [query] = queries;
+ assert.ok(query.text.includes("INSERT INTO audit_events"));
+ assert.ok((query.params[0] as string).startsWith("aud_"));
+ assert.equal(query.params[1], "usr_123");
+ assert.equal(query.params[2], "user.signup");
+ assert.equal(query.params[3], JSON.stringify({ handle: "@testuser" }));
+ assert.equal(query.params[4], "127.0.0.1");
+ assert.equal(query.params[5], "TestAgent/1.0");
+});
+
+test("logAuditEvent uses defaults for optional fields", async () => {
+ const { db, queries } = createMockDb();
+
+ await logAuditEvent(db, {
+ userId: "usr_456",
+ type: "user.login",
+ });
+
+ assert.equal(queries.length, 1);
+ const [query] = queries;
+ assert.equal(query.params[3], "{}");
+ assert.equal(query.params[4], null);
+ assert.equal(query.params[5], null);
+});
+
+test("logAuditEvent scrubs sensitive fields from details", async () => {
+ const { db, queries } = createMockDb();
+
+ await logAuditEvent(db, {
+ userId: "usr_789",
+ type: "user.signup",
+ details: {
+ handle: "@test",
+ password: "secret123",
+ accessToken: "atk_leaked",
+ refreshToken: "rtk_leaked",
+ email: "user@example.com",
+ safeField: "kept",
+ },
+ });
+
+ assert.equal(queries.length, 1);
+ const stored = JSON.parse(queries[0].params[3] as string);
+ assert.equal(stored.handle, "@test");
+ assert.equal(stored.safeField, "kept");
+ assert.equal(stored.password, undefined);
+ assert.equal(stored.accessToken, undefined);
+ assert.equal(stored.refreshToken, undefined);
+ assert.equal(stored.email, undefined);
+});
+
+test("AuditEventType accepts all valid event types", () => {
+ const validTypes: AuditEventType[] = [
+ "user.signup",
+ "user.login",
+ "user.logout",
+ "provider.connect",
+ "provider.disconnect",
+ "account.export",
+ "account.delete",
+ "sync.start",
+ "sync.complete",
+ "sync.fail",
+ "rating.create",
+ "review.create",
+ "review.update",
+ "review.delete",
+ "list.create",
+ "admin.mapping_override",
+ ];
+
+ assert.equal(validTypes.length, 16);
+ // Type-level assertion: this compiles only if all types are valid
+ for (const t of validTypes) {
+ assert.ok(typeof t === "string");
+ }
+});
diff --git a/backend/src/tests/error-handling.test.ts b/backend/src/tests/error-handling.test.ts
new file mode 100644
index 0000000..c3197c1
--- /dev/null
+++ b/backend/src/tests/error-handling.test.ts
@@ -0,0 +1,222 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { FastifyInstance } from "fastify";
+
+let app: FastifyInstance | undefined;
+
+const setup = async (): Promise => {
+ try {
+ const { buildServer } = await import("../server");
+ app = await buildServer();
+ await app.db.query("SELECT 1");
+ return true;
+ } catch {
+ if (app) await app.close().catch(() => {});
+ app = undefined;
+ return false;
+ }
+};
+
+test("Error handling", async (t) => {
+ const ready = await setup();
+ if (!ready) {
+ t.skip("Database or Redis not available");
+ return;
+ }
+
+ t.after(async () => {
+ if (app) await app.close();
+ });
+
+ const suffix = Date.now().toString(36);
+
+ // Create a test user for authenticated tests
+ let accessToken = "";
+ let userId = "";
+
+ await t.test("setup: create test user", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/auth/signup",
+ payload: {
+ email: `err_${suffix}@test.local`,
+ password: "TestPass123!",
+ handle: `e_${suffix}`,
+ },
+ });
+ assert.equal(res.statusCode, 201);
+ const body = JSON.parse(res.payload);
+ accessToken = body.accessToken;
+ userId = body.userId;
+ });
+
+ await t.test("invalid JSON body returns 400", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/auth/login",
+ headers: { "content-type": "application/json" },
+ payload: "{invalid-json",
+ });
+
+ assert.ok(res.statusCode >= 400 && res.statusCode < 500);
+ });
+
+ await t.test("missing auth header returns 401", async () => {
+ const res = await app!.inject({
+ method: "GET",
+ url: "/v1/me",
+ });
+
+ assert.equal(res.statusCode, 401);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.error.code, "UNAUTHORIZED");
+ });
+
+ await t.test("expired/invalid session returns 401", async () => {
+ const res = await app!.inject({
+ method: "GET",
+ url: "/v1/me",
+ headers: { authorization: "Bearer atk_invalidtoken12345678901234" },
+ });
+
+ assert.equal(res.statusCode, 401);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.error.code, "UNAUTHORIZED");
+ });
+
+ await t.test("invalid album ID returns 404", async () => {
+ const res = await app!.inject({
+ method: "GET",
+ url: "/v1/albums/nonexistent_album_id",
+ });
+
+ assert.equal(res.statusCode, 404);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.error.code, "NOT_FOUND");
+ });
+
+ await t.test("duplicate rating on same album is idempotent", async () => {
+ const key1 = `dup-rate1-${suffix}`;
+ const key2 = `dup-rate2-${suffix}`;
+
+ const res1 = await app!.inject({
+ method: "POST",
+ url: "/v1/ratings",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": key1,
+ },
+ payload: { albumId: "alb_1", value: 3.5 },
+ });
+
+ assert.equal(res1.statusCode, 201);
+
+ // Second rating on same album with different key → upsert (idempotent at DB level)
+ const res2 = await app!.inject({
+ method: "POST",
+ url: "/v1/ratings",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": key2,
+ },
+ payload: { albumId: "alb_1", value: 4.0 },
+ });
+
+ assert.equal(res2.statusCode, 201);
+ const body2 = JSON.parse(res2.payload);
+ assert.equal(body2.value, 4.0);
+ });
+
+ await t.test("SQL injection attempt in search is safely handled", async () => {
+ const res = await app!.inject({
+ method: "GET",
+ url: "/v1/search?q=' OR 1=1; DROP TABLE users; --",
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.ok(Array.isArray(body.items));
+ });
+
+ await t.test("XSS in review body is stripped by sanitization", async () => {
+ const xssPayload = '';
+
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/reviews",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": `xss-review-${suffix}`,
+ },
+ payload: { albumId: "alb_2", body: xssPayload },
+ });
+
+ assert.equal(res.statusCode, 201);
+ const body = JSON.parse(res.payload);
+ // HTML tags are stripped by server-side sanitization
+ assert.equal(body.body, 'alert("xss")');
+ });
+
+ await t.test("missing idempotency key returns 400", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/ratings",
+ headers: { authorization: `Bearer ${accessToken}` },
+ payload: { albumId: "alb_1", value: 3.0 },
+ });
+
+ assert.equal(res.statusCode, 400);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.error.code, "IDEMPOTENCY_KEY_REQUIRED");
+ });
+
+ await t.test("validation: review body exceeding max length returns 400", async () => {
+ const longBody = "x".repeat(5001);
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/reviews",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": `long-review-${suffix}`,
+ },
+ payload: { albumId: "alb_1", body: longBody },
+ });
+
+ // Zod validation should reject this
+ assert.ok(res.statusCode >= 400 && res.statusCode < 500);
+ });
+
+ await t.test("validation: list title exceeding max length returns 400", async () => {
+ const longTitle = "x".repeat(201);
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/lists",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": `long-list-${suffix}`,
+ },
+ payload: { title: longTitle },
+ });
+
+ assert.ok(res.statusCode >= 400 && res.statusCode < 500);
+ });
+
+ await t.test("validation: handle with invalid chars returns 400", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/auth/signup",
+ payload: {
+ email: `bad_handle_${suffix}@test.local`,
+ password: "TestPass123!",
+ handle: "bad handle!@#",
+ },
+ });
+
+ assert.ok(res.statusCode >= 400 && res.statusCode < 500);
+ });
+
+ // Cleanup test user
+ await t.test("cleanup: delete test user", async () => {
+ await app!.db.query("DELETE FROM users WHERE id = $1", [userId]);
+ });
+});
diff --git a/backend/src/tests/import.test.ts b/backend/src/tests/import.test.ts
new file mode 100644
index 0000000..5f86309
--- /dev/null
+++ b/backend/src/tests/import.test.ts
@@ -0,0 +1,90 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { generateDedupKey } from "../modules/import";
+
+describe("generateDedupKey", () => {
+ it("produces deterministic key for same inputs", () => {
+ const date = new Date("2026-03-17T12:00:00Z");
+ const key1 = generateDedupKey("usr_1", "cnb_abc", date);
+ const key2 = generateDedupKey("usr_1", "cnb_abc", date);
+ assert.equal(key1, key2);
+ });
+
+ it("buckets plays within the same 10-minute window", () => {
+ const t1 = new Date("2026-03-17T12:00:00Z");
+ const t2 = new Date("2026-03-17T12:05:00Z"); // 5 min later, same bucket
+ const key1 = generateDedupKey("usr_1", "cnb_abc", t1);
+ const key2 = generateDedupKey("usr_1", "cnb_abc", t2);
+ assert.equal(key1, key2);
+ });
+
+ it("separates plays in different 10-minute windows", () => {
+ const t1 = new Date("2026-03-17T12:00:00Z");
+ const t2 = new Date("2026-03-17T12:10:00Z"); // exactly next bucket
+ const key1 = generateDedupKey("usr_1", "cnb_abc", t1);
+ const key2 = generateDedupKey("usr_1", "cnb_abc", t2);
+ assert.notEqual(key1, key2);
+ });
+
+ it("separates different users", () => {
+ const date = new Date("2026-03-17T12:00:00Z");
+ const key1 = generateDedupKey("usr_1", "cnb_abc", date);
+ const key2 = generateDedupKey("usr_2", "cnb_abc", date);
+ assert.notEqual(key1, key2);
+ });
+
+ it("separates different albums", () => {
+ const date = new Date("2026-03-17T12:00:00Z");
+ const key1 = generateDedupKey("usr_1", "cnb_abc", date);
+ const key2 = generateDedupKey("usr_1", "cnb_xyz", date);
+ assert.notEqual(key1, key2);
+ });
+
+ it("includes all three components in the key", () => {
+ const date = new Date("2026-03-17T12:00:00Z");
+ const key = generateDedupKey("usr_1", "cnb_abc", date);
+ assert.ok(key.startsWith("usr_1:cnb_abc:"));
+ // Verify bucket value is a number
+ const parts = key.split(":");
+ assert.equal(parts.length, 3);
+ assert.ok(Number.isInteger(Number(parts[2])));
+ });
+});
+
+describe("sync job state transitions", () => {
+ it("documents valid state transitions", () => {
+ // This is a documentation/specification test — validates our state machine design
+ const validTransitions: Record = {
+ queued: ["running", "cancelled"],
+ running: ["completed", "failed", "cancelled"],
+ completed: [],
+ failed: [],
+ cancelled: [],
+ };
+
+ // queued can transition to running or cancelled
+ assert.ok(validTransitions.queued.includes("running"));
+ assert.ok(validTransitions.queued.includes("cancelled"));
+
+ // running can transition to completed, failed, or cancelled
+ assert.ok(validTransitions.running.includes("completed"));
+ assert.ok(validTransitions.running.includes("failed"));
+ assert.ok(validTransitions.running.includes("cancelled"));
+
+ // terminal states have no transitions
+ assert.equal(validTransitions.completed.length, 0);
+ assert.equal(validTransitions.failed.length, 0);
+ assert.equal(validTransitions.cancelled.length, 0);
+ });
+});
+
+describe("cursor persistence", () => {
+ it("documents cursor behavior across syncs", () => {
+ // Specification test: cursor is per user+provider
+ // The composite primary key (user_id, provider) ensures one cursor per pair
+ // UPSERT via ON CONFLICT ensures cursor is updated, not duplicated
+ const cursorKey = { userId: "usr_1", provider: "spotify" };
+ assert.equal(typeof cursorKey.userId, "string");
+ assert.equal(typeof cursorKey.provider, "string");
+ });
+});
diff --git a/backend/src/tests/integration.test.ts b/backend/src/tests/integration.test.ts
new file mode 100644
index 0000000..435735f
--- /dev/null
+++ b/backend/src/tests/integration.test.ts
@@ -0,0 +1,337 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { FastifyInstance } from "fastify";
+
+let app: FastifyInstance | undefined;
+
+const setup = async (): Promise => {
+ try {
+ const { buildServer } = await import("../server");
+ app = await buildServer();
+ await app.db.query("SELECT 1");
+ return true;
+ } catch {
+ if (app) await app.close().catch(() => {});
+ app = undefined;
+ return false;
+ }
+};
+
+test("Integration: full user journey", async (t) => {
+ const ready = await setup();
+ if (!ready) {
+ t.skip("Database or Redis not available");
+ return;
+ }
+
+ t.after(async () => {
+ if (app) await app.close();
+ });
+
+ const suffix = Date.now().toString(36);
+ const email = `integ_${suffix}@test.local`;
+ const password = "TestPass123!";
+ const handle = `t_${suffix}`;
+
+ let accessToken = "";
+ let refreshToken = "";
+ let userId = "";
+ const albumId = "alb_1";
+ let reviewId = "";
+ let listId = "";
+ let activityId = "";
+
+ // Step 1: Signup
+ await t.test("1. signup creates user", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/auth/signup",
+ payload: { email, password, handle },
+ });
+
+ assert.equal(res.statusCode, 201);
+ const body = JSON.parse(res.payload);
+ assert.ok(body.accessToken);
+ assert.ok(body.refreshToken);
+ assert.ok(body.userId);
+ assert.equal(body.handle, `@${handle}`);
+
+ accessToken = body.accessToken;
+ refreshToken = body.refreshToken;
+ userId = body.userId;
+
+ const user = await app!.db.query("SELECT id, email FROM users WHERE id = $1", [userId]);
+ assert.equal(user.rowCount, 1);
+ assert.equal(user.rows[0].email, email);
+ });
+
+ // Step 2: Login
+ await t.test("2. login returns tokens", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/auth/login",
+ payload: { email, password },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.ok(body.accessToken);
+ assert.ok(body.refreshToken);
+ assert.equal(body.userId, userId);
+
+ accessToken = body.accessToken;
+ refreshToken = body.refreshToken;
+ });
+
+ // Step 3: Search albums
+ await t.test("3. search albums returns results", async () => {
+ const res = await app!.inject({
+ method: "GET",
+ url: "/v1/search?q=chromakopia",
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.ok(Array.isArray(body.items));
+ assert.ok(body.items.length > 0);
+ assert.equal(body.items[0].id, albumId);
+ });
+
+ // Step 4: Create rating (idempotent)
+ await t.test("4. create rating and verify idempotency", async () => {
+ const idempotencyKey = `rate-${suffix}`;
+
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/ratings",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": idempotencyKey,
+ },
+ payload: { albumId, value: 4.5 },
+ });
+
+ assert.equal(res.statusCode, 201);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.albumId, albumId);
+ assert.equal(body.value, 4.5);
+ assert.equal(body.userId, userId);
+
+ // Verify idempotency: same key returns same result
+ const res2 = await app!.inject({
+ method: "POST",
+ url: "/v1/ratings",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": idempotencyKey,
+ },
+ payload: { albumId, value: 4.5 },
+ });
+
+ assert.equal(res2.statusCode, 200);
+ const body2 = JSON.parse(res2.payload);
+ assert.equal(body2.albumId, body.albumId);
+ assert.equal(body2.value, body.value);
+ });
+
+ // Step 5: Create review
+ await t.test("5. create review and verify stored", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/reviews",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": `rev-create-${suffix}`,
+ },
+ payload: { albumId, body: "An incredible sonic journey." },
+ });
+
+ assert.equal(res.statusCode, 201);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.albumId, albumId);
+ assert.equal(body.body, "An incredible sonic journey.");
+ assert.equal(body.revision, 0);
+ reviewId = body.id;
+
+ const dbReview = await app!.db.query("SELECT body FROM reviews WHERE id = $1", [reviewId]);
+ assert.equal(dbReview.rows[0].body, "An incredible sonic journey.");
+ });
+
+ // Step 6: Update review
+ await t.test("6. update review increments revision", async () => {
+ const res = await app!.inject({
+ method: "PUT",
+ url: `/v1/reviews/${reviewId}`,
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": `rev-update-${suffix}`,
+ },
+ payload: { body: "An incredible sonic journey. Updated thoughts.", expectedRevision: 0 },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.revision, 1);
+ assert.equal(body.body, "An incredible sonic journey. Updated thoughts.");
+ });
+
+ // Step 7: Create list
+ await t.test("7. create list", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/lists",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": `list-create-${suffix}`,
+ },
+ payload: { title: "Best of 2024", note: "Top picks" },
+ });
+
+ assert.equal(res.statusCode, 201);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.title, "Best of 2024");
+ assert.equal(body.ownerId, userId);
+ assert.deepEqual(body.items, []);
+ listId = body.id;
+ });
+
+ // Step 8: Add item to list
+ await t.test("8. add item to list verifies position", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: `/v1/lists/${listId}/items`,
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": `list-item-${suffix}`,
+ },
+ payload: { albumId, note: "Must listen" },
+ });
+
+ assert.equal(res.statusCode, 201);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.items.length, 1);
+ assert.equal(body.items[0].albumId, albumId);
+ assert.equal(body.items[0].position, 1);
+ });
+
+ // Step 9: Get feed
+ await t.test("9. get feed shows activity events", async () => {
+ // Invalidate feed cache first
+ await app!.db.redis.del(`feed:${userId}:page1`);
+
+ const res = await app!.inject({
+ method: "GET",
+ url: "/v1/feed",
+ headers: { authorization: `Bearer ${accessToken}` },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.ok(Array.isArray(body.items));
+ assert.ok(body.items.length > 0);
+
+ activityId = body.items[0].id;
+ });
+
+ // Step 10: React to activity
+ await t.test("10. react to activity increments count", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: `/v1/activity/${activityId}/react`,
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": `react-${suffix}`,
+ },
+ payload: { reaction: "fire" },
+ });
+
+ assert.equal(res.statusCode, 201);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.activityId, activityId);
+ assert.ok(body.reactions >= 1);
+ });
+
+ // Step 11: Get profile
+ await t.test("11. profile stats are updated", async () => {
+ // Bust cache to get fresh data
+ await app!.db.redis.del(`profile:${userId}`);
+
+ const res = await app!.inject({
+ method: "GET",
+ url: "/v1/me",
+ headers: { authorization: `Bearer ${accessToken}` },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.id, userId);
+ assert.ok(body.logCount >= 1);
+ assert.ok(body.reviewCount >= 1);
+ assert.ok(body.listCount >= 1);
+ });
+
+ // Step 12: Get weekly recap
+ await t.test("12. weekly recap returns aggregation", async () => {
+ const res = await app!.inject({
+ method: "GET",
+ url: "/v1/recaps/weekly/latest",
+ headers: { authorization: `Bearer ${accessToken}` },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.ok(body.weekStart);
+ assert.ok(body.weekEnd);
+ assert.ok(typeof body.totalLogs === "number");
+ });
+
+ // Step 13: Export data
+ await t.test("13. export contains all user data", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/account/export",
+ headers: { authorization: `Bearer ${accessToken}` },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.ok(body.generatedAt);
+ assert.ok(body.profile);
+ assert.equal(body.profile.id, userId);
+ assert.ok(Array.isArray(body.ratings));
+ assert.ok(body.ratings.length >= 1);
+ assert.ok(Array.isArray(body.reviews));
+ assert.ok(body.reviews.length >= 1);
+ assert.ok(Array.isArray(body.lists));
+ assert.ok(body.lists.length >= 1);
+ });
+
+ // Step 14: Delete account
+ await t.test("14. delete account cascades all data", async () => {
+ const res = await app!.inject({
+ method: "DELETE",
+ url: "/v1/account",
+ headers: { authorization: `Bearer ${accessToken}` },
+ });
+
+ assert.equal(res.statusCode, 204);
+
+ const user = await app!.db.query("SELECT id FROM users WHERE id = $1", [userId]);
+ assert.equal(user.rowCount, 0);
+
+ const ratings = await app!.db.query("SELECT id FROM ratings WHERE user_id = $1", [userId]);
+ assert.equal(ratings.rowCount, 0);
+
+ const reviews = await app!.db.query("SELECT id FROM reviews WHERE user_id = $1", [userId]);
+ assert.equal(reviews.rowCount, 0);
+
+ const lists = await app!.db.query("SELECT id FROM lists WHERE owner_id = $1", [userId]);
+ assert.equal(lists.rowCount, 0);
+
+ const sessions = await app!.db.query(
+ "SELECT access_token FROM sessions WHERE user_id = $1",
+ [userId],
+ );
+ assert.equal(sessions.rowCount, 0);
+ });
+});
diff --git a/backend/src/tests/mapping.test.ts b/backend/src/tests/mapping.test.ts
new file mode 100644
index 0000000..3782b82
--- /dev/null
+++ b/backend/src/tests/mapping.test.ts
@@ -0,0 +1,109 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { normalizeText } from "../lib/normalize";
+import { scoreMatch } from "../modules/mapping";
+
+describe("normalizeText", () => {
+ it("lowercases input", () => {
+ assert.equal(normalizeText("HELLO World"), "hello world");
+ });
+
+ it("strips accents / diacritics", () => {
+ assert.equal(normalizeText("Beyoncé"), "beyonce");
+ assert.equal(normalizeText("Café Résumé"), "cafe resume");
+ });
+
+ it("removes special characters", () => {
+ assert.equal(normalizeText("Rock & Roll!"), "rock roll");
+ assert.equal(normalizeText("AC/DC — Highway"), "acdc highway");
+ });
+
+ it("collapses whitespace and trims", () => {
+ assert.equal(normalizeText(" lots of space "), "lots of space");
+ });
+
+ it("handles empty string", () => {
+ assert.equal(normalizeText(""), "");
+ });
+
+ it("handles mixed accents and special chars", () => {
+ assert.equal(normalizeText("Ñoño's Café!"), "nonos cafe");
+ });
+});
+
+describe("scoreMatch", () => {
+ const makeCandidate = (overrides: Partial<{
+ normalized_title: string;
+ artist_normalized_name: string;
+ year: number | null;
+ track_count: number | null;
+ }> = {}) => ({
+ normalized_title: "chromakopia",
+ artist_normalized_name: "tyler the creator",
+ year: 2024 as number | null,
+ track_count: 14 as number | null,
+ ...overrides,
+ });
+
+ it("gives 1.0 for exact match on all fields", () => {
+ const score = scoreMatch(
+ "chromakopia",
+ "tyler the creator",
+ makeCandidate(),
+ { title: "CHROMAKOPIA", artist: "Tyler, the Creator", year: 2024, trackCount: 14 },
+ );
+ assert.equal(score, 1.0);
+ });
+
+ it("gives 0.8 for title + artist match only", () => {
+ const score = scoreMatch(
+ "chromakopia",
+ "tyler the creator",
+ makeCandidate({ year: null, track_count: null }),
+ { title: "CHROMAKOPIA", artist: "Tyler, the Creator" },
+ );
+ assert.equal(score, 0.8);
+ });
+
+ it("gives 0.9 for title + artist + year within ±1", () => {
+ const score = scoreMatch(
+ "chromakopia",
+ "tyler the creator",
+ makeCandidate({ year: 2023, track_count: null }),
+ { title: "CHROMAKOPIA", artist: "Tyler, the Creator", year: 2024 },
+ );
+ assert.equal(score, 0.9);
+ });
+
+ it("gives 0.5 for title-only match", () => {
+ const score = scoreMatch(
+ "chromakopia",
+ "tyler the creator",
+ makeCandidate({ artist_normalized_name: "someone else" }),
+ { title: "CHROMAKOPIA", artist: "Tyler, the Creator", year: 2024, trackCount: 14 },
+ );
+ // title 0.5, artist mismatch 0, year +0.1, track +0.1 = 0.7
+ assert.equal(score, 0.7);
+ });
+
+ it("gives 0 when nothing matches", () => {
+ const score = scoreMatch(
+ "gnx",
+ "kendrick lamar",
+ makeCandidate(),
+ { title: "GNX", artist: "Kendrick Lamar", year: 2020 },
+ );
+ assert.equal(score, 0);
+ });
+
+ it("handles null year and track_count gracefully", () => {
+ const score = scoreMatch(
+ "chromakopia",
+ "tyler the creator",
+ makeCandidate({ year: null, track_count: null }),
+ { title: "CHROMAKOPIA", artist: "Tyler, the Creator", year: 2024, trackCount: 14 },
+ );
+ // title 0.5 + artist 0.3 = 0.8 (year and track can't match null)
+ assert.equal(score, 0.8);
+ });
+});
diff --git a/backend/src/tests/production-readiness.test.ts b/backend/src/tests/production-readiness.test.ts
new file mode 100644
index 0000000..ac50705
--- /dev/null
+++ b/backend/src/tests/production-readiness.test.ts
@@ -0,0 +1,169 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { FastifyInstance } from "fastify";
+
+let app: FastifyInstance | undefined;
+
+const setup = async (): Promise => {
+ try {
+ const { buildServer } = await import("../server");
+ app = await buildServer();
+ await app.db.query("SELECT 1");
+ return true;
+ } catch {
+ if (app) await app.close().catch(() => {});
+ app = undefined;
+ return false;
+ }
+};
+
+test("Production readiness checks", async (t) => {
+ const ready = await setup();
+ if (!ready) {
+ t.skip("Database or Redis not available");
+ return;
+ }
+
+ t.after(async () => {
+ if (app) await app.close();
+ });
+
+ await t.test("health check returns ok when services are up", async () => {
+ const res = await app!.inject({ method: "GET", url: "/health" });
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.status, "ok");
+ assert.equal(body.checks.postgres, "up");
+ assert.equal(body.checks.redis, "up");
+ });
+
+ await t.test("security headers are present", async () => {
+ const res = await app!.inject({ method: "GET", url: "/health" });
+ // Helmet should add these headers
+ assert.ok(res.headers["x-content-type-options"]);
+ assert.ok(res.headers["x-frame-options"]);
+ });
+
+ await t.test("OpenAPI docs endpoint is reachable", async () => {
+ const res = await app!.inject({ method: "GET", url: "/docs/json" });
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.openapi, "3.1.0");
+ assert.equal(body.info.title, "SoundScore API");
+ });
+
+ await t.test("session expiry column exists in database", async () => {
+ const result = await app!.db.query(
+ `SELECT column_name FROM information_schema.columns
+ WHERE table_name = 'sessions' AND column_name = 'expires_at'`,
+ );
+ assert.equal(result.rowCount, 1);
+ });
+
+ await t.test("search_vector column exists on albums", async () => {
+ const result = await app!.db.query(
+ `SELECT column_name FROM information_schema.columns
+ WHERE table_name = 'albums' AND column_name = 'search_vector'`,
+ );
+ assert.equal(result.rowCount, 1);
+ });
+
+ await t.test("required indexes exist", async () => {
+ const indexes = await app!.db.query<{ indexname: string }>(
+ `SELECT indexname FROM pg_indexes WHERE tablename IN ('ratings', 'reviews', 'activity_events', 'sessions')`,
+ );
+ const names = indexes.rows.map((r) => r.indexname);
+ assert.ok(names.includes("idx_ratings_album"), "idx_ratings_album missing");
+ assert.ok(names.includes("idx_reviews_album"), "idx_reviews_album missing");
+ assert.ok(names.includes("idx_activity_created"), "idx_activity_created missing");
+ assert.ok(names.includes("idx_sessions_expires"), "idx_sessions_expires missing");
+ });
+
+ const suffix = Date.now().toString(36);
+
+ await t.test("pagination: feed accepts cursor and limit params", async () => {
+ // Create a test user
+ const signupRes = await app!.inject({
+ method: "POST",
+ url: "/v1/auth/signup",
+ payload: {
+ email: `pag_${suffix}@test.local`,
+ password: "TestPass123!",
+ handle: `pg${suffix}`,
+ },
+ });
+ assert.equal(signupRes.statusCode, 201);
+ const { accessToken, userId } = JSON.parse(signupRes.payload);
+
+ const res = await app!.inject({
+ method: "GET",
+ url: "/v1/feed?limit=5",
+ headers: { authorization: `Bearer ${accessToken}` },
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.ok(Array.isArray(body.items));
+ assert.ok("nextCursor" in body);
+
+ // Cleanup
+ await app!.db.query("DELETE FROM users WHERE id = $1", [userId]);
+ });
+
+ await t.test("search pagination works", async () => {
+ const res = await app!.inject({
+ method: "GET",
+ url: "/v1/search?limit=2",
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = JSON.parse(res.payload);
+ assert.ok(Array.isArray(body.items));
+ assert.ok("nextCursor" in body);
+ });
+
+ await t.test("signup returns 201 Created", async () => {
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/auth/signup",
+ payload: {
+ email: `status_${suffix}@test.local`,
+ password: "TestPass123!",
+ handle: `s_${suffix}`,
+ },
+ });
+ assert.equal(res.statusCode, 201);
+ const { userId } = JSON.parse(res.payload);
+ await app!.db.query("DELETE FROM users WHERE id = $1", [userId]);
+ });
+
+ await t.test("sanitization: HTML stripped from review body", async () => {
+ // Create a user to test with
+ const signupRes = await app!.inject({
+ method: "POST",
+ url: "/v1/auth/signup",
+ payload: {
+ email: `san_${suffix}@test.local`,
+ password: "TestPass123!",
+ handle: `sn${suffix}`,
+ },
+ });
+ const { accessToken, userId } = JSON.parse(signupRes.payload);
+
+ const res = await app!.inject({
+ method: "POST",
+ url: "/v1/reviews",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "idempotency-key": `san-review-${suffix}`,
+ },
+ payload: { albumId: "alb_1", body: "Great album!" },
+ });
+
+ assert.ok(res.statusCode >= 200 && res.statusCode <= 201);
+ const body = JSON.parse(res.payload);
+ assert.equal(body.body, "Great album!");
+
+ await app!.db.query("DELETE FROM users WHERE id = $1", [userId]);
+ });
+});
diff --git a/backend/src/tests/providers.test.ts b/backend/src/tests/providers.test.ts
new file mode 100644
index 0000000..5f2e768
--- /dev/null
+++ b/backend/src/tests/providers.test.ts
@@ -0,0 +1,172 @@
+import assert from "node:assert/strict";
+import crypto from "node:crypto";
+import test from "node:test";
+import { SpotifyAdapter } from "../lib/spotify-adapter";
+import { getAdapter, SUPPORTED_PROVIDERS } from "../lib/provider-registry";
+import type { ProviderAdapter, TokenBundle } from "../lib/provider-adapter";
+
+// ---------------------------------------------------------------------------
+// Provider registry
+// ---------------------------------------------------------------------------
+
+test("getAdapter returns SpotifyAdapter for 'spotify'", () => {
+ const adapter = getAdapter("spotify");
+ assert.ok(adapter);
+ assert.equal(adapter.name, "spotify");
+});
+
+test("getAdapter returns null for unsupported provider", () => {
+ assert.equal(getAdapter("tidal"), null);
+ assert.equal(getAdapter(""), null);
+});
+
+test("SUPPORTED_PROVIDERS includes spotify", () => {
+ assert.ok(SUPPORTED_PROVIDERS.includes("spotify"));
+});
+
+// ---------------------------------------------------------------------------
+// SpotifyAdapter — OAuth URL generation
+// ---------------------------------------------------------------------------
+
+test("SpotifyAdapter.getOAuthUrl builds correct URL with state and redirect", () => {
+ const adapter = new SpotifyAdapter();
+ const state = crypto.randomBytes(16).toString("hex");
+ const redirectUri = "https://app.soundscore.io/callback";
+
+ const url = adapter.getOAuthUrl(state, redirectUri);
+ const parsed = new URL(url);
+
+ assert.equal(parsed.origin, "https://accounts.spotify.com");
+ assert.equal(parsed.pathname, "/authorize");
+ assert.equal(parsed.searchParams.get("response_type"), "code");
+ assert.equal(parsed.searchParams.get("state"), state);
+ assert.equal(parsed.searchParams.get("redirect_uri"), redirectUri);
+ assert.ok(parsed.searchParams.get("scope")?.includes("user-read-recently-played"));
+ assert.ok(parsed.searchParams.get("scope")?.includes("user-read-email"));
+ assert.ok(parsed.searchParams.get("scope")?.includes("user-library-read"));
+});
+
+test("SpotifyAdapter.getOAuthUrl accepts custom scopes", () => {
+ const adapter = new SpotifyAdapter();
+ const url = adapter.getOAuthUrl("state123", "https://example.com/cb", ["streaming"]);
+ const parsed = new URL(url);
+
+ assert.equal(parsed.searchParams.get("scope"), "streaming");
+});
+
+// ---------------------------------------------------------------------------
+// OAuth state generation pattern
+// ---------------------------------------------------------------------------
+
+test("crypto.randomBytes generates unique state values", () => {
+ const states = new Set();
+ for (let i = 0; i < 100; i++) {
+ states.add(crypto.randomBytes(32).toString("hex"));
+ }
+ assert.equal(states.size, 100, "All 100 generated states should be unique");
+});
+
+test("state is 64 hex characters (32 bytes)", () => {
+ const state = crypto.randomBytes(32).toString("hex");
+ assert.equal(state.length, 64);
+ assert.match(state, /^[0-9a-f]{64}$/);
+});
+
+// ---------------------------------------------------------------------------
+// ProviderAdapter interface conformance
+// ---------------------------------------------------------------------------
+
+test("SpotifyAdapter implements ProviderAdapter interface", () => {
+ const adapter: ProviderAdapter = new SpotifyAdapter();
+ assert.equal(typeof adapter.name, "string");
+ assert.equal(typeof adapter.getOAuthUrl, "function");
+ assert.equal(typeof adapter.exchangeCode, "function");
+ assert.equal(typeof adapter.refreshToken, "function");
+ assert.equal(typeof adapter.revokeToken, "function");
+});
+
+test("SpotifyAdapter.revokeToken resolves without error", async () => {
+ const adapter = new SpotifyAdapter();
+ // Spotify revoke is a no-op, should resolve cleanly
+ await adapter.revokeToken("any-token");
+});
+
+// ---------------------------------------------------------------------------
+// Token expiry logic (unit tests for the comparison pattern used in token-refresh)
+// ---------------------------------------------------------------------------
+
+const REFRESH_BUFFER_MS = 5 * 60 * 1000;
+
+test("token is considered fresh when expiry is >5 minutes away", () => {
+ const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 min from now
+ const needsRefresh = expiresAt.getTime() - Date.now() <= REFRESH_BUFFER_MS;
+ assert.equal(needsRefresh, false);
+});
+
+test("token is considered stale when expiry is <5 minutes away", () => {
+ const expiresAt = new Date(Date.now() + 2 * 60 * 1000); // 2 min from now
+ const needsRefresh = expiresAt.getTime() - Date.now() <= REFRESH_BUFFER_MS;
+ assert.equal(needsRefresh, true);
+});
+
+test("token is considered stale when already expired", () => {
+ const expiresAt = new Date(Date.now() - 60 * 1000); // 1 min ago
+ const needsRefresh = expiresAt.getTime() - Date.now() <= REFRESH_BUFFER_MS;
+ assert.equal(needsRefresh, true);
+});
+
+// ---------------------------------------------------------------------------
+// Provider validation pattern
+// ---------------------------------------------------------------------------
+
+test("provider validation rejects unknown providers", () => {
+ const VALID = new Set(SUPPORTED_PROVIDERS);
+ assert.equal(VALID.has("spotify"), true);
+ assert.equal(VALID.has("apple_music" as typeof SUPPORTED_PROVIDERS[number]), false);
+ assert.equal(VALID.has("tidal" as typeof SUPPORTED_PROVIDERS[number]), false);
+});
+
+// ---------------------------------------------------------------------------
+// Token bundle shape
+// ---------------------------------------------------------------------------
+
+test("TokenBundle minimal shape (access_token only)", () => {
+ const bundle: TokenBundle = { access_token: "tok_abc" };
+ assert.equal(bundle.access_token, "tok_abc");
+ assert.equal(bundle.refresh_token, undefined);
+ assert.equal(bundle.expires_in, undefined);
+ assert.equal(bundle.scope, undefined);
+});
+
+test("TokenBundle full shape", () => {
+ const bundle: TokenBundle = {
+ access_token: "tok_abc",
+ refresh_token: "rtk_def",
+ expires_in: 3600,
+ scope: "user-read-recently-played user-read-email",
+ };
+ assert.equal(bundle.access_token, "tok_abc");
+ assert.equal(bundle.refresh_token, "rtk_def");
+ assert.equal(bundle.expires_in, 3600);
+ assert.ok(bundle.scope?.includes("user-read-recently-played"));
+});
+
+// ---------------------------------------------------------------------------
+// Scope parsing (mirrors callback handler logic)
+// ---------------------------------------------------------------------------
+
+test("scope string splits into array correctly", () => {
+ const scope = "user-read-recently-played user-read-email user-library-read";
+ const scopes = scope.split(" ");
+ assert.deepEqual(scopes, [
+ "user-read-recently-played",
+ "user-read-email",
+ "user-library-read",
+ ]);
+});
+
+test("empty scope produces empty array", () => {
+ const scope: string = "";
+ const scopes = scope ? scope.split(" ") : [];
+ assert.deepEqual(scopes, []);
+});
diff --git a/backend/src/tests/retry.test.ts b/backend/src/tests/retry.test.ts
new file mode 100644
index 0000000..da61a3a
--- /dev/null
+++ b/backend/src/tests/retry.test.ts
@@ -0,0 +1,90 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { withRetry } from "../lib/retry";
+
+test("withRetry returns result on first success", async () => {
+ const result = await withRetry(async () => 42);
+ assert.equal(result, 42);
+});
+
+test("withRetry retries on failure then succeeds", async () => {
+ let attempts = 0;
+ const result = await withRetry(
+ async () => {
+ attempts++;
+ if (attempts < 3) throw new Error("fail");
+ return "ok";
+ },
+ { maxAttempts: 3, backoffMs: 1 },
+ );
+
+ assert.equal(result, "ok");
+ assert.equal(attempts, 3);
+});
+
+test("withRetry throws after max attempts", async () => {
+ let attempts = 0;
+ await assert.rejects(
+ () =>
+ withRetry(
+ async () => {
+ attempts++;
+ throw new Error("persistent failure");
+ },
+ { maxAttempts: 3, backoffMs: 1 },
+ ),
+ { message: "persistent failure" },
+ );
+
+ assert.equal(attempts, 3);
+});
+
+test("withRetry respects maxBackoffMs cap", async () => {
+ let attempts = 0;
+ const start = Date.now();
+
+ await assert.rejects(
+ () =>
+ withRetry(
+ async () => {
+ attempts++;
+ throw new Error("fail");
+ },
+ { maxAttempts: 4, backoffMs: 1, maxBackoffMs: 5 },
+ ),
+ { message: "fail" },
+ );
+
+ const elapsed = Date.now() - start;
+ assert.equal(attempts, 4);
+ // With maxBackoff=5ms and backoff=1ms, total delay should be small
+ assert.ok(elapsed < 500, `Expected fast completion but took ${elapsed}ms`);
+});
+
+test("withRetry calls onRetry callback", async () => {
+ const retries: number[] = [];
+ let attempts = 0;
+
+ await assert.rejects(
+ () =>
+ withRetry(
+ async () => {
+ attempts++;
+ throw new Error("fail");
+ },
+ {
+ maxAttempts: 3,
+ backoffMs: 1,
+ onRetry: (attempt) => retries.push(attempt),
+ },
+ ),
+ { message: "fail" },
+ );
+
+ assert.deepEqual(retries, [1, 2]);
+});
+
+test("withRetry uses default options", async () => {
+ const result = await withRetry(async () => "default-ok");
+ assert.equal(result, "default-ok");
+});
diff --git a/deep-research-report.md b/deep-research-report.md
new file mode 100644
index 0000000..bd33617
--- /dev/null
+++ b/deep-research-report.md
@@ -0,0 +1,206 @@
+# SoundScore and the Social Music Market in Early 2026
+
+## SoundScore’s product thesis and current direction
+
+SoundScore is positioned as “Letterboxd for music”: a dedicated place to log *albums*, apply a consistent rating scale, write real reviews, and make your taste legible on a profile—then layer in social discovery (friends, activity, lists) on top of that. Your own summary describes a Phase 1 loop that’s deliberately simple—Spotify login → surface albums you actually listened to → rate/review → profile identity (top albums + recent activity) → lightweight community views per album—followed by a roadmap toward lists, a friend graph, and recommendation features.
+
+Two important context points from what’s publicly visible on GitHub and in the broader ecosystem:
+
+- There are multiple “SoundScore” codebases in the wild; one public repo under your handle describes a Spotify-connected album-rating app and shows a more traditional React + Node/Express + Mongo stack, with planned “social features” like follows and an activity feed. citeturn18view0
+- “Letterboxd for music” is not only a common pitch—it’s a pattern that many founders are *actively* pursuing right now, including multiple products that explicitly market themselves that way (examples in the next section). citeturn20search0turn20search6turn20search9turn20search27
+
+That combination shapes the strategic problem: SoundScore isn’t competing only with “apps that rate albums.” It’s competing with (a) entrenched music database communities, (b) new mobile-first “identity + sharing” music social apps, and (c) the streaming platforms themselves as they add more social features.
+
+## The market landscape and the closest comparable products
+
+Social music is best understood as several overlapping sub-markets. A “Letterboxd for music” product sits at the intersection of **catalog + opinion + identity + social distribution**, usually with a third-party catalog (Spotify, Apple Music, MusicBrainz, etc.) as the backbone.
+
+### The most direct competitors: album logging, ratings, reviews, lists
+
+Musicboard is the cleanest mainstream analogue to a Letterboxd-like music journal: reviews, ratings, lists, and profiles. A TechRadar feature (Nov 2024) describes Musicboard as “Letterboxd for music lovers,” with a free tier and a Pro subscription priced at $4.99/£4.99 per month. citeturn8view0 That same piece reports the founders’ claim of “over 400,000 users” and ~16,000 users added per month—driven by word-of-mouth and people sharing reviews online. citeturn8view0 Separately, Musicboard’s own marketing claims “over 15 million ratings recorded” and that “hundreds of thousands” use the platform. citeturn7search0
+
+Musotic explicitly brands itself as a social music-review platform and claims to be “the ‘Letterboxd for music.’” citeturn20search0turn7search33 Its app-store listings emphasize reviews on songs/albums, DMs, and “detailed stats,” and it supports sign-up via Spotify or Apple Music. citeturn7search1turn7search5turn7search29 As of early 2026 it appears very small on Android (Google Play listing shows “500+ downloads”). citeturn7search1
+
+Musis (“Rate Music for Spotify”) sits in a similar functional neighborhood: rate/review albums & songs, connect Spotify, see recently played, and generate Spotify playlists based on ratings. citeturn20search5turn20search16 It has meaningful distribution on Android compared with newer entrants (“100K+ downloads” on Google Play). citeturn20search16
+
+Beyond these, there is an active long tail of newer/indie “music journal” products that directly mirror the Letterboxd framing, including Spinlist (actively waitlisting; previously crowdfunded as “Letterboxd for Music Lovers”), citeturn20search11turn20search27 Tuniverse (“The Letterboxd for music lovers”), citeturn20search9 Factory.fm (“Rate, review and log your favourite albums”), citeturn20search33 Musicboxd (albums, custom lists, and social following), citeturn20search1 and Noisefloor (album ratings/reviews and identity positioning). citeturn20search2
+
+Takeaway: the *blueprint* (rate + review + profile + lists + friends) is widely copied; differentiation comes from culture, friction, and distribution—more than from “having reviews.”
+
+### Entrenched incumbents: massive community databases and “taste infrastructure”
+
+Rate Your Music (RYM) is one of the most entrenched “taste catalog” communities in music. Wikipedia characterizes it as a social cataloging site where users catalog releases and films, assign ratings (0.5 to 5 stars), and write reviews and lists. citeturn12search2 Wikipedia also reports scale indicators that matter competitively: ~1.3 million users (March 2025), millions of releases, and very large accumulated rating volume. citeturn12search2 RYM’s own modernization effort (“Sonemic”) frames RYM as “one of the largest and most comprehensive music databases and communities online” and describes a multi-year project to upgrade the product in-place. citeturn12search9 Traffic-wise, third-party measurement estimates RYM at ~15.11M visits in January 2026 (Semrush). citeturn12search4
+
+Album of the Year (AOTY) is another large-scale ratings/reviews destination focused on albums, charts, and year-end lists aggregation. citeturn1search1 Its scale is also meaningful via third-party traffic estimates: ~12.59M visits in January 2026 (Semrush). citeturn12search5
+
+Last.fm occupies a different “taste infrastructure” role: automatic listening history (“scrobbling”), personal charts, and community stats. In the context of building a social music product, it’s less “Letterboxd-style reviews” and more “ground truth listening + identity graphs.” Last.fm’s own site positions itself as “The world’s largest online music service.” citeturn11search1 Third-party traffic measurement estimates ~30.5M total visits (Similarweb). citeturn11search4 A dataset repository summarizing Last.fm states the service “has claimed over 40 million active users,” which is directionally useful but should be treated as a claim rather than an audited figure. citeturn11search13
+
+Discogs is “taste infrastructure” for collectors: a crowdsourced database plus marketplace and collection/wantlist tools. A Discogs EU Digital Services Act statement reports ~3 million “average monthly active recipients of the service” (Mar–Aug 2025), well below the EU’s 45M VLOP threshold. citeturn16search1 Discogs also reports enormous collection activity: >114.2M items cataloged into collections in 2025 and >920M items in collections overall, with projections to surpass one billion cataloged items in 2026. citeturn16search2turn16search6
+
+These four incumbents set a tough baseline: even if a new app nails UX, it is competing against decades of accumulated ratings, reviews, charts, and communities.
+
+### The fast-growing adjacent category: “passive social listening” and identity-first music social
+
+Airbuds is the standout example of a *successful* modern music social app, but its premise is different: not “write reviews,” but “make listening itself automatically social.” TechCrunch reports Airbuds has seen “over 15 million app downloads” and “5 million monthly active users,” with 1.5 million daily users, and raised $5M led by Seven Seven Six (Alexis Ohanian’s firm). citeturn15view0 Airbuds intentionally lowers posting friction: connect a streaming service and your listening becomes the feed; users react with emojis/stickers/selfies and chat, with privacy controls like “ghost mode.” citeturn15view0
+
+Airbuds matters to SoundScore for two reasons:
+- It demonstrates *demand* for “taste as identity” at scale. citeturn15view0
+- It demonstrates a pattern: **the more the product can generate social content from existing behavior (listening) without asking users to write**, the easier growth and retention become. citeturn15view0
+
+## Is the “Letterboxd for music” space oversaturated?
+
+It is crowded, but not “solved.”
+
+### Why it feels saturated
+
+Multiple actively developed products explicitly market the “Letterboxd for music” concept, spanning apps (Musicboard, Musotic, Musis) and newer entrants (Tuniverse, Spinlist, Factory.fm, Musicboxd, Noisefloor). citeturn8view0turn20search0turn20search9turn20search11turn20search33turn20search1turn20search2 On top of that, open-source hobby projects replicate the concept (“Letterboxd for Spotify” / trackboxd-style repos), which is a sign the idea is both compelling and widely attempted. citeturn20search13turn20search31
+
+So yes: **new entrants trying to be “Letterboxd for music” are numerous**, which increases the risk that any generic implementation (“rate albums, have a feed, have lists”) will be ignored.
+
+### Why it’s not truly oversaturated in the way, say, “photo sharing apps” are
+
+Despite many attempts, the market is fragmented:
+- The biggest “opinion + catalog” communities (RYM, AOTY) are primarily web-first and feel old-school to many mobile-native users, even if their scale is large. citeturn12search4turn12search5turn12search9
+- The most “Letterboxd-like” mobile product (Musicboard) has shown meaningful growth—but also illustrates operational fragility. TechCrunch reports Musicboard experienced outages, its website went offline, and its Android app disappeared from Google Play, with downloads estimated around 462,000 by Appfigures. citeturn10view0 AppBrain also reports the Android app was unpublished from Google Play (Sep 22, 2025). citeturn20search23
+- The fastest-growing “social music” winner (Airbuds) is *not* a review product; it’s a passive feed product. citeturn15view0
+
+That’s a key strategic observation: **“music social” is proving viable, but the winning mechanics may not be long-form reviews**.
+
+## What a social music product needs to succeed
+
+A social music product succeeds when it balances two opposing forces:
+- Music taste is deeply identity-linked, which makes it powerful social currency.
+- But “posting about music” is high-effort and often feels performative or niche.
+
+The best-performing products reduce effort while amplifying identity.
+
+### A low-friction creation loop that still feels meaningful
+
+Airbuds’ founder rationale (as quoted by TechCrunch) is blunt: asking users to create playlists or do manual work is “a lot of effort,” and the widget model made music-sharing “effortless” because it rides on what users already do (listen). citeturn15view0 That same article notes Airbuds’ feature-gating is designed partly because “the app only really works if you add your friends.” citeturn15view0
+
+For SoundScore, the equivalent “low-friction” loop is: the system already knows what you listened to (Spotify context), and it nudges you to rate/review *without making you search and remember*.
+
+The design tension: requiring a written review improves quality, but increases friction versus tap-only rating. Musicboard’s founders credit growth to people sharing reviews and building community—suggesting that meaningful written content can drive word-of-mouth if the community culture rewards it. citeturn8view0
+
+### Identity primitives that are simple, legible, and shareable
+
+Airbuds emphasizes self-expression via profiles (“Space”), favorite artists/albums/lyrics, and weekly recaps. citeturn15view0 Musicboard’s founders similarly argue music taste is entwined with personality and highlight profile customization as a key paid feature. citeturn8view0
+
+In practice, the “identity primitives” that repeatedly show up in successful/viral music products are:
+- compact favorites (top albums/artists) that fit on a profile,
+- periodic recaps (weekly/yearly) that can be screenshotted and shared,
+- lightweight reactions and comments that don’t demand essays.
+
+Even Spotify’s own Q4 2025 commentary highlights “Wrapped” as a major cultural moment, reinforcing that music identity packaged as shareable data is a proven engagement lever. citeturn13search0
+
+### Community formation mechanics and moderation
+
+Musicboard’s TechRadar profile emphasizes community positivity and micro-communities (“Clans”) as a planned direction. citeturn8view0 Airbuds uses friend-based sharing plus messaging and reactions; it’s built around “real human friends,” not public follower spam. citeturn15view0
+
+For SoundScore-style products, community risk is not theoretical: if a feed is public and “review” culture lacks norms, you end up with low-quality one-liners, harassment, or stan wars. The strongest products establish:
+- clear contribution norms (what counts as a “review”),
+- anti-spam and anti-brigading controls,
+- export/portability to maintain trust (Musicboard users explicitly worried about exporting data during outages). citeturn10view0
+
+### A credible catalog strategy and platform dependency plan
+
+If your catalog and listening context come from Spotify, you inherit Spotify’s platform rules and access constraints (detailed later). citeturn3view0turn14view0turn2view0
+
+A common strategic move is to decouple:
+- use Spotify (and/or Apple Music) primarily for *linking and listening context*,
+- use open metadata sources for *catalog identity and resilience*.
+
+MusicBrainz positions itself as an open music encyclopedia with an API, meant for developers needing music metadata. citeturn21search0turn21search25 Its core data is licensed under CC0 (“effectively placing the data into the Public Domain”). citeturn21search2 ListenBrainz (from MetaBrainz) focuses on tracking listening and is explicitly open-source/open-data, offering an alternative model for “listening identity” without dependence on a single streaming vendor. citeturn21search1turn21search13
+
+## How social music products are performing today
+
+The question “do social music products succeed?” depends heavily on what “success” means. Below are concrete signals (user counts, traffic estimates, and operational outcomes) across the current field.
+
+### Scale and traction benchmarks
+
+Airbuds is the clearest modern “music social” breakout: TechCrunch reports 15M+ downloads and 5M MAUs, with 1.5M daily users, and venture funding (a $5M round led by Seven Seven Six). citeturn15view0
+
+Musicboard shows that “Letterboxd-like reviews for music” can reach hundreds of thousands of users: TechRadar reports the founders’ claim of 400,000+ users and ~16,000 new users per month (as of Nov 2024). citeturn8view0 But it also demonstrates fragility: TechCrunch reports repeated outages, the Play Store disappearance, and ~462,000 downloads (Appfigures estimate), plus community concern about data export. citeturn10view0
+
+RateYourMusic and Album of the Year show that *web-first* music rating communities can be very large: Semrush estimates ~15.11M visits to rateyourmusic.com and ~12.59M visits to albumoftheyear.org in January 2026. citeturn12search4turn12search5 RYM’s registered user count is often cited at ~1.3M (March 2025). citeturn12search2
+
+Last.fm remains a major “taste stats” destination; Similarweb estimates ~30.5M visits, and a dataset repository notes Last.fm has “claimed over 40 million active users.” citeturn11search4turn11search13
+
+Discogs shows sustained success when social + catalog is paired with a clear economic engine (marketplace fees) and collector behavior: Discogs reported ~3M average monthly active recipients (Mar–Aug 2025) and >920M items in user collections, with >114.2M items added in 2025. citeturn16search1turn16search2turn16search6
+
+### A practical reading of these outcomes for SoundScore
+
+The market evidence suggests three patterns:
+
+- **Passive social (sharing listening automatically) scales faster than “write reviews.”** Airbuds’ mechanics are designed around low-effort, real-time sharing and reactions. citeturn15view0
+- **“Letterboxd for music” can reach real scale, but retention and sustainability are hard.** Musicboard reached hundreds of thousands of users but appears operationally strained in 2025–2026. citeturn8view0turn10view0
+- **The biggest opinionated catalog communities are entrenched and defensible.** RYM/AOTY’s traffic scale implies you’re not competing for a tiny niche; you’re competing with large incumbents that already own the “music ratings database” mindshare for many serious listeners. citeturn12search4turn12search5turn12search9
+
+## What can make or break SoundScore specifically in 2026
+
+A “Letterboxd for music, powered by Spotify” concept is not just a product question—it’s now a platform-access question because Spotify materially tightened developer access in February 2026.
+
+### Spotify’s platform rules have become a gating risk
+
+Spotify announced on February 6, 2026 that “Development Mode” is being reduced in scope and is not meant to be “a foundation for building or scaling a business on Spotify,” with new restrictions including Premium-only developer access, a single Development Mode Client ID, up to five authorized users per app, and limited endpoint access. citeturn3view0
+
+Spotify’s migration guide sets a clear timeline: new Development Mode restrictions began February 11, 2026, and the same restrictions apply to existing Development Mode apps starting March 9, 2026. citeturn5view0 It also reiterates that Development Mode apps require the owner to have Spotify Premium, and the app stops working if that subscription lapses. citeturn5view0turn14view0
+
+Spotify’s “Quota modes” documentation is even more explicit: Development Mode supports up to five authenticated users and requires allowlisting; Extended quota mode removes that allowlist and supports unlimited users—but Spotify only accepts applications “from organizations (not individuals)” and lists implementation requirements including a legally registered business, an active launched service, and at least **250k MAUs** (plus other criteria). citeturn14view0
+
+For an indie consumer app, those requirements imply a catch-22: you may not be able to scale on Spotify’s API unless you’re already large.
+
+### Spotify policy compliance is non-optional
+
+Spotify’s Developer Policy requires transparency and a privacy policy, limits data collection to what’s needed, and requires a user-accessible mechanism to disconnect their Spotify account—after which you must delete and stop processing that user’s personal data. citeturn2view0
+
+It also imposes attribution/link-back constraints when displaying Spotify content: metadata and cover art must be accompanied by a link back to the relevant album/content/playlist on Spotify, and you must not offer Spotify metadata/cover art as a standalone product. citeturn2view0
+
+These rules directly shape SoundScore’s UI patterns (album pages, cover art, outbound “Listen on Spotify” links) and its data model (how long tokens and Spotify-derived personal data are retained). citeturn2view0
+
+## Strategic implications and white-space opportunities for SoundScore
+
+SoundScore’s core idea is still viable—but a generic “ratings + reviews + lists + follows” clone is unlikely to win. The white space is in **how** the product reduces friction, builds a culture, and survives platform constraints.
+
+### Choose a wedge where incumbents are weakest
+
+RYM/AOTY are strongest at “global database + charts.” citeturn12search2turn1search1turn12search4turn12search5 Airbuds is strongest at “friends + passive feed.” citeturn15view0 Musicboard showed there’s appetite for a Letterboxd-like journal, but its recent instability creates an opening for a trustworthy, export-friendly alternative. citeturn10view0turn20search23
+
+A defensible wedge for SoundScore (aligned with your described “Spotify as listening source of truth”) is: **“rate what you actually listened to, with the lowest possible effort”**—then progressively deepen (reviews, track picks, lists) for power users.
+
+Concrete product moves that align with market evidence:
+- Make “log it” effortless (one tap to log, optional review later) to avoid the “write an essay” trap that kills daily retention—while still supporting high-quality reviews for those who want them. This follows the Airbuds lesson that asking users to “do something” manually is a major adoption barrier. citeturn15view0
+- Build share objects that travel: a weekly “SoundScore Recap” card, a “Top 6 albums” profile card, and a “recent ratings” strip that people can screenshot. Airbuds’ weekly recap and Spotify Wrapped’s cultural footprint show that shareable stats drive acquisition. citeturn15view0turn13search0
+- Treat lists as a core distribution surface (Letterboxd’s strongest feature is arguably lists, not star ratings), but differentiate list *semantics* (e.g., “albums I would defend,” “albums that raised my ceiling,” “best headphones albums,” etc.) to encourage taste-as-identity rather than generic rankings.
+
+### Reduce Spotify dependency risk early
+
+Given Spotify’s March 2026 Development Mode constraints and the steep Extended quota bar (org-only + 250k MAUs), SoundScore should plan for one of these architectures:
+
+A “Spotify optional” path: allow sign-up without Spotify, use open catalog metadata (e.g., MusicBrainz API) and only connect Spotify for “import listening context” features. MusicBrainz provides an API for music metadata, and its core dataset is CC0-licensed, which can meaningfully reduce platform lock-in. citeturn21search0turn21search2
+
+A multi-provider path: support Apple Music alongside Spotify for login/import (Musotic positions itself this way), which can (a) expand your market and (b) prevent Spotify policy shocks from becoming existential. citeturn7search1turn7search29
+
+A ListenBrainz-compatible path: if “listening stats identity” is essential, an open-source/open-data tracking layer exists (ListenBrainz) and can serve as either an alternate data source or a fallback identity graph. citeturn21search1turn21search13
+
+### Operational excellence is a competitive advantage in this niche
+
+Musicboard’s 2025–2026 turbulence shows that reliability, communication, and data portability can be *strategic differentiators*, not just engineering hygiene. Users explicitly sought communication and export options when outages hit. citeturn10view0
+
+For SoundScore, the “trust stack” should be productized:
+- clear export (CSV/JSON) and deletion controls,
+- transparent status page and incident comms,
+- explicit privacy posture aligned to Spotify policy (disconnect/delete). citeturn2view0turn10view0
+
+### Monetization models that fit the category
+
+The most consistent monetization pattern in this space is subscription for power-user identity features:
+- Musicboard’s Pro tier is positioned around profile customization and “standing out” ($4.99/month). citeturn8view0
+- Discogs monetizes through marketplace fees and collector tooling, which is a different category but reinforces that *utilities* and *identity tools* can monetize when tied to a durable hobby. citeturn16search1turn16search2
+
+For SoundScore, likely monetization that matches your concept:
+- subscription for advanced stats, profile customization, and premium list features,
+- optional “supporter” tier that signals identity (similar to Letterboxd/indie communities),
+- affiliate revenue via outbound links (e.g., vinyl, merch) if you expand beyond Spotify catalog assumptions (but this moves you into Discogs/Bandcamp adjacency).
+
+The key is to avoid monetization that triggers streaming licensing territory—Spotify’s platform policies and quota gating show that building anything that looks like a “commercial streaming integration” is a risk. citeturn2view0turn14view0turn3view0
\ No newline at end of file
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..8844130
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,59 @@
+services:
+ backend:
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ container_name: soundscore-backend
+ env_file:
+ - .env.production
+ ports:
+ - "8080:8080"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 512M
+ cpus: "1.0"
+
+ postgres:
+ image: postgres:16-alpine
+ container_name: soundscore-postgres
+ env_file:
+ - .env.production
+ volumes:
+ - soundscore_postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 1G
+ cpus: "1.0"
+
+ redis:
+ image: redis:7-alpine
+ container_name: soundscore-redis
+ command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"]
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 3s
+ retries: 5
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 256M
+ cpus: "0.5"
+
+volumes:
+ soundscore_postgres_data:
diff --git a/docs/screenshots/spotify-album-art.png b/docs/screenshots/spotify-album-art.png
new file mode 100644
index 0000000..5f09ac6
Binary files /dev/null and b/docs/screenshots/spotify-album-art.png differ
diff --git a/docs/screenshots/theme-amethyst.png b/docs/screenshots/theme-amethyst.png
new file mode 100644
index 0000000..83ec4f2
Binary files /dev/null and b/docs/screenshots/theme-amethyst.png differ
diff --git a/docs/screenshots/theme-emerald.png b/docs/screenshots/theme-emerald.png
new file mode 100644
index 0000000..3c18f1c
Binary files /dev/null and b/docs/screenshots/theme-emerald.png differ
diff --git a/docs/screenshots/theme-midnight.png b/docs/screenshots/theme-midnight.png
new file mode 100644
index 0000000..31d83ab
Binary files /dev/null and b/docs/screenshots/theme-midnight.png differ
diff --git a/ios/SoundScore/SoundScore.xcodeproj/project.pbxproj b/ios/SoundScore/SoundScore.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..1abeba1
--- /dev/null
+++ b/ios/SoundScore/SoundScore.xcodeproj/project.pbxproj
@@ -0,0 +1,542 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ A10000010000000000000001 /* SoundScoreApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000001; };
+ A10000010000000000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000002; };
+ A10000010000000000000003 /* SSColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000003; };
+ A10000010000000000000004 /* SSTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000004; };
+ A10000010000000000000005 /* Tab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000005; };
+ A10000010000000000000006 /* FloatingTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000006; };
+ A10000010000000000000007 /* GlassCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000007; };
+ A10000010000000000000008 /* FeedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000008; };
+ A10000010000000000000009 /* LogScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000009; };
+ A10000010000000000000010 /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000010; };
+ A10000010000000000000011 /* ListsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000011; };
+ A10000010000000000000012 /* ProfileScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000012; };
+ B10000010000000000000001 /* Album.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000001; };
+ B10000010000000000000002 /* FeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000002; };
+ B10000010000000000000003 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000003; };
+ B10000010000000000000004 /* UserList.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000004; };
+ B10000010000000000000005 /* WeeklyRecap.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000005; };
+ B10000010000000000000006 /* NotificationPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000006; };
+ B10000010000000000000007 /* PresentationHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000007; };
+ B10000010000000000000008 /* SeedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000020000000000000008; };
+ C10000010000000000000001 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000020000000000000001; };
+ C10000010000000000000002 /* LogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000020000000000000002; };
+ C10000010000000000000003 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000020000000000000003; };
+ C10000010000000000000004 /* ListsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000020000000000000004; };
+ C10000010000000000000005 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000020000000000000005; };
+ D10000010000000000000001 /* StarRating.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000001; };
+ D10000010000000000000002 /* AlbumArtwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000002; };
+ D10000010000000000000003 /* AvatarCircle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000003; };
+ D10000010000000000000004 /* PillSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000004; };
+ D10000010000000000000005 /* SyncBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000005; };
+ D10000010000000000000006 /* EmptyState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000006; };
+ D10000010000000000000007 /* TimelineEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000007; };
+ D10000010000000000000008 /* GlassIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000008; };
+ D10000010000000000000009 /* ScreenHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000009; };
+ D10000010000000000000010 /* SectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000010; };
+ D10000010000000000000011 /* StatPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000011; };
+ D10000010000000000000012 /* ActionChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000012; };
+ D10000010000000000000013 /* MosaicCover.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000013; };
+ D10000010000000000000014 /* TrendChartRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000014; };
+ D10000010000000000000015 /* AppBackdrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000015; };
+ D10000010000000000000016 /* SSButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000016; };
+ E10000010000000000000001 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000020000000000000001; };
+ E10000010000000000000002 /* SoundScoreAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000020000000000000002; };
+ E10000010000000000000003 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000020000000000000003; };
+ E10000010000000000000004 /* OutboxStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000020000000000000004; };
+ E10000010000000000000005 /* SoundScoreRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000020000000000000005; };
+ E10000010000000000000006 /* SpotifyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000020000000000000006; };
+ G10000010000000000000001 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = G10000020000000000000001; };
+ A10000010000000000000013 /* AuthScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000013; };
+ F10000010000000000000001 /* AlbumDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000020000000000000001; };
+ F10000010000000000000002 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000020000000000000002; };
+ F10000010000000000000003 /* ReviewSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000020000000000000003; };
+ F10000010000000000000004 /* SkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000020000000000000004; };
+ F1000001000000000000000X /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000002000000000000000X; };
+ N10000010000000000000001 /* Track.swift in Sources */ = {isa = PBXBuildFile; fileRef = N10000020000000000000001; };
+ N10000010000000000000002 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = N10000020000000000000002; };
+ N10000010000000000000003 /* AIBuddyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = N10000020000000000000003; };
+ N10000010000000000000004 /* CadenceCharacter.swift in Sources */ = {isa = PBXBuildFile; fileRef = N10000020000000000000004; };
+ N10000010000000000000005 /* ListCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = N10000020000000000000005; };
+ N10000010000000000000006 /* AIBuddyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = N10000020000000000000006; };
+ N10000010000000000000008 /* AlbumDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = N10000020000000000000008; };
+ P10000010000000000000001 /* SongRatingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = P10000020000000000000001; };
+ Q10000010000000000000001 /* CadenceActionCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = Q10000020000000000000001; };
+ P10000010000000000000002 /* AlbumRatingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = P10000020000000000000002; };
+ N10000010000000000000009 /* AIBuddyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = N10000020000000000000009; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ A10000020000000000000001 /* SoundScoreApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundScoreApp.swift; sourceTree = ""; };
+ A10000020000000000000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ A10000020000000000000003 /* SSColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSColors.swift; sourceTree = ""; };
+ A10000020000000000000004 /* SSTypography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSTypography.swift; sourceTree = ""; };
+ A10000020000000000000005 /* Tab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tab.swift; sourceTree = ""; };
+ A10000020000000000000006 /* FloatingTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabBar.swift; sourceTree = ""; };
+ A10000020000000000000007 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = ""; };
+ A10000020000000000000008 /* FeedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedScreen.swift; sourceTree = ""; };
+ A10000020000000000000009 /* LogScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogScreen.swift; sourceTree = ""; };
+ A10000020000000000000010 /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = ""; };
+ A10000020000000000000011 /* ListsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsScreen.swift; sourceTree = ""; };
+ A10000020000000000000012 /* ProfileScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileScreen.swift; sourceTree = ""; };
+ A10000030000000000000001 /* SoundScore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SoundScore.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ B10000020000000000000001 /* Album.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Album.swift; sourceTree = ""; };
+ B10000020000000000000002 /* FeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItem.swift; sourceTree = ""; };
+ B10000020000000000000003 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; };
+ B10000020000000000000004 /* UserList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserList.swift; sourceTree = ""; };
+ B10000020000000000000005 /* WeeklyRecap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklyRecap.swift; sourceTree = ""; };
+ B10000020000000000000006 /* NotificationPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreferences.swift; sourceTree = ""; };
+ B10000020000000000000007 /* PresentationHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationHelpers.swift; sourceTree = ""; };
+ B10000020000000000000008 /* SeedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedData.swift; sourceTree = ""; };
+ C10000020000000000000001 /* FeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewModel.swift; sourceTree = ""; };
+ C10000020000000000000002 /* LogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewModel.swift; sourceTree = ""; };
+ C10000020000000000000003 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; };
+ C10000020000000000000004 /* ListsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsViewModel.swift; sourceTree = ""; };
+ C10000020000000000000005 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; };
+ D10000020000000000000001 /* StarRating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarRating.swift; sourceTree = ""; };
+ D10000020000000000000002 /* AlbumArtwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumArtwork.swift; sourceTree = ""; };
+ D10000020000000000000003 /* AvatarCircle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCircle.swift; sourceTree = ""; };
+ D10000020000000000000004 /* PillSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillSearchBar.swift; sourceTree = ""; };
+ D10000020000000000000005 /* SyncBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncBanner.swift; sourceTree = ""; };
+ D10000020000000000000006 /* EmptyState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyState.swift; sourceTree = ""; };
+ D10000020000000000000007 /* TimelineEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineEntry.swift; sourceTree = ""; };
+ D10000020000000000000008 /* GlassIconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassIconButton.swift; sourceTree = ""; };
+ D10000020000000000000009 /* ScreenHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenHeader.swift; sourceTree = ""; };
+ D10000020000000000000010 /* SectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionHeader.swift; sourceTree = ""; };
+ D10000020000000000000011 /* StatPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatPill.swift; sourceTree = ""; };
+ D10000020000000000000012 /* ActionChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionChip.swift; sourceTree = ""; };
+ D10000020000000000000013 /* MosaicCover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MosaicCover.swift; sourceTree = ""; };
+ D10000020000000000000014 /* TrendChartRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendChartRow.swift; sourceTree = ""; };
+ D10000020000000000000015 /* AppBackdrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBackdrop.swift; sourceTree = ""; };
+ D10000020000000000000016 /* SSButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSButton.swift; sourceTree = ""; };
+ E10000020000000000000001 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; };
+ E10000020000000000000002 /* SoundScoreAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundScoreAPI.swift; sourceTree = ""; };
+ E10000020000000000000003 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; };
+ E10000020000000000000004 /* OutboxStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxStore.swift; sourceTree = ""; };
+ E10000020000000000000005 /* SoundScoreRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundScoreRepository.swift; sourceTree = ""; };
+ E10000020000000000000006 /* SpotifyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyService.swift; sourceTree = ""; };
+ G10000020000000000000001 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; };
+ G10000020000000000000002 /* Secrets.swift.template */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Secrets.swift.template"; sourceTree = ""; };
+ A10000020000000000000013 /* AuthScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthScreen.swift; sourceTree = ""; };
+ F10000020000000000000001 /* AlbumDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumDetailScreen.swift; sourceTree = ""; };
+ F10000020000000000000002 /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; };
+ F10000020000000000000003 /* ReviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewSheet.swift; sourceTree = ""; };
+ F10000020000000000000004 /* SkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonView.swift; sourceTree = ""; };
+ F1000002000000000000000X /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; };
+ N10000020000000000000001 /* Track.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Track.swift; sourceTree = ""; };
+ N10000020000000000000002 /* SplashScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreen.swift; sourceTree = ""; };
+ N10000020000000000000003 /* AIBuddyScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIBuddyScreen.swift; sourceTree = ""; };
+ N10000020000000000000004 /* CadenceCharacter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CadenceCharacter.swift; sourceTree = ""; };
+ N10000020000000000000005 /* ListCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListCards.swift; sourceTree = ""; };
+ N10000020000000000000006 /* AIBuddyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIBuddyService.swift; sourceTree = ""; };
+ N10000020000000000000008 /* AlbumDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumDetailViewModel.swift; sourceTree = ""; };
+ P10000020000000000000001 /* SongRatingSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SongRatingSheet.swift; sourceTree = ""; };
+ Q10000020000000000000001 /* CadenceActionCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CadenceActionCards.swift; sourceTree = ""; };
+ P10000020000000000000002 /* AlbumRatingSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumRatingSheet.swift; sourceTree = ""; };
+ N10000020000000000000009 /* AIBuddyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIBuddyViewModel.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXGroup section */
+ A10000040000000000000001 = {
+ isa = PBXGroup;
+ children = (
+ A10000040000000000000002 /* SoundScore */,
+ A10000040000000000000006 /* Products */,
+ );
+ sourceTree = "";
+ };
+ A10000040000000000000002 /* SoundScore */ = {
+ isa = PBXGroup;
+ children = (
+ A10000020000000000000001 /* SoundScoreApp.swift */,
+ A10000020000000000000002 /* ContentView.swift */,
+ A10000040000000000000003 /* Theme */,
+ A10000040000000000000004 /* Components */,
+ A10000040000000000000005 /* Screens */,
+ A10000040000000000000007 /* Models */,
+ A10000040000000000000008 /* ViewModels */,
+ E10000040000000000000001 /* Services */,
+ G10000040000000000000001 /* Config */,
+ );
+ path = SoundScore;
+ sourceTree = "";
+ };
+ A10000040000000000000003 /* Theme */ = {
+ isa = PBXGroup;
+ children = (
+ A10000020000000000000003 /* SSColors.swift */,
+ A10000020000000000000004 /* SSTypography.swift */,
+ F1000002000000000000000X /* ThemeManager.swift */,
+ );
+ path = Theme;
+ sourceTree = "";
+ };
+ A10000040000000000000004 /* Components */ = {
+ isa = PBXGroup;
+ children = (
+ A10000020000000000000005 /* Tab.swift */,
+ A10000020000000000000006 /* FloatingTabBar.swift */,
+ A10000020000000000000007 /* GlassCard.swift */,
+ D10000020000000000000001 /* StarRating.swift */,
+ D10000020000000000000002 /* AlbumArtwork.swift */,
+ D10000020000000000000003 /* AvatarCircle.swift */,
+ D10000020000000000000004 /* PillSearchBar.swift */,
+ D10000020000000000000005 /* SyncBanner.swift */,
+ D10000020000000000000006 /* EmptyState.swift */,
+ D10000020000000000000007 /* TimelineEntry.swift */,
+ D10000020000000000000008 /* GlassIconButton.swift */,
+ D10000020000000000000009 /* ScreenHeader.swift */,
+ D10000020000000000000010 /* SectionHeader.swift */,
+ D10000020000000000000011 /* StatPill.swift */,
+ D10000020000000000000012 /* ActionChip.swift */,
+ D10000020000000000000013 /* MosaicCover.swift */,
+ D10000020000000000000014 /* TrendChartRow.swift */,
+ D10000020000000000000015 /* AppBackdrop.swift */,
+ D10000020000000000000016 /* SSButton.swift */,
+ F10000020000000000000003 /* ReviewSheet.swift */,
+ F10000020000000000000004 /* SkeletonView.swift */,
+ N10000020000000000000004 /* CadenceCharacter.swift */,
+ N10000020000000000000005 /* ListCards.swift */,
+ P10000020000000000000001 /* SongRatingSheet.swift */,
+ P10000020000000000000002 /* AlbumRatingSheet.swift */,
+ Q10000020000000000000001 /* CadenceActionCards.swift */,
+ );
+ path = Components;
+ sourceTree = "";
+ };
+ A10000040000000000000005 /* Screens */ = {
+ isa = PBXGroup;
+ children = (
+ A10000020000000000000008 /* FeedScreen.swift */,
+ A10000020000000000000009 /* LogScreen.swift */,
+ A10000020000000000000010 /* SearchScreen.swift */,
+ A10000020000000000000011 /* ListsScreen.swift */,
+ A10000020000000000000012 /* ProfileScreen.swift */,
+ A10000020000000000000013 /* AuthScreen.swift */,
+ F10000020000000000000001 /* AlbumDetailScreen.swift */,
+ F10000020000000000000002 /* SettingsScreen.swift */,
+ N10000020000000000000002 /* SplashScreen.swift */,
+ N10000020000000000000003 /* AIBuddyScreen.swift */,
+ );
+ path = Screens;
+ sourceTree = "";
+ };
+ A10000040000000000000006 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ A10000030000000000000001 /* SoundScore.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ A10000040000000000000007 /* Models */ = {
+ isa = PBXGroup;
+ children = (
+ B10000020000000000000001 /* Album.swift */,
+ B10000020000000000000002 /* FeedItem.swift */,
+ B10000020000000000000003 /* UserProfile.swift */,
+ B10000020000000000000004 /* UserList.swift */,
+ B10000020000000000000005 /* WeeklyRecap.swift */,
+ B10000020000000000000006 /* NotificationPreferences.swift */,
+ B10000020000000000000007 /* PresentationHelpers.swift */,
+ B10000020000000000000008 /* SeedData.swift */,
+ N10000020000000000000001 /* Track.swift */,
+ );
+ path = Models;
+ sourceTree = "";
+ };
+ A10000040000000000000008 /* ViewModels */ = {
+ isa = PBXGroup;
+ children = (
+ C10000020000000000000001 /* FeedViewModel.swift */,
+ C10000020000000000000002 /* LogViewModel.swift */,
+ C10000020000000000000003 /* SearchViewModel.swift */,
+ C10000020000000000000004 /* ListsViewModel.swift */,
+ C10000020000000000000005 /* ProfileViewModel.swift */,
+ N10000020000000000000008 /* AlbumDetailViewModel.swift */,
+ N10000020000000000000009 /* AIBuddyViewModel.swift */,
+ );
+ path = ViewModels;
+ sourceTree = "";
+ };
+ E10000040000000000000001 /* Services */ = {
+ isa = PBXGroup;
+ children = (
+ E10000020000000000000001 /* APIClient.swift */,
+ E10000020000000000000002 /* SoundScoreAPI.swift */,
+ E10000020000000000000003 /* AuthManager.swift */,
+ E10000020000000000000004 /* OutboxStore.swift */,
+ E10000020000000000000005 /* SoundScoreRepository.swift */,
+ E10000020000000000000006 /* SpotifyService.swift */,
+ N10000020000000000000006 /* AIBuddyService.swift */,
+ );
+ path = Services;
+ sourceTree = "";
+ };
+ G10000040000000000000001 /* Config */ = {
+ isa = PBXGroup;
+ children = (
+ G10000020000000000000001 /* Secrets.swift */,
+ G10000020000000000000002 /* Secrets.swift.template */,
+ );
+ path = Config;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ A10000050000000000000001 /* SoundScore */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A10000070000000000000001 /* Build configuration list for PBXNativeTarget "SoundScore" */;
+ buildPhases = (
+ A10000060000000000000001 /* Sources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = SoundScore;
+ productName = SoundScore;
+ productReference = A10000030000000000000001 /* SoundScore.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ A10000080000000000000001 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1500;
+ LastUpgradeCheck = 1500;
+ };
+ buildConfigurationList = A10000070000000000000002 /* Build configuration list for PBXProject "SoundScore" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = A10000040000000000000001;
+ productRefGroup = A10000040000000000000006 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ A10000050000000000000001 /* SoundScore */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXSourcesBuildPhase section */
+ A10000060000000000000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A10000010000000000000001 /* SoundScoreApp.swift in Sources */,
+ A10000010000000000000002 /* ContentView.swift in Sources */,
+ A10000010000000000000003 /* SSColors.swift in Sources */,
+ A10000010000000000000004 /* SSTypography.swift in Sources */,
+ A10000010000000000000005 /* Tab.swift in Sources */,
+ A10000010000000000000006 /* FloatingTabBar.swift in Sources */,
+ A10000010000000000000007 /* GlassCard.swift in Sources */,
+ A10000010000000000000008 /* FeedScreen.swift in Sources */,
+ A10000010000000000000009 /* LogScreen.swift in Sources */,
+ A10000010000000000000010 /* SearchScreen.swift in Sources */,
+ A10000010000000000000011 /* ListsScreen.swift in Sources */,
+ A10000010000000000000012 /* ProfileScreen.swift in Sources */,
+ B10000010000000000000001 /* Album.swift in Sources */,
+ B10000010000000000000002 /* FeedItem.swift in Sources */,
+ B10000010000000000000003 /* UserProfile.swift in Sources */,
+ B10000010000000000000004 /* UserList.swift in Sources */,
+ B10000010000000000000005 /* WeeklyRecap.swift in Sources */,
+ B10000010000000000000006 /* NotificationPreferences.swift in Sources */,
+ B10000010000000000000007 /* PresentationHelpers.swift in Sources */,
+ B10000010000000000000008 /* SeedData.swift in Sources */,
+ C10000010000000000000001 /* FeedViewModel.swift in Sources */,
+ C10000010000000000000002 /* LogViewModel.swift in Sources */,
+ C10000010000000000000003 /* SearchViewModel.swift in Sources */,
+ C10000010000000000000004 /* ListsViewModel.swift in Sources */,
+ C10000010000000000000005 /* ProfileViewModel.swift in Sources */,
+ D10000010000000000000001 /* StarRating.swift in Sources */,
+ D10000010000000000000002 /* AlbumArtwork.swift in Sources */,
+ D10000010000000000000003 /* AvatarCircle.swift in Sources */,
+ D10000010000000000000004 /* PillSearchBar.swift in Sources */,
+ D10000010000000000000005 /* SyncBanner.swift in Sources */,
+ D10000010000000000000006 /* EmptyState.swift in Sources */,
+ D10000010000000000000007 /* TimelineEntry.swift in Sources */,
+ D10000010000000000000008 /* GlassIconButton.swift in Sources */,
+ D10000010000000000000009 /* ScreenHeader.swift in Sources */,
+ D10000010000000000000010 /* SectionHeader.swift in Sources */,
+ D10000010000000000000011 /* StatPill.swift in Sources */,
+ D10000010000000000000012 /* ActionChip.swift in Sources */,
+ D10000010000000000000013 /* MosaicCover.swift in Sources */,
+ D10000010000000000000014 /* TrendChartRow.swift in Sources */,
+ D10000010000000000000015 /* AppBackdrop.swift in Sources */,
+ D10000010000000000000016 /* SSButton.swift in Sources */,
+ E10000010000000000000001 /* APIClient.swift in Sources */,
+ E10000010000000000000002 /* SoundScoreAPI.swift in Sources */,
+ E10000010000000000000003 /* AuthManager.swift in Sources */,
+ E10000010000000000000004 /* OutboxStore.swift in Sources */,
+ E10000010000000000000005 /* SoundScoreRepository.swift in Sources */,
+ E10000010000000000000006 /* SpotifyService.swift in Sources */,
+ G10000010000000000000001 /* Secrets.swift in Sources */,
+ A10000010000000000000013 /* AuthScreen.swift in Sources */,
+ F10000010000000000000001 /* AlbumDetailScreen.swift in Sources */,
+ F10000010000000000000002 /* SettingsScreen.swift in Sources */,
+ F10000010000000000000003 /* ReviewSheet.swift in Sources */,
+ F10000010000000000000004 /* SkeletonView.swift in Sources */,
+ F1000001000000000000000X /* ThemeManager.swift in Sources */,
+ N10000010000000000000001 /* Track.swift in Sources */,
+ N10000010000000000000002 /* SplashScreen.swift in Sources */,
+ N10000010000000000000003 /* AIBuddyScreen.swift in Sources */,
+ N10000010000000000000004 /* CadenceCharacter.swift in Sources */,
+ N10000010000000000000005 /* ListCards.swift in Sources */,
+ N10000010000000000000006 /* AIBuddyService.swift in Sources */,
+ N10000010000000000000008 /* AlbumDetailViewModel.swift in Sources */,
+ N10000010000000000000009 /* AIBuddyViewModel.swift in Sources */,
+ P10000010000000000000001 /* SongRatingSheet.swift in Sources */,
+ P10000010000000000000002 /* AlbumRatingSheet.swift in Sources */,
+ Q10000010000000000000001 /* CadenceActionCards.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ A10000090000000000000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 18.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.soundscore.app;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ A10000090000000000000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 18.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.soundscore.app;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ A10000090000000000000003 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ A10000090000000000000004 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_OPTIMIZATION_LEVEL = s;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ A10000070000000000000001 /* Build configuration list for PBXNativeTarget "SoundScore" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A10000090000000000000001 /* Debug */,
+ A10000090000000000000002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A10000070000000000000002 /* Build configuration list for PBXProject "SoundScore" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A10000090000000000000003 /* Debug */,
+ A10000090000000000000004 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = A10000080000000000000001 /* Project object */;
+}
diff --git a/ios/SoundScore/SoundScore/Components/ActionChip.swift b/ios/SoundScore/SoundScore/Components/ActionChip.swift
new file mode 100644
index 0000000..f0df052
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/ActionChip.swift
@@ -0,0 +1,31 @@
+import SwiftUI
+
+struct ActionChip: View {
+ let text: String
+ let icon: String
+ var active: Bool = false
+ var onTap: (() -> Void)?
+
+ var body: some View {
+ Button(action: { onTap?() }) {
+ HStack(spacing: 4) {
+ Image(systemName: icon)
+ .font(.system(size: 12))
+ Text(text)
+ .font(SSTypography.labelSmall)
+ }
+ .foregroundColor(active ? ThemeManager.shared.primary : SSColors.chromeDim)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(
+ Capsule()
+ .fill(active ? ThemeManager.shared.primaryDim : SSColors.glassBg)
+ )
+ .overlay(
+ Capsule()
+ .stroke(active ? ThemeManager.shared.primary.opacity(0.3) : SSColors.feedItemBorder, lineWidth: 0.5)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/AlbumArtwork.swift b/ios/SoundScore/SoundScore/Components/AlbumArtwork.swift
new file mode 100644
index 0000000..40258cf
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/AlbumArtwork.swift
@@ -0,0 +1,59 @@
+import SwiftUI
+
+struct AlbumArtwork: View {
+ let artworkUrl: String?
+ let colors: [Color]
+ var cornerRadius: CGFloat = 16
+
+ @State private var shimmerOffset: CGFloat = -200
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack {
+ LinearGradient(
+ colors: colors,
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+
+ if let url = artworkUrl, let imageUrl = URL(string: url) {
+ AsyncImage(url: imageUrl) { phase in
+ switch phase {
+ case .success(let image):
+ image.resizable().scaledToFill()
+ default:
+ shimmerOverlay(size: geo.size)
+ }
+ }
+ } else {
+ shimmerOverlay(size: geo.size)
+ }
+
+ LinearGradient(
+ colors: [SSColors.overlayLight, SSColors.overlayOnImage],
+ startPoint: .top, endPoint: .bottom
+ )
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
+ }
+
+ private func shimmerOverlay(size: CGSize) -> some View {
+ Rectangle()
+ .fill(Color.clear)
+ .overlay(
+ LinearGradient(
+ colors: [Color.white.opacity(0), Color.white.opacity(0.08), Color.white.opacity(0)],
+ startPoint: .leading, endPoint: .trailing
+ )
+ .frame(width: size.width * 0.6)
+ .offset(x: shimmerOffset)
+ )
+ .clipped()
+ .onAppear {
+ withAnimation(.linear(duration: 3.2).repeatForever(autoreverses: false)) {
+ shimmerOffset = size.width + 200
+ }
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/AlbumRatingSheet.swift b/ios/SoundScore/SoundScore/Components/AlbumRatingSheet.swift
new file mode 100644
index 0000000..a419d4f
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/AlbumRatingSheet.swift
@@ -0,0 +1,106 @@
+import SwiftUI
+
+struct AlbumRatingSheet: View {
+ let album: Album
+ @State var rating: Float
+ var onRate: (Float) -> Void
+ var onSaveReview: (String) -> Void
+ @State private var reviewText: String = ""
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 20) {
+ // Album header
+ HStack(spacing: 14) {
+ AlbumArtwork(artworkUrl: album.artworkUrl, colors: album.artColors, cornerRadius: 16)
+ .frame(width: 80, height: 80)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(album.title)
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ .lineLimit(2)
+ Text(album.artist)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.textSecondary)
+ Text(String(album.year))
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+
+ Spacer()
+ }
+
+ // Rating
+ VStack(spacing: 10) {
+ StarRating(rating: rating, onRate: { newRating in
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ rating = newRating
+ }, starSize: 32, maxStars: 6)
+
+ if rating > 0 {
+ Text(String(format: "%.1f / 6", rating))
+ .font(SSTypography.headlineMedium)
+ .foregroundColor(SSColors.accentAmber)
+ .fontWeight(.bold)
+ } else {
+ Text("Tap to rate")
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ }
+ .padding(.vertical, 8)
+
+ // Review
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Review")
+ .font(SSTypography.labelMedium)
+ .foregroundColor(SSColors.textSecondary)
+ .textCase(.uppercase)
+
+ TextEditor(text: $reviewText)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .scrollContentBackground(.hidden)
+ .frame(minHeight: 100, maxHeight: 200)
+ .padding(12)
+ .background(SSColors.glassBg)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ .overlay(
+ RoundedRectangle(cornerRadius: 14)
+ .stroke(SSColors.glassBorder, lineWidth: 0.5)
+ )
+
+ HStack {
+ Text("\(reviewText.count)/500")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(reviewText.count > 500 ? SSColors.accentCoral : SSColors.textTertiary)
+ Spacer()
+ }
+ }
+
+ // Save
+ SSButton(text: "Save") {
+ onRate(rating)
+ if !reviewText.trimmingCharacters(in: .whitespaces).isEmpty {
+ onSaveReview(reviewText)
+ }
+ dismiss()
+ }
+
+ Button {
+ dismiss()
+ } label: {
+ Text("Cancel")
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeMedium)
+ }
+ }
+ .padding(.horizontal, 24)
+ .padding(.top, 24)
+ .padding(.bottom, 40)
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/AppBackdrop.swift b/ios/SoundScore/SoundScore/Components/AppBackdrop.swift
new file mode 100644
index 0000000..687fbb3
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/AppBackdrop.swift
@@ -0,0 +1,43 @@
+import SwiftUI
+
+struct AppBackdrop: View {
+ @ObservedObject private var themeManager = ThemeManager.shared
+
+ var body: some View {
+ ZStack {
+ // Solid black base
+ Color.black
+
+ // Full-screen theme color wash
+ themeManager.primary.opacity(0.10)
+
+ // Base gradient with theme-tinted dark colors
+ LinearGradient(
+ colors: [SSColors.darkElevated, SSColors.darkBase],
+ startPoint: .top, endPoint: .bottom
+ )
+
+ // Primary glow — strong, covers upper-left quadrant
+ RadialGradient(
+ colors: [
+ themeManager.primary.opacity(0.45),
+ themeManager.primary.opacity(0.15),
+ Color.clear
+ ],
+ center: .topLeading,
+ startRadius: 0, endRadius: 500
+ )
+
+ // Secondary glow — covers lower-right
+ RadialGradient(
+ colors: [
+ themeManager.secondary.opacity(0.20),
+ Color.clear
+ ],
+ center: .bottomTrailing,
+ startRadius: 0, endRadius: 400
+ )
+ }
+ .ignoresSafeArea()
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/AvatarCircle.swift b/ios/SoundScore/SoundScore/Components/AvatarCircle.swift
new file mode 100644
index 0000000..19e52de
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/AvatarCircle.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+struct AvatarCircle: View {
+ let initials: String
+ let gradientColors: [Color]
+ var size: CGFloat = 38
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .fill(LinearGradient(colors: gradientColors, startPoint: .topLeading, endPoint: .bottomTrailing))
+ Circle()
+ .stroke(SSColors.glassBorder, lineWidth: 1.5)
+ Text(initials.uppercased())
+ .font(.system(size: size * 0.35, weight: .bold, design: .rounded))
+ .foregroundColor(.white)
+ }
+ .frame(width: size, height: size)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/CadenceActionCards.swift b/ios/SoundScore/SoundScore/Components/CadenceActionCards.swift
new file mode 100644
index 0000000..b8c9c83
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/CadenceActionCards.swift
@@ -0,0 +1,388 @@
+import SwiftUI
+
+// MARK: - Review Draft Card
+
+struct CadenceReviewCard: View {
+ let albumId: String
+ let albumTitle: String
+ let artworkUrl: String?
+ let artColors: [Color]
+ @State var reviewText: String
+ @State var rating: Float
+ var onSend: (String, Float) -> Void
+ var onDiscard: () -> Void
+
+ @State private var isEditing = false
+ @State private var sent = false
+
+ var body: some View {
+ if sent {
+ sentConfirmation
+ } else {
+ cardContent
+ }
+ }
+
+ private var cardContent: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ // Header
+ HStack(spacing: 10) {
+ AlbumArtwork(artworkUrl: artworkUrl, colors: artColors, cornerRadius: 10)
+ .frame(width: 48, height: 48)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Review Draft")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(ThemeManager.shared.primary)
+ .textCase(.uppercase)
+ Text(albumTitle)
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ .lineLimit(1)
+ }
+ Spacer()
+ StarRating(rating: rating, onRate: isEditing ? { rating = $0 } : nil, starSize: 12)
+ }
+
+ // Review text
+ if isEditing {
+ TextEditor(text: $reviewText)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .scrollContentBackground(.hidden)
+ .frame(minHeight: 80, maxHeight: 140)
+ .padding(10)
+ .background(SSColors.glassBg)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .overlay(RoundedRectangle(cornerRadius: 12).stroke(ThemeManager.shared.primary.opacity(0.3), lineWidth: 1))
+ } else {
+ Text(reviewText)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight.opacity(0.9))
+ .italic()
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(SSColors.glassBg)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+
+ // Action buttons
+ HStack(spacing: 10) {
+ Button {
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
+ sent = true
+ }
+ onSend(reviewText, rating)
+ } label: {
+ HStack(spacing: 6) {
+ Image(systemName: "arrow.up.circle.fill")
+ .font(.system(size: 14))
+ Text("Send")
+ .font(SSTypography.labelLarge)
+ .fontWeight(.bold)
+ }
+ .foregroundColor(SSColors.darkBase)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(ThemeManager.shared.primary)
+ .clipShape(Capsule())
+ }
+ .buttonStyle(.plain)
+
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ isEditing.toggle()
+ }
+ } label: {
+ HStack(spacing: 4) {
+ Image(systemName: isEditing ? "checkmark" : "pencil")
+ .font(.system(size: 12))
+ Text(isEditing ? "Done" : "Edit")
+ .font(SSTypography.labelMedium)
+ }
+ .foregroundColor(SSColors.chromeLight)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .background(SSColors.glassBg)
+ .clipShape(Capsule())
+ .overlay(Capsule().stroke(SSColors.glassBorder, lineWidth: 0.5))
+ }
+ .buttonStyle(.plain)
+
+ Spacer()
+
+ Button {
+ onDiscard()
+ } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundColor(SSColors.chromeFaint)
+ .frame(width: 32, height: 32)
+ .background(SSColors.glassBg)
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(14)
+ .background(
+ RoundedRectangle(cornerRadius: 20).fill(.ultraThinMaterial)
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(ThemeManager.shared.primary.opacity(0.3), lineWidth: 1)
+ )
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ }
+
+ private var sentConfirmation: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 20))
+ .foregroundColor(SSColors.accentGreen)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Review saved")
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ Text(albumTitle)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ }
+ Spacer()
+ }
+ .padding(14)
+ .background(
+ RoundedRectangle(cornerRadius: 16).fill(SSColors.accentGreen.opacity(0.1))
+ .overlay(RoundedRectangle(cornerRadius: 16).stroke(SSColors.accentGreen.opacity(0.3), lineWidth: 1))
+ )
+ .transition(.scale.combined(with: .opacity))
+ }
+}
+
+// MARK: - Batch Rating Card
+
+struct CadenceBatchRatingCard: View {
+ let ratings: [CadenceAction]
+ var onApplyAll: ([CadenceAction]) -> Void
+ var onDiscard: () -> Void
+
+ @State private var appliedIndices: Set = []
+ @State private var allApplied = false
+
+ var body: some View {
+ if allApplied {
+ appliedConfirmation
+ } else {
+ cardContent
+ }
+ }
+
+ private var cardContent: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Image(systemName: "star.fill")
+ .foregroundColor(SSColors.accentAmber)
+ Text("Batch Ratings")
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ Spacer()
+ Text("\(ratings.count) albums")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+
+ ForEach(Array(ratings.enumerated()), id: \.element.id) { index, action in
+ HStack(spacing: 10) {
+ let album = SoundScoreRepository.shared.albums.first { $0.id == action.albumId }
+ AlbumArtwork(
+ artworkUrl: album?.artworkUrl,
+ colors: album?.artColors ?? AlbumColors.forest,
+ cornerRadius: 8
+ )
+ .frame(width: 36, height: 36)
+
+ Text(action.albumTitle)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .lineLimit(1)
+
+ Spacer()
+
+ if appliedIndices.contains(index) {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(SSColors.accentGreen)
+ .transition(.scale.combined(with: .opacity))
+ } else {
+ Text("\(action.value)/6")
+ .font(SSTypography.labelLarge)
+ .foregroundColor(SSColors.accentAmber)
+ .fontWeight(.bold)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ HStack(spacing: 10) {
+ Button {
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ applyAllWithAnimation()
+ } label: {
+ HStack(spacing: 6) {
+ Image(systemName: "checkmark.circle")
+ .font(.system(size: 14))
+ Text("Apply All")
+ .font(SSTypography.labelLarge)
+ .fontWeight(.bold)
+ }
+ .foregroundColor(SSColors.darkBase)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
+ .background(SSColors.accentAmber)
+ .clipShape(Capsule())
+ }
+ .buttonStyle(.plain)
+
+ Spacer()
+
+ Button { onDiscard() } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundColor(SSColors.chromeFaint)
+ .frame(width: 32, height: 32)
+ .background(SSColors.glassBg)
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(14)
+ .background(
+ RoundedRectangle(cornerRadius: 20).fill(.ultraThinMaterial)
+ .overlay(RoundedRectangle(cornerRadius: 20).stroke(SSColors.accentAmber.opacity(0.3), lineWidth: 1))
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ }
+
+ private var appliedConfirmation: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 20))
+ .foregroundColor(SSColors.accentGreen)
+ Text("\(ratings.count) albums rated")
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ Spacer()
+ }
+ .padding(14)
+ .background(
+ RoundedRectangle(cornerRadius: 16).fill(SSColors.accentGreen.opacity(0.1))
+ .overlay(RoundedRectangle(cornerRadius: 16).stroke(SSColors.accentGreen.opacity(0.3), lineWidth: 1))
+ )
+ .transition(.scale.combined(with: .opacity))
+ }
+
+ private func applyAllWithAnimation() {
+ for (index, _) in ratings.enumerated() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.3) {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
+ appliedIndices.insert(index)
+ }
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ }
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(ratings.count) * 0.3 + 0.3) {
+ onApplyAll(ratings)
+ withAnimation(.spring(response: 0.4)) {
+ allApplied = true
+ }
+ }
+ }
+}
+
+// MARK: - Quick Rate Card
+
+struct CadenceQuickRateCard: View {
+ let action: CadenceAction
+ var onConfirm: (CadenceAction) -> Void
+ var onDiscard: () -> Void
+
+ @State private var confirmed = false
+
+ var body: some View {
+ if confirmed {
+ HStack(spacing: 8) {
+ Image(systemName: "star.fill")
+ .foregroundColor(SSColors.accentAmber)
+ Text("Rated \(action.albumTitle) → \(action.value)/6")
+ .font(SSTypography.labelMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.semibold)
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .background(SSColors.accentAmber.opacity(0.1))
+ .clipShape(Capsule())
+ .overlay(Capsule().stroke(SSColors.accentAmber.opacity(0.3), lineWidth: 1))
+ .transition(.scale.combined(with: .opacity))
+ } else {
+ HStack(spacing: 10) {
+ let album = SoundScoreRepository.shared.albums.first { $0.id == action.albumId }
+ AlbumArtwork(
+ artworkUrl: album?.artworkUrl,
+ colors: album?.artColors ?? AlbumColors.forest,
+ cornerRadius: 10
+ )
+ .frame(width: 44, height: 44)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(action.albumTitle)
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ .lineLimit(1)
+ StarRating(rating: Float(action.value) ?? 0, starSize: 12)
+ }
+
+ Spacer()
+
+ Button {
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
+ confirmed = true
+ }
+ onConfirm(action)
+ } label: {
+ Text("Confirm")
+ .font(SSTypography.labelMedium)
+ .fontWeight(.bold)
+ .foregroundColor(SSColors.darkBase)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 8)
+ .background(SSColors.accentAmber)
+ .clipShape(Capsule())
+ }
+ .buttonStyle(.plain)
+
+ Button { onDiscard() } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundColor(SSColors.chromeFaint)
+ .frame(width: 28, height: 28)
+ .background(SSColors.glassBg)
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(12)
+ .background(
+ RoundedRectangle(cornerRadius: 16).fill(.ultraThinMaterial)
+ .overlay(RoundedRectangle(cornerRadius: 16).stroke(SSColors.accentAmber.opacity(0.3), lineWidth: 1))
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/CadenceCharacter.swift b/ios/SoundScore/SoundScore/Components/CadenceCharacter.swift
new file mode 100644
index 0000000..2aa1def
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/CadenceCharacter.swift
@@ -0,0 +1,144 @@
+import SwiftUI
+
+enum CadenceState {
+ case idle
+ case thinking
+ case happy
+}
+
+struct CadenceCharacter: View {
+ var state: CadenceState = .idle
+ var size: CGFloat = 120
+
+ @State private var bobOffset: CGFloat = 0
+ @State private var eyeOffset: CGFloat = 0
+ @State private var bounceScale: CGFloat = 1.0
+
+ private var primaryColor: Color { ThemeManager.shared.primary }
+
+ var body: some View {
+ ZStack {
+ // Body: rounded teardrop (music note head shape)
+ bodyShape
+ .fill(
+ LinearGradient(
+ colors: [primaryColor, primaryColor.opacity(0.7)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ .frame(width: size, height: size)
+ .shadow(color: primaryColor.opacity(0.4), radius: 16, y: 6)
+
+ // Eyes
+ HStack(spacing: size * 0.15) {
+ Circle()
+ .fill(Color.white)
+ .frame(width: size * 0.12, height: size * 0.12)
+ Circle()
+ .fill(Color.white)
+ .frame(width: size * 0.12, height: size * 0.12)
+ }
+ .offset(y: -size * 0.05 + eyeOffset)
+
+ // Smile (happy state)
+ if state == .happy {
+ smileArc
+ .stroke(Color.white, lineWidth: 2.5)
+ .frame(width: size * 0.2, height: size * 0.08)
+ .offset(y: size * 0.1)
+ }
+
+ // Headphones
+ headphones
+ }
+ .scaleEffect(bounceScale)
+ .offset(y: bobOffset)
+ .onAppear { startAnimations() }
+ .onChange(of: state) { startAnimations() }
+ }
+
+ // MARK: - Shapes
+
+ private var bodyShape: some Shape {
+ RoundedRectangle(cornerRadius: size * 0.35)
+ }
+
+ private var smileArc: some Shape {
+ SmileShape()
+ }
+
+ private var headphones: some View {
+ ZStack {
+ // Band
+ Capsule()
+ .stroke(SSColors.chromeMedium, lineWidth: 3)
+ .frame(width: size * 0.7, height: size * 0.15)
+ .offset(y: -size * 0.4)
+
+ // Left ear
+ Circle()
+ .fill(SSColors.chromeMedium)
+ .frame(width: size * 0.12, height: size * 0.12)
+ .offset(x: -size * 0.35, y: -size * 0.33)
+
+ // Right ear
+ Circle()
+ .fill(SSColors.chromeMedium)
+ .frame(width: size * 0.12, height: size * 0.12)
+ .offset(x: size * 0.35, y: -size * 0.33)
+ }
+ }
+
+ // MARK: - Arms
+
+ // MARK: - Animations
+
+ private func startAnimations() {
+ switch state {
+ case .idle:
+ withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
+ bobOffset = -6
+ }
+ withAnimation(.default) {
+ eyeOffset = 0
+ bounceScale = 1.0
+ }
+
+ case .thinking:
+ withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) {
+ bobOffset = -4
+ bounceScale = 1.03
+ }
+ withAnimation(.easeInOut(duration: 0.5)) {
+ eyeOffset = -4
+ }
+
+ case .happy:
+ withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) {
+ bounceScale = 1.1
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
+ bounceScale = 1.0
+ }
+ }
+ withAnimation(.default) {
+ eyeOffset = 0
+ bobOffset = 0
+ }
+ }
+ }
+}
+
+private struct SmileShape: Shape {
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+ path.move(to: CGPoint(x: rect.minX, y: rect.minY))
+ path.addQuadCurve(
+ to: CGPoint(x: rect.maxX, y: rect.minY),
+ control: CGPoint(x: rect.midX, y: rect.maxY)
+ )
+ return path
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/EmptyState.swift b/ios/SoundScore/SoundScore/Components/EmptyState.swift
new file mode 100644
index 0000000..d1af189
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/EmptyState.swift
@@ -0,0 +1,39 @@
+import SwiftUI
+
+struct EmptyState: View {
+ let title: String
+ let subtitle: String
+ var icon: String?
+ var actionLabel: String?
+ var onAction: (() -> Void)?
+
+ var body: some View {
+ GlassCard(cornerRadius: 22, borderColor: SSColors.feedItemBorder) {
+ VStack(spacing: 12) {
+ if let icon {
+ ZStack {
+ Circle()
+ .fill(SSColors.glassBg)
+ .frame(width: 48, height: 48)
+ Image(systemName: icon)
+ .font(.system(size: 20))
+ .foregroundColor(SSColors.chromeDim)
+ }
+ }
+ Text(title)
+ .font(SSTypography.titleLarge)
+ .foregroundColor(SSColors.chromeLight)
+ Text(subtitle)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textTertiary)
+ .multilineTextAlignment(.center)
+ if let actionLabel, let onAction {
+ SSButton(text: actionLabel, action: onAction)
+ .padding(.top, 4)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/FloatingTabBar.swift b/ios/SoundScore/SoundScore/Components/FloatingTabBar.swift
new file mode 100644
index 0000000..673bfde
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/FloatingTabBar.swift
@@ -0,0 +1,44 @@
+import SwiftUI
+
+struct FloatingTabBar: View {
+ @Binding var selectedTab: Tab
+
+ var body: some View {
+ HStack(spacing: 0) {
+ ForEach(Tab.allCases, id: \.self) { tab in
+ Button {
+ withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
+ selectedTab = tab
+ }
+ let generator = UIImpactFeedbackGenerator(style: .light)
+ generator.impactOccurred()
+ } label: {
+ VStack(spacing: 4) {
+ Image(systemName: selectedTab == tab ? tab.iconFilled : tab.icon)
+ .font(.system(size: selectedTab == tab ? 24 : 20))
+ .foregroundColor(
+ selectedTab == tab
+ ? ThemeManager.shared.primary
+ : SSColors.chromeDim
+ )
+
+ Circle()
+ .fill(ThemeManager.shared.primary)
+ .frame(width: 4, height: 4)
+ .opacity(selectedTab == tab ? 1 : 0)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ }
+ .frame(height: 64)
+ .background(
+ RoundedRectangle(cornerRadius: 24)
+ .fill(.ultraThinMaterial)
+ .overlay(
+ RoundedRectangle(cornerRadius: 24)
+ .stroke(SSColors.glassBorder, lineWidth: 0.5)
+ )
+ )
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/GlassCard.swift b/ios/SoundScore/SoundScore/Components/GlassCard.swift
new file mode 100644
index 0000000..b111916
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/GlassCard.swift
@@ -0,0 +1,101 @@
+import SwiftUI
+
+struct GlassCard: View {
+ var tintColor: Color?
+ var cornerRadius: CGFloat
+ var borderColor: Color
+ var contentPadding: EdgeInsets
+ var frosted: Bool
+ var onTap: (() -> Void)?
+ let content: () -> Content
+
+ @State private var isPressed = false
+
+ init(
+ tintColor: Color? = nil,
+ cornerRadius: CGFloat = 20,
+ borderColor: Color = SSColors.feedItemBorder,
+ contentPadding: EdgeInsets = EdgeInsets(top: 14, leading: 14, bottom: 14, trailing: 14),
+ frosted: Bool = false,
+ onTap: (() -> Void)? = nil,
+ @ViewBuilder content: @escaping () -> Content
+ ) {
+ self.tintColor = tintColor
+ self.cornerRadius = cornerRadius
+ self.borderColor = borderColor
+ self.contentPadding = contentPadding
+ self.frosted = frosted
+ self.onTap = onTap
+ self.content = content
+ }
+
+ var body: some View {
+ content()
+ .padding(contentPadding)
+ .background(
+ ZStack {
+ RoundedRectangle(cornerRadius: cornerRadius)
+ .fill(.ultraThinMaterial)
+ if let tint = tintColor {
+ RoundedRectangle(cornerRadius: cornerRadius)
+ .fill(
+ LinearGradient(
+ colors: [tint.opacity(0.15), tint.opacity(0.03)],
+ startPoint: .topLeading, endPoint: .bottomTrailing
+ )
+ )
+ }
+ if frosted {
+ RoundedRectangle(cornerRadius: cornerRadius)
+ .fill(SSColors.glassHighlight.opacity(0.06))
+ }
+ }
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: cornerRadius)
+ .stroke(borderColor, lineWidth: 0.5)
+ )
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
+ .scaleEffect(isPressed ? 0.97 : 1.0)
+ .animation(.spring(response: 0.3, dampingFraction: 0.65), value: isPressed)
+ .if(onTap != nil) { view in
+ view.onTapGesture { onTap?() }
+ .onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
+ isPressed = pressing
+ }, perform: {})
+ }
+ }
+}
+
+extension View {
+ @ViewBuilder
+ func `if`(_ condition: Bool, transform: (Self) -> Transform) -> some View {
+ if condition {
+ transform(self)
+ } else {
+ self
+ }
+ }
+}
+
+struct PlaceholderScreen: View {
+ let title: String
+ let subtitle: String
+
+ var body: some View {
+ VStack(spacing: 16) {
+ Spacer()
+ Text(title)
+ .font(SSTypography.displaySmall)
+ .foregroundColor(SSColors.chromeLight)
+ Text(subtitle)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.textSecondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(SSColors.darkBase)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/GlassIconButton.swift b/ios/SoundScore/SoundScore/Components/GlassIconButton.swift
new file mode 100644
index 0000000..646d19f
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/GlassIconButton.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+struct GlassIconButton: View {
+ let icon: String
+ let label: String
+ var tint: Color = SSColors.chromeLight
+ var action: () -> Void = {}
+
+ var body: some View {
+ Button(action: action) {
+ VStack(spacing: 6) {
+ ZStack {
+ Circle()
+ .fill(.ultraThinMaterial)
+ .frame(width: 48, height: 48)
+ Circle()
+ .stroke(SSColors.glassBorder, lineWidth: 0.5)
+ .frame(width: 48, height: 48)
+ Image(systemName: icon)
+ .font(.system(size: 18))
+ .foregroundColor(tint)
+ }
+ Text(label)
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.chromeDim)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/ListCards.swift b/ios/SoundScore/SoundScore/Components/ListCards.swift
new file mode 100644
index 0000000..a31c4b1
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/ListCards.swift
@@ -0,0 +1,81 @@
+import SwiftUI
+
+struct FeaturedListHero: View {
+ let showcase: ListShowcase
+ var onSelectAlbum: (Album) -> Void = { _ in }
+
+ var body: some View {
+ Button {
+ if let firstAlbum = showcase.coverAlbums.first {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onSelectAlbum(firstAlbum)
+ }
+ } label: {
+ ZStack(alignment: .bottomLeading) {
+ if let cover = showcase.coverAlbums.first {
+ AlbumArtwork(artworkUrl: cover.artworkUrl, colors: cover.artColors, cornerRadius: 24)
+ } else {
+ RoundedRectangle(cornerRadius: 24)
+ .fill(SSColors.glassBg)
+ }
+
+ LinearGradient(
+ colors: [.clear, SSColors.overlayDark],
+ startPoint: .init(x: 0.5, y: 0.15),
+ endPoint: .bottom
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("FEATURED")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(ThemeManager.shared.primary)
+ .fontWeight(.bold)
+ Text(showcase.list.title)
+ .font(SSTypography.headlineMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ Text("\(showcase.list.curatorHandle) · \(showcase.list.albumIds.count) albums · \(showcase.list.saves) saves")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ }
+ .padding(16)
+ }
+ .frame(height: 180)
+ .frame(maxWidth: .infinity)
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+ .overlay(
+ RoundedRectangle(cornerRadius: 24)
+ .stroke(SSColors.feedItemBorder, lineWidth: 0.5)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct CompactListCard: View {
+ let showcase: ListShowcase
+ var onSelectAlbum: (Album) -> Void = { _ in }
+
+ var body: some View {
+ GlassCard(cornerRadius: 20, borderColor: SSColors.feedItemBorder, contentPadding: EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), onTap: {
+ if let firstAlbum = showcase.coverAlbums.first {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onSelectAlbum(firstAlbum)
+ }
+ }) {
+ VStack(alignment: .leading, spacing: 10) {
+ MosaicCover(albums: showcase.coverAlbums, cornerRadius: 14)
+ Text(showcase.list.title)
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.semibold)
+ .lineLimit(1)
+ Text("\(showcase.list.albumIds.count) albums")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ }
+ .frame(width: 180)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/MosaicCover.swift b/ios/SoundScore/SoundScore/Components/MosaicCover.swift
new file mode 100644
index 0000000..b71d5a7
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/MosaicCover.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+struct MosaicCover: View {
+ let albums: [Album]
+ var cornerRadius: CGFloat = 14
+ var size: CGFloat = 110
+
+ var body: some View {
+ let cellSize = (size - 4) / 2
+
+ LazyVGrid(columns: [GridItem(.fixed(cellSize), spacing: 4), GridItem(.fixed(cellSize), spacing: 4)], spacing: 4) {
+ ForEach(0..<4, id: \.self) { index in
+ if index < albums.count {
+ AlbumArtwork(
+ artworkUrl: albums[index].artworkUrl,
+ colors: albums[index].artColors,
+ cornerRadius: index == 0 ? cornerRadius * 0.5 : cornerRadius * 0.5
+ )
+ .frame(width: cellSize, height: cellSize)
+ } else {
+ RoundedRectangle(cornerRadius: cornerRadius * 0.5)
+ .fill(SSColors.glassBg)
+ .frame(width: cellSize, height: cellSize)
+ }
+ }
+ }
+ .frame(width: size, height: size)
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/PillSearchBar.swift b/ios/SoundScore/SoundScore/Components/PillSearchBar.swift
new file mode 100644
index 0000000..e745b59
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/PillSearchBar.swift
@@ -0,0 +1,41 @@
+import SwiftUI
+
+struct PillSearchBar: View {
+ @Binding var query: String
+ var placeholder: String = "Search albums, artists..."
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 16, weight: .medium))
+ .foregroundColor(SSColors.chromeDim)
+
+ TextField("", text: $query, prompt: Text(placeholder).foregroundColor(SSColors.chromeDim))
+ .font(SSTypography.bodyLarge)
+ .foregroundColor(SSColors.chromeLight)
+ .tint(ThemeManager.shared.primary)
+
+ if !query.isEmpty {
+ Button { query = "" } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 16))
+ .foregroundColor(SSColors.chromeDim)
+ }
+ } else {
+ Image(systemName: "mic")
+ .font(.system(size: 16, weight: .medium))
+ .foregroundColor(SSColors.chromeDim)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 24)
+ .fill(SSColors.glassFrosted)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 24)
+ .stroke(SSColors.glassBorder, lineWidth: 0.5)
+ )
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/ReviewSheet.swift b/ios/SoundScore/SoundScore/Components/ReviewSheet.swift
new file mode 100644
index 0000000..fda4215
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/ReviewSheet.swift
@@ -0,0 +1,103 @@
+import SwiftUI
+
+struct ReviewSheet: View {
+ let album: Album
+ @Binding var rating: Float
+ @State private var reviewText = ""
+ @State private var isSaving = false
+ @Environment(\.dismiss) private var dismiss
+
+ private let maxChars = 500
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(spacing: 12) {
+ AlbumArtwork(artworkUrl: album.artworkUrl, colors: album.artColors, cornerRadius: 12)
+ .frame(width: 56, height: 56)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(album.title)
+ .font(SSTypography.titleLarge)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ .lineLimit(1)
+ Text(album.artist)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ }
+ Spacer()
+ }
+
+ VStack(spacing: 6) {
+ Text("Your rating")
+ .font(SSTypography.labelMedium)
+ .foregroundColor(SSColors.textTertiary)
+ StarRating(rating: rating, onRate: { newRating in
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ rating = newRating
+ }, starSize: 26)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+
+ ZStack(alignment: .topLeading) {
+ if reviewText.isEmpty {
+ Text("What did you think? The vibe, the production, the moment you knew...")
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeFaint)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 14)
+ }
+ TextEditor(text: $reviewText)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .scrollContentBackground(.hidden)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ }
+ .frame(minHeight: 140)
+ .background(SSColors.glassBg)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .overlay(
+ RoundedRectangle(cornerRadius: 16)
+ .stroke(SSColors.glassBorder, lineWidth: 0.5)
+ )
+ .onChange(of: reviewText) { _, newValue in
+ if newValue.count > maxChars {
+ reviewText = String(newValue.prefix(maxChars))
+ }
+ }
+
+ HStack {
+ Spacer()
+ Text("\(reviewText.count)/\(maxChars)")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(
+ reviewText.count > maxChars - 50 ? SSColors.accentCoral : SSColors.textTertiary
+ )
+ }
+
+ SSButton(text: isSaving ? "Saving..." : "Save Review") {
+ guard !isSaving else { return }
+ isSaving = true
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ SoundScoreRepository.shared.saveReview(
+ albumId: album.id,
+ reviewText: reviewText,
+ rating: rating
+ )
+ isSaving = false
+ dismiss()
+ }
+ .opacity(rating > 0 || !reviewText.isEmpty ? 1.0 : 0.5)
+ .disabled(rating == 0 && reviewText.isEmpty)
+
+ SSGhostButton(text: "Cancel") {
+ dismiss()
+ }
+
+ Spacer()
+ }
+ .padding(.horizontal, 24)
+ .padding(.top, 24)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/SSButton.swift b/ios/SoundScore/SoundScore/Components/SSButton.swift
new file mode 100644
index 0000000..f4cf245
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/SSButton.swift
@@ -0,0 +1,43 @@
+import SwiftUI
+
+struct SSButton: View {
+ let text: String
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(text)
+ .font(SSTypography.labelLarge)
+ .foregroundColor(SSColors.darkBase)
+ .padding(.horizontal, 24)
+ .padding(.vertical, 12)
+ .frame(maxWidth: .infinity)
+ .background(ThemeManager.shared.primary)
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct SSGhostButton: View {
+ let text: String
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(text)
+ .font(SSTypography.labelLarge)
+ .foregroundColor(SSColors.chromeLight)
+ .padding(.horizontal, 24)
+ .padding(.vertical, 12)
+ .frame(maxWidth: .infinity)
+ .background(.ultraThinMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(SSColors.glassBorder, lineWidth: 0.5)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/ScreenHeader.swift b/ios/SoundScore/SoundScore/Components/ScreenHeader.swift
new file mode 100644
index 0000000..78d58c0
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/ScreenHeader.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+
+struct ScreenHeader: View {
+ let title: String
+ let subtitle: String
+ var actionLabel: String?
+ var onAction: (() -> Void)?
+
+ var body: some View {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(SSTypography.displayMedium)
+ .foregroundColor(SSColors.chromeLight)
+ Text(subtitle)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.textSecondary)
+ }
+ Spacer()
+ if let actionLabel, let onAction {
+ Button(action: onAction) {
+ Text(actionLabel)
+ .font(SSTypography.labelLarge)
+ .foregroundColor(ThemeManager.shared.primary)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(ThemeManager.shared.primaryDim)
+ .clipShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/SectionHeader.swift b/ios/SoundScore/SoundScore/Components/SectionHeader.swift
new file mode 100644
index 0000000..0ed6097
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/SectionHeader.swift
@@ -0,0 +1,26 @@
+import SwiftUI
+
+struct SectionHeader: View {
+ let eyebrow: String
+ let title: String
+ var trailing: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ Text(eyebrow.uppercased())
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textTertiary)
+ Spacer()
+ if let trailing {
+ Text(trailing)
+ .font(SSTypography.labelSmall)
+ .foregroundColor(ThemeManager.shared.primary)
+ }
+ }
+ Text(title)
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/SkeletonView.swift b/ios/SoundScore/SoundScore/Components/SkeletonView.swift
new file mode 100644
index 0000000..4e931cc
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/SkeletonView.swift
@@ -0,0 +1,36 @@
+import SwiftUI
+
+struct SkeletonView: View {
+ var width: CGFloat? = nil
+ var height: CGFloat = 16
+ var cornerRadius: CGFloat = 8
+
+ @State private var shimmerOffset: CGFloat = -200
+
+ var body: some View {
+ RoundedRectangle(cornerRadius: cornerRadius)
+ .fill(SSColors.glassBg)
+ .frame(width: width, height: height)
+ .overlay(
+ GeometryReader { geo in
+ LinearGradient(
+ colors: [
+ Color.white.opacity(0),
+ Color.white.opacity(0.06),
+ Color.white.opacity(0),
+ ],
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ .frame(width: geo.size.width * 0.6)
+ .offset(x: shimmerOffset)
+ .onAppear {
+ withAnimation(.linear(duration: 2.4).repeatForever(autoreverses: false)) {
+ shimmerOffset = geo.size.width + 200
+ }
+ }
+ }
+ )
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/SongRatingSheet.swift b/ios/SoundScore/SoundScore/Components/SongRatingSheet.swift
new file mode 100644
index 0000000..596e7d7
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/SongRatingSheet.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+struct SongRatingSheet: View {
+ let track: Track
+ let albumTitle: String
+ @State var rating: Float
+ var onRate: (Float) -> Void
+ @State private var note: String = ""
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ VStack(spacing: 20) {
+ // Track header
+ HStack(spacing: 12) {
+ Text("\(track.trackNumber)")
+ .font(SSTypography.labelMedium)
+ .fontWeight(.bold)
+ .foregroundColor(SSColors.chromeLight)
+ .frame(width: 28, height: 28)
+ .background(ThemeManager.shared.primary.opacity(0.2))
+ .clipShape(Circle())
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(track.title)
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ .lineLimit(2)
+ Text(albumTitle)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ }
+
+ Spacer()
+
+ Text(track.formattedDuration)
+ .font(SSTypography.labelMedium)
+ .foregroundColor(SSColors.textTertiary)
+ }
+
+ // Star rating
+ VStack(spacing: 8) {
+ StarRating(rating: rating, onRate: { newRating in
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ rating = newRating
+ }, starSize: 28, maxStars: 6)
+
+ if rating > 0 {
+ Text(String(format: "%.1f / 6", rating))
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.accentAmber)
+ .fontWeight(.bold)
+ }
+ }
+ .padding(.vertical, 4)
+
+ // Note field
+ TextField("Add a note about this track...", text: $note, axis: .vertical)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .lineLimit(3)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ .background(SSColors.glassBg)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ .overlay(
+ RoundedRectangle(cornerRadius: 14)
+ .stroke(SSColors.glassBorder, lineWidth: 0.5)
+ )
+
+ HStack {
+ Text("\(note.count)/200")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(note.count > 200 ? SSColors.accentCoral : SSColors.textTertiary)
+ Spacer()
+ }
+ .padding(.top, -12)
+
+ // Save button
+ SSButton(text: "Save") {
+ onRate(rating)
+ dismiss()
+ }
+
+ Button {
+ dismiss()
+ } label: {
+ Text("Cancel")
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeMedium)
+ }
+
+ Spacer()
+ }
+ .padding(.horizontal, 24)
+ .padding(.top, 24)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/StarRating.swift b/ios/SoundScore/SoundScore/Components/StarRating.swift
new file mode 100644
index 0000000..91c2bf9
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/StarRating.swift
@@ -0,0 +1,60 @@
+import SwiftUI
+
+struct StarRating: View {
+ let rating: Float
+ var onRate: ((Float) -> Void)?
+ var starSize: CGFloat = 14
+ var maxStars: Int = 6
+
+ @State private var animateScale: [Bool] = []
+
+ var body: some View {
+ HStack(spacing: starSize * 0.15) {
+ ForEach(0.. Image {
+ let threshold = Float(index) + 1
+ if rating >= threshold {
+ return Image(systemName: "star.fill")
+ } else if rating >= threshold - 0.5 {
+ return Image(systemName: "star.leadinghalf.filled")
+ } else {
+ return Image(systemName: "star")
+ }
+ }
+
+ private func starColor(for index: Int) -> Color {
+ Float(index) + 0.5 <= rating ? SSColors.accentAmber : SSColors.chromeFaint
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/StatPill.swift b/ios/SoundScore/SoundScore/Components/StatPill.swift
new file mode 100644
index 0000000..8d6a08e
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/StatPill.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+struct StatPill: View {
+ let value: String
+ let label: String
+ var highlight: Bool = false
+ var accentColor: Color = ThemeManager.shared.primary
+
+ var body: some View {
+ VStack(spacing: 2) {
+ Text(value)
+ .font(SSTypography.headlineMedium)
+ .foregroundColor(highlight ? accentColor : SSColors.chromeLight)
+ .fontWeight(.black)
+ Text(label.uppercased())
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(highlight ? accentColor.opacity(0.08) : SSColors.glassBg)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 16)
+ .stroke(highlight ? accentColor.opacity(0.2) : SSColors.feedItemBorder, lineWidth: 0.5)
+ )
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/SyncBanner.swift b/ios/SoundScore/SoundScore/Components/SyncBanner.swift
new file mode 100644
index 0000000..0cdad4b
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/SyncBanner.swift
@@ -0,0 +1,25 @@
+import SwiftUI
+
+struct SyncBanner: View {
+ let message: String?
+
+ var body: some View {
+ if let message {
+ HStack(spacing: 8) {
+ Image(systemName: "icloud.slash")
+ .font(.system(size: 14, weight: .medium))
+ Text(message)
+ .font(SSTypography.bodySmall)
+ }
+ .foregroundColor(SSColors.accentAmber)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 14)
+ .fill(SSColors.accentAmberDim)
+ )
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/Tab.swift b/ios/SoundScore/SoundScore/Components/Tab.swift
new file mode 100644
index 0000000..1e66e23
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/Tab.swift
@@ -0,0 +1,39 @@
+import SwiftUI
+
+enum Tab: String, CaseIterable {
+ case feed
+ case log
+ case search
+ case aiBuddy
+ case profile
+
+ var label: String {
+ switch self {
+ case .feed: "Feed"
+ case .log: "Diary"
+ case .search: "Discover"
+ case .aiBuddy: "Cadence"
+ case .profile: "Profile"
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .feed: "rectangle.stack"
+ case .log: "book"
+ case .search: "magnifyingglass"
+ case .aiBuddy: "sparkles"
+ case .profile: "person.circle"
+ }
+ }
+
+ var iconFilled: String {
+ switch self {
+ case .feed: "rectangle.stack.fill"
+ case .log: "book.fill"
+ case .search: "magnifyingglass"
+ case .aiBuddy: "sparkles"
+ case .profile: "person.circle.fill"
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/TimelineEntry.swift b/ios/SoundScore/SoundScore/Components/TimelineEntry.swift
new file mode 100644
index 0000000..d60bb46
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/TimelineEntry.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+
+struct TimelineEntry: View {
+ let dateLabel: String
+ let timeLabel: String
+ let content: () -> Content
+
+ init(dateLabel: String, timeLabel: String, @ViewBuilder content: @escaping () -> Content) {
+ self.dateLabel = dateLabel
+ self.timeLabel = timeLabel
+ self.content = content
+ }
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ VStack(spacing: 4) {
+ Text(dateLabel)
+ .font(SSTypography.labelMedium)
+ .foregroundColor(ThemeManager.shared.primary)
+ Text(timeLabel)
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.chromeDim)
+ Rectangle()
+ .fill(SSColors.glassBorder)
+ .frame(width: 1)
+ .frame(maxHeight: .infinity)
+ }
+ .frame(width: 48)
+
+ content()
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Components/TrendChartRow.swift b/ios/SoundScore/SoundScore/Components/TrendChartRow.swift
new file mode 100644
index 0000000..9f32314
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Components/TrendChartRow.swift
@@ -0,0 +1,50 @@
+import SwiftUI
+
+struct TrendChartRow: View {
+ let entry: ChartEntry
+
+ var body: some View {
+ GlassCard(cornerRadius: 18, borderColor: SSColors.feedItemBorder, contentPadding: EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)) {
+ HStack(spacing: 12) {
+ ZStack {
+ Circle()
+ .fill(ThemeManager.shared.primaryDim)
+ .frame(width: 32, height: 32)
+ Text("\(entry.rank)")
+ .font(SSTypography.labelLarge)
+ .foregroundColor(ThemeManager.shared.primary)
+ }
+
+ AlbumArtwork(
+ artworkUrl: entry.album.artworkUrl,
+ colors: entry.album.artColors,
+ cornerRadius: 12
+ )
+ .frame(width: 44, height: 44)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(entry.album.title)
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.semibold)
+ .lineLimit(1)
+ Text("\(entry.album.artist) · \(entry.album.logCount) logs")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ .lineLimit(1)
+ }
+
+ Spacer()
+
+ HStack(spacing: 2) {
+ Image(systemName: "arrow.up.right")
+ .font(.system(size: 10, weight: .bold))
+ Text(entry.movementLabel)
+ .font(SSTypography.labelSmall)
+ .fontWeight(.bold)
+ }
+ .foregroundColor(ThemeManager.shared.primary)
+ }
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Config/Secrets.swift.template b/ios/SoundScore/SoundScore/Config/Secrets.swift.template
new file mode 100644
index 0000000..2e09a35
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Config/Secrets.swift.template
@@ -0,0 +1,7 @@
+// Copy this file to Secrets.swift and fill in your API keys.
+// Secrets.swift is gitignored and will not be committed.
+
+enum Secrets {
+ static let spotifyClientId = "YOUR_SPOTIFY_CLIENT_ID"
+ static let spotifyClientSecret = "YOUR_SPOTIFY_CLIENT_SECRET"
+}
diff --git a/ios/SoundScore/SoundScore/ContentView.swift b/ios/SoundScore/SoundScore/ContentView.swift
new file mode 100644
index 0000000..9455f67
--- /dev/null
+++ b/ios/SoundScore/SoundScore/ContentView.swift
@@ -0,0 +1,78 @@
+import SwiftUI
+
+struct ContentView: View {
+ @StateObject private var authManager = AuthManager.shared
+ @StateObject private var repository = SoundScoreRepository.shared
+ @ObservedObject private var themeManager = ThemeManager.shared
+ @State private var selectedTab: Tab = .feed
+ @State private var selectedAlbum: Album?
+ @State private var showSettings = false
+ @State private var showSplash = true
+
+ var body: some View {
+ Group {
+ if showSplash {
+ SplashScreen {
+ withAnimation(.easeInOut(duration: 0.3)) {
+ showSplash = false
+ }
+ }
+ } else if authManager.isAuthenticated {
+ NavigationStack {
+ ZStack(alignment: .bottom) {
+ AppBackdrop()
+
+ TabContent(
+ selectedTab: selectedTab,
+ onSelectAlbum: { selectedAlbum = $0 },
+ onOpenSettings: { showSettings = true }
+ )
+
+ FloatingTabBar(selectedTab: $selectedTab)
+ .padding(.horizontal, 24)
+ .padding(.bottom, 8)
+ }
+ .navigationDestination(item: $selectedAlbum) { album in
+ AlbumDetailScreen(album: album)
+ }
+ .navigationDestination(isPresented: $showSettings) {
+ SettingsScreen()
+ }
+ }
+ } else {
+ ZStack {
+ AppBackdrop()
+ AuthScreen()
+ }
+ }
+ }
+ .environmentObject(authManager)
+ .environmentObject(repository)
+ .environmentObject(ThemeManager.shared)
+ }
+}
+
+struct TabContent: View {
+ let selectedTab: Tab
+ var onSelectAlbum: (Album) -> Void = { _ in }
+ var onOpenSettings: () -> Void = {}
+
+ var body: some View {
+ switch selectedTab {
+ case .feed:
+ FeedScreen(onSelectAlbum: onSelectAlbum)
+ case .log:
+ LogScreen(onSelectAlbum: onSelectAlbum)
+ case .search:
+ SearchScreen(onSelectAlbum: onSelectAlbum)
+ case .aiBuddy:
+ AIBuddyScreen()
+ case .profile:
+ ProfileScreen(onSelectAlbum: onSelectAlbum, onOpenSettings: onOpenSettings)
+ }
+ }
+}
+
+#Preview {
+ ContentView()
+}
diff --git a/ios/SoundScore/SoundScore/Models/Album.swift b/ios/SoundScore/SoundScore/Models/Album.swift
new file mode 100644
index 0000000..073866f
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Models/Album.swift
@@ -0,0 +1,15 @@
+import SwiftUI
+
+struct Album: Identifiable, Hashable {
+ let id: String
+ let title: String
+ let artist: String
+ let year: Int
+ let artColors: [Color]
+ var artworkUrl: String?
+ var avgRating: Float
+ var logCount: Int
+
+ static func == (lhs: Album, rhs: Album) -> Bool { lhs.id == rhs.id }
+ func hash(into hasher: inout Hasher) { hasher.combine(id) }
+}
diff --git a/ios/SoundScore/SoundScore/Models/FeedItem.swift b/ios/SoundScore/SoundScore/Models/FeedItem.swift
new file mode 100644
index 0000000..659dc35
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Models/FeedItem.swift
@@ -0,0 +1,14 @@
+import Foundation
+
+struct FeedItem: Identifiable {
+ let id: String
+ let username: String
+ let action: String
+ var album: Album
+ let rating: Float
+ var reviewSnippet: String?
+ var likes: Int
+ var comments: Int
+ var timeAgo: String
+ var isLiked: Bool
+}
diff --git a/ios/SoundScore/SoundScore/Models/NotificationPreferences.swift b/ios/SoundScore/SoundScore/Models/NotificationPreferences.swift
new file mode 100644
index 0000000..02a109a
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Models/NotificationPreferences.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+struct NotificationPreferences {
+ var socialEnabled: Bool = true
+ var recapEnabled: Bool = true
+ var commentEnabled: Bool = true
+ var reactionEnabled: Bool = true
+ var quietHoursStart: Int = 22
+ var quietHoursEnd: Int = 7
+}
diff --git a/ios/SoundScore/SoundScore/Models/PresentationHelpers.swift b/ios/SoundScore/SoundScore/Models/PresentationHelpers.swift
new file mode 100644
index 0000000..495b289
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Models/PresentationHelpers.swift
@@ -0,0 +1,120 @@
+import SwiftUI
+
+struct LogSummaryStat {
+ let value: String
+ let label: String
+ let caption: String
+}
+
+struct RecentLogEntry: Identifiable {
+ var id: String { "\(album.id)-\(timeLabel)" }
+ let album: Album
+ let rating: Float
+ let dateLabel: String
+ let timeLabel: String
+ let caption: String
+}
+
+struct BrowseGenre: Identifiable {
+ var id: String { name }
+ let name: String
+ let caption: String
+ let colors: [Color]
+}
+
+struct ChartEntry: Identifiable {
+ var id: String { album.id }
+ let rank: Int
+ let album: Album
+ let movementLabel: String
+}
+
+struct ListShowcase: Identifiable {
+ var id: String { list.id }
+ let list: UserList
+ let coverAlbums: [Album]
+}
+
+struct ProfileMetric: Identifiable {
+ var id: String { label }
+ let value: String
+ let label: String
+}
+
+func buildTrendingAlbums(_ albums: [Album]) -> [Album] {
+ albums.sorted { $0.logCount > $1.logCount }
+}
+
+func buildLogSummaryStats(_ ratings: [String: Float]) -> [LogSummaryStat] {
+ let average = ratings.isEmpty ? 0 : ratings.values.reduce(0, +) / Float(ratings.count)
+ let weekLogs = ratings.count
+ let streak = min(ratings.count + 2, 9)
+ return [
+ LogSummaryStat(value: "\(weekLogs)", label: "This week", caption: "New logs"),
+ LogSummaryStat(value: String(format: "%.1f★", average), label: "Average", caption: "Your current pace"),
+ LogSummaryStat(value: "\(streak) d", label: "Streak", caption: "Listening every day"),
+ ]
+}
+
+func buildRecentLogs(_ albums: [Album], _ ratings: [String: Float]) -> [RecentLogEntry] {
+ let moments: [(String, String, String)] = [
+ ("Today", "11:48 PM", "Late-night replay. Worth the full write-up."),
+ ("Yesterday", "7:12 PM", "Instant favorite chorus. Logged before dinner."),
+ ("Mar 11", "9:03 AM", "Sharp production details on the second listen."),
+ ("Mar 09", "6:41 PM", "Saved for the weekend drive and it landed."),
+ ]
+ return albums
+ .sorted { (ratings[$0.id] ?? 0) > (ratings[$1.id] ?? 0) }
+ .prefix(moments.count)
+ .enumerated()
+ .map { index, album in
+ let (date, time, caption) = moments[index]
+ return RecentLogEntry(
+ album: album,
+ rating: ratings[album.id] ?? album.avgRating,
+ dateLabel: date,
+ timeLabel: time,
+ caption: caption
+ )
+ }
+}
+
+func buildBrowseGenres() -> [BrowseGenre] {
+ [
+ BrowseGenre(name: "Alt Rap", caption: "Dense bars, stranger palettes", colors: AlbumColors.forest),
+ BrowseGenre(name: "Night Pop", caption: "Glossy hooks with a bite", colors: AlbumColors.rose),
+ BrowseGenre(name: "Leftfield R&B", caption: "Warm low end, sharp edges", colors: AlbumColors.lagoon),
+ BrowseGenre(name: "Indie Mutations", caption: "Guitars that still feel digital", colors: AlbumColors.orchid),
+ ]
+}
+
+func buildChartEntries(_ albums: [Album]) -> [ChartEntry] {
+ let labels = ["+18%", "+12%", "+9%", "+6%", "+4%"]
+ return albums
+ .sorted { $0.logCount > $1.logCount }
+ .prefix(labels.count)
+ .enumerated()
+ .map { index, album in
+ ChartEntry(rank: index + 1, album: album, movementLabel: labels[index])
+ }
+}
+
+func resolveListShowcases(_ lists: [UserList], _ albums: [Album]) -> [ListShowcase] {
+ lists.map { list in
+ let covers = list.albumIds.compactMap { id in albums.first { $0.id == id } }.prefix(4)
+ return ListShowcase(list: list, coverAlbums: Array(covers))
+ }
+}
+
+func buildProfileMetrics(_ profile: UserProfile) -> [ProfileMetric] {
+ [
+ ProfileMetric(value: "\(profile.albumsCount)", label: "Albums"),
+ ProfileMetric(value: "\(profile.listCount)", label: "Lists"),
+ ProfileMetric(value: "\(profile.followingCount)", label: "Following"),
+ ProfileMetric(value: "\(profile.followersCount)", label: "Followers"),
+ ]
+}
+
+func buildFavoriteAlbums(_ profile: UserProfile) -> [Album] {
+ Array(profile.favoriteAlbums.prefix(6))
+}
diff --git a/ios/SoundScore/SoundScore/Models/SeedData.swift b/ios/SoundScore/SoundScore/Models/SeedData.swift
new file mode 100644
index 0000000..2a11b8d
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Models/SeedData.swift
@@ -0,0 +1,199 @@
+import SwiftUI
+
+enum SeedData {
+ private static func hiRes(_ url: String) -> String {
+ url.replacingOccurrences(of: "100x100bb.jpg", with: "600x600bb.jpg")
+ .replacingOccurrences(of: "100x100bb.png", with: "600x600bb.png")
+ }
+
+ static let albums: [Album] = [
+ Album(
+ id: "alb_1", title: "CHROMAKOPIA", artist: "Tyler, the Creator", year: 2024,
+ artColors: AlbumColors.forest,
+ artworkUrl: hiRes("https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/b6/ef/ee/b6efeefa-fc99-37d1-ad21-0d769b2a4958/196872796971.jpg/100x100bb.jpg"),
+ avgRating: 4.3, logCount: 2100
+ ),
+ Album(
+ id: "alb_2", title: "GNX", artist: "Kendrick Lamar", year: 2024,
+ artColors: AlbumColors.midnight,
+ artworkUrl: hiRes("https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/54/28/14/54281424-eece-0935-299d-fdd2ab403f92/24UM1IM28978.rgb.jpg/100x100bb.jpg"),
+ avgRating: 4.1, logCount: 1800
+ ),
+ Album(
+ id: "alb_3", title: "Short n' Sweet", artist: "Sabrina Carpenter", year: 2024,
+ artColors: AlbumColors.lime,
+ artworkUrl: hiRes("https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/a1/1c/ca/a11ccab6-7d4c-e041-d028-998bcebeb709/24UMGIM61704.rgb.jpg/100x100bb.jpg"),
+ avgRating: 3.8, logCount: 950
+ ),
+ Album(
+ id: "alb_4", title: "Brat", artist: "Charli XCX", year: 2024,
+ artColors: AlbumColors.rose,
+ artworkUrl: "https://i.scdn.co/image/ab67616d0000b273f88b43d15fd14e9525338b59",
+ avgRating: 4.0, logCount: 3200
+ ),
+ Album(
+ id: "alb_5", title: "Manning Fireside", artist: "Mk.gee", year: 2024,
+ artColors: AlbumColors.lagoon,
+ artworkUrl: "https://i.scdn.co/image/ab67616d0000b2732a8e8b10d2ada6d5a9a459a8",
+ avgRating: 3.9, logCount: 620
+ ),
+ Album(
+ id: "alb_6", title: "The Great Impersonator", artist: "Halsey", year: 2024,
+ artColors: AlbumColors.ember,
+ artworkUrl: "https://i.scdn.co/image/ab67616d0000b2732e53476c915af358ba2aac02",
+ avgRating: 3.5, logCount: 430
+ ),
+ Album(
+ id: "alb_7", title: "HIT ME HARD AND SOFT", artist: "Billie Eilish", year: 2024,
+ artColors: AlbumColors.midnight,
+ artworkUrl: "https://i.scdn.co/image/ab67616d0000b27371d62ea7ea8a5be92d3c1f62",
+ avgRating: 4.2, logCount: 1650
+ ),
+ Album(
+ id: "alb_8", title: "The Tortured Poets Department", artist: "Taylor Swift", year: 2024,
+ artColors: AlbumColors.slate,
+ artworkUrl: "https://i.scdn.co/image/ab67616d0000b2738ecc33f195df6aa257c39eaa",
+ avgRating: 3.7, logCount: 2800
+ ),
+ Album(
+ id: "alb_9", title: "Cowboy Carter", artist: "Beyoncé", year: 2024,
+ artColors: AlbumColors.amber,
+ artworkUrl: "https://i.scdn.co/image/ab67616d0000b273208e593c3565dae1295b5a26",
+ avgRating: 4.4, logCount: 3100
+ ),
+ Album(
+ id: "alb_10", title: "Romance", artist: "Fontaines D.C.", year: 2024,
+ artColors: AlbumColors.forest,
+ artworkUrl: "https://i.scdn.co/image/ab67616d0000b273f69e28716be1331924f25f2e",
+ avgRating: 4.0, logCount: 780
+ ),
+ Album(
+ id: "alb_11", title: "Lives Outgrown", artist: "Beth Gibbons", year: 2024,
+ artColors: AlbumColors.orchid,
+ artworkUrl: "https://i.scdn.co/image/ab67616d0000b27316997b4a53ae6b42a4b803be",
+ avgRating: 4.1, logCount: 520
+ ),
+ Album(
+ id: "alb_12", title: "Forever", artist: "Skrillex", year: 2024,
+ artColors: AlbumColors.lagoon,
+ artworkUrl: "https://i.scdn.co/image/ab67616d0000b2732a7e34a68952017d900c6bb8",
+ avgRating: 3.6, logCount: 890
+ ),
+ ]
+
+ static let feedItems: [FeedItem] = [
+ FeedItem(
+ id: "f1", username: "rohan", action: "logged a perfect score",
+ album: albums[0], rating: 5.0,
+ reviewSnippet: "Tyler made a world, not just a tracklist.",
+ likes: 12, comments: 3, timeAgo: "2h", isLiked: true
+ ),
+ FeedItem(
+ id: "f2", username: "priya", action: "left a glowing review",
+ album: albums[2], rating: 4.0,
+ reviewSnippet: "Hooks for days, but the production is what sticks.",
+ likes: 8, comments: 1, timeAgo: "5h", isLiked: false
+ ),
+ FeedItem(
+ id: "f3", username: "kai", action: "added this to a late-night list",
+ album: albums[3], rating: 4.5,
+ reviewSnippet: "The whole thing feels fluorescent and slightly dangerous.",
+ likes: 24, comments: 7, timeAgo: "8h", isLiked: false
+ ),
+ FeedItem(
+ id: "f4", username: "zara", action: "rated this on first listen",
+ album: albums[6], rating: 4.5,
+ reviewSnippet: "Billie went somewhere darker and it suits her perfectly.",
+ likes: 31, comments: 5, timeAgo: "12h", isLiked: false
+ ),
+ FeedItem(
+ id: "f5", username: "alex", action: "defended this in the group chat",
+ album: albums[8], rating: 5.0,
+ reviewSnippet: "Country + Beyoncé = genre-breaking territory.",
+ likes: 42, comments: 11, timeAgo: "1d", isLiked: true
+ ),
+ FeedItem(
+ id: "f6", username: "jordan", action: "added a hot take",
+ album: albums[7], rating: 3.0,
+ reviewSnippet: "The bonus tracks dilute what could've been a tight masterpiece.",
+ likes: 15, comments: 9, timeAgo: "1d", isLiked: false
+ ),
+ FeedItem(
+ id: "f7", username: "mia", action: "logged a quiet favorite",
+ album: albums[10], rating: 4.5,
+ reviewSnippet: "Beth Gibbons made the most patient album of the year.",
+ likes: 9, comments: 2, timeAgo: "2d", isLiked: false
+ ),
+ FeedItem(
+ id: "f8", username: "sam", action: "discovered a sleeper hit",
+ album: albums[9], rating: 4.0,
+ reviewSnippet: "Fontaines D.C. shifted gears and it completely works.",
+ likes: 18, comments: 4, timeAgo: "3d", isLiked: true
+ ),
+ ]
+
+ static let logInitialRatings: [String: Float] = [
+ "alb_1": 5, "alb_2": 4.5, "alb_3": 4, "alb_4": 4.5,
+ ]
+
+ static let myProfile = UserProfile(
+ handle: "@madhav",
+ bio: "Taste journal for records worth replaying at 1 a.m.",
+ logCount: 142, reviewCount: 38, listCount: 24,
+ topAlbums: [
+ (albums[0], 5.0), (albums[2], 4.5), (albums[3], 4.5),
+ (albums[4], 4.0), (albums[5], 4.0), (albums[1], 3.5),
+ ],
+ genres: ["Indie Sleaze", "Alt Rap", "Digital Pop", "Neo-Soul", "Late Night", "Country Futurism", "Post-Punk Revival", "Avg 4.1 ★"],
+ avgRating: 4.1, albumsCount: 142,
+ followingCount: 186, followersCount: 248,
+ favoriteAlbums: [albums[0], albums[3], albums[8], albums[6], albums[4], albums[10]]
+ )
+
+ static let initialLists: [UserList] = [
+ UserList(id: "l1", title: "Albums I Would Defend",
+ note: "Chaotic, immediate, impossible to half-love.",
+ albumIds: ["alb_4", "alb_1", "alb_2", "alb_3"], curatorHandle: "@madhav", saves: 128),
+ UserList(id: "l2", title: "Midnight Headphones",
+ note: "For the train ride home when the city still feels loud.",
+ albumIds: ["alb_5", "alb_6", "alb_1", "alb_2"], curatorHandle: "@priya", saves: 84),
+ UserList(id: "l3", title: "2024 Pop Mutations",
+ note: "Big hooks, weird textures, zero safe choices.",
+ albumIds: ["alb_3", "alb_4", "alb_2", "alb_1"], curatorHandle: "@kai", saves: 67),
+ UserList(id: "l4", title: "2024 Rap Monuments",
+ note: "The bars and beats that defined the year.",
+ albumIds: ["alb_1", "alb_2", "alb_9", "alb_7"], curatorHandle: "@alex", saves: 95),
+ UserList(id: "l5", title: "Headphone Albums Only",
+ note: "Albums that demand isolation and full attention.",
+ albumIds: ["alb_5", "alb_11", "alb_7", "alb_6"], curatorHandle: "@madhav", saves: 112),
+ ]
+
+ static let sampleTracks: [String: [Track]] = [
+ "alb_1": [
+ Track(id: "t1_1", albumId: "alb_1", title: "St. Chroma", trackNumber: 1, durationMs: 218_000, spotifyId: nil),
+ Track(id: "t1_2", albumId: "alb_1", title: "Rah Tah Tah", trackNumber: 2, durationMs: 156_000, spotifyId: nil),
+ Track(id: "t1_3", albumId: "alb_1", title: "Noid", trackNumber: 3, durationMs: 203_000, spotifyId: nil),
+ Track(id: "t1_4", albumId: "alb_1", title: "Darling, I", trackNumber: 4, durationMs: 247_000, spotifyId: nil),
+ ],
+ "alb_2": [
+ Track(id: "t2_1", albumId: "alb_2", title: "wacced out murals", trackNumber: 1, durationMs: 325_000, spotifyId: nil),
+ Track(id: "t2_2", albumId: "alb_2", title: "squabble up", trackNumber: 2, durationMs: 152_000, spotifyId: nil),
+ Track(id: "t2_3", albumId: "alb_2", title: "luther", trackNumber: 3, durationMs: 268_000, spotifyId: nil),
+ Track(id: "t2_4", albumId: "alb_2", title: "tv off", trackNumber: 4, durationMs: 276_000, spotifyId: nil),
+ ],
+ "alb_3": [
+ Track(id: "t3_1", albumId: "alb_3", title: "Taste", trackNumber: 1, durationMs: 177_000, spotifyId: nil),
+ Track(id: "t3_2", albumId: "alb_3", title: "Please Please Please", trackNumber: 2, durationMs: 186_000, spotifyId: nil),
+ Track(id: "t3_3", albumId: "alb_3", title: "Espresso", trackNumber: 3, durationMs: 175_000, spotifyId: nil),
+ ],
+ ]
+
+ static let defaultNotificationPreferences = NotificationPreferences()
+
+ static let initialRecap = WeeklyRecap(
+ id: "rcp_local", weekStart: "2026-03-03", weekEnd: "2026-03-10",
+ totalLogs: 12, averageRating: 4.1,
+ shareText: "My SoundScore week: 12 logs, avg 4.1★",
+ deepLink: "https://soundscore.app/recaps/weekly/2026-03-03"
+ )
+}
diff --git a/ios/SoundScore/SoundScore/Models/Track.swift b/ios/SoundScore/SoundScore/Models/Track.swift
new file mode 100644
index 0000000..a5334d2
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Models/Track.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+struct Track: Identifiable, Hashable {
+ let id: String
+ let albumId: String
+ let title: String
+ let trackNumber: Int
+ let durationMs: Int?
+ let spotifyId: String?
+
+ var formattedDuration: String {
+ guard let ms = durationMs else { return "--:--" }
+ let totalSeconds = ms / 1000
+ let minutes = totalSeconds / 60
+ let seconds = totalSeconds % 60
+ return String(format: "%d:%02d", minutes, seconds)
+ }
+
+ static func == (lhs: Track, rhs: Track) -> Bool { lhs.id == rhs.id }
+ func hash(into hasher: inout Hasher) { hasher.combine(id) }
+}
diff --git a/ios/SoundScore/SoundScore/Models/UserList.swift b/ios/SoundScore/SoundScore/Models/UserList.swift
new file mode 100644
index 0000000..830c3bc
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Models/UserList.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+struct UserList: Identifiable {
+ let id: String
+ let title: String
+ var note: String?
+ var albumIds: [String]
+ var curatorHandle: String
+ var saves: Int
+}
diff --git a/ios/SoundScore/SoundScore/Models/UserProfile.swift b/ios/SoundScore/SoundScore/Models/UserProfile.swift
new file mode 100644
index 0000000..042657b
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Models/UserProfile.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+struct UserProfile {
+ let handle: String
+ let bio: String
+ let logCount: Int
+ let reviewCount: Int
+ let listCount: Int
+ let topAlbums: [(Album, Float)]
+ let genres: [String]
+ let avgRating: Float
+ var albumsCount: Int
+ var followingCount: Int
+ var followersCount: Int
+ var favoriteAlbums: [Album]
+}
diff --git a/ios/SoundScore/SoundScore/Models/WeeklyRecap.swift b/ios/SoundScore/SoundScore/Models/WeeklyRecap.swift
new file mode 100644
index 0000000..faefd55
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Models/WeeklyRecap.swift
@@ -0,0 +1,11 @@
+import Foundation
+
+struct WeeklyRecap: Identifiable {
+ let id: String
+ let weekStart: String
+ let weekEnd: String
+ let totalLogs: Int
+ let averageRating: Float
+ let shareText: String
+ let deepLink: String
+}
diff --git a/ios/SoundScore/SoundScore/Screens/AIBuddyScreen.swift b/ios/SoundScore/SoundScore/Screens/AIBuddyScreen.swift
new file mode 100644
index 0000000..d2b0180
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Screens/AIBuddyScreen.swift
@@ -0,0 +1,284 @@
+import SwiftUI
+
+struct AIBuddyScreen: View {
+ @StateObject private var viewModel = AIBuddyViewModel()
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header
+ VStack(spacing: 8) {
+ CadenceCharacter(state: viewModel.cadenceState, size: 80)
+ .padding(.top, 24)
+ Text("Cadence")
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ Text("Your AI music agent")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.bottom, 12)
+
+ // Confirmation toast
+ if let confirmation = viewModel.actionConfirmation {
+ confirmationBanner(confirmation)
+ }
+
+ // Suggestion chips
+ if !viewModel.suggestions.isEmpty {
+ suggestionChips.padding(.bottom, 8)
+ }
+
+ chatArea
+ inputBar
+ }
+ .background(AppBackdrop())
+ }
+
+ // MARK: - Confirmation Banner
+
+ private func confirmationBanner(_ text: String) -> some View {
+ HStack(spacing: 8) {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(SSColors.accentGreen)
+ Text(text)
+ .font(SSTypography.labelMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.semibold)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(SSColors.accentGreen.opacity(0.15))
+ .clipShape(Capsule())
+ .overlay(Capsule().stroke(SSColors.accentGreen.opacity(0.3), lineWidth: 1))
+ .transition(.move(edge: .top).combined(with: .opacity))
+ .animation(.spring(response: 0.3), value: viewModel.actionConfirmation)
+ .padding(.bottom, 4)
+ }
+
+ // MARK: - Suggestion Chips
+
+ private var suggestionChips: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 10) {
+ ForEach(Array(viewModel.suggestions.enumerated()), id: \.element.id) { index, chip in
+ Button {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ viewModel.tapSuggestion(chip)
+ } label: {
+ HStack(spacing: 6) {
+ Image(systemName: chip.icon)
+ .font(.system(size: 13, weight: .medium))
+ Text(chip.label)
+ .font(SSTypography.bodySmall)
+ .lineLimit(1)
+ }
+ .foregroundColor(SSColors.chromeLight)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 9)
+ .background(SSColors.glassBg)
+ .clipShape(Capsule())
+ .overlay(
+ Capsule().stroke(
+ LinearGradient(
+ colors: [ThemeManager.shared.primary.opacity(0.5), SSColors.glassBorder],
+ startPoint: .topLeading, endPoint: .bottomTrailing
+ ), lineWidth: 1
+ )
+ )
+ }
+ .animation(.easeOut(duration: 0.35).delay(Double(index) * 0.08), value: viewModel.suggestions.count)
+ }
+ }
+ .padding(.horizontal, 16)
+ }
+ .frame(height: 44)
+ }
+
+ // MARK: - Chat Area
+
+ private var chatArea: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 10) {
+ ForEach(viewModel.messages) { message in
+ messageView(message).id(message.id)
+ }
+ if viewModel.isThinking {
+ thinkingBubble.id("thinking")
+ }
+ if let error = viewModel.errorMessage {
+ Text(error)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.accentCoral)
+ .padding(.horizontal, 16)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ .padding(.bottom, 16)
+ }
+ .onChange(of: viewModel.messages.count) {
+ if let lastId = viewModel.messages.last?.id {
+ withAnimation(.easeOut(duration: 0.2)) {
+ proxy.scrollTo(lastId, anchor: .bottom)
+ }
+ }
+ }
+ }
+ }
+
+ // MARK: - Message View (text + action cards)
+
+ @ViewBuilder
+ private func messageView(_ message: ChatMessage) -> some View {
+ VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 8) {
+ // Text bubble
+ if !message.content.isEmpty {
+ chatBubble(message)
+ }
+
+ // Render rich action cards
+ if message.role == .assistant && !message.actions.isEmpty {
+ let reviews = message.actions.filter { $0.type == .draftReview }
+ let ratings = message.actions.filter { $0.type == .rateAlbum }
+
+ // Review draft cards
+ ForEach(reviews) { action in
+ let album = SoundScoreRepository.shared.albums.first { $0.id == action.albumId }
+ CadenceReviewCard(
+ albumId: action.albumId,
+ albumTitle: action.albumTitle,
+ artworkUrl: album?.artworkUrl,
+ artColors: album?.artColors ?? AlbumColors.forest,
+ reviewText: action.value,
+ rating: Float(SoundScoreRepository.shared.ratings[action.albumId] ?? 0),
+ onSend: { text, rating in
+ viewModel.executeReview(
+ albumId: action.albumId,
+ albumTitle: action.albumTitle,
+ reviewText: text,
+ rating: rating
+ )
+ },
+ onDiscard: { viewModel.discardAction(messageId: message.id, actionId: action.id) }
+ )
+ }
+
+ // Batch ratings (3+ albums) or individual quick-rate cards
+ if ratings.count >= 3 {
+ CadenceBatchRatingCard(
+ ratings: ratings,
+ onApplyAll: { viewModel.executeBatchRatings($0) },
+ onDiscard: {
+ for r in ratings {
+ viewModel.discardAction(messageId: message.id, actionId: r.id)
+ }
+ }
+ )
+ } else {
+ ForEach(ratings) { action in
+ CadenceQuickRateCard(
+ action: action,
+ onConfirm: { viewModel.executeRating($0) },
+ onDiscard: { viewModel.discardAction(messageId: message.id, actionId: action.id) }
+ )
+ }
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: message.role == .user ? .trailing : .leading)
+ }
+
+ // MARK: - Chat Bubbles
+
+ @ViewBuilder
+ private func chatBubble(_ message: ChatMessage) -> some View {
+ if message.role == .assistant {
+ HStack(spacing: 0) {
+ RoundedRectangle(cornerRadius: 1)
+ .fill(ThemeManager.shared.primary)
+ .frame(width: 2)
+ .padding(.vertical, 6)
+ Text(message.content)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ }
+ .background(
+ RoundedRectangle(cornerRadius: 18).fill(.ultraThinMaterial)
+ .overlay(RoundedRectangle(cornerRadius: 18).stroke(SSColors.glassBorder, lineWidth: 0.5))
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 18))
+ } else {
+ Text(message.content)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .background(
+ RoundedRectangle(cornerRadius: 18)
+ .fill(LinearGradient(
+ colors: [ThemeManager.shared.primary.opacity(0.3), ThemeManager.shared.primary.opacity(0.15)],
+ startPoint: .topLeading, endPoint: .bottomTrailing
+ ))
+ )
+ }
+ }
+
+ private var thinkingBubble: some View {
+ HStack {
+ HStack(spacing: 6) {
+ ForEach(0..<3, id: \.self) { i in
+ Circle()
+ .fill(SSColors.chromeMedium)
+ .frame(width: 8, height: 8)
+ .opacity(0.6)
+ .animation(.easeInOut(duration: 0.6).repeatForever().delay(Double(i) * 0.2), value: viewModel.isThinking)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 18).fill(.ultraThinMaterial)
+ .overlay(RoundedRectangle(cornerRadius: 18).stroke(SSColors.glassBorder, lineWidth: 0.5))
+ )
+ Spacer(minLength: 60)
+ }
+ }
+
+ // MARK: - Input Bar
+
+ private var inputBar: some View {
+ HStack(spacing: 10) {
+ TextField("Ask about music...", text: $viewModel.inputText)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(SSColors.glassBg)
+ .clipShape(RoundedRectangle(cornerRadius: 22))
+ .overlay(RoundedRectangle(cornerRadius: 22).stroke(SSColors.glassBorder, lineWidth: 0.5))
+ .onSubmit { viewModel.sendMessage() }
+
+ Button {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ viewModel.sendMessage()
+ } label: {
+ Image(systemName: "arrow.up.circle.fill")
+ .font(.system(size: 32))
+ .foregroundColor(
+ viewModel.inputText.trimmingCharacters(in: .whitespaces).isEmpty
+ ? SSColors.chromeFaint : ThemeManager.shared.primary
+ )
+ }
+ .disabled(viewModel.inputText.trimmingCharacters(in: .whitespaces).isEmpty || viewModel.isThinking)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .padding(.bottom, 90)
+ .background(SSColors.darkElevated.opacity(0.9).ignoresSafeArea(edges: .bottom))
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Screens/AlbumDetailScreen.swift b/ios/SoundScore/SoundScore/Screens/AlbumDetailScreen.swift
new file mode 100644
index 0000000..1347653
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Screens/AlbumDetailScreen.swift
@@ -0,0 +1,454 @@
+import SwiftUI
+
+struct AlbumDetailScreen: View {
+ let album: Album
+ @StateObject private var viewModel: AlbumDetailViewModel
+ @State private var showAlbumRatingSheet = false
+ @State private var selectedTrack: Track?
+ @State private var ratingTab: Int = 0
+ @Environment(\.dismiss) private var dismiss
+
+ init(album: Album) {
+ self.album = album
+ self._viewModel = StateObject(wrappedValue: AlbumDetailViewModel(album: album))
+ }
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 16) {
+ heroSection
+ metadataSection
+ ratingTabPicker
+ if ratingTab == 0 {
+ rateReviewSection
+ listsContainingAlbum
+ alsoByArtist
+ } else {
+ tracklistSection
+ songsBreakdown
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 120)
+ }
+ .background(AppBackdrop())
+ .navigationBarBackButtonHidden(true)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button { dismiss() } label: {
+ toolbarCircle {
+ Image(systemName: "chevron.left")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(SSColors.chromeLight)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ ToolbarItem(placement: .navigationBarTrailing) {
+ ShareLink(item: "\(album.title) by \(album.artist) — rated on SoundScore") {
+ toolbarCircle {
+ Image(systemName: "square.and.arrow.up")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(SSColors.chromeLight)
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showAlbumRatingSheet) {
+ AlbumRatingSheet(
+ album: album,
+ rating: viewModel.userRating,
+ onRate: { viewModel.updateAlbumRating($0) },
+ onSaveReview: { SoundScoreRepository.shared.saveReview(albumId: album.id, reviewText: $0, rating: viewModel.userRating) }
+ )
+ .presentationDetents([.medium, .large])
+ .presentationDragIndicator(.visible)
+ .presentationBackground(SSColors.darkElevated)
+ }
+ .sheet(item: $selectedTrack) { track in
+ SongRatingSheet(
+ track: track,
+ albumTitle: album.title,
+ rating: viewModel.trackRatings[track.id] ?? 0,
+ onRate: { viewModel.updateTrackRating(trackId: track.id, rating: $0) }
+ )
+ .presentationDetents([.medium])
+ .presentationDragIndicator(.visible)
+ .presentationBackground(SSColors.darkElevated)
+ }
+ }
+
+ // MARK: - Toolbar
+
+ @ViewBuilder
+ private func toolbarCircle(@ViewBuilder content: () -> Content) -> some View {
+ ZStack {
+ Circle()
+ .fill(SSColors.darkElevated.opacity(0.8))
+ .frame(width: 36, height: 36)
+ Circle()
+ .stroke(SSColors.glassBorder, lineWidth: 0.5)
+ .frame(width: 36, height: 36)
+ content()
+ }
+ }
+
+ // MARK: - Hero
+
+ private var heroSection: some View {
+ ZStack(alignment: .bottomLeading) {
+ AlbumArtwork(artworkUrl: album.artworkUrl, colors: album.artColors, cornerRadius: 24)
+ .frame(height: 340)
+ .frame(maxWidth: .infinity)
+
+ LinearGradient(
+ colors: [.clear, .clear, SSColors.overlayDark.opacity(0.5), SSColors.overlayDark],
+ startPoint: .init(x: 0.5, y: 0.0),
+ endPoint: .bottom
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(album.title)
+ .font(SSTypography.displayMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ .lineLimit(2)
+ Text(album.artist)
+ .font(SSTypography.bodyLarge)
+ .foregroundColor(SSColors.chromeLight)
+ Text(String(album.year))
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.chromeDim)
+ }
+ .padding(18)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+ .overlay(
+ RoundedRectangle(cornerRadius: 24)
+ .stroke(SSColors.feedItemBorder, lineWidth: 0.5)
+ )
+ }
+
+ // MARK: - Metadata
+
+ private var metadataSection: some View {
+ HStack {
+ HStack(spacing: 6) {
+ StarRating(rating: album.avgRating, starSize: 22)
+ Text(String(format: "%.1f", album.avgRating))
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.accentAmber)
+ .fontWeight(.bold)
+ .lineLimit(1)
+ .fixedSize()
+ }
+ Spacer()
+ HStack(spacing: 4) {
+ Image(systemName: "music.note.list")
+ .font(.system(size: 14))
+ .foregroundColor(ThemeManager.shared.primary)
+ Text("\(album.logCount) logs")
+ .font(SSTypography.labelMedium)
+ .foregroundColor(SSColors.textSecondary)
+ }
+ }
+ .padding(.horizontal, 4)
+ }
+
+ // MARK: - Segmented Tab
+
+ private var ratingTabPicker: some View {
+ HStack(spacing: 0) {
+ tabButton(title: "Album", index: 0)
+ tabButton(title: "Songs", index: 1)
+ }
+ .padding(3)
+ .background(SSColors.glassBg)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ .overlay(
+ RoundedRectangle(cornerRadius: 14)
+ .stroke(SSColors.glassBorder, lineWidth: 0.5)
+ )
+ }
+
+ private func tabButton(title: String, index: Int) -> some View {
+ Button {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ ratingTab = index
+ }
+ } label: {
+ Text(title)
+ .font(SSTypography.labelLarge)
+ .fontWeight(ratingTab == index ? .bold : .regular)
+ .foregroundColor(ratingTab == index ? SSColors.chromeLight : SSColors.chromeMedium)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ .background(
+ ratingTab == index
+ ? ThemeManager.shared.primary.opacity(0.2)
+ : Color.clear
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 11))
+ }
+ .buttonStyle(.plain)
+ }
+
+ // MARK: - Tracklist
+
+ private var tracklistSection: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ SectionHeader(
+ eyebrow: "Tracklist",
+ title: "\(viewModel.tracks.count) tracks",
+ trailing: viewModel.isLoadingTracks ? "Loading..." : nil
+ )
+
+ if viewModel.isLoadingTracks && viewModel.tracks.isEmpty {
+ ForEach(0..<4, id: \.self) { _ in
+ SkeletonView()
+ .frame(height: 48)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ } else {
+ ForEach(Array(viewModel.tracks.enumerated()), id: \.element.id) { index, track in
+ trackRow(track, isEven: index % 2 == 0)
+ }
+ }
+ }
+ }
+
+ private func trackRow(_ track: Track, isEven: Bool) -> some View {
+ Button {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ selectedTrack = track
+ } label: {
+ HStack(spacing: 10) {
+ Text("\(track.trackNumber)")
+ .font(SSTypography.labelMedium)
+ .foregroundColor(SSColors.textTertiary)
+ .frame(width: 22, alignment: .trailing)
+
+ Text(track.title)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .lineLimit(1)
+
+ Spacer()
+
+ Text(track.formattedDuration)
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textTertiary)
+ .frame(width: 40, alignment: .trailing)
+
+ // Rating badge
+ if let rating = viewModel.trackRatings[track.id], rating > 0 {
+ HStack(spacing: 2) {
+ Image(systemName: "star.fill")
+ .font(.system(size: 10))
+ .foregroundColor(SSColors.accentAmber)
+ Text(String(format: "%.1f", rating))
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.accentAmber)
+ .fontWeight(.bold)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 3)
+ .background(SSColors.accentAmber.opacity(0.15))
+ .clipShape(Capsule())
+ } else {
+ Text("Rate")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.chromeFaint)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(isEven ? SSColors.glassBg : SSColors.glassBg.opacity(0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(SSColors.feedItemBorder, lineWidth: 0.5)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ // MARK: - Songs Breakdown
+
+ private var songsBreakdown: some View {
+ let ratedTracks = viewModel.tracks.filter { viewModel.trackRatings[$0.id] != nil && viewModel.trackRatings[$0.id]! > 0 }
+ let avgSongRating: Float = ratedTracks.isEmpty ? 0 : ratedTracks.map { viewModel.trackRatings[$0.id]! }.reduce(0, +) / Float(ratedTracks.count)
+ let highest = ratedTracks.max(by: { (viewModel.trackRatings[$0.id] ?? 0) < (viewModel.trackRatings[$1.id] ?? 0) })
+ let lowest = ratedTracks.min(by: { (viewModel.trackRatings[$0.id] ?? 0) < (viewModel.trackRatings[$1.id] ?? 0) })
+
+ return Group {
+ if !ratedTracks.isEmpty {
+ GlassCard(tintColor: ThemeManager.shared.primary.opacity(0.3), cornerRadius: 18, borderColor: ThemeManager.shared.primary.opacity(0.15)) {
+ VStack(spacing: 10) {
+ Text("Songs Breakdown")
+ .font(SSTypography.labelMedium)
+ .foregroundColor(SSColors.textSecondary)
+ .textCase(.uppercase)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ HStack {
+ VStack(spacing: 2) {
+ Text(String(format: "%.1f", avgSongRating))
+ .font(SSTypography.headlineMedium)
+ .foregroundColor(SSColors.accentAmber)
+ .fontWeight(.bold)
+ Text("AVG")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ .frame(maxWidth: .infinity)
+
+ if let h = highest {
+ VStack(spacing: 2) {
+ Text(h.title)
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .lineLimit(1)
+ Text("HIGHEST")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.accentGreen)
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ if let l = lowest, lowest?.id != highest?.id {
+ VStack(spacing: 2) {
+ Text(l.title)
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .lineLimit(1)
+ Text("LOWEST")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.accentCoral)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+
+ Text("\(ratedTracks.count)/\(viewModel.tracks.count) songs rated")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ }
+ }
+ }
+ }
+
+ // MARK: - Rate & Review (tappable for modal)
+
+ private var rateReviewSection: some View {
+ Button {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ showAlbumRatingSheet = true
+ } label: {
+ GlassCard(tintColor: ThemeManager.shared.primary.opacity(0.4), cornerRadius: 22, borderColor: ThemeManager.shared.primary.opacity(0.15)) {
+ VStack(spacing: 14) {
+ HStack {
+ Text("Your Album Rating")
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.system(size: 12))
+ .foregroundColor(SSColors.chromeFaint)
+ }
+
+ HStack {
+ StarRating(rating: viewModel.userRating, starSize: 22)
+ Spacer()
+ if viewModel.userRating > 0 {
+ Text(String(format: "%.1f / 6", viewModel.userRating))
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.accentAmber)
+ .fontWeight(.bold)
+ } else {
+ Text("Tap to rate & review")
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ }
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ }
+
+ // MARK: - Lists
+
+ private var listsContainingAlbum: some View {
+ let matchingLists = SoundScoreRepository.shared.lists.filter { $0.albumIds.contains(album.id) }
+ return Group {
+ if !matchingLists.isEmpty {
+ SectionHeader(eyebrow: "Your lists", title: "In your collections")
+ ForEach(matchingLists) { list in
+ GlassCard(tintColor: SSColors.accentViolet.opacity(0.3), cornerRadius: 16, borderColor: SSColors.accentViolet.opacity(0.15),
+ contentPadding: EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)) {
+ HStack {
+ Image(systemName: "list.bullet.rectangle")
+ .font(.system(size: 14))
+ .foregroundColor(SSColors.accentViolet)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(list.title)
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.semibold)
+ Text("\(list.albumIds.count) albums · \(list.curatorHandle)")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.system(size: 12))
+ .foregroundColor(SSColors.chromeFaint)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // MARK: - Also By Artist
+
+ private var alsoByArtist: some View {
+ let otherAlbums = SoundScoreRepository.shared.albums.filter { $0.artist == album.artist && $0.id != album.id }
+ return Group {
+ SectionHeader(eyebrow: "More", title: "Also by \(album.artist)")
+ if otherAlbums.isEmpty {
+ GlassCard(cornerRadius: 16, borderColor: SSColors.feedItemBorder) {
+ Text("No other albums in your library yet.")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textTertiary)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ }
+ } else {
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 12) {
+ ForEach(otherAlbums) { other in
+ NavigationLink(value: other) {
+ VStack(spacing: 6) {
+ AlbumArtwork(artworkUrl: other.artworkUrl, colors: other.artColors, cornerRadius: 14)
+ .frame(width: 120, height: 120)
+ Text(other.title)
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .lineLimit(1)
+ }
+ .frame(width: 120)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Screens/AuthScreen.swift b/ios/SoundScore/SoundScore/Screens/AuthScreen.swift
new file mode 100644
index 0000000..15caf5e
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Screens/AuthScreen.swift
@@ -0,0 +1,173 @@
+import SwiftUI
+
+struct AuthScreen: View {
+ @EnvironmentObject var authManager: AuthManager
+
+ @State private var email = ""
+ @State private var password = ""
+ @State private var handle = ""
+ @State private var isSignup = false
+ @State private var isLoading = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 24) {
+ Spacer().frame(height: 60)
+
+ VStack(spacing: 8) {
+ Image(systemName: "waveform.circle.fill")
+ .font(.system(size: 64))
+ .foregroundColor(ThemeManager.shared.primary)
+ Text("SoundScore")
+ .font(SSTypography.headlineMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ Text("Your taste, your journal.")
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.textSecondary)
+ }
+
+ GlassCard(
+ cornerRadius: 24,
+ borderColor: SSColors.glassBorder,
+ frosted: true
+ ) {
+ VStack(spacing: 16) {
+ if isSignup {
+ AuthField(
+ icon: "at",
+ placeholder: "Handle",
+ text: $handle
+ )
+ }
+
+ AuthField(
+ icon: "envelope",
+ placeholder: "Email",
+ text: $email,
+ keyboardType: .emailAddress
+ )
+
+ AuthField(
+ icon: "lock",
+ placeholder: "Password",
+ text: $password,
+ isSecure: true
+ )
+
+ if let error = errorMessage {
+ Text(error)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.accentCoral)
+ .multilineTextAlignment(.center)
+ }
+
+ SSButton(
+ text: isLoading
+ ? "..."
+ : (isSignup ? "Create Account" : "Log In")
+ ) {
+ submit()
+ }
+ .disabled(isLoading)
+ .opacity(isLoading ? 0.6 : 1)
+
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ isSignup.toggle()
+ errorMessage = nil
+ }
+ } label: {
+ Text(
+ isSignup
+ ? "Already have an account? Log in"
+ : "Don't have an account? Sign up"
+ )
+ .font(SSTypography.bodySmall)
+ .foregroundColor(ThemeManager.shared.primary)
+ }
+ }
+ }
+ .padding(.horizontal, 4)
+
+ Spacer()
+ }
+ .padding(.horizontal, 24)
+ }
+ }
+
+ private func submit() {
+ guard !email.isEmpty, !password.isEmpty else {
+ errorMessage = "Please fill in all fields."
+ return
+ }
+ if isSignup, handle.isEmpty {
+ errorMessage = "Handle is required for signup."
+ return
+ }
+
+ isLoading = true
+ errorMessage = nil
+
+ Task {
+ do {
+ if isSignup {
+ try await authManager.signup(
+ email: email, password: password, handle: handle
+ )
+ } else {
+ try await authManager.login(
+ email: email, password: password
+ )
+ }
+ await MainActor.run { isLoading = false }
+ Task { await SoundScoreRepository.shared.refresh() }
+ } catch {
+ await MainActor.run {
+ errorMessage = error.localizedDescription
+ isLoading = false
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Auth Field Component
+
+private struct AuthField: View {
+ let icon: String
+ let placeholder: String
+ @Binding var text: String
+ var keyboardType: UIKeyboardType = .default
+ var isSecure: Bool = false
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: icon)
+ .foregroundColor(SSColors.textTertiary)
+ .frame(width: 20)
+
+ if isSecure {
+ SecureField(placeholder, text: $text)
+ .foregroundColor(SSColors.chromeLight)
+ .font(SSTypography.bodyMedium)
+ } else {
+ TextField(placeholder, text: $text)
+ .foregroundColor(SSColors.chromeLight)
+ .font(SSTypography.bodyMedium)
+ .keyboardType(keyboardType)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ .background(SSColors.glassBg)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ .overlay(
+ RoundedRectangle(cornerRadius: 14)
+ .stroke(SSColors.glassBorder, lineWidth: 0.5)
+ )
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Screens/FeedScreen.swift b/ios/SoundScore/SoundScore/Screens/FeedScreen.swift
new file mode 100644
index 0000000..e65d723
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Screens/FeedScreen.swift
@@ -0,0 +1,263 @@
+import SwiftUI
+
+struct FeedScreen: View {
+ @StateObject private var viewModel = FeedViewModel()
+ var onSelectAlbum: (Album) -> Void = { _ in }
+ @State private var appeared = false
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 18) {
+ SyncBanner(message: viewModel.syncMessage)
+
+ if let error = viewModel.errorMessage {
+ ErrorBanner(message: error, onRetry: { viewModel.refresh() })
+ }
+
+ ScreenHeader(
+ title: "Feed",
+ subtitle: "What your people are logging right now."
+ )
+
+ if viewModel.isLoading && viewModel.items.isEmpty {
+ ForEach(0..<3, id: \.self) { _ in
+ SkeletonView()
+ .frame(height: 160)
+ .clipShape(RoundedRectangle(cornerRadius: 22))
+ }
+ } else {
+ if !viewModel.trendingAlbums.isEmpty {
+ SectionHeader(eyebrow: "Trending", title: "Hot this week")
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 14) {
+ ForEach(Array(viewModel.trendingAlbums.enumerated()), id: \.element.id) { index, album in
+ TrendingHeroCard(album: album, rank: index + 1)
+ .onTapGesture {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onSelectAlbum(album)
+ }
+ }
+ }
+ .padding(.trailing, 8)
+ }
+ }
+
+ // Collections section (lists in feed)
+ if !viewModel.featuredLists.isEmpty {
+ SectionHeader(eyebrow: "Curated", title: "Collections")
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 12) {
+ ForEach(viewModel.featuredLists.prefix(4)) { showcase in
+ CompactListCard(showcase: showcase, onSelectAlbum: onSelectAlbum)
+ }
+ }
+ .padding(.trailing, 8)
+ }
+ }
+
+ if viewModel.items.isEmpty {
+ EmptyState(
+ title: "Your feed is quiet",
+ subtitle: "Follow friends to see their ratings, reviews, and lists here.",
+ icon: "person.2"
+ )
+ } else {
+ SectionHeader(eyebrow: "Activity", title: "From your circle")
+
+ ForEach(Array(viewModel.items.enumerated()), id: \.element.id) { index, item in
+ FeedActivityCard(item: item, onSelectAlbum: onSelectAlbum) {
+ viewModel.toggleLike(item.id)
+ }
+ .opacity(appeared ? 1 : 0)
+ .offset(y: appeared ? 0 : 20)
+ .animation(.easeOut(duration: 0.35).delay(Double(index) * 0.04), value: appeared)
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 16)
+ .padding(.bottom, 120)
+ }
+ .refreshable { await SoundScoreRepository.shared.refresh() }
+ .onAppear { appeared = true }
+ }
+}
+
+// MARK: - Error Banner
+
+struct ErrorBanner: View {
+ let message: String
+ var onRetry: (() -> Void)?
+
+ var body: some View {
+ GlassCard(tintColor: SSColors.accentCoral, cornerRadius: 16, borderColor: SSColors.accentCoral.opacity(0.3)) {
+ HStack(spacing: 10) {
+ Image(systemName: "wifi.exclamationmark")
+ .font(.system(size: 14))
+ .foregroundColor(SSColors.accentCoral)
+ Text(message)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.chromeLight)
+ Spacer()
+ if let onRetry {
+ Button {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onRetry()
+ } label: {
+ Text("Retry")
+ .font(SSTypography.labelMedium)
+ .fontWeight(.semibold)
+ .foregroundColor(SSColors.accentCoral)
+ }
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Trending Card (redesigned with rank badge, glow, border)
+
+private struct TrendingHeroCard: View {
+ let album: Album
+ let rank: Int
+
+ private var dominantColor: Color {
+ album.artColors.first ?? ThemeManager.shared.primary
+ }
+
+ var body: some View {
+ ZStack(alignment: .bottomLeading) {
+ AlbumArtwork(artworkUrl: album.artworkUrl, colors: album.artColors, cornerRadius: 24)
+
+ LinearGradient(
+ colors: [.clear, .clear, SSColors.overlayDark.opacity(0.6), SSColors.overlayDark],
+ startPoint: .init(x: 0.5, y: 0.0),
+ endPoint: .bottom
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(album.title)
+ .font(SSTypography.titleLarge)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ .lineLimit(2)
+ Text(album.artist)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ Spacer().frame(height: 6)
+ HStack {
+ StarRating(rating: album.avgRating, starSize: 12)
+ Spacer()
+ Text("\(album.logCount)")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(ThemeManager.shared.primary)
+ }
+ }
+ .padding(14)
+
+ // Rank badge
+ VStack {
+ HStack {
+ Text("#\(rank)")
+ .font(SSTypography.labelSmall)
+ .fontWeight(.black)
+ .foregroundColor(SSColors.chromeLight)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(dominantColor.opacity(0.85))
+ .clipShape(Capsule())
+ .padding(10)
+ Spacer()
+ }
+ Spacer()
+ }
+ }
+ .frame(width: 220, height: 280)
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+ .overlay(
+ RoundedRectangle(cornerRadius: 24)
+ .stroke(dominantColor.opacity(0.4), lineWidth: 1)
+ )
+ .shadow(color: dominantColor.opacity(0.3), radius: 12, y: 4)
+ }
+}
+
+private struct FeedActivityCard: View {
+ let item: FeedItem
+ var onSelectAlbum: (Album) -> Void = { _ in }
+ let onToggleLike: () -> Void
+
+ var body: some View {
+ GlassCard(cornerRadius: 22, borderColor: SSColors.feedItemBorder, contentPadding: EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ HStack(spacing: 10) {
+ AvatarCircle(initials: String(item.username.prefix(2)), gradientColors: avatarColors(item.username))
+ VStack(alignment: .leading, spacing: 1) {
+ Text("@\(item.username)")
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ Text(item.action)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ }
+ }
+ Spacer()
+ Text(item.timeAgo)
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+
+ HStack(spacing: 12) {
+ AlbumArtwork(artworkUrl: item.album.artworkUrl, colors: item.album.artColors, cornerRadius: 16)
+ .frame(width: 72, height: 72)
+ .onTapGesture {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onSelectAlbum(item.album)
+ }
+ VStack(alignment: .leading, spacing: 4) {
+ Text(item.album.title)
+ .font(SSTypography.titleLarge)
+ .fontWeight(.semibold)
+ .foregroundColor(SSColors.chromeLight)
+ Text("\(item.album.artist) · \(String(item.album.year))")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ StarRating(rating: item.rating, starSize: 14)
+ }
+ }
+
+ if let snippet = item.reviewSnippet, !snippet.isEmpty {
+ Text("\"\(snippet)\"")
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight.opacity(0.9))
+ .italic()
+ }
+
+ HStack(spacing: 8) {
+ ActionChip(text: "\(item.likes)", icon: "heart", active: item.isLiked, onTap: onToggleLike)
+ ActionChip(text: "\(item.comments)", icon: "bubble.left")
+ ShareLink(item: "\(item.username) rated \(item.album.title) by \(item.album.artist)") {
+ ActionChip(text: "Share", icon: "square.and.arrow.up")
+ }
+ }
+ }
+ }
+ }
+}
+
+private func avatarColors(_ username: String) -> [Color] {
+ let hash = abs(username.hashValue)
+ let palettes: [[Color]] = [
+ AlbumColors.forest, AlbumColors.rose, AlbumColors.orchid,
+ AlbumColors.lagoon, AlbumColors.amber, AlbumColors.midnight,
+ AlbumColors.lime, AlbumColors.ember, AlbumColors.coral,
+ AlbumColors.slate,
+ ]
+ return palettes[hash % palettes.count]
+}
diff --git a/ios/SoundScore/SoundScore/Screens/ListsScreen.swift b/ios/SoundScore/SoundScore/Screens/ListsScreen.swift
new file mode 100644
index 0000000..dfdbead
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Screens/ListsScreen.swift
@@ -0,0 +1,94 @@
+import SwiftUI
+
+struct ListsScreen: View {
+ @StateObject private var viewModel = ListsViewModel()
+ @State private var showCreateSheet = false
+ @State private var draftTitle = ""
+ var onSelectAlbum: (Album) -> Void = { _ in }
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 16) {
+ SyncBanner(message: viewModel.syncMessage)
+
+ if let error = viewModel.errorMessage {
+ ErrorBanner(message: error)
+ }
+
+ ScreenHeader(
+ title: "Lists",
+ subtitle: "Curated collections worth sharing.",
+ actionLabel: "Create",
+ onAction: { showCreateSheet = true }
+ )
+
+ if let featured = viewModel.showcases.first {
+ FeaturedListHero(showcase: featured, onSelectAlbum: onSelectAlbum)
+ }
+
+ if viewModel.showcases.count > 1 {
+ SectionHeader(eyebrow: "Your lists", title: "Collections")
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 12) {
+ ForEach(Array(viewModel.showcases.dropFirst())) { showcase in
+ CompactListCard(showcase: showcase, onSelectAlbum: onSelectAlbum)
+ }
+ }
+ .padding(.trailing, 8)
+ }
+ }
+
+ if viewModel.showcases.isEmpty {
+ EmptyState(
+ title: "Build your first collection",
+ subtitle: "Arrange records into ranked moods, eras, or arguments worth sharing.",
+ icon: "text.badge.plus",
+ actionLabel: "Create a list",
+ onAction: { showCreateSheet = true }
+ )
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 16)
+ .padding(.bottom, 120)
+ }
+ .refreshable { await SoundScoreRepository.shared.refresh() }
+ .sheet(isPresented: $showCreateSheet) {
+ CreateListSheet(
+ draftTitle: $draftTitle,
+ onCreate: {
+ viewModel.createList(title: draftTitle)
+ draftTitle = ""
+ showCreateSheet = false
+ }
+ )
+ .presentationDetents([.medium])
+ .presentationDragIndicator(.visible)
+ .presentationBackground(SSColors.darkElevated)
+ }
+ }
+}
+
+private struct CreateListSheet: View {
+ @Binding var draftTitle: String
+ let onCreate: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Create a list")
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+
+ PillSearchBar(query: $draftTitle, placeholder: "Albums I Would Defend...")
+
+ SSButton(text: "Create", action: onCreate)
+ .opacity(draftTitle.trimmingCharacters(in: .whitespaces).isEmpty ? 0.5 : 1.0)
+ .disabled(draftTitle.trimmingCharacters(in: .whitespaces).isEmpty)
+
+ Spacer()
+ }
+ .padding(.horizontal, 24)
+ .padding(.top, 24)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Screens/LogScreen.swift b/ios/SoundScore/SoundScore/Screens/LogScreen.swift
new file mode 100644
index 0000000..7466cff
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Screens/LogScreen.swift
@@ -0,0 +1,239 @@
+import SwiftUI
+
+struct LogScreen: View {
+ @StateObject private var viewModel = LogViewModel()
+ var onSelectAlbum: (Album) -> Void = { _ in }
+ @State private var showSearchSheet = false
+
+ var body: some View {
+ ZStack(alignment: .bottomTrailing) {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 16) {
+ SyncBanner(message: viewModel.syncMessage)
+
+ if let error = viewModel.errorMessage {
+ ErrorBanner(message: error, onRetry: {
+ Task { await SoundScoreRepository.shared.refresh() }
+ })
+ }
+
+ ScreenHeader(title: "Diary", subtitle: "Your listening journal. Rate, log, repeat.")
+
+ if !viewModel.summaryStats.isEmpty {
+ Text(viewModel.summaryStats.map { "\($0.value) \($0.label.lowercased())" }.joined(separator: " · "))
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.textSecondary)
+ .padding(.horizontal, 4)
+ }
+
+ SectionHeader(eyebrow: "Quick rate", title: "Tap to rate")
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 12) {
+ ForEach(viewModel.quickLogAlbums) { album in
+ QuickRateCard(
+ album: album,
+ rating: viewModel.ratings[album.id] ?? 0,
+ onRate: { viewModel.updateRating(albumId: album.id, rating: $0) },
+ onSelectAlbum: onSelectAlbum
+ )
+ }
+ }
+ .padding(.trailing, 8)
+ }
+
+ if !viewModel.recentLogs.isEmpty {
+ SectionHeader(eyebrow: "Recent", title: "Your diary entries")
+
+ ForEach(viewModel.recentLogs) { entry in
+ TimelineEntry(dateLabel: entry.dateLabel, timeLabel: entry.timeLabel) {
+ DiaryEntryCard(entry: entry, onSelectAlbum: onSelectAlbum)
+ }
+ }
+ }
+
+ GlassCard(cornerRadius: 20, borderColor: SSColors.feedItemBorder) {
+ VStack(spacing: 4) {
+ Text("Write Later")
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ Text("Queue albums for later review — coming soon")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 16)
+ .padding(.bottom, 120)
+ }
+ .refreshable { await SoundScoreRepository.shared.refresh() }
+
+ Button {
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ showSearchSheet = true
+ } label: {
+ Image(systemName: "plus")
+ .font(.system(size: 22, weight: .bold))
+ .foregroundColor(SSColors.darkBase)
+ .frame(width: 56, height: 56)
+ .background(ThemeManager.shared.primary)
+ .clipShape(Circle())
+ .shadow(color: ThemeManager.shared.primary.opacity(0.3), radius: 10, y: 4)
+ }
+ .padding(.trailing, 20)
+ .padding(.bottom, 100)
+ }
+ .sheet(isPresented: $showSearchSheet) {
+ QuickLogSearchSheet(onSelectAlbum: { album in
+ showSearchSheet = false
+ onSelectAlbum(album)
+ })
+ .presentationDetents([.large])
+ .presentationDragIndicator(.visible)
+ .presentationBackground(SSColors.darkElevated)
+ }
+ }
+}
+
+// MARK: - Quick Log Search Sheet
+
+private struct QuickLogSearchSheet: View {
+ @StateObject private var viewModel = SearchViewModel()
+ var onSelectAlbum: (Album) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Log an album")
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+
+ PillSearchBar(query: $viewModel.query, placeholder: "Search albums to log...")
+
+ if viewModel.results.isEmpty && !viewModel.query.isEmpty {
+ EmptyState(
+ title: "No matches",
+ subtitle: "Try a different search term.",
+ icon: "magnifyingglass"
+ )
+ } else {
+ ScrollView {
+ LazyVStack(spacing: 8) {
+ ForEach(viewModel.results) { album in
+ Button {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onSelectAlbum(album)
+ } label: {
+ HStack(spacing: 12) {
+ AlbumArtwork(artworkUrl: album.artworkUrl, colors: album.artColors, cornerRadius: 12)
+ .frame(width: 48, height: 48)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(album.title)
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.semibold)
+ Text(album.artist)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ }
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.system(size: 12))
+ .foregroundColor(SSColors.chromeFaint)
+ }
+ .padding(.vertical, 4)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ Spacer()
+ }
+ .padding(.horizontal, 24)
+ .padding(.top, 24)
+ }
+}
+
+private struct QuickRateCard: View {
+ let album: Album
+ let rating: Float
+ let onRate: (Float) -> Void
+ var onSelectAlbum: (Album) -> Void = { _ in }
+
+ var body: some View {
+ GlassCard(cornerRadius: 20, borderColor: SSColors.feedItemBorder, contentPadding: EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)) {
+ VStack(alignment: .leading, spacing: 8) {
+ ZStack(alignment: .topTrailing) {
+ AlbumArtwork(artworkUrl: album.artworkUrl, colors: album.artColors, cornerRadius: 14)
+ .frame(width: 124, height: 130)
+ .onTapGesture {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onSelectAlbum(album)
+ }
+ if rating > 0 {
+ Text(String(format: "%.1f", rating))
+ .font(SSTypography.labelSmall)
+ .fontWeight(.bold)
+ .foregroundColor(SSColors.accentAmber)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 3)
+ .background(SSColors.darkBase.opacity(0.7))
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ .padding(6)
+ .transition(.scale.combined(with: .opacity))
+ }
+ }
+ Text(album.title)
+ .font(SSTypography.titleMedium)
+ .fontWeight(.semibold)
+ .foregroundColor(SSColors.chromeLight)
+ .lineLimit(1)
+ Text(album.artist)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ .lineLimit(1)
+ StarRating(rating: rating, onRate: onRate, starSize: 14)
+ }
+ }
+ .frame(width: 140)
+ }
+}
+
+private struct DiaryEntryCard: View {
+ let entry: RecentLogEntry
+ var onSelectAlbum: (Album) -> Void = { _ in }
+
+ var body: some View {
+ GlassCard(cornerRadius: 18, borderColor: SSColors.feedItemBorder, contentPadding: EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)) {
+ HStack(spacing: 10) {
+ AlbumArtwork(artworkUrl: entry.album.artworkUrl, colors: entry.album.artColors, cornerRadius: 14)
+ .frame(width: 56, height: 56)
+ .onTapGesture {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onSelectAlbum(entry.album)
+ }
+ VStack(alignment: .leading, spacing: 2) {
+ Text(entry.album.title)
+ .font(SSTypography.titleMedium)
+ .fontWeight(.semibold)
+ .foregroundColor(SSColors.chromeLight)
+ Text(entry.album.artist)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ if !entry.caption.isEmpty {
+ Text(entry.caption)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textTertiary)
+ .lineLimit(1)
+ }
+ }
+ Spacer()
+ StarRating(rating: entry.rating, starSize: 12)
+ }
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Screens/ProfileScreen.swift b/ios/SoundScore/SoundScore/Screens/ProfileScreen.swift
new file mode 100644
index 0000000..b63a1ef
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Screens/ProfileScreen.swift
@@ -0,0 +1,375 @@
+import SwiftUI
+
+struct ProfileScreen: View {
+ @StateObject private var viewModel = ProfileViewModel()
+ var onSelectAlbum: (Album) -> Void = { _ in }
+ var onOpenSettings: () -> Void = {}
+ @State private var appeared = false
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 16) {
+ SyncBanner(message: viewModel.syncMessage)
+
+ if let error = viewModel.errorMessage {
+ ErrorBanner(message: error)
+ }
+
+ heroBanner
+ statsRow
+ actionBar
+
+ if !viewModel.favoriteAlbums.isEmpty {
+ favoritesSection
+ }
+
+ if !viewModel.genres.isEmpty {
+ tasteDNASection
+ }
+
+ if let recap = viewModel.recap {
+ recapCard(recap)
+ }
+
+ if !viewModel.recentActivity.isEmpty {
+ recentActivitySection
+ }
+ }
+ .padding(.bottom, 120)
+ }
+ .refreshable { await SoundScoreRepository.shared.refresh() }
+ .onAppear { appeared = true }
+ }
+
+ // MARK: - Hero Banner
+
+ private var heroBanner: some View {
+ ZStack(alignment: .bottom) {
+ ZStack {
+ let artworks = viewModel.favoriteAlbums.prefix(4)
+ if !artworks.isEmpty {
+ GeometryReader { geo in
+ let size = geo.size
+ ZStack {
+ ForEach(Array(artworks.enumerated()), id: \.element.id) { index, album in
+ AlbumArtwork(artworkUrl: album.artworkUrl, colors: album.artColors, cornerRadius: 0)
+ .frame(width: size.width / 2, height: 140)
+ .offset(
+ x: index % 2 == 0 ? -size.width / 4 : size.width / 4,
+ y: index < 2 ? -70 : 70
+ )
+ }
+ }
+ .frame(width: size.width, height: size.height)
+ .blur(radius: 30)
+ }
+ } else {
+ ThemeManager.shared.primary.opacity(0.15)
+ }
+
+ LinearGradient(
+ colors: [SSColors.overlayMedium, SSColors.overlayDark],
+ startPoint: .top, endPoint: .bottom
+ )
+ }
+ .frame(height: 280)
+
+ VStack(spacing: 10) {
+ ZStack {
+ Circle()
+ .fill(ThemeManager.shared.primary.opacity(0.2))
+ .frame(width: 106, height: 106)
+ .blur(radius: 12)
+
+ AvatarCircle(
+ initials: String(viewModel.handle.dropFirst().prefix(2)),
+ gradientColors: [ThemeManager.shared.primary, SSColors.accentViolet],
+ size: 96
+ )
+ .overlay(
+ Circle()
+ .stroke(ThemeManager.shared.primary, lineWidth: 3)
+ .frame(width: 96, height: 96)
+ )
+ .shadow(color: ThemeManager.shared.primary.opacity(0.4), radius: 12, y: 4)
+ }
+
+ Text(viewModel.handle)
+ .font(SSTypography.displayMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+
+ Text(viewModel.bio)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.textSecondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+ }
+ .padding(.bottom, 20)
+ }
+ }
+
+ // MARK: - Stats
+
+ private var statsRow: some View {
+ HStack(spacing: 10) {
+ ForEach(viewModel.metrics) { metric in
+ GlassCard(cornerRadius: 16, borderColor: SSColors.feedItemBorder,
+ contentPadding: EdgeInsets(top: 12, leading: 8, bottom: 12, trailing: 8)) {
+ VStack(spacing: 4) {
+ Text(metric.value)
+ .font(SSTypography.headlineMedium)
+ .foregroundColor(metric.label == "Albums" ? ThemeManager.shared.primary : SSColors.chromeLight)
+ .fontWeight(.black)
+ Text(metric.label.uppercased())
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ }
+ .padding(.horizontal, 20)
+ }
+
+ // MARK: - Actions
+
+ private var actionBar: some View {
+ GlassCard(cornerRadius: 18, borderColor: SSColors.feedItemBorder,
+ contentPadding: EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)) {
+ HStack(spacing: 12) {
+ Button {} label: {
+ Text("Edit Profile")
+ .font(SSTypography.labelLarge)
+ .foregroundColor(SSColors.chromeLight)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 8)
+ .background(ThemeManager.shared.primary.opacity(0.25))
+ .clipShape(Capsule())
+ }
+ .buttonStyle(.plain)
+
+ Spacer()
+
+ ShareLink(item: viewModel.shareProfileText()) {
+ Image(systemName: "square.and.arrow.up")
+ .font(.system(size: 16))
+ .foregroundColor(SSColors.chromeMedium)
+ .frame(width: 36, height: 36)
+ .background(SSColors.glassBg)
+ .clipShape(Circle())
+ }
+
+ Button { onOpenSettings() } label: {
+ Image(systemName: "gearshape")
+ .font(.system(size: 16))
+ .foregroundColor(SSColors.chromeMedium)
+ .frame(width: 36, height: 36)
+ .background(SSColors.glassBg)
+ .clipShape(Circle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 20)
+ }
+
+ // MARK: - Favorites
+
+ private var favoritesSection: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ SectionHeader(eyebrow: "Top picks", title: "Favorites", trailing: "See All")
+ .padding(.horizontal, 20)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 12) {
+ ForEach(Array(viewModel.favoriteAlbums.enumerated()), id: \.element.id) { index, album in
+ Button {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onSelectAlbum(album)
+ } label: {
+ ZStack(alignment: .bottom) {
+ AlbumArtwork(artworkUrl: album.artworkUrl, colors: album.artColors, cornerRadius: 18)
+
+ LinearGradient(
+ colors: [.clear, SSColors.overlayDark],
+ startPoint: .init(x: 0.5, y: 0.4), endPoint: .bottom
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 18))
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(album.title)
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ .lineLimit(1)
+ Text(album.artist)
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textSecondary)
+ .lineLimit(1)
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(width: 140, height: 180)
+ .clipShape(RoundedRectangle(cornerRadius: 18))
+ .overlay(RoundedRectangle(cornerRadius: 18).stroke(SSColors.feedItemBorder, lineWidth: 0.5))
+ }
+ .buttonStyle(.plain)
+ .opacity(appeared ? 1 : 0)
+ .offset(y: appeared ? 0 : 20)
+ .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(Double(index) * 0.08), value: appeared)
+ }
+ }
+ .padding(.horizontal, 20)
+ }
+ }
+ }
+
+ // MARK: - Taste DNA
+
+ private var tasteDNASection: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ SectionHeader(eyebrow: "Your vibe", title: "Taste DNA")
+ .padding(.horizontal, 20)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 10) {
+ let palettes: [[Color]] = [
+ AlbumColors.forest, AlbumColors.orchid, AlbumColors.lagoon,
+ AlbumColors.ember, AlbumColors.rose, AlbumColors.midnight,
+ ]
+ ForEach(Array(viewModel.genres.enumerated()), id: \.offset) { index, genre in
+ Text(genre)
+ .font(SSTypography.labelLarge)
+ .fontWeight(.bold)
+ .foregroundColor(.white)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 14)
+ .frame(height: 56)
+ .background(
+ LinearGradient(
+ colors: palettes[index % palettes.count],
+ startPoint: .topLeading, endPoint: .bottomTrailing
+ )
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.white.opacity(0.15), lineWidth: 0.5))
+ }
+ }
+ .padding(.horizontal, 20)
+ }
+ }
+ }
+
+ // MARK: - Recap
+
+ private func recapCard(_ recap: WeeklyRecap) -> some View {
+ GlassCard(tintColor: ThemeManager.shared.primary, cornerRadius: 22, borderColor: ThemeManager.shared.primary.opacity(0.2)) {
+ VStack(spacing: 12) {
+ HStack {
+ Image(systemName: "chart.bar.fill")
+ .foregroundColor(ThemeManager.shared.primary)
+ Text("Weekly Recap")
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ Spacer()
+ }
+
+ HStack {
+ VStack(spacing: 2) {
+ Text("\(recap.totalLogs)")
+ .font(SSTypography.headlineMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.black)
+ Text("LOGS")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ .frame(maxWidth: .infinity)
+
+ VStack(spacing: 2) {
+ Text(String(format: "%.1f", recap.averageRating))
+ .font(SSTypography.headlineMedium)
+ .foregroundColor(SSColors.accentAmber)
+ .fontWeight(.black)
+ Text("AVG")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ ShareLink(item: recap.shareText) {
+ HStack {
+ Image(systemName: "square.and.arrow.up")
+ .font(.system(size: 13))
+ Text("Share Recap")
+ .font(SSTypography.labelMedium)
+ }
+ .foregroundColor(ThemeManager.shared.primary)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(ThemeManager.shared.primary.opacity(0.15))
+ .clipShape(Capsule())
+ }
+ }
+ }
+ .padding(.horizontal, 20)
+ }
+
+ // MARK: - Recent Activity
+
+ private var recentActivitySection: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ SectionHeader(eyebrow: "Recent", title: "Activity")
+ .padding(.horizontal, 20)
+
+ ForEach(viewModel.recentActivity) { entry in
+ HStack(spacing: 0) {
+ RoundedRectangle(cornerRadius: 1)
+ .fill(LinearGradient(colors: entry.album.artColors, startPoint: .top, endPoint: .bottom))
+ .frame(width: 3)
+ .padding(.vertical, 4)
+
+ HStack(spacing: 10) {
+ AlbumArtwork(artworkUrl: entry.album.artworkUrl, colors: entry.album.artColors, cornerRadius: 12)
+ .frame(width: 56, height: 56)
+ .onTapGesture {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onSelectAlbum(entry.album)
+ }
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(entry.album.title)
+ .font(SSTypography.titleMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.semibold)
+ .lineLimit(1)
+ Text(entry.album.artist)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ .lineLimit(1)
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 3) {
+ StarRating(rating: entry.rating, starSize: 10)
+ Text(entry.dateLabel)
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ }
+ .background(SSColors.glassBg)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ .overlay(RoundedRectangle(cornerRadius: 14).stroke(SSColors.feedItemBorder, lineWidth: 0.5))
+ .padding(.horizontal, 20)
+ }
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Screens/SearchScreen.swift b/ios/SoundScore/SoundScore/Screens/SearchScreen.swift
new file mode 100644
index 0000000..ea092c2
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Screens/SearchScreen.swift
@@ -0,0 +1,222 @@
+import SwiftUI
+
+struct SearchScreen: View {
+ @StateObject private var viewModel = SearchViewModel()
+ var onSelectAlbum: (Album) -> Void = { _ in }
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 16) {
+ SyncBanner(message: viewModel.syncMessage)
+
+ if let error = viewModel.errorMessage {
+ ErrorBanner(message: error)
+ }
+
+ ScreenHeader(title: "Discover", subtitle: "Browse by mood, genre, or find the record in your head.")
+
+ PillSearchBar(query: $viewModel.query)
+
+ if viewModel.query.trimmingCharacters(in: .whitespaces).isEmpty {
+ browseContent
+ } else if viewModel.isSearching {
+ HStack {
+ Spacer()
+ ProgressView()
+ .tint(ThemeManager.shared.primary)
+ Spacer()
+ }
+ .padding(.vertical, 32)
+ } else {
+ searchResults
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 16)
+ .padding(.bottom, 120)
+ }
+ .refreshable { await SoundScoreRepository.shared.refresh() }
+ }
+
+ @ViewBuilder
+ private var browseContent: some View {
+ if !viewModel.chartEntries.isEmpty {
+ SectionHeader(eyebrow: "Trending now", title: "Most logged this week")
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 14) {
+ ForEach(viewModel.chartEntries.prefix(4)) { entry in
+ TrendingSearchCard(album: entry.album, rank: entry.rank)
+ .onTapGesture {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onSelectAlbum(entry.album)
+ }
+ }
+ }
+ .padding(.trailing, 8)
+ }
+ }
+
+ SectionHeader(eyebrow: "Browse", title: "Explore by genre")
+
+ let rows = stride(from: 0, to: viewModel.browseGenres.count, by: 2).map { i in
+ Array(viewModel.browseGenres[i.. Void = {}
+
+ var body: some View {
+ Button {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ onTap()
+ } label: {
+ GlassCard(tintColor: genre.colors.last, cornerRadius: 20, borderColor: SSColors.feedItemBorder) {
+ VStack(alignment: .leading, spacing: 0) {
+ RoundedRectangle(cornerRadius: 10)
+ .fill(LinearGradient(colors: genre.colors, startPoint: .topLeading, endPoint: .bottomTrailing))
+ .frame(width: 32, height: 32)
+ Spacer()
+ VStack(alignment: .leading, spacing: 2) {
+ Text(genre.name)
+ .font(SSTypography.titleMedium)
+ .fontWeight(.bold)
+ .foregroundColor(SSColors.chromeLight)
+ Text(genre.caption)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 120)
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+private struct SearchResultCard: View {
+ let album: Album
+
+ var body: some View {
+ GlassCard(cornerRadius: 18, borderColor: SSColors.feedItemBorder, contentPadding: EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)) {
+ HStack(spacing: 12) {
+ AlbumArtwork(artworkUrl: album.artworkUrl, colors: album.artColors, cornerRadius: 16)
+ .frame(width: 64, height: 64)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(album.title)
+ .font(SSTypography.titleMedium)
+ .fontWeight(.semibold)
+ .foregroundColor(SSColors.chromeLight)
+ Text("\(album.artist) · \(String(album.year))")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textSecondary)
+ }
+ Spacer()
+ VStack(alignment: .trailing, spacing: 4) {
+ StarRating(rating: album.avgRating, starSize: 12)
+ Text("\(album.logCount) logs")
+ .font(SSTypography.labelSmall)
+ .foregroundColor(SSColors.textTertiary)
+ }
+ }
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Screens/SettingsScreen.swift b/ios/SoundScore/SoundScore/Screens/SettingsScreen.swift
new file mode 100644
index 0000000..c4d0c03
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Screens/SettingsScreen.swift
@@ -0,0 +1,378 @@
+import SwiftUI
+
+struct SettingsScreen: View {
+ @StateObject private var viewModel = ProfileViewModel()
+ @ObservedObject private var themeManager = ThemeManager.shared
+ @EnvironmentObject private var authManager: AuthManager
+ @Environment(\.dismiss) private var dismiss
+ @State private var showDeleteConfirm = false
+ @State private var previewTheme: AccentTheme = ThemeManager.shared.current
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 16) {
+ themeSection
+ accountSection
+ notificationsSection
+ quietHoursSection
+ dataSection
+ aboutSection
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 16)
+ .padding(.bottom, 120)
+ }
+ .background(AppBackdrop())
+ .navigationTitle("Settings")
+ .navigationBarTitleDisplayMode(.large)
+ .navigationBarBackButtonHidden(true)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button { dismiss() } label: {
+ ZStack {
+ Circle()
+ .fill(.ultraThinMaterial)
+ .frame(width: 36, height: 36)
+ Circle()
+ .stroke(SSColors.glassBorder, lineWidth: 0.5)
+ .frame(width: 36, height: 36)
+ Image(systemName: "chevron.left")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(SSColors.chromeLight)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .alert("Delete Account", isPresented: $showDeleteConfirm) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete", role: .destructive) {
+ Task {
+ try? await SoundScoreAPI().deleteAccount()
+ await MainActor.run { authManager.logout() }
+ }
+ }
+ } message: {
+ Text("This will permanently delete your account and all your data. This cannot be undone.")
+ }
+ }
+
+ private var themeSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Theme")
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ .padding(.horizontal, 4)
+
+ TabView(selection: $previewTheme) {
+ ForEach(AccentTheme.allCases) { theme in
+ ThemePreviewCard(
+ theme: theme,
+ isActive: themeManager.current == theme
+ )
+ .tag(theme)
+ }
+ }
+ .tabViewStyle(.page(indexDisplayMode: .never))
+ .frame(height: 180)
+ .onChange(of: previewTheme) { _, newTheme in
+ withAnimation(.easeInOut(duration: 0.35)) {
+ themeManager.current = newTheme
+ }
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ }
+ .onAppear { previewTheme = themeManager.current }
+
+ HStack(spacing: 8) {
+ ForEach(AccentTheme.allCases) { theme in
+ Circle()
+ .fill(theme == previewTheme ? theme.primary : Color.white.opacity(0.25))
+ .frame(width: 6, height: 6)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+
+ private var accountSection: some View {
+ GlassCard(cornerRadius: 22, borderColor: SSColors.feedItemBorder, frosted: true) {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Account")
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+
+ SettingsRow(label: "Handle", value: viewModel.profile?.handle ?? "@unknown")
+ SettingsRow(label: "Bio", value: viewModel.profile?.bio ?? "")
+ }
+ }
+ }
+
+ private var notificationsSection: some View {
+ GlassCard(cornerRadius: 22, borderColor: SSColors.feedItemBorder, frosted: true) {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Notifications")
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+
+ ToggleRow(label: "Social activity", isOn: $viewModel.notificationPreferences.socialEnabled)
+ ToggleRow(label: "Weekly recap", isOn: $viewModel.notificationPreferences.recapEnabled)
+ ToggleRow(label: "Comments", isOn: $viewModel.notificationPreferences.commentEnabled)
+ ToggleRow(label: "Reactions", isOn: $viewModel.notificationPreferences.reactionEnabled)
+ }
+ }
+ .onChange(of: viewModel.notificationPreferences.socialEnabled) { _, _ in viewModel.saveNotificationPreferences() }
+ .onChange(of: viewModel.notificationPreferences.recapEnabled) { _, _ in viewModel.saveNotificationPreferences() }
+ .onChange(of: viewModel.notificationPreferences.commentEnabled) { _, _ in viewModel.saveNotificationPreferences() }
+ .onChange(of: viewModel.notificationPreferences.reactionEnabled) { _, _ in viewModel.saveNotificationPreferences() }
+ }
+
+ private var quietHoursSection: some View {
+ GlassCard(cornerRadius: 22, borderColor: SSColors.feedItemBorder, frosted: true) {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Quiet Hours")
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Start")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textTertiary)
+ Stepper(
+ "\(viewModel.notificationPreferences.quietHoursStart):00",
+ value: $viewModel.notificationPreferences.quietHoursStart,
+ in: 0...23
+ )
+ .font(SSTypography.titleLarge)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.semibold)
+ }
+ Spacer()
+ Image(systemName: "moon.fill")
+ .foregroundColor(SSColors.accentViolet)
+ Spacer()
+ VStack(alignment: .trailing, spacing: 2) {
+ Text("End")
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textTertiary)
+ Stepper(
+ "\(viewModel.notificationPreferences.quietHoursEnd):00",
+ value: $viewModel.notificationPreferences.quietHoursEnd,
+ in: 0...23
+ )
+ .font(SSTypography.titleLarge)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.semibold)
+ }
+ }
+ }
+ }
+ .onChange(of: viewModel.notificationPreferences.quietHoursStart) { _, _ in viewModel.saveNotificationPreferences() }
+ .onChange(of: viewModel.notificationPreferences.quietHoursEnd) { _, _ in viewModel.saveNotificationPreferences() }
+ }
+
+ private var dataSection: some View {
+ GlassCard(cornerRadius: 22, borderColor: SSColors.feedItemBorder, frosted: true) {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Data")
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+
+ SSGhostButton(text: "Export Data") {
+ Task {
+ SoundScoreRepository.shared.outboxStore.enqueue(OutboxOperation(
+ type: .exportData,
+ payload: [:]
+ ))
+ await SoundScoreRepository.shared.syncOutbox()
+ }
+ viewModel.showExportSuccess = true
+ }
+
+ Button {
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ showDeleteConfirm = true
+ } label: {
+ Text("Delete Account")
+ .font(SSTypography.labelLarge)
+ .foregroundColor(SSColors.accentCoral)
+ .padding(.horizontal, 24)
+ .padding(.vertical, 12)
+ .frame(maxWidth: .infinity)
+ .background(SSColors.accentCoral.opacity(0.12))
+ .clipShape(RoundedRectangle(cornerRadius: 20))
+ .overlay(
+ RoundedRectangle(cornerRadius: 20)
+ .stroke(SSColors.accentCoral.opacity(0.3), lineWidth: 0.5)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ private var aboutSection: some View {
+ GlassCard(cornerRadius: 22, borderColor: SSColors.feedItemBorder, frosted: true) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("About")
+ .font(SSTypography.headlineSmall)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+
+ HStack {
+ Text("SoundScore")
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.textSecondary)
+ Spacer()
+ Text("Version 0.1.0")
+ .font(SSTypography.labelMedium)
+ .foregroundColor(SSColors.textTertiary)
+ }
+
+ Button {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ authManager.logout()
+ dismiss()
+ } label: {
+ HStack {
+ Image(systemName: "rectangle.portrait.and.arrow.right")
+ .font(.system(size: 14))
+ Text("Sign Out")
+ }
+ .font(SSTypography.labelLarge)
+ .foregroundColor(SSColors.accentCoral)
+ .padding(.top, 8)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+}
+
+private struct ThemePreviewCard: View {
+ let theme: AccentTheme
+ let isActive: Bool
+
+ var body: some View {
+ ZStack {
+ // Background gradient preview
+ RoundedRectangle(cornerRadius: 22)
+ .fill(
+ LinearGradient(
+ colors: [theme.colors.darkElevated, theme.colors.darkBase],
+ startPoint: .top, endPoint: .bottom
+ )
+ )
+
+ // Glow
+ RadialGradient(
+ colors: [theme.backdropGlow, Color.clear],
+ center: .topLeading,
+ startRadius: 0, endRadius: 200
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 22))
+
+ // Content preview
+ VStack(spacing: 16) {
+ Text(theme.label)
+ .font(SSTypography.headlineMedium)
+ .foregroundColor(.white.opacity(0.94))
+ .fontWeight(.bold)
+
+ // Mini UI preview
+ HStack(spacing: 10) {
+ // Mock album card
+ RoundedRectangle(cornerRadius: 10)
+ .fill(
+ LinearGradient(
+ colors: [theme.primary.opacity(0.6), theme.secondary.opacity(0.4)],
+ startPoint: .topLeading, endPoint: .bottomTrailing
+ )
+ )
+ .frame(width: 50, height: 50)
+
+ VStack(alignment: .leading, spacing: 4) {
+ RoundedRectangle(cornerRadius: 4)
+ .fill(Color.white.opacity(0.7))
+ .frame(width: 90, height: 10)
+ RoundedRectangle(cornerRadius: 4)
+ .fill(Color.white.opacity(0.35))
+ .frame(width: 60, height: 8)
+ HStack(spacing: 3) {
+ ForEach(0..<5, id: \.self) { i in
+ Image(systemName: i < 4 ? "star.fill" : "star")
+ .font(.system(size: 8))
+ .foregroundColor(SSColors.accentAmber)
+ }
+ }
+ }
+ Spacer()
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .background(
+ RoundedRectangle(cornerRadius: 14)
+ .fill(.ultraThinMaterial)
+ .overlay(
+ RoundedRectangle(cornerRadius: 14)
+ .stroke(Color.white.opacity(0.14), lineWidth: 0.5)
+ )
+ )
+ .padding(.horizontal, 20)
+
+ // Mock tab bar dots
+ HStack(spacing: 20) {
+ ForEach(0..<5, id: \.self) { i in
+ Circle()
+ .fill(i == 0 ? theme.primary : Color.white.opacity(0.3))
+ .frame(width: 8, height: 8)
+ }
+ }
+ }
+ }
+ .overlay(
+ RoundedRectangle(cornerRadius: 22)
+ .stroke(isActive ? theme.primary : Color.white.opacity(0.12), lineWidth: isActive ? 2 : 0.5)
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 22))
+ .padding(.horizontal, 8)
+ }
+}
+
+private struct SettingsRow: View {
+ let label: String
+ let value: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(label)
+ .font(SSTypography.bodySmall)
+ .foregroundColor(SSColors.textTertiary)
+ Text(value)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight)
+ }
+ }
+}
+
+private struct ToggleRow: View {
+ let label: String
+ @Binding var isOn: Bool
+
+ var body: some View {
+ HStack {
+ Text(label)
+ .font(SSTypography.bodyMedium)
+ .foregroundColor(SSColors.chromeLight)
+ Spacer()
+ Toggle("", isOn: $isOn)
+ .tint(ThemeManager.shared.primary)
+ .labelsHidden()
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Screens/SplashScreen.swift b/ios/SoundScore/SoundScore/Screens/SplashScreen.swift
new file mode 100644
index 0000000..aa5b982
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Screens/SplashScreen.swift
@@ -0,0 +1,41 @@
+import SwiftUI
+
+struct SplashScreen: View {
+ var onComplete: () -> Void = {}
+
+ @State private var iconScale: CGFloat = 0.5
+ @State private var iconOpacity: Double = 0
+ @State private var textOpacity: Double = 0
+
+ var body: some View {
+ ZStack {
+ AppBackdrop()
+
+ VStack(spacing: 16) {
+ Image(systemName: "waveform.circle.fill")
+ .font(.system(size: 80))
+ .foregroundColor(ThemeManager.shared.primary)
+ .scaleEffect(iconScale)
+ .opacity(iconOpacity)
+
+ Text("SoundScore")
+ .font(SSTypography.displayMedium)
+ .foregroundColor(SSColors.chromeLight)
+ .fontWeight(.bold)
+ .opacity(textOpacity)
+ }
+ }
+ .onAppear {
+ withAnimation(.spring(response: 0.6, dampingFraction: 0.7)) {
+ iconScale = 1.0
+ iconOpacity = 1.0
+ }
+ withAnimation(.easeIn(duration: 0.4).delay(0.3)) {
+ textOpacity = 1.0
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
+ onComplete()
+ }
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Services/AIBuddyService.swift b/ios/SoundScore/SoundScore/Services/AIBuddyService.swift
new file mode 100644
index 0000000..bd5d7e3
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Services/AIBuddyService.swift
@@ -0,0 +1,225 @@
+import Foundation
+
+struct ChatMessage: Identifiable, Equatable {
+ let id: String
+ let role: ChatRole
+ let content: String
+ let timestamp: Date
+ var actions: [CadenceAction]
+
+ init(role: ChatRole, content: String, actions: [CadenceAction] = []) {
+ self.id = UUID().uuidString
+ self.role = role
+ self.content = content
+ self.timestamp = Date()
+ self.actions = actions
+ }
+
+ static func == (lhs: ChatMessage, rhs: ChatMessage) -> Bool {
+ lhs.id == rhs.id
+ }
+}
+
+enum ChatRole: String {
+ case user
+ case assistant
+ case system
+}
+
+// MARK: - Agentic Actions
+
+enum CadenceActionType: String, Codable {
+ case rateAlbum
+ case draftReview
+ case addToList
+}
+
+struct CadenceAction: Identifiable, Equatable {
+ let id = UUID()
+ let type: CadenceActionType
+ let albumId: String
+ let albumTitle: String
+ let label: String
+ let value: String // rating value or review text
+
+ static func == (lhs: CadenceAction, rhs: CadenceAction) -> Bool {
+ lhs.id == rhs.id
+ }
+}
+
+// MARK: - Service
+
+actor AIBuddyService {
+ static let shared = AIBuddyService()
+
+ private let model = "gemini-2.5-flash"
+
+ private var apiURL: URL {
+ URL(string: "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent?key=\(Secrets.geminiAPIKey)")!
+ }
+
+ func sendMessage(
+ messages: [ChatMessage],
+ userContext: String,
+ albumCatalog: String
+ ) async throws -> (text: String, actions: [CadenceAction]) {
+ let systemPrompt = buildSystemPrompt(userContext: userContext, albumCatalog: albumCatalog)
+
+ var contents: [[String: Any]] = []
+ for msg in messages {
+ let role: String = msg.role == .assistant ? "model" : "user"
+ contents.append([
+ "role": role,
+ "parts": [["text": msg.content]],
+ ])
+ }
+
+ let body: [String: Any] = [
+ "contents": contents,
+ "systemInstruction": [
+ "parts": [["text": systemPrompt]],
+ ],
+ "generationConfig": [
+ "temperature": 0.85,
+ "maxOutputTokens": 800,
+ ],
+ ]
+
+ var request = URLRequest(url: apiURL)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await URLSession.shared.data(for: request)
+ } catch {
+ throw AIBuddyError.networkError
+ }
+
+ guard let http = response as? HTTPURLResponse else {
+ throw AIBuddyError.networkError
+ }
+
+ if http.statusCode == 400 || http.statusCode == 403 {
+ throw AIBuddyError.invalidKey
+ }
+
+ guard (200...299).contains(http.statusCode) else {
+ throw AIBuddyError.apiError(http.statusCode)
+ }
+
+ let decoded = try JSONDecoder().decode(GeminiResponse.self, from: data)
+ guard let candidate = decoded.candidates?.first,
+ let part = candidate.content?.parts?.first,
+ let rawText = part.text, !rawText.isEmpty
+ else {
+ throw AIBuddyError.noResponse
+ }
+
+ let (cleanText, actions) = parseActions(from: rawText)
+ return (cleanText, actions)
+ }
+
+ // MARK: - Action Parsing
+
+ private func parseActions(from text: String) -> (String, [CadenceAction]) {
+ var actions: [CadenceAction] = []
+ var cleanText = text
+
+ // Parse [RATE:album_id:album_title:rating]
+ let ratePattern = /\[RATE:([^:]+):([^:]+):([0-9.]+)\]/
+ for match in text.matches(of: ratePattern) {
+ actions.append(CadenceAction(
+ type: .rateAlbum,
+ albumId: String(match.1),
+ albumTitle: String(match.2),
+ label: "Rate \(match.2) → \(match.3)/6",
+ value: String(match.3)
+ ))
+ cleanText = cleanText.replacingOccurrences(of: String(match.0), with: "")
+ }
+
+ // Parse [REVIEW:album_id:album_title:review text here]
+ let reviewPattern = /\[REVIEW:([^:]+):([^:]+):([^\]]+)\]/
+ for match in text.matches(of: reviewPattern) {
+ actions.append(CadenceAction(
+ type: .draftReview,
+ albumId: String(match.1),
+ albumTitle: String(match.2),
+ label: "Save review for \(match.2)",
+ value: String(match.3)
+ ))
+ cleanText = cleanText.replacingOccurrences(of: String(match.0), with: "")
+ }
+
+ return (cleanText.trimmingCharacters(in: .whitespacesAndNewlines), actions)
+ }
+
+ // MARK: - System Prompt
+
+ private func buildSystemPrompt(userContext: String, albumCatalog: String) -> String {
+ """
+ You are Cadence, an AI music agent inside the SoundScore app. You're fun, opinionated, \
+ and deeply knowledgeable about music. You speak casually but with real insight.
+
+ YOU CAN TAKE ACTIONS. When appropriate, include action tags in your response:
+ - To suggest rating an album: [RATE:album_id:Album Title:4.5]
+ - To draft a review: [REVIEW:album_id:Album Title:Your review text here]
+
+ IMPORTANT RULES FOR ACTIONS:
+ - Only use album IDs from the catalog below. Never invent IDs.
+ - Only suggest actions when the user asks you to rate, review, or when it naturally fits.
+ - You can draft reviews in the user's voice — match their taste and style.
+ - Ratings are on a 6-point scale (0-6). Be honest and specific with scores.
+ - Place action tags at the END of your message, after your conversational text.
+
+ PERSONALITY:
+ - You have strong opinions but respect the user's taste.
+ - Reference specific production details, lyrics, or musical choices when discussing albums.
+ - Compare albums to other works to add context.
+ - Keep responses 2-3 paragraphs max unless drafting a review.
+ - When drafting reviews, write 3-5 sentences that feel personal and specific.
+ - If asked about non-music topics, redirect playfully.
+
+ ALBUM CATALOG (id: title by artist):
+ \(albumCatalog)
+
+ \(userContext.isEmpty ? "" : "LISTENER PROFILE:\n\(userContext)")
+ """
+ }
+}
+
+enum AIBuddyError: LocalizedError, Equatable {
+ case networkError
+ case invalidKey
+ case apiError(Int)
+ case noResponse
+
+ var errorDescription: String? {
+ switch self {
+ case .networkError: "Network error. Check your connection."
+ case .invalidKey: "Invalid API key. Check your Gemini configuration."
+ case .apiError(let code): "API error (code \(code))."
+ case .noResponse: "No response from Cadence."
+ }
+ }
+}
+
+// MARK: - Gemini Response Types
+
+private struct GeminiResponse: Decodable {
+ let candidates: [GeminiCandidate]?
+}
+
+private struct GeminiCandidate: Decodable {
+ let content: GeminiContent?
+}
+
+private struct GeminiContent: Decodable {
+ let parts: [GeminiPart]?
+}
+
+private struct GeminiPart: Decodable {
+ let text: String?
+}
diff --git a/ios/SoundScore/SoundScore/Services/APIClient.swift b/ios/SoundScore/SoundScore/Services/APIClient.swift
new file mode 100644
index 0000000..ccaed6e
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Services/APIClient.swift
@@ -0,0 +1,250 @@
+import Foundation
+
+// MARK: - Error Types
+
+enum ApiError: Error, LocalizedError {
+ case networkError(Error)
+ case unauthorized
+ case serverError(Int, String)
+ case decodingError(Error)
+
+ var errorDescription: String? {
+ switch self {
+ case .networkError(let error):
+ return "Network error: \(error.localizedDescription)"
+ case .unauthorized:
+ return "Session expired. Please log in again."
+ case .serverError(let code, let message):
+ return "Server error \(code): \(message)"
+ case .decodingError(let error):
+ return "Decoding error: \(error.localizedDescription)"
+ }
+ }
+}
+
+// MARK: - API Client
+
+final class APIClient {
+ static let shared = APIClient()
+
+ let baseURL: String
+ private let session: URLSession
+
+ private let decoder: JSONDecoder = {
+ let d = JSONDecoder()
+ d.keyDecodingStrategy = .convertFromSnakeCase
+ return d
+ }()
+
+ private let encoder = JSONEncoder()
+
+ init(baseURL: String = "http://localhost:8080") {
+ self.baseURL = baseURL
+ self.session = URLSession.shared
+ }
+
+ // MARK: - Typed Returns
+
+ func get(
+ _ path: String,
+ queryItems: [URLQueryItem]? = nil,
+ authenticated: Bool = true
+ ) async throws -> T {
+ let data = try await raw(
+ method: "GET", path: path,
+ queryItems: queryItems, authenticated: authenticated
+ )
+ return try decode(data)
+ }
+
+ func post(
+ _ path: String,
+ body: Data? = nil,
+ headers: [String: String] = [:],
+ authenticated: Bool = true
+ ) async throws -> T {
+ let data = try await raw(
+ method: "POST", path: path, body: body,
+ extraHeaders: headers, authenticated: authenticated
+ )
+ return try decode(data)
+ }
+
+ func put(
+ _ path: String,
+ body: Data? = nil,
+ headers: [String: String] = [:],
+ authenticated: Bool = true
+ ) async throws -> T {
+ let data = try await raw(
+ method: "PUT", path: path, body: body,
+ extraHeaders: headers, authenticated: authenticated
+ )
+ return try decode(data)
+ }
+
+ func delete(
+ _ path: String,
+ authenticated: Bool = true
+ ) async throws -> T {
+ let data = try await raw(
+ method: "DELETE", path: path, authenticated: authenticated
+ )
+ return try decode(data)
+ }
+
+ // MARK: - Void Returns
+
+ func postVoid(
+ _ path: String,
+ body: Data? = nil,
+ headers: [String: String] = [:],
+ authenticated: Bool = true
+ ) async throws {
+ _ = try await raw(
+ method: "POST", path: path, body: body,
+ extraHeaders: headers, authenticated: authenticated
+ )
+ }
+
+ func putVoid(
+ _ path: String,
+ body: Data? = nil,
+ headers: [String: String] = [:],
+ authenticated: Bool = true
+ ) async throws {
+ _ = try await raw(
+ method: "PUT", path: path, body: body,
+ extraHeaders: headers, authenticated: authenticated
+ )
+ }
+
+ func deleteVoid(
+ _ path: String,
+ authenticated: Bool = true
+ ) async throws {
+ _ = try await raw(
+ method: "DELETE", path: path, authenticated: authenticated
+ )
+ }
+
+ func getRaw(
+ _ path: String,
+ authenticated: Bool = true
+ ) async throws -> Data {
+ try await raw(method: "GET", path: path, authenticated: authenticated)
+ }
+
+ // MARK: - Core
+
+ private func raw(
+ method: String,
+ path: String,
+ body: Data? = nil,
+ queryItems: [URLQueryItem]? = nil,
+ extraHeaders: [String: String] = [:],
+ authenticated: Bool,
+ isRetry: Bool = false
+ ) async throws -> Data {
+ let request = buildURLRequest(
+ method: method, path: path, body: body,
+ queryItems: queryItems, extraHeaders: extraHeaders,
+ authenticated: authenticated
+ )
+
+ #if DEBUG
+ print("[API] \(method) \(path)")
+ #endif
+
+ let responseData: Data
+ let httpResponse: HTTPURLResponse
+
+ do {
+ let (data, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw ApiError.networkError(URLError(.badServerResponse))
+ }
+ responseData = data
+ httpResponse = http
+ } catch let error as ApiError {
+ throw error
+ } catch {
+ throw ApiError.networkError(error)
+ }
+
+ #if DEBUG
+ print("[API] \(httpResponse.statusCode) \(path) (\(responseData.count) bytes)")
+ #endif
+
+ if httpResponse.statusCode == 401 && authenticated && !isRetry {
+ do {
+ try await AuthManager.shared.refresh()
+ return try await raw(
+ method: method, path: path, body: body,
+ queryItems: queryItems, extraHeaders: extraHeaders,
+ authenticated: authenticated, isRetry: true
+ )
+ } catch {
+ throw ApiError.unauthorized
+ }
+ }
+
+ guard (200...299).contains(httpResponse.statusCode) else {
+ if httpResponse.statusCode == 401 {
+ throw ApiError.unauthorized
+ }
+ let message = String(data: responseData, encoding: .utf8) ?? "Unknown error"
+ throw ApiError.serverError(httpResponse.statusCode, message)
+ }
+
+ return responseData
+ }
+
+ private func buildURLRequest(
+ method: String,
+ path: String,
+ body: Data?,
+ queryItems: [URLQueryItem]?,
+ extraHeaders: [String: String],
+ authenticated: Bool
+ ) -> URLRequest {
+ var components = URLComponents(string: baseURL + path)
+ if let queryItems, !queryItems.isEmpty {
+ components?.queryItems = queryItems
+ }
+ guard let url = components?.url else {
+ fatalError("[APIClient] Invalid URL: \(baseURL + path)")
+ }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = method
+
+ if authenticated, let token = AuthManager.shared.accessToken {
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ }
+
+ for (key, value) in extraHeaders {
+ request.setValue(value, forHTTPHeaderField: key)
+ }
+
+ if let body {
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+ }
+
+ return request
+ }
+
+ private func decode(_ data: Data) throws -> T {
+ do {
+ return try decoder.decode(T.self, from: data)
+ } catch {
+ #if DEBUG
+ if let json = String(data: data, encoding: .utf8) {
+ print("[API] Decode error for \(T.self): \(error)\nJSON: \(json.prefix(500))")
+ }
+ #endif
+ throw ApiError.decodingError(error)
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Services/AuthManager.swift b/ios/SoundScore/SoundScore/Services/AuthManager.swift
new file mode 100644
index 0000000..8c1b768
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Services/AuthManager.swift
@@ -0,0 +1,133 @@
+import Foundation
+import Combine
+
+class AuthManager: ObservableObject {
+ static let shared = AuthManager()
+
+ @Published var isAuthenticated: Bool = false
+ @Published var currentHandle: String?
+
+ private(set) var accessToken: String?
+ private var refreshTokenValue: String?
+
+ private let baseURL: String
+ private let session = URLSession.shared
+ private let decoder: JSONDecoder = {
+ let d = JSONDecoder()
+ d.keyDecodingStrategy = .convertFromSnakeCase
+ return d
+ }()
+ private let encoder = JSONEncoder()
+
+ private init(baseURL: String = "http://localhost:8080") {
+ self.baseURL = baseURL
+ self.accessToken = UserDefaults.standard.string(forKey: "ss_accessToken")
+ self.refreshTokenValue = UserDefaults.standard.string(forKey: "ss_refreshToken")
+ self.currentHandle = UserDefaults.standard.string(forKey: "ss_handle")
+ self.isAuthenticated = (accessToken != nil)
+ }
+
+ // MARK: - Public API
+
+ func login(email: String, password: String) async throws {
+ let body = AuthRequestBody(email: email, password: password, handle: nil)
+ let response: AuthResponseBody = try await authRequest(
+ path: "/v1/auth/login", body: body
+ )
+ await applyAuth(response)
+ }
+
+ func signup(email: String, password: String, handle: String) async throws {
+ let body = AuthRequestBody(email: email, password: password, handle: handle)
+ let response: AuthResponseBody = try await authRequest(
+ path: "/v1/auth/signup", body: body
+ )
+ await applyAuth(response)
+ }
+
+ @MainActor
+ func devLogin(handle: String) {
+ accessToken = "dev_token_\(UUID().uuidString)"
+ refreshTokenValue = "dev_refresh_\(UUID().uuidString)"
+ currentHandle = handle
+ isAuthenticated = true
+ UserDefaults.standard.set(accessToken, forKey: "ss_accessToken")
+ UserDefaults.standard.set(refreshTokenValue, forKey: "ss_refreshToken")
+ UserDefaults.standard.set(handle, forKey: "ss_handle")
+ }
+
+ func refresh() async throws {
+ guard let token = refreshTokenValue else { throw ApiError.unauthorized }
+ let body = RefreshRequestBody(refreshToken: token)
+ let response: AuthResponseBody = try await authRequest(
+ path: "/v1/auth/refresh", body: body
+ )
+ await applyAuth(response)
+ }
+
+ @MainActor
+ func logout() {
+ accessToken = nil
+ refreshTokenValue = nil
+ currentHandle = nil
+ isAuthenticated = false
+ UserDefaults.standard.removeObject(forKey: "ss_accessToken")
+ UserDefaults.standard.removeObject(forKey: "ss_refreshToken")
+ UserDefaults.standard.removeObject(forKey: "ss_handle")
+ }
+
+ // MARK: - Private
+
+ @MainActor
+ private func applyAuth(_ response: AuthResponseBody) {
+ accessToken = response.accessToken
+ refreshTokenValue = response.refreshToken
+ currentHandle = response.handle
+ isAuthenticated = true
+ UserDefaults.standard.set(response.accessToken, forKey: "ss_accessToken")
+ UserDefaults.standard.set(response.refreshToken, forKey: "ss_refreshToken")
+ UserDefaults.standard.set(response.handle, forKey: "ss_handle")
+ }
+
+ private func authRequest(
+ path: String, body: B
+ ) async throws -> T {
+ guard let url = URL(string: baseURL + path) else {
+ throw ApiError.networkError(URLError(.badURL))
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try encoder.encode(body)
+
+ let (data, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw ApiError.networkError(URLError(.badServerResponse))
+ }
+ guard (200...299).contains(http.statusCode) else {
+ if http.statusCode == 401 { throw ApiError.unauthorized }
+ let message = String(data: data, encoding: .utf8) ?? "Unknown error"
+ throw ApiError.serverError(http.statusCode, message)
+ }
+ return try decoder.decode(T.self, from: data)
+ }
+}
+
+// MARK: - Auth DTOs
+
+private struct AuthRequestBody: Encodable {
+ let email: String
+ let password: String
+ let handle: String?
+}
+
+private struct RefreshRequestBody: Encodable {
+ let refreshToken: String
+}
+
+private struct AuthResponseBody: Decodable {
+ let accessToken: String
+ let refreshToken: String
+ let userId: String
+ let handle: String
+}
diff --git a/ios/SoundScore/SoundScore/Services/OutboxStore.swift b/ios/SoundScore/SoundScore/Services/OutboxStore.swift
new file mode 100644
index 0000000..8b57be8
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Services/OutboxStore.swift
@@ -0,0 +1,92 @@
+import Foundation
+import Combine
+
+// MARK: - Outbox Types
+
+enum OutboxOperationType: String {
+ case rateAlbum
+ case rateTrack
+ case createReview
+ case toggleReaction
+ case createList
+ case exportData
+ case registerDeviceToken
+ case updateNotificationPreferences
+}
+
+struct OutboxOperation: Identifiable {
+ let id: UUID
+ let type: OutboxOperationType
+ let payload: [String: String]
+ let idempotencyKey: UUID
+ let createdAt: Date
+ var attemptCount: Int
+ var nextAttemptAt: Date
+ var lastError: String?
+
+ init(
+ type: OutboxOperationType,
+ payload: [String: String],
+ id: UUID = UUID(),
+ idempotencyKey: UUID = UUID(),
+ createdAt: Date = Date(),
+ attemptCount: Int = 0,
+ nextAttemptAt: Date = Date(),
+ lastError: String? = nil
+ ) {
+ self.id = id
+ self.type = type
+ self.payload = payload
+ self.idempotencyKey = idempotencyKey
+ self.createdAt = createdAt
+ self.attemptCount = attemptCount
+ self.nextAttemptAt = nextAttemptAt
+ self.lastError = lastError
+ }
+}
+
+// MARK: - In-Memory Store
+
+class InMemoryOutboxStore: ObservableObject {
+ @Published var pending: [OutboxOperation] = []
+
+ func enqueue(_ op: OutboxOperation) {
+ pending.append(op)
+ }
+
+ func markDispatched(_ id: UUID) {
+ pending.removeAll { $0.id == id }
+ }
+
+ func markFailed(_ id: UUID, error: String) {
+ guard let index = pending.firstIndex(where: { $0.id == id }) else { return }
+ let nextAttempt = pending[index].attemptCount + 1
+ let backoffSeconds = pow(2.0, Double(min(nextAttempt, 6)))
+ pending[index].attemptCount = nextAttempt
+ pending[index].nextAttemptAt = Date().addingTimeInterval(backoffSeconds)
+ pending[index].lastError = error
+ }
+}
+
+// MARK: - Sync Engine
+
+struct OutboxSyncEngine {
+ let store: InMemoryOutboxStore
+
+ func flush(handler: (OutboxOperation) async throws -> Void) async {
+ let snapshot = store.pending
+ let now = Date()
+
+ for op in snapshot {
+ guard op.nextAttemptAt <= now else { continue }
+ do {
+ try await handler(op)
+ await MainActor.run { store.markDispatched(op.id) }
+ } catch {
+ await MainActor.run {
+ store.markFailed(op.id, error: error.localizedDescription)
+ }
+ }
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Services/SoundScoreAPI.swift b/ios/SoundScore/SoundScore/Services/SoundScoreAPI.swift
new file mode 100644
index 0000000..83a73fb
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Services/SoundScoreAPI.swift
@@ -0,0 +1,298 @@
+import Foundation
+
+// MARK: - API Response DTOs
+
+struct CursorPage: Decodable {
+ let items: [T]
+ let nextCursor: String?
+}
+
+struct AlbumDto: Decodable {
+ let id: String
+ let title: String
+ let artist: String
+ let year: Int
+ let artworkUrl: String?
+ let avgRating: Float
+ let logCount: Int
+}
+
+struct UserProfileDto: Decodable {
+ let id: String
+ let handle: String
+ let bio: String
+ let logCount: Int
+ let reviewCount: Int
+ let listCount: Int
+ let avgRating: Float
+}
+
+struct ActivityObjectDto: Decodable {
+ let type: String
+ let id: String
+}
+
+struct ActivityEventDto: Decodable {
+ let id: String
+ let actorId: String
+ let type: String
+ let activityObject: ActivityObjectDto
+ let createdAt: String
+ let reactions: Int
+ let comments: Int
+
+ enum CodingKeys: String, CodingKey {
+ case id, actorId, type
+ case activityObject = "object"
+ case createdAt, reactions, comments
+ }
+}
+
+struct WeeklyRecapDto: Decodable {
+ let id: String
+ let weekStart: String
+ let weekEnd: String
+ let totalLogs: Int
+ let averageRating: Float
+ let shareText: String
+ let deepLink: String
+}
+
+struct NotificationPreferenceDto: Codable {
+ let socialEnabled: Bool
+ let recapEnabled: Bool
+ let commentEnabled: Bool
+ let reactionEnabled: Bool
+ let quietHoursStart: Int
+ let quietHoursEnd: Int
+}
+
+struct TrackDto: Decodable {
+ let id: String
+ let albumId: String
+ let title: String
+ let trackNumber: Int
+ let durationMs: Int?
+ let spotifyId: String?
+}
+
+struct TrackRatingDto: Decodable {
+ let id: String
+ let trackId: String
+ let albumId: String
+ let value: Float
+}
+
+struct ListDetailDto: Decodable {
+ let id: String
+ let title: String
+ let note: String?
+ let ownerId: String
+ let items: [ListItemDto]
+}
+
+struct ListItemDto: Decodable {
+ let id: String
+ let albumId: String
+ let position: Int
+}
+
+// MARK: - SoundScore API
+
+struct SoundScoreAPI {
+ private let client: APIClient
+ private let encoder = JSONEncoder()
+
+ init(client: APIClient = .shared) {
+ self.client = client
+ }
+
+ // MARK: Catalog
+
+ func searchAlbums(query: String) async throws -> CursorPage {
+ try await client.get(
+ "/v1/search",
+ queryItems: [URLQueryItem(name: "q", value: query)]
+ )
+ }
+
+ func getAlbum(id: String) async throws -> AlbumDto {
+ try await client.get("/v1/albums/\(id)")
+ }
+
+ // MARK: Tracks
+
+ func getAlbumTracks(albumId: String) async throws -> [TrackDto] {
+ try await client.get("/v1/albums/\(albumId)/tracks")
+ }
+
+ func getAlbumTrackRatings(albumId: String) async throws -> [TrackRatingDto] {
+ try await client.get("/v1/albums/\(albumId)/track-ratings")
+ }
+
+ func createTrackRating(
+ trackId: String, albumId: String, value: Float, idempotencyKey: String
+ ) async throws {
+ struct Body: Encodable { let trackId: String; let albumId: String; let value: Float }
+ let data = try encoder.encode(Body(trackId: trackId, albumId: albumId, value: value))
+ try await client.postVoid(
+ "/v1/track-ratings", body: data,
+ headers: ["idempotency-key": idempotencyKey]
+ )
+ }
+
+ // MARK: Ratings
+
+ func createRating(
+ albumId: String, value: Float, idempotencyKey: String
+ ) async throws {
+ struct Body: Encodable { let albumId: String; let value: Float }
+ let data = try encoder.encode(Body(albumId: albumId, value: value))
+ try await client.postVoid(
+ "/v1/ratings", body: data,
+ headers: ["idempotency-key": idempotencyKey]
+ )
+ }
+
+ // MARK: Reviews
+
+ func createReview(
+ albumId: String, body reviewBody: String, idempotencyKey: String
+ ) async throws {
+ struct Body: Encodable { let albumId: String; let body: String }
+ let data = try encoder.encode(Body(albumId: albumId, body: reviewBody))
+ try await client.postVoid(
+ "/v1/reviews", body: data,
+ headers: ["idempotency-key": idempotencyKey]
+ )
+ }
+
+ func updateReview(id: String, body reviewBody: String, revision: Int) async throws {
+ struct Body: Encodable { let body: String; let expectedRevision: Int }
+ let data = try encoder.encode(Body(body: reviewBody, expectedRevision: revision))
+ try await client.putVoid("/v1/reviews/\(id)", body: data)
+ }
+
+ func deleteReview(id: String) async throws {
+ try await client.deleteVoid("/v1/reviews/\(id)")
+ }
+
+ // MARK: Lists
+
+ func createList(
+ title: String, note: String? = nil, idempotencyKey: String
+ ) async throws {
+ struct Body: Encodable { let title: String; let note: String? }
+ let data = try encoder.encode(Body(title: title, note: note))
+ try await client.postVoid(
+ "/v1/lists", body: data,
+ headers: ["idempotency-key": idempotencyKey]
+ )
+ }
+
+ func getList(id: String) async throws -> ListDetailDto {
+ try await client.get("/v1/lists/\(id)")
+ }
+
+ func addListItem(
+ listId: String, albumId: String, idempotencyKey: String
+ ) async throws {
+ struct Body: Encodable { let albumId: String }
+ let data = try encoder.encode(Body(albumId: albumId))
+ try await client.postVoid(
+ "/v1/lists/\(listId)/items", body: data,
+ headers: ["idempotency-key": idempotencyKey]
+ )
+ }
+
+ func removeListItem(listId: String, itemId: String) async throws {
+ try await client.deleteVoid("/v1/lists/\(listId)/items/\(itemId)")
+ }
+
+ // MARK: Feed
+
+ func getFeed(cursor: String? = nil) async throws -> CursorPage {
+ var queryItems: [URLQueryItem] = []
+ if let cursor {
+ queryItems.append(URLQueryItem(name: "cursor", value: cursor))
+ }
+ return try await client.get(
+ "/v1/feed",
+ queryItems: queryItems.isEmpty ? nil : queryItems
+ )
+ }
+
+ func reactToActivity(
+ id: String, reaction: String, idempotencyKey: String
+ ) async throws {
+ struct Body: Encodable { let reaction: String }
+ let data = try encoder.encode(Body(reaction: reaction))
+ try await client.postVoid(
+ "/v1/activity/\(id)/react", body: data,
+ headers: ["idempotency-key": idempotencyKey]
+ )
+ }
+
+ func commentOnActivity(id: String, body commentBody: String) async throws {
+ struct Body: Encodable { let body: String }
+ let data = try encoder.encode(Body(body: commentBody))
+ try await client.postVoid("/v1/activity/\(id)/comment", body: data)
+ }
+
+ // MARK: Social
+
+ func follow(userId: String) async throws {
+ try await client.postVoid("/v1/follow/\(userId)")
+ }
+
+ func unfollow(userId: String) async throws {
+ try await client.deleteVoid("/v1/follow/\(userId)")
+ }
+
+ func getProfile(handle: String) async throws -> UserProfileDto {
+ try await client.get("/v1/me")
+ }
+
+ // MARK: Recaps
+
+ func getWeeklyRecap() async throws -> WeeklyRecapDto {
+ try await client.get("/v1/recaps/weekly/latest")
+ }
+
+ // MARK: Push
+
+ func registerDevice(
+ platform: String, token: String, idempotencyKey: String
+ ) async throws {
+ struct Body: Encodable { let platform: String; let deviceToken: String }
+ let data = try encoder.encode(Body(platform: platform, deviceToken: token))
+ try await client.postVoid(
+ "/v1/push/tokens", body: data,
+ headers: ["idempotency-key": idempotencyKey]
+ )
+ }
+
+ func getPreferences() async throws -> NotificationPreferenceDto {
+ try await client.get("/v1/push/preferences")
+ }
+
+ func updatePreferences(
+ _ prefs: NotificationPreferenceDto, idempotencyKey: String
+ ) async throws {
+ let data = try encoder.encode(prefs)
+ try await client.putVoid(
+ "/v1/push/preferences", body: data,
+ headers: ["idempotency-key": idempotencyKey]
+ )
+ }
+
+ // MARK: Trust
+
+ func exportData() async throws -> Data {
+ try await client.getRaw("/v1/account/export")
+ }
+
+ func deleteAccount() async throws {
+ try await client.deleteVoid("/v1/account")
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Services/SoundScoreRepository.swift b/ios/SoundScore/SoundScore/Services/SoundScoreRepository.swift
new file mode 100644
index 0000000..a541936
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Services/SoundScoreRepository.swift
@@ -0,0 +1,390 @@
+import Foundation
+import SwiftUI
+import Combine
+
+class SoundScoreRepository: ObservableObject {
+ static let shared = SoundScoreRepository()
+
+ @Published var albums: [Album]
+ @Published var feedItems: [FeedItem]
+ @Published var profile: UserProfile
+ @Published var ratings: [String: Float]
+ @Published var tracksByAlbum: [String: [Track]]
+ @Published var trackRatings: [String: Float]
+ @Published var lists: [UserList]
+ @Published var latestRecap: WeeklyRecap?
+ @Published var syncMessage: String?
+ @Published var isLoading: Bool = false
+ @Published var errorMessage: String?
+
+ private let api = SoundScoreAPI()
+ let outboxStore = InMemoryOutboxStore()
+ private lazy var outboxEngine = OutboxSyncEngine(store: outboxStore)
+
+ private init() {
+ self.albums = SeedData.albums
+ self.feedItems = SeedData.feedItems
+ self.profile = SeedData.myProfile
+ self.ratings = SeedData.logInitialRatings
+ self.tracksByAlbum = SeedData.sampleTracks
+ self.trackRatings = [:]
+ self.lists = SeedData.initialLists
+ self.latestRecap = SeedData.initialRecap
+ self.syncMessage = nil
+ Task { await enrichAlbumsWithArtwork() }
+ }
+
+ // MARK: - Spotify Artwork Enrichment
+
+ private func enrichAlbumsWithArtwork() async {
+ let albumsNeedingArt = albums.filter { $0.artworkUrl == nil }
+ guard !albumsNeedingArt.isEmpty else { return }
+
+ for album in albumsNeedingArt {
+ if let url = await SpotifyService.shared.artworkUrl(title: album.title, artist: album.artist) {
+ await MainActor.run {
+ if let index = self.albums.firstIndex(where: { $0.id == album.id }) {
+ self.albums[index].artworkUrl = url
+ }
+ // Also update artwork in feedItems that reference this album
+ for i in self.feedItems.indices where self.feedItems[i].album.id == album.id {
+ self.feedItems[i].album.artworkUrl = url
+ }
+ }
+ }
+ // Spotify rate limit: 1 request per ~100ms is safe with client credentials
+ try? await Task.sleep(nanoseconds: 150_000_000)
+ }
+ }
+
+ // MARK: - Refresh from API
+
+ func refresh() async {
+ guard AuthManager.shared.isAuthenticated else { return }
+
+ await MainActor.run {
+ self.isLoading = true
+ self.errorMessage = nil
+ }
+
+ do {
+ let remoteAlbums = try await api.searchAlbums(query: "")
+ let mapped = remoteAlbums.items.map { mapAlbum($0) }
+
+ let remoteProfile = try await api.getProfile(handle: "")
+
+ let remoteFeed = try await api.getFeed()
+ let feedMapped = remoteFeed.items.map { mapFeedItem($0) }
+
+ await MainActor.run {
+ if !mapped.isEmpty { self.albums = mapped }
+ self.profile = UserProfile(
+ handle: remoteProfile.handle,
+ bio: remoteProfile.bio,
+ logCount: remoteProfile.logCount,
+ reviewCount: remoteProfile.reviewCount,
+ listCount: remoteProfile.listCount,
+ topAlbums: self.profile.topAlbums,
+ genres: self.profile.genres,
+ avgRating: remoteProfile.avgRating,
+ albumsCount: remoteProfile.logCount,
+ followingCount: self.profile.followingCount,
+ followersCount: self.profile.followersCount,
+ favoriteAlbums: self.profile.favoriteAlbums
+ )
+ self.feedItems = feedMapped.isEmpty ? self.feedItems : feedMapped
+ self.syncMessage = nil
+ }
+
+ if let recap = try? await api.getWeeklyRecap() {
+ await MainActor.run {
+ self.latestRecap = WeeklyRecap(
+ id: recap.id,
+ weekStart: recap.weekStart,
+ weekEnd: recap.weekEnd,
+ totalLogs: recap.totalLogs,
+ averageRating: recap.averageRating,
+ shareText: recap.shareText,
+ deepLink: recap.deepLink
+ )
+ }
+ }
+ } catch {
+ await MainActor.run {
+ self.syncMessage = "Offline mode: \(error.localizedDescription)"
+ self.errorMessage = "Could not reach SoundScore servers. Showing cached data."
+ self.isLoading = false
+ }
+ return
+ }
+
+ await MainActor.run {
+ self.isLoading = false
+ }
+ }
+
+ // MARK: - Local Queries
+
+ func searchAlbums(query: String) -> [Album] {
+ let normalized = query.trimmingCharacters(in: .whitespaces)
+ if normalized.isEmpty { return albums }
+ let lower = normalized.lowercased()
+ return albums.filter {
+ $0.title.lowercased().contains(lower) ||
+ $0.artist.lowercased().contains(lower)
+ }
+ }
+
+ // MARK: - Track Operations
+
+ func fetchTracks(albumId: String) async {
+ // If we already have tracks from seed data, skip
+ if let existing = tracksByAlbum[albumId], !existing.isEmpty { return }
+
+ // Try API first
+ do {
+ let remoteTracks = try await api.getAlbumTracks(albumId: albumId)
+ let mapped = remoteTracks.map { dto in
+ Track(
+ id: dto.id, albumId: dto.albumId, title: dto.title,
+ trackNumber: dto.trackNumber, durationMs: dto.durationMs,
+ spotifyId: dto.spotifyId
+ )
+ }
+ if !mapped.isEmpty {
+ await MainActor.run { self.tracksByAlbum[albumId] = mapped }
+ return
+ }
+ } catch {
+ // Fall through to Spotify
+ }
+
+ // Try Spotify if album has a known spotifyId
+ if let album = albums.first(where: { $0.id == albumId }) {
+ let results = await SpotifyService.shared.searchAlbums(query: "\(album.title) \(album.artist)", limit: 1)
+ if let spotifyAlbum = results.first {
+ let spotifyTracks = await SpotifyService.shared.fetchAlbumTracks(spotifyAlbumId: spotifyAlbum.spotifyId)
+ let mapped = spotifyTracks.map { st in
+ Track(
+ id: "st_\(albumId)_\(st.trackNumber)",
+ albumId: albumId,
+ title: st.title,
+ trackNumber: st.trackNumber,
+ durationMs: st.durationMs,
+ spotifyId: st.spotifyId
+ )
+ }
+ if !mapped.isEmpty {
+ await MainActor.run { self.tracksByAlbum[albumId] = mapped }
+ }
+ }
+ }
+ }
+
+ func updateTrackRating(trackId: String, albumId: String, rating: Float) {
+ trackRatings[trackId] = rating
+ outboxStore.enqueue(OutboxOperation(
+ type: .rateTrack,
+ payload: ["trackId": trackId, "albumId": albumId, "rating": String(rating)]
+ ))
+ Task { await syncOutbox() }
+ }
+
+ // MARK: - Mutations (optimistic + outbox)
+
+ func updateRating(albumId: String, rating: Float) {
+ outboxStore.enqueue(OutboxOperation(
+ type: .rateAlbum,
+ payload: ["albumId": albumId, "rating": String(rating)]
+ ))
+ ratings[albumId] = rating
+
+ let values = ratings.values
+ let avg = values.isEmpty
+ ? profile.avgRating
+ : values.reduce(0, +) / Float(values.count)
+
+ profile = UserProfile(
+ handle: profile.handle, bio: profile.bio,
+ logCount: profile.logCount, reviewCount: profile.reviewCount,
+ listCount: profile.listCount, topAlbums: profile.topAlbums,
+ genres: profile.genres, avgRating: avg,
+ albumsCount: profile.albumsCount,
+ followingCount: profile.followingCount,
+ followersCount: profile.followersCount,
+ favoriteAlbums: profile.favoriteAlbums
+ )
+ Task { await syncOutbox() }
+ }
+
+ func toggleLike(feedItemId: String) {
+ outboxStore.enqueue(OutboxOperation(
+ type: .toggleReaction,
+ payload: ["feedItemId": feedItemId]
+ ))
+ guard let index = feedItems.firstIndex(where: { $0.id == feedItemId }) else { return }
+ feedItems[index].isLiked.toggle()
+ feedItems[index].likes = max(0, feedItems[index].likes + (feedItems[index].isLiked ? 1 : -1))
+ Task { await syncOutbox() }
+ }
+
+ func saveReview(albumId: String, reviewText: String, rating: Float) {
+ outboxStore.enqueue(OutboxOperation(
+ type: .createReview,
+ payload: [
+ "albumId": albumId,
+ "body": reviewText,
+ "rating": String(rating),
+ ]
+ ))
+ // Also persist the rating optimistically
+ if rating > 0 {
+ updateRating(albumId: albumId, rating: rating)
+ }
+ Task { await syncOutbox() }
+ }
+
+ func createList(title: String) {
+ let trimmed = title.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { return }
+
+ outboxStore.enqueue(OutboxOperation(
+ type: .createList,
+ payload: ["title": trimmed]
+ ))
+
+ let newList = UserList(
+ id: "l_\(UUID().uuidString.prefix(8))",
+ title: trimmed, note: nil, albumIds: [],
+ curatorHandle: AuthManager.shared.currentHandle ?? "@user",
+ saves: 0
+ )
+ lists.append(newList)
+ Task { await syncOutbox() }
+ }
+
+ // MARK: - Outbox Sync
+
+ func syncOutbox() async {
+ await outboxEngine.flush { [self] op in
+ switch op.type {
+ case .rateAlbum:
+ let albumId = op.payload["albumId"] ?? ""
+ let rating = Float(op.payload["rating"] ?? "0") ?? 0
+ try await api.createRating(
+ albumId: albumId, value: rating,
+ idempotencyKey: op.idempotencyKey.uuidString
+ )
+ case .rateTrack:
+ let trackId = op.payload["trackId"] ?? ""
+ let albumId = op.payload["albumId"] ?? ""
+ let rating = Float(op.payload["rating"] ?? "0") ?? 0
+ try await api.createTrackRating(
+ trackId: trackId, albumId: albumId, value: rating,
+ idempotencyKey: op.idempotencyKey.uuidString
+ )
+ case .createReview:
+ let albumId = op.payload["albumId"] ?? ""
+ let body = op.payload["body"] ?? ""
+ try await api.createReview(
+ albumId: albumId, body: body,
+ idempotencyKey: op.idempotencyKey.uuidString
+ )
+ case .toggleReaction:
+ let activityId = op.payload["feedItemId"] ?? ""
+ try await api.reactToActivity(
+ id: activityId, reaction: "like",
+ idempotencyKey: op.idempotencyKey.uuidString
+ )
+ case .createList:
+ let title = op.payload["title"] ?? ""
+ try await api.createList(
+ title: title,
+ idempotencyKey: op.idempotencyKey.uuidString
+ )
+ case .exportData:
+ break
+ case .registerDeviceToken:
+ let platform = op.payload["platform"] ?? ""
+ let token = op.payload["deviceToken"] ?? ""
+ try await api.registerDevice(
+ platform: platform, token: token,
+ idempotencyKey: op.idempotencyKey.uuidString
+ )
+ case .updateNotificationPreferences:
+ let prefs = NotificationPreferenceDto(
+ socialEnabled: op.payload["socialEnabled"] == "true",
+ recapEnabled: op.payload["recapEnabled"] == "true",
+ commentEnabled: op.payload["commentEnabled"] == "true",
+ reactionEnabled: op.payload["reactionEnabled"] == "true",
+ quietHoursStart: Int(op.payload["quietHoursStart"] ?? "22") ?? 22,
+ quietHoursEnd: Int(op.payload["quietHoursEnd"] ?? "7") ?? 7
+ )
+ try await api.updatePreferences(
+ prefs,
+ idempotencyKey: op.idempotencyKey.uuidString
+ )
+ }
+ }
+
+ let pending = outboxStore.pending
+ await MainActor.run {
+ if pending.isEmpty {
+ self.syncMessage = nil
+ } else {
+ self.syncMessage = "Pending \(pending.count) offline ops"
+ }
+ }
+ }
+
+ // MARK: - Mappers
+
+ private func mapAlbum(_ dto: AlbumDto) -> Album {
+ let colors = SeedData.albums.first { $0.id == dto.id }?.artColors
+ ?? SeedData.albums.randomElement()?.artColors
+ ?? AlbumColors.forest
+ return Album(
+ id: dto.id, title: dto.title, artist: dto.artist, year: dto.year,
+ artColors: colors, artworkUrl: dto.artworkUrl,
+ avgRating: dto.avgRating, logCount: dto.logCount
+ )
+ }
+
+ private func mapFeedItem(_ event: ActivityEventDto) -> FeedItem {
+ let resolvedAlbum = albums.first { $0.id == event.activityObject.id }
+ ?? albums.first
+ ?? SeedData.albums[0]
+ let action: String
+ switch event.type {
+ case "RATED_ALBUM": action = "rated"
+ case "WROTE_REVIEW": action = "reviewed"
+ case "CREATED_LIST": action = "created a list"
+ case "ADDED_LIST_ITEM": action = "updated a list"
+ default: action = "posted"
+ }
+ return FeedItem(
+ id: event.id, username: event.actorId, action: action,
+ album: resolvedAlbum,
+ rating: resolvedAlbum.avgRating,
+ reviewSnippet: nil,
+ likes: event.reactions, comments: event.comments,
+ timeAgo: formatTimeAgo(event.createdAt), isLiked: false
+ )
+ }
+
+ private func formatTimeAgo(_ isoDate: String) -> String {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ guard let date = formatter.date(from: isoDate) else {
+ return String(isoDate.prefix(16))
+ }
+ let seconds = Int(Date().timeIntervalSince(date))
+ switch seconds {
+ case ..<60: return "now"
+ case ..<3600: return "\(seconds / 60)m"
+ case ..<86400: return "\(seconds / 3600)h"
+ case ..<604800: return "\(seconds / 86400)d"
+ default: return "\(seconds / 604800)w"
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Services/SpotifyService.swift b/ios/SoundScore/SoundScore/Services/SpotifyService.swift
new file mode 100644
index 0000000..72c5d86
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Services/SpotifyService.swift
@@ -0,0 +1,216 @@
+import Foundation
+
+actor SpotifyService {
+ static let shared = SpotifyService()
+
+ private let clientId = Secrets.spotifyClientId
+ private let clientSecret = Secrets.spotifyClientSecret
+ private let tokenURL = URL(string: "https://accounts.spotify.com/api/token")!
+ private let searchURL = URL(string: "https://api.spotify.com/v1/search")!
+
+ private var accessToken: String?
+ private var tokenExpiry: Date = .distantPast
+ private var artworkCache: [String: String] = [:]
+
+ // MARK: - Public API
+
+ /// Search Spotify for albums, returns array of (title, artist, artworkUrl, spotifyId)
+ func searchAlbums(query: String, limit: Int = 5) async -> [SpotifyAlbumResult] {
+ guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { return [] }
+
+ do {
+ let token = try await ensureToken()
+
+ var components = URLComponents(url: searchURL, resolvingAgainstBaseURL: false)!
+ components.queryItems = [
+ URLQueryItem(name: "q", value: query),
+ URLQueryItem(name: "type", value: "album"),
+ URLQueryItem(name: "limit", value: String(min(limit, 10)))
+ ]
+ guard let url = components.url else { return [] }
+
+ var request = URLRequest(url: url)
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
+ return []
+ }
+
+ let decoded = try JSONDecoder().decode(SpotifySearchResponse.self, from: data)
+ return decoded.albums.items.compactMap { album in
+ guard let imageUrl = album.images.first?.url else { return nil }
+ return SpotifyAlbumResult(
+ title: album.name,
+ artist: album.artists.first?.name ?? "Unknown",
+ artworkUrl: imageUrl,
+ spotifyId: album.id,
+ year: parseYear(album.releaseDate)
+ )
+ }
+ } catch {
+ #if DEBUG
+ print("[Spotify] Search error: \(error)")
+ #endif
+ return []
+ }
+ }
+
+ /// Look up artwork URL for a specific album by title + artist
+ func artworkUrl(title: String, artist: String) async -> String? {
+ let cacheKey = "\(title)|\(artist)".lowercased()
+ if let cached = artworkCache[cacheKey] { return cached }
+
+ let results = await searchAlbums(query: "\(title) \(artist)", limit: 1)
+ guard let first = results.first else { return nil }
+ artworkCache[cacheKey] = first.artworkUrl
+ return first.artworkUrl
+ }
+
+ /// Fetch tracks for a Spotify album
+ func fetchAlbumTracks(spotifyAlbumId: String) async -> [SpotifyTrackResult] {
+ do {
+ let token = try await ensureToken()
+
+ guard let url = URL(string: "https://api.spotify.com/v1/albums/\(spotifyAlbumId)/tracks?limit=50") else { return [] }
+
+ var request = URLRequest(url: url)
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
+ return []
+ }
+
+ let decoded = try JSONDecoder().decode(SpotifyTracksResponse.self, from: data)
+ return decoded.items.enumerated().map { index, item in
+ SpotifyTrackResult(
+ title: item.name,
+ trackNumber: item.trackNumber ?? (index + 1),
+ durationMs: item.durationMs,
+ spotifyId: item.id
+ )
+ }
+ } catch {
+ #if DEBUG
+ print("[Spotify] Tracks error: \(error)")
+ #endif
+ return []
+ }
+ }
+
+ // MARK: - Auth (Client Credentials)
+
+ private func ensureToken() async throws -> String {
+ if let token = accessToken, Date() < tokenExpiry {
+ return token
+ }
+
+ let credentials = Data("\(clientId):\(clientSecret)".utf8).base64EncodedString()
+
+ var request = URLRequest(url: tokenURL)
+ request.httpMethod = "POST"
+ request.setValue("Basic \(credentials)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+ request.httpBody = "grant_type=client_credentials".data(using: .utf8)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
+ throw SpotifyError.authFailed
+ }
+
+ let tokenResponse = try JSONDecoder().decode(SpotifyTokenResponse.self, from: data)
+ accessToken = tokenResponse.accessToken
+ tokenExpiry = Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn - 60))
+ return tokenResponse.accessToken
+ }
+
+ private func parseYear(_ releaseDate: String) -> Int {
+ Int(releaseDate.prefix(4)) ?? 0
+ }
+}
+
+// MARK: - Models
+
+struct SpotifyAlbumResult {
+ let title: String
+ let artist: String
+ let artworkUrl: String
+ let spotifyId: String
+ let year: Int
+}
+
+struct SpotifyTrackResult {
+ let title: String
+ let trackNumber: Int
+ let durationMs: Int
+ let spotifyId: String
+}
+
+enum SpotifyError: Error {
+ case authFailed
+}
+
+// MARK: - Spotify API Response Types
+
+private struct SpotifyTokenResponse: Decodable {
+ let accessToken: String
+ let tokenType: String
+ let expiresIn: Int
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ case tokenType = "token_type"
+ case expiresIn = "expires_in"
+ }
+}
+
+private struct SpotifySearchResponse: Decodable {
+ let albums: SpotifyAlbumsPage
+}
+
+private struct SpotifyAlbumsPage: Decodable {
+ let items: [SpotifyAlbum]
+}
+
+private struct SpotifyAlbum: Decodable {
+ let id: String
+ let name: String
+ let artists: [SpotifyArtist]
+ let images: [SpotifyImage]
+ let releaseDate: String
+
+ enum CodingKeys: String, CodingKey {
+ case id, name, artists, images
+ case releaseDate = "release_date"
+ }
+}
+
+private struct SpotifyArtist: Decodable {
+ let name: String
+}
+
+private struct SpotifyImage: Decodable {
+ let url: String
+ let height: Int?
+ let width: Int?
+}
+
+// MARK: - Spotify Tracks Response Types
+
+private struct SpotifyTracksResponse: Decodable {
+ let items: [SpotifyTrackItem]
+}
+
+private struct SpotifyTrackItem: Decodable {
+ let id: String
+ let name: String
+ let trackNumber: Int?
+ let durationMs: Int
+
+ enum CodingKeys: String, CodingKey {
+ case id, name
+ case trackNumber = "track_number"
+ case durationMs = "duration_ms"
+ }
+}
diff --git a/ios/SoundScore/SoundScore/SoundScoreApp.swift b/ios/SoundScore/SoundScore/SoundScoreApp.swift
new file mode 100644
index 0000000..a3b167d
--- /dev/null
+++ b/ios/SoundScore/SoundScore/SoundScoreApp.swift
@@ -0,0 +1,11 @@
+import SwiftUI
+
+@main
+struct SoundScoreApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .preferredColorScheme(.dark)
+ }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Theme/SSColors.swift b/ios/SoundScore/SoundScore/Theme/SSColors.swift
new file mode 100644
index 0000000..3981ce0
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Theme/SSColors.swift
@@ -0,0 +1,77 @@
+import SwiftUI
+
+enum SSColors {
+ // MARK: - Theme-adaptive backgrounds (change per theme)
+ static var darkBase: Color { ThemeManager.shared.colors.darkBase }
+ static var darkSurface: Color { ThemeManager.shared.colors.darkSurface }
+ static var darkElevated: Color { ThemeManager.shared.colors.darkElevated }
+
+ // MARK: - Glass (white-based, adapts via ultraThinMaterial against theme bg)
+ static let glassBg = Color.white.opacity(0.07)
+ static let glassBorder = Color.white.opacity(0.18)
+ static let glassFrosted = Color.white.opacity(0.16)
+ static let glassSheet = Color.white.opacity(0.10)
+
+ // MARK: - Chrome text
+ static let chromeLight = Color.white.opacity(0.94)
+ static let chromeMedium = Color.white.opacity(0.70)
+ static let chromeDim = Color.white.opacity(0.44)
+ static let chromeFaint = Color.white.opacity(0.24)
+
+ // MARK: - Semantic accent (fixed, not theme-dependent)
+ static let accentGreen = Color(hex: 0x1ED760)
+ static let accentAmber = Color(hex: 0xFFA726)
+ static let accentCoral = Color(hex: 0xFF6B6B)
+ static let accentViolet = Color(hex: 0xB388FF)
+
+ // MARK: - Semantic text
+ static let textPrimary = Color.white.opacity(0.95)
+ static let textSecondary = Color.white.opacity(0.72)
+ static let textTertiary = Color.white.opacity(0.55)
+
+ // MARK: - Dim variants
+ static let accentGreenDim = Color(hex: 0x1ED760, alpha: 0.12)
+ static let accentAmberDim = Color(hex: 0xFFA726, alpha: 0.12)
+ static let accentCoralDim = Color(hex: 0xFF6B6B, alpha: 0.12)
+ static let accentVioletDim = Color(hex: 0xB388FF, alpha: 0.12)
+
+ // MARK: - Borders & highlights
+ static let feedItemBorder = Color.white.opacity(0.12)
+ static let glassHighlight = Color.white.opacity(0.19)
+
+ // MARK: - Overlays (always black-based)
+ static let overlayDark = Color.black.opacity(0.7)
+ static let overlayMedium = Color.black.opacity(0.5)
+ static let overlayLight = Color.black.opacity(0.3)
+ static let overlayOnImage = Color.black.opacity(0.22)
+
+ // MARK: - Glass elevation levels
+ static let glassLevel1 = Color.white.opacity(0.04)
+ static let glassLevel2 = Color.white.opacity(0.08)
+ static let glassLevel3 = Color.white.opacity(0.14)
+}
+
+enum AlbumColors {
+ static let forest: [Color] = [Color(hex: 0x2D6A4F), Color(hex: 0x95D5B2)]
+ static let lime: [Color] = [Color(hex: 0x4C956C), Color(hex: 0xD8F3DC)]
+ static let ember: [Color] = [Color(hex: 0xE76F51), Color(hex: 0xF4A261)]
+ static let orchid: [Color] = [Color(hex: 0x7B2CBF), Color(hex: 0xC77DFF)]
+ static let lagoon: [Color] = [Color(hex: 0x0077B6), Color(hex: 0x90E0EF)]
+ static let rose: [Color] = [Color(hex: 0xE63946), Color(hex: 0xFFB4A2)]
+ static let midnight: [Color] = [Color(hex: 0x1D3557), Color(hex: 0x457B9D)]
+ static let slate: [Color] = [Color(hex: 0x495057), Color(hex: 0xADB5BD)]
+ static let coral: [Color] = [Color(hex: 0xFF6B6B), Color(hex: 0xFFC09F)]
+ static let amber: [Color] = [Color(hex: 0xFFA726), Color(hex: 0xFFE082)]
+}
+
+extension Color {
+ init(hex: UInt, alpha: Double = 1.0) {
+ self.init(
+ .sRGB,
+ red: Double((hex >> 16) & 0xFF) / 255,
+ green: Double((hex >> 8) & 0xFF) / 255,
+ blue: Double(hex & 0xFF) / 255,
+ opacity: alpha
+ )
+ }
+}
diff --git a/ios/SoundScore/SoundScore/Theme/SSTypography.swift b/ios/SoundScore/SoundScore/Theme/SSTypography.swift
new file mode 100644
index 0000000..f12b5a6
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Theme/SSTypography.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+enum SSTypography {
+ static let displaySmall: Font = .system(size: 22, weight: .bold, design: .rounded)
+ static let displayMedium: Font = .system(size: 28, weight: .bold, design: .rounded)
+
+ static let headlineSmall: Font = .system(size: 18, weight: .semibold, design: .rounded)
+ static let headlineMedium: Font = .system(size: 22, weight: .semibold, design: .rounded)
+
+ static let titleMedium: Font = .system(size: 14, weight: .medium, design: .rounded)
+ static let titleLarge: Font = .system(size: 16, weight: .semibold, design: .rounded)
+
+ static let bodySmall: Font = .system(size: 12, weight: .regular, design: .rounded)
+ static let bodyMedium: Font = .system(size: 14, weight: .regular, design: .rounded)
+ static let bodyLarge: Font = .system(size: 16, weight: .regular, design: .rounded)
+
+ static let labelSmall: Font = .system(size: 10, weight: .medium, design: .rounded)
+ static let labelMedium: Font = .system(size: 12, weight: .medium, design: .rounded)
+ static let labelLarge: Font = .system(size: 14, weight: .semibold, design: .rounded)
+}
diff --git a/ios/SoundScore/SoundScore/Theme/ThemeManager.swift b/ios/SoundScore/SoundScore/Theme/ThemeManager.swift
new file mode 100644
index 0000000..4ee6686
--- /dev/null
+++ b/ios/SoundScore/SoundScore/Theme/ThemeManager.swift
@@ -0,0 +1,126 @@
+import SwiftUI
+
+struct ThemeColorScheme {
+ let darkBase: Color
+ let darkSurface: Color
+ let darkElevated: Color
+}
+
+enum AccentTheme: String, CaseIterable, Identifiable {
+ case emerald, bonfire, rose, amethyst, midnight, gilt
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .emerald: return "Emerald"
+ case .bonfire: return "Bonfire"
+ case .rose: return "Rose"
+ case .amethyst: return "Amethyst"
+ case .midnight: return "Midnight"
+ case .gilt: return "Gilt"
+ }
+ }
+
+ var primary: Color {
+ switch self {
+ case .emerald: return Color(hex: 0x1ED760)
+ case .bonfire: return Color(hex: 0xFF8C42)
+ case .rose: return Color(hex: 0xFF6B8A)
+ case .amethyst: return Color(hex: 0xB388FF)
+ case .midnight: return Color(hex: 0x4FC3F7)
+ case .gilt: return Color(hex: 0xFFD54F)
+ }
+ }
+
+ var primaryDim: Color { primary.opacity(0.12) }
+
+ var secondary: Color {
+ switch self {
+ case .emerald: return Color(hex: 0xFFA726)
+ case .bonfire: return Color(hex: 0xFF6B6B)
+ case .rose: return Color(hex: 0xFFA726)
+ case .amethyst: return Color(hex: 0x4FC3F7)
+ case .midnight: return Color(hex: 0xB388FF)
+ case .gilt: return Color(hex: 0xFF8C42)
+ }
+ }
+
+ var secondaryDim: Color { secondary.opacity(0.12) }
+
+ var backdropGlow: Color { primary.opacity(0.40) }
+ var backdropSecondaryGlow: Color { secondary.opacity(0.18) }
+
+ var colors: ThemeColorScheme {
+ switch self {
+ case .emerald:
+ return ThemeColorScheme(
+ darkBase: Color(hex: 0x020A04),
+ darkSurface: Color(hex: 0x061A10),
+ darkElevated: Color(hex: 0x0A2A18)
+ )
+ case .bonfire:
+ return ThemeColorScheme(
+ darkBase: Color(hex: 0x0A0402),
+ darkSurface: Color(hex: 0x180E06),
+ darkElevated: Color(hex: 0x2A1808)
+ )
+ case .rose:
+ return ThemeColorScheme(
+ darkBase: Color(hex: 0x0A0306),
+ darkSurface: Color(hex: 0x18080E),
+ darkElevated: Color(hex: 0x2A0E18)
+ )
+ case .amethyst:
+ return ThemeColorScheme(
+ darkBase: Color(hex: 0x060210),
+ darkSurface: Color(hex: 0x0E081A),
+ darkElevated: Color(hex: 0x1C0E2A)
+ )
+ case .midnight:
+ return ThemeColorScheme(
+ darkBase: Color(hex: 0x020410),
+ darkSurface: Color(hex: 0x060E1E),
+ darkElevated: Color(hex: 0x0C182E)
+ )
+ case .gilt:
+ return ThemeColorScheme(
+ darkBase: Color(hex: 0x0A0802),
+ darkSurface: Color(hex: 0x181206),
+ darkElevated: Color(hex: 0x2A2008)
+ )
+ }
+ }
+
+ /// Migration from old rawValues
+ static func from(legacy raw: String) -> AccentTheme {
+ switch raw {
+ case "mint": return .emerald
+ case "sunset": return .bonfire
+ case "coral": return .rose
+ case "lavender": return .amethyst
+ case "ocean": return .midnight
+ case "gold": return .gilt
+ default: return AccentTheme(rawValue: raw) ?? .emerald
+ }
+ }
+}
+
+class ThemeManager: ObservableObject {
+ static let shared = ThemeManager()
+
+ @Published var current: AccentTheme {
+ didSet { UserDefaults.standard.set(current.rawValue, forKey: "ss_accentTheme") }
+ }
+
+ var primary: Color { current.primary }
+ var primaryDim: Color { current.primaryDim }
+ var secondary: Color { current.secondary }
+ var secondaryDim: Color { current.secondaryDim }
+ var colors: ThemeColorScheme { current.colors }
+
+ private init() {
+ let saved = UserDefaults.standard.string(forKey: "ss_accentTheme") ?? "emerald"
+ self.current = AccentTheme.from(legacy: saved)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/ViewModels/AIBuddyViewModel.swift b/ios/SoundScore/SoundScore/ViewModels/AIBuddyViewModel.swift
new file mode 100644
index 0000000..662c7ca
--- /dev/null
+++ b/ios/SoundScore/SoundScore/ViewModels/AIBuddyViewModel.swift
@@ -0,0 +1,174 @@
+import SwiftUI
+import Combine
+
+struct SuggestionChip: Identifiable {
+ let id = UUID()
+ let label: String
+ let prompt: String
+ let icon: String
+}
+
+@MainActor
+class AIBuddyViewModel: ObservableObject {
+ @Published var messages: [ChatMessage] = []
+ @Published var inputText: String = ""
+ @Published var isThinking: Bool = false
+ @Published var cadenceState: CadenceState = .idle
+ @Published var errorMessage: String?
+ @Published var suggestions: [SuggestionChip] = []
+ @Published var actionConfirmation: String?
+
+ init() {
+ messages.append(ChatMessage(
+ role: .assistant,
+ content: "Hey! I'm Cadence, your music agent. I can rate albums, draft reviews in your voice, and help you discover music. Try asking me to review something or rate a few albums at once."
+ ))
+ loadInitialSuggestions()
+ }
+
+ // MARK: - Suggestions
+
+ private func loadInitialSuggestions() {
+ let repo = SoundScoreRepository.shared
+ let topRated = repo.ratings.sorted { $0.value > $1.value }
+ let topAlbum = topRated.first.flatMap { entry in repo.albums.first { $0.id == entry.key } }
+ let unrated = repo.albums.filter { repo.ratings[$0.id] == nil }
+
+ let topName = topAlbum?.title ?? "CHROMAKOPIA"
+ let topArtist = topAlbum?.artist ?? "Tyler, the Creator"
+ let unratedNames = unrated.prefix(3).map(\.title).joined(separator: ", ")
+
+ suggestions = [
+ SuggestionChip(label: "Draft a review for \(topName)", prompt: "Write a review for \(topName) in my voice and let me edit it", icon: "square.and.pencil"),
+ SuggestionChip(label: "Rate my unrated albums", prompt: "Rate these for me: \(unratedNames)", icon: "star"),
+ SuggestionChip(label: "What should I listen to next?", prompt: "Based on my taste, what should I listen to next?", icon: "headphones"),
+ SuggestionChip(label: "Roast my taste", prompt: "Roast my music taste based on my ratings. Be brutally honest but funny.", icon: "flame"),
+ SuggestionChip(label: "Deep cuts from \(topArtist)", prompt: "What are some deep cuts from \(topArtist)?", icon: "waveform"),
+ ]
+ }
+
+ func tapSuggestion(_ chip: SuggestionChip) {
+ inputText = chip.prompt
+ sendMessage()
+ suggestions = []
+ }
+
+ // MARK: - Send Message
+
+ func sendMessage() {
+ let text = inputText.trimmingCharacters(in: .whitespaces)
+ guard !text.isEmpty else { return }
+ guard !Secrets.geminiAPIKey.isEmpty else {
+ errorMessage = "Gemini API key not configured."
+ return
+ }
+
+ messages.append(ChatMessage(role: .user, content: text))
+ inputText = ""
+ isThinking = true
+ cadenceState = .thinking
+ errorMessage = nil
+
+ Task {
+ do {
+ let result = try await AIBuddyService.shared.sendMessage(
+ messages: messages.filter { $0.role != .system },
+ userContext: buildUserContext(),
+ albumCatalog: buildAlbumCatalog()
+ )
+ let msg = ChatMessage(role: .assistant, content: result.text, actions: result.actions)
+ self.messages.append(msg)
+ self.isThinking = false
+ self.cadenceState = .happy
+ self.generateFollowUpSuggestions(hadActions: !result.actions.isEmpty)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+ if self.cadenceState == .happy { self.cadenceState = .idle }
+ }
+ } catch {
+ self.errorMessage = error.localizedDescription
+ self.isThinking = false
+ self.cadenceState = .idle
+ }
+ }
+ }
+
+ // MARK: - Execute Actions
+
+ func executeRating(_ action: CadenceAction) {
+ guard let rating = Float(action.value) else { return }
+ SoundScoreRepository.shared.updateRating(albumId: action.albumId, rating: rating)
+ showConfirmation("Rated \(action.albumTitle) → \(action.value)/6")
+ }
+
+ func executeBatchRatings(_ actions: [CadenceAction]) {
+ for action in actions {
+ if let rating = Float(action.value) {
+ SoundScoreRepository.shared.updateRating(albumId: action.albumId, rating: rating)
+ }
+ }
+ showConfirmation("\(actions.count) albums rated")
+ }
+
+ func executeReview(albumId: String, albumTitle: String, reviewText: String, rating: Float) {
+ if rating > 0 {
+ SoundScoreRepository.shared.updateRating(albumId: albumId, rating: rating)
+ }
+ SoundScoreRepository.shared.saveReview(albumId: albumId, reviewText: reviewText, rating: rating)
+ showConfirmation("Review saved for \(albumTitle)")
+ }
+
+ func discardAction(messageId: String, actionId: UUID) {
+ if let idx = messages.firstIndex(where: { $0.id == messageId }) {
+ messages[idx].actions.removeAll { $0.id == actionId }
+ }
+ }
+
+ private func showConfirmation(_ text: String) {
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ withAnimation(.spring(response: 0.3)) { actionConfirmation = text }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+ withAnimation { self.actionConfirmation = nil }
+ }
+ }
+
+ // MARK: - Follow-up Suggestions
+
+ private func generateFollowUpSuggestions(hadActions: Bool) {
+ let repo = SoundScoreRepository.shared
+ let unrated = repo.albums.filter { repo.ratings[$0.id] == nil }
+ var chips: [SuggestionChip] = []
+
+ if hadActions {
+ chips.append(SuggestionChip(label: "Do another one", prompt: "Rate and review another album from my library", icon: "arrow.clockwise"))
+ }
+ if let next = unrated.first {
+ chips.append(SuggestionChip(label: "Review \(next.title)", prompt: "Draft a review for \(next.title) by \(next.artist)", icon: "square.and.pencil"))
+ }
+ chips.append(SuggestionChip(label: "Tell me more", prompt: "Tell me more about that", icon: "text.bubble"))
+ chips.append(SuggestionChip(label: "Something different", prompt: "Surprise me with something completely different", icon: "shuffle"))
+ suggestions = chips
+ }
+
+ // MARK: - Context Builders
+
+ private func buildUserContext() -> String {
+ let repo = SoundScoreRepository.shared
+ let ratedAlbums = repo.ratings.compactMap { (albumId, rating) -> String? in
+ guard let album = repo.albums.first(where: { $0.id == albumId }) else { return nil }
+ return "\(album.title) by \(album.artist): \(rating)/6"
+ }
+ let genres = repo.profile.genres.prefix(5).joined(separator: ", ")
+ let avgRating = String(format: "%.1f", repo.profile.avgRating)
+ var context = "Rated \(repo.ratings.count)/\(repo.albums.count) albums. Avg: \(avgRating)/6. Genres: \(genres)."
+ if !ratedAlbums.isEmpty {
+ context += "\nRatings: \(ratedAlbums.joined(separator: "; "))."
+ }
+ return context
+ }
+
+ private func buildAlbumCatalog() -> String {
+ SoundScoreRepository.shared.albums.map {
+ "\($0.id): \($0.title) by \($0.artist) (\($0.year))"
+ }.joined(separator: "\n")
+ }
+}
diff --git a/ios/SoundScore/SoundScore/ViewModels/AlbumDetailViewModel.swift b/ios/SoundScore/SoundScore/ViewModels/AlbumDetailViewModel.swift
new file mode 100644
index 0000000..a8d89c6
--- /dev/null
+++ b/ios/SoundScore/SoundScore/ViewModels/AlbumDetailViewModel.swift
@@ -0,0 +1,56 @@
+import Foundation
+import Combine
+
+class AlbumDetailViewModel: ObservableObject {
+ let album: Album
+
+ @Published var tracks: [Track] = []
+ @Published var trackRatings: [String: Float] = [:]
+ @Published var userRating: Float = 0
+ @Published var isLoadingTracks: Bool = true
+
+ private var cancellables = Set()
+
+ init(album: Album) {
+ self.album = album
+ let repo = SoundScoreRepository.shared
+
+ self.userRating = repo.ratings[album.id] ?? 0
+ self.tracks = repo.tracksByAlbum[album.id] ?? []
+ self.trackRatings = repo.trackRatings
+ self.isLoadingTracks = tracks.isEmpty
+
+ repo.$tracksByAlbum
+ .receive(on: RunLoop.main)
+ .map { $0[album.id] ?? [] }
+ .sink { [weak self] newTracks in
+ self?.tracks = newTracks
+ if !newTracks.isEmpty {
+ self?.isLoadingTracks = false
+ }
+ }
+ .store(in: &cancellables)
+
+ repo.$trackRatings
+ .receive(on: RunLoop.main)
+ .assign(to: &$trackRatings)
+
+ repo.$ratings
+ .receive(on: RunLoop.main)
+ .map { $0[album.id] ?? 0 }
+ .assign(to: &$userRating)
+
+ Task { await repo.fetchTracks(albumId: album.id) }
+ }
+
+ func updateAlbumRating(_ rating: Float) {
+ userRating = rating
+ SoundScoreRepository.shared.updateRating(albumId: album.id, rating: rating)
+ }
+
+ func updateTrackRating(trackId: String, rating: Float) {
+ SoundScoreRepository.shared.updateTrackRating(
+ trackId: trackId, albumId: album.id, rating: rating
+ )
+ }
+}
diff --git a/ios/SoundScore/SoundScore/ViewModels/FeedViewModel.swift b/ios/SoundScore/SoundScore/ViewModels/FeedViewModel.swift
new file mode 100644
index 0000000..04d90c2
--- /dev/null
+++ b/ios/SoundScore/SoundScore/ViewModels/FeedViewModel.swift
@@ -0,0 +1,55 @@
+import Foundation
+import Combine
+
+class FeedViewModel: ObservableObject {
+ @Published var items: [FeedItem]
+ @Published var trendingAlbums: [Album]
+ @Published var featuredLists: [ListShowcase]
+ @Published var syncMessage: String?
+ @Published var isLoading: Bool
+ @Published var errorMessage: String?
+
+ init() {
+ let repo = SoundScoreRepository.shared
+ self.items = repo.feedItems
+ self.trendingAlbums = buildTrendingAlbums(repo.albums)
+ self.featuredLists = resolveListShowcases(repo.lists, repo.albums)
+ self.syncMessage = repo.syncMessage
+ self.isLoading = repo.isLoading
+ self.errorMessage = repo.errorMessage
+
+ repo.$feedItems
+ .receive(on: RunLoop.main)
+ .assign(to: &$items)
+
+ repo.$albums
+ .receive(on: RunLoop.main)
+ .map { buildTrendingAlbums($0) }
+ .assign(to: &$trendingAlbums)
+
+ Publishers.CombineLatest(repo.$lists, repo.$albums)
+ .receive(on: RunLoop.main)
+ .map { resolveListShowcases($0, $1) }
+ .assign(to: &$featuredLists)
+
+ repo.$syncMessage
+ .receive(on: RunLoop.main)
+ .assign(to: &$syncMessage)
+
+ repo.$isLoading
+ .receive(on: RunLoop.main)
+ .assign(to: &$isLoading)
+
+ repo.$errorMessage
+ .receive(on: RunLoop.main)
+ .assign(to: &$errorMessage)
+ }
+
+ func toggleLike(_ id: String) {
+ SoundScoreRepository.shared.toggleLike(feedItemId: id)
+ }
+
+ func refresh() {
+ Task { await SoundScoreRepository.shared.refresh() }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/ViewModels/ListsViewModel.swift b/ios/SoundScore/SoundScore/ViewModels/ListsViewModel.swift
new file mode 100644
index 0000000..bae8e6d
--- /dev/null
+++ b/ios/SoundScore/SoundScore/ViewModels/ListsViewModel.swift
@@ -0,0 +1,44 @@
+import Foundation
+import Combine
+
+class ListsViewModel: ObservableObject {
+ @Published var lists: [UserList]
+ @Published var showcases: [ListShowcase]
+ @Published var syncMessage: String?
+ @Published var isLoading: Bool
+ @Published var errorMessage: String?
+
+ init() {
+ let repo = SoundScoreRepository.shared
+ self.lists = repo.lists
+ self.showcases = resolveListShowcases(repo.lists, repo.albums)
+ self.syncMessage = repo.syncMessage
+ self.isLoading = repo.isLoading
+ self.errorMessage = repo.errorMessage
+
+ repo.$lists
+ .receive(on: RunLoop.main)
+ .assign(to: &$lists)
+
+ Publishers.CombineLatest(repo.$lists, repo.$albums)
+ .receive(on: RunLoop.main)
+ .map { resolveListShowcases($0, $1) }
+ .assign(to: &$showcases)
+
+ repo.$syncMessage
+ .receive(on: RunLoop.main)
+ .assign(to: &$syncMessage)
+
+ repo.$isLoading
+ .receive(on: RunLoop.main)
+ .assign(to: &$isLoading)
+
+ repo.$errorMessage
+ .receive(on: RunLoop.main)
+ .assign(to: &$errorMessage)
+ }
+
+ func createList(title: String) {
+ SoundScoreRepository.shared.createList(title: title)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/ViewModels/LogViewModel.swift b/ios/SoundScore/SoundScore/ViewModels/LogViewModel.swift
new file mode 100644
index 0000000..1c2cc3a
--- /dev/null
+++ b/ios/SoundScore/SoundScore/ViewModels/LogViewModel.swift
@@ -0,0 +1,57 @@
+import Foundation
+import Combine
+
+class LogViewModel: ObservableObject {
+ @Published var quickLogAlbums: [Album]
+ @Published var ratings: [String: Float]
+ @Published var summaryStats: [LogSummaryStat]
+ @Published var recentLogs: [RecentLogEntry]
+ @Published var syncMessage: String?
+ @Published var isLoading: Bool
+ @Published var errorMessage: String?
+
+ init() {
+ let repo = SoundScoreRepository.shared
+ self.quickLogAlbums = repo.albums
+ self.ratings = repo.ratings
+ self.summaryStats = buildLogSummaryStats(repo.ratings)
+ self.recentLogs = buildRecentLogs(repo.albums, repo.ratings)
+ self.syncMessage = repo.syncMessage
+ self.isLoading = repo.isLoading
+ self.errorMessage = repo.errorMessage
+
+ repo.$albums
+ .receive(on: RunLoop.main)
+ .assign(to: &$quickLogAlbums)
+
+ repo.$ratings
+ .receive(on: RunLoop.main)
+ .assign(to: &$ratings)
+
+ repo.$ratings
+ .receive(on: RunLoop.main)
+ .map { buildLogSummaryStats($0) }
+ .assign(to: &$summaryStats)
+
+ Publishers.CombineLatest(repo.$albums, repo.$ratings)
+ .receive(on: RunLoop.main)
+ .map { buildRecentLogs($0, $1) }
+ .assign(to: &$recentLogs)
+
+ repo.$syncMessage
+ .receive(on: RunLoop.main)
+ .assign(to: &$syncMessage)
+
+ repo.$isLoading
+ .receive(on: RunLoop.main)
+ .assign(to: &$isLoading)
+
+ repo.$errorMessage
+ .receive(on: RunLoop.main)
+ .assign(to: &$errorMessage)
+ }
+
+ func updateRating(albumId: String, rating: Float) {
+ SoundScoreRepository.shared.updateRating(albumId: albumId, rating: rating)
+ }
+}
diff --git a/ios/SoundScore/SoundScore/ViewModels/ProfileViewModel.swift b/ios/SoundScore/SoundScore/ViewModels/ProfileViewModel.swift
new file mode 100644
index 0000000..72ba4f3
--- /dev/null
+++ b/ios/SoundScore/SoundScore/ViewModels/ProfileViewModel.swift
@@ -0,0 +1,95 @@
+import Foundation
+import Combine
+
+class ProfileViewModel: ObservableObject {
+ @Published var profile: UserProfile?
+ @Published var metrics: [ProfileMetric]
+ @Published var favoriteAlbums: [Album]
+ @Published var genres: [String]
+ @Published var notificationPreferences: NotificationPreferences
+ @Published var recap: WeeklyRecap?
+ @Published var recentActivity: [RecentLogEntry]
+ @Published var syncMessage: String?
+ @Published var isLoading: Bool
+ @Published var errorMessage: String?
+ @Published var showExportSuccess = false
+ @Published var showDeleteConfirm = false
+
+ var handle: String { profile?.handle ?? "@user" }
+ var bio: String { profile?.bio ?? "" }
+
+ init() {
+ let repo = SoundScoreRepository.shared
+ self.profile = repo.profile
+ self.metrics = buildProfileMetrics(repo.profile)
+ self.favoriteAlbums = buildFavoriteAlbums(repo.profile)
+ self.genres = repo.profile.genres
+ self.notificationPreferences = SeedData.defaultNotificationPreferences
+ self.recap = repo.latestRecap
+ self.syncMessage = repo.syncMessage
+ self.isLoading = repo.isLoading
+ self.errorMessage = repo.errorMessage
+ self.recentActivity = buildRecentLogs(repo.albums, repo.ratings)
+
+ repo.$profile
+ .receive(on: RunLoop.main)
+ .map { Optional($0) }
+ .assign(to: &$profile)
+
+ repo.$profile
+ .receive(on: RunLoop.main)
+ .map { buildProfileMetrics($0) }
+ .assign(to: &$metrics)
+
+ repo.$profile
+ .receive(on: RunLoop.main)
+ .map { buildFavoriteAlbums($0) }
+ .assign(to: &$favoriteAlbums)
+
+ repo.$profile
+ .receive(on: RunLoop.main)
+ .map { $0.genres }
+ .assign(to: &$genres)
+
+ repo.$latestRecap
+ .receive(on: RunLoop.main)
+ .assign(to: &$recap)
+
+ Publishers.CombineLatest(repo.$albums, repo.$ratings)
+ .receive(on: RunLoop.main)
+ .map { buildRecentLogs($0, $1) }
+ .assign(to: &$recentActivity)
+
+ repo.$syncMessage
+ .receive(on: RunLoop.main)
+ .assign(to: &$syncMessage)
+
+ repo.$isLoading
+ .receive(on: RunLoop.main)
+ .assign(to: &$isLoading)
+
+ repo.$errorMessage
+ .receive(on: RunLoop.main)
+ .assign(to: &$errorMessage)
+ }
+
+ func shareProfileText() -> String {
+ guard let profile else { return "" }
+ return "Check out my SoundScore profile: \(profile.handle)\n\(profile.albumsCount) albums logged · avg \(String(format: "%.1f", profile.avgRating))★"
+ }
+
+ func saveNotificationPreferences() {
+ SoundScoreRepository.shared.outboxStore.enqueue(OutboxOperation(
+ type: .updateNotificationPreferences,
+ payload: [
+ "socialEnabled": String(notificationPreferences.socialEnabled),
+ "recapEnabled": String(notificationPreferences.recapEnabled),
+ "commentEnabled": String(notificationPreferences.commentEnabled),
+ "reactionEnabled": String(notificationPreferences.reactionEnabled),
+ "quietHoursStart": String(notificationPreferences.quietHoursStart),
+ "quietHoursEnd": String(notificationPreferences.quietHoursEnd),
+ ]
+ ))
+ Task { await SoundScoreRepository.shared.syncOutbox() }
+ }
+}
diff --git a/ios/SoundScore/SoundScore/ViewModels/SearchViewModel.swift b/ios/SoundScore/SoundScore/ViewModels/SearchViewModel.swift
new file mode 100644
index 0000000..c5f6737
--- /dev/null
+++ b/ios/SoundScore/SoundScore/ViewModels/SearchViewModel.swift
@@ -0,0 +1,91 @@
+import Foundation
+import Combine
+
+class SearchViewModel: ObservableObject {
+ @Published var query: String = ""
+ @Published var results: [Album] = []
+ @Published var browseGenres: [BrowseGenre]
+ @Published var chartEntries: [ChartEntry]
+ @Published var syncMessage: String?
+ @Published var isSearching: Bool = false
+ @Published var errorMessage: String?
+
+ private var cancellables = Set()
+ private var searchTask: Task?
+
+ init() {
+ let repo = SoundScoreRepository.shared
+ self.browseGenres = buildBrowseGenres()
+ self.chartEntries = buildChartEntries(repo.albums)
+ self.syncMessage = repo.syncMessage
+ self.errorMessage = repo.errorMessage
+
+ $query
+ .debounce(for: .milliseconds(350), scheduler: RunLoop.main)
+ .sink { [weak self] q in
+ self?.performSearch(q)
+ }
+ .store(in: &cancellables)
+
+ repo.$albums
+ .receive(on: RunLoop.main)
+ .map { buildChartEntries($0) }
+ .assign(to: &$chartEntries)
+
+ repo.$syncMessage
+ .receive(on: RunLoop.main)
+ .assign(to: &$syncMessage)
+
+ repo.$errorMessage
+ .receive(on: RunLoop.main)
+ .assign(to: &$errorMessage)
+ }
+
+ func updateQuery(_ text: String) {
+ query = text
+ }
+
+ private func performSearch(_ q: String) {
+ searchTask?.cancel()
+
+ let trimmed = q.trimmingCharacters(in: .whitespaces)
+ if trimmed.isEmpty {
+ results = []
+ isSearching = false
+ return
+ }
+
+ isSearching = true
+
+ // Local results first
+ let localResults = SoundScoreRepository.shared.searchAlbums(query: trimmed)
+
+ searchTask = Task { @MainActor in
+ // Show local results immediately
+ self.results = localResults
+
+ // Then fetch Spotify results and merge
+ let spotifyResults = await SpotifyService.shared.searchAlbums(query: trimmed, limit: 5)
+ guard !Task.isCancelled else { return }
+
+ let localIds = Set(localResults.map { "\($0.title.lowercased())|\($0.artist.lowercased())" })
+ let remoteAlbums = spotifyResults.compactMap { result -> Album? in
+ let key = "\(result.title.lowercased())|\(result.artist.lowercased())"
+ guard !localIds.contains(key) else { return nil }
+ return Album(
+ id: "spot_\(result.spotifyId)",
+ title: result.title,
+ artist: result.artist,
+ year: result.year,
+ artColors: AlbumColors.forest,
+ artworkUrl: result.artworkUrl,
+ avgRating: 0,
+ logCount: 0
+ )
+ }
+
+ self.results = localResults + remoteAlbums
+ self.isSearching = false
+ }
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 7a2f8ad..bd1e275 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,13 +17,17 @@
"version": "0.1.0",
"dependencies": {
"@fastify/cors": "^10.0.1",
+ "@fastify/helmet": "^13.0.2",
"@fastify/rate-limit": "^10.3.0",
+ "@fastify/swagger": "^9.7.0",
+ "@fastify/swagger-ui": "^5.2.5",
"@soundscore/contracts": "0.1.0",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.7",
"fastify": "^5.2.0",
"ioredis": "^5.4.2",
- "pg": "^8.13.1"
+ "pg": "^8.13.1",
+ "zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
@@ -31,6 +35,18 @@
"@types/pg": "^8.15.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "backend/node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -475,6 +491,22 @@
"node": ">=18"
}
},
+ "node_modules/@fastify/accept-negotiator": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
+ "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -567,6 +599,26 @@
],
"license": "MIT"
},
+ "node_modules/@fastify/helmet": {
+ "version": "13.0.2",
+ "resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.0.2.tgz",
+ "integrity": "sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "fastify-plugin": "^5.0.0",
+ "helmet": "^8.0.0"
+ }
+ },
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
@@ -627,6 +679,99 @@
"toad-cache": "^3.7.0"
}
},
+ "node_modules/@fastify/send": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
+ "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@lukeed/ms": "^2.0.2",
+ "escape-html": "~1.0.3",
+ "fast-decode-uri-component": "^1.0.1",
+ "http-errors": "^2.0.0",
+ "mime": "^3"
+ }
+ },
+ "node_modules/@fastify/static": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz",
+ "integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/accept-negotiator": "^2.0.0",
+ "@fastify/send": "^4.0.0",
+ "content-disposition": "^1.0.1",
+ "fastify-plugin": "^5.0.0",
+ "fastq": "^1.17.1",
+ "glob": "^13.0.0"
+ }
+ },
+ "node_modules/@fastify/swagger": {
+ "version": "9.7.0",
+ "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.7.0.tgz",
+ "integrity": "sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "fastify-plugin": "^5.0.0",
+ "json-schema-resolver": "^3.0.0",
+ "openapi-types": "^12.1.3",
+ "rfdc": "^1.3.1",
+ "yaml": "^2.4.2"
+ }
+ },
+ "node_modules/@fastify/swagger-ui": {
+ "version": "5.2.5",
+ "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-5.2.5.tgz",
+ "integrity": "sha512-ky3I0LAkXKX/prwSDpoQ3kscBKsj2Ha6Gp1/JfgQSqyx0bm9F2bE//XmGVGj2cR9l5hUjZYn60/hqn7e+OLgWQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/static": "^9.0.0",
+ "fastify-plugin": "^5.0.0",
+ "openapi-types": "^12.1.3",
+ "rfdc": "^1.3.1",
+ "yaml": "^2.4.1"
+ }
+ },
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
@@ -753,12 +898,33 @@
"fastq": "^1.17.1"
}
},
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
+ "node_modules/brace-expansion": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
+ "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@ -768,6 +934,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/content-disposition": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+ "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -807,6 +986,15 @@
"node": ">=0.10"
}
},
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -870,6 +1058,12 @@
"@esbuild/win32-x64": "0.27.3"
}
},
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@@ -1031,6 +1225,58 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
+ "node_modules/glob": {
+ "version": "13.0.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
+ "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minimatch": "^10.2.2",
+ "minipass": "^7.1.3",
+ "path-scurry": "^2.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+ "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
"node_modules/ioredis": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz",
@@ -1083,6 +1329,23 @@
"dequal": "^2.0.3"
}
},
+ "node_modules/json-schema-resolver": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz",
+ "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "fast-uri": "^3.0.5",
+ "rfdc": "^1.1.4"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1"
+ }
+ },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -1138,6 +1401,51 @@
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
+ "node_modules/lru-cache": {
+ "version": "11.2.7",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
+ "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/mime": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
+ "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+ "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/mnemonist": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz",
@@ -1168,6 +1476,28 @@
"node": ">=14.0.0"
}
},
+ "node_modules/openapi-types": {
+ "version": "12.1.3",
+ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
+ "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
+ "license": "MIT"
+ },
+ "node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
@@ -1491,6 +1821,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -1515,6 +1851,15 @@
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
@@ -1536,6 +1881,15 @@
"node": ">=12"
}
},
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
@@ -1586,6 +1940,21 @@
"node": ">=0.4"
}
},
+ "node_modules/yaml": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
diff --git a/packages/contracts/src/compliance.ts b/packages/contracts/src/compliance.ts
new file mode 100644
index 0000000..ab08bcd
--- /dev/null
+++ b/packages/contracts/src/compliance.ts
@@ -0,0 +1,47 @@
+import { z } from "zod";
+import { ProviderName } from "./provider";
+
+/** Where provider attribution must be displayed per Terms of Service. */
+export const AttributionPlacement = z.enum([
+ "search_results",
+ "album_detail",
+ "now_playing",
+ "share_card",
+]);
+export type AttributionPlacement = z.infer;
+
+/** Attribution text and assets required by a provider's branding guidelines. */
+export const AttributionRequirementSchema = z.object({
+ provider: ProviderName,
+ displayText: z.string(),
+ logoUrl: z.string().url().optional(),
+ linkUrl: z.string().url().optional(),
+ mustDisplayIn: z.array(AttributionPlacement),
+});
+
+/** A single compliance rule violation detected during a check. */
+export const ComplianceViolationSchema = z.object({
+ code: z.string(),
+ message: z.string(),
+ severity: z.enum(["error", "warning"]),
+});
+
+/** Result of running a compliance check against a provider integration. */
+export const ComplianceCheckResponseSchema = z.object({
+ provider: ProviderName,
+ compliant: z.boolean(),
+ violations: z.array(ComplianceViolationSchema),
+});
+
+/** Data retention rules dictated by a provider's developer agreement. */
+export const DataRetentionPolicySchema = z.object({
+ provider: ProviderName,
+ maxTokenLifetimeDays: z.number().int().positive(),
+ mustDeleteOnDisconnect: z.boolean(),
+ listeningDataRetentionDays: z.number().int().positive().optional(),
+});
+
+export type AttributionRequirement = z.infer;
+export type ComplianceViolation = z.infer;
+export type ComplianceCheckResponse = z.infer;
+export type DataRetentionPolicy = z.infer;
diff --git a/packages/contracts/src/endpoints.ts b/packages/contracts/src/endpoints.ts
index 6118c1b..e5a3937 100644
--- a/packages/contracts/src/endpoints.ts
+++ b/packages/contracts/src/endpoints.ts
@@ -1,9 +1,13 @@
import { z } from "zod";
export const SignUpRequestSchema = z.object({
- email: z.string().email(),
- password: z.string().min(8),
- handle: z.string().min(2).max(24),
+ email: z.string().email().max(254),
+ password: z.string().min(8).max(128),
+ handle: z
+ .string()
+ .min(2)
+ .max(30)
+ .regex(/^[\w]+$/, "Handle must contain only alphanumeric characters and underscores"),
});
export const LoginRequestSchema = z.object({
@@ -23,36 +27,42 @@ export const AuthResponseSchema = z.object({
});
export const CreateRatingRequestSchema = z.object({
- albumId: z.string(),
- value: z.number().min(0).max(5),
+ albumId: z.string().max(100),
+ value: z.number().min(0).max(6),
+});
+
+export const CreateTrackRatingRequestSchema = z.object({
+ trackId: z.string().max(100),
+ albumId: z.string().max(100),
+ value: z.number().min(0).max(6),
});
export const CreateReviewRequestSchema = z.object({
- albumId: z.string(),
- body: z.string().min(1),
+ albumId: z.string().max(100),
+ body: z.string().min(1).max(5000),
});
export const UpdateReviewRequestSchema = z.object({
- body: z.string().min(1),
+ body: z.string().min(1).max(5000),
expectedRevision: z.number().int().nonnegative(),
});
export const CreateListRequestSchema = z.object({
- title: z.string().min(1),
- note: z.string().optional(),
+ title: z.string().min(1).max(200),
+ note: z.string().max(1000).optional(),
});
export const AddListItemRequestSchema = z.object({
- albumId: z.string(),
- note: z.string().optional(),
+ albumId: z.string().max(100),
+ note: z.string().max(1000).optional(),
});
export const ReactActivityRequestSchema = z.object({
- reaction: z.string().min(1),
+ reaction: z.string().min(1).max(50),
});
export const CommentActivityRequestSchema = z.object({
- body: z.string().min(1),
+ body: z.string().min(1).max(2000),
});
export const UpsertNotificationPreferenceSchema = z.object({
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 86fb6b2..5dadf60 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -2,3 +2,7 @@ export * from "./common";
export * from "./models";
export * from "./events";
export * from "./endpoints";
+export * from "./provider";
+export * from "./mapping";
+export * from "./sync";
+export * from "./compliance";
diff --git a/packages/contracts/src/mapping.ts b/packages/contracts/src/mapping.ts
new file mode 100644
index 0000000..36ac208
--- /dev/null
+++ b/packages/contracts/src/mapping.ts
@@ -0,0 +1,81 @@
+import { z } from "zod";
+import { ProviderName } from "./provider";
+
+/** Types of canonical entities owned by SoundScore. */
+export const CanonicalEntityType = z.enum(["artist", "album", "track"]);
+export type CanonicalEntityType = z.infer;
+
+/** Canonical artist entity — provider-independent. */
+export const CanonicalArtistSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ normalizedName: z.string(),
+ createdAt: z.string().datetime(),
+});
+
+/** Canonical album (release) entity — provider-independent. */
+export const CanonicalAlbumSchema = z.object({
+ id: z.string(),
+ title: z.string(),
+ normalizedTitle: z.string(),
+ artistId: z.string(),
+ year: z.number().int().optional(),
+ trackCount: z.number().int().positive().optional(),
+ createdAt: z.string().datetime(),
+});
+
+/** Confidence level of a canonical-to-provider mapping. */
+export const MappingStatus = z.enum(["confirmed", "pending", "ambiguous", "unmapped"]);
+export type MappingStatus = z.infer;
+
+/** How a mapping was established. */
+export const MappingProvenance = z.enum([
+ "auto_match",
+ "user_confirm",
+ "admin_override",
+ "provider_link",
+]);
+export type MappingProvenance = z.infer;
+
+/** Link between a canonical entity and a provider-specific ID. */
+export const ProviderMappingSchema = z.object({
+ id: z.string(),
+ canonicalId: z.string(),
+ canonicalType: CanonicalEntityType,
+ provider: ProviderName,
+ providerId: z.string(),
+ confidence: z.number().min(0).max(1),
+ provenance: MappingProvenance,
+ status: MappingStatus,
+ createdAt: z.string().datetime(),
+});
+
+/** Look up the canonical entity for a given provider ID. */
+export const MappingLookupRequestSchema = z.object({
+ provider: ProviderName,
+ providerId: z.string(),
+});
+
+/** Result of a mapping lookup — canonical entity plus all known mappings. */
+export const MappingLookupResponseSchema = z.object({
+ canonical: CanonicalAlbumSchema.nullable(),
+ mappings: z.array(ProviderMappingSchema),
+ status: MappingStatus,
+});
+
+/** Request to resolve a provider item to a canonical entity using metadata. */
+export const ResolveMappingRequestSchema = z.object({
+ provider: ProviderName,
+ providerId: z.string(),
+ title: z.string(),
+ artist: z.string(),
+ year: z.number().int().optional(),
+ trackCount: z.number().int().positive().optional(),
+});
+
+export type CanonicalArtist = z.infer;
+export type CanonicalAlbum = z.infer;
+export type ProviderMapping = z.infer;
+export type MappingLookupRequest = z.infer;
+export type MappingLookupResponse = z.infer;
+export type ResolveMappingRequest = z.infer;
diff --git a/packages/contracts/src/models.ts b/packages/contracts/src/models.ts
index 28b40f4..c6d51c2 100644
--- a/packages/contracts/src/models.ts
+++ b/packages/contracts/src/models.ts
@@ -6,7 +6,7 @@ export const AlbumSchema = z.object({
artist: z.string(),
year: z.number().int(),
artworkUrl: z.string().url().nullable(),
- avgRating: z.number().min(0).max(5),
+ avgRating: z.number().min(0).max(6),
logCount: z.number().int().nonnegative(),
});
@@ -14,7 +14,26 @@ export const RatingSchema = z.object({
id: z.string(),
userId: z.string(),
albumId: z.string(),
- value: z.number().min(0).max(5),
+ value: z.number().min(0).max(6),
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+});
+
+export const TrackSchema = z.object({
+ id: z.string(),
+ albumId: z.string(),
+ title: z.string(),
+ trackNumber: z.number().int().positive(),
+ durationMs: z.number().int().nonnegative().nullable(),
+ spotifyId: z.string().nullable(),
+});
+
+export const TrackRatingSchema = z.object({
+ id: z.string(),
+ userId: z.string(),
+ trackId: z.string(),
+ albumId: z.string(),
+ value: z.number().min(0).max(6),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
@@ -36,7 +55,7 @@ export const UserProfileSchema = z.object({
logCount: z.number().int().nonnegative(),
reviewCount: z.number().int().nonnegative(),
listCount: z.number().int().nonnegative(),
- avgRating: z.number().min(0).max(5),
+ avgRating: z.number().min(0).max(6),
});
export const ListSchema = z.object({
@@ -57,7 +76,7 @@ export const ListSchema = z.object({
export const RecapAlbumSchema = z.object({
albumId: z.string(),
- rating: z.number().min(0).max(5),
+ rating: z.number().min(0).max(6),
});
export const WeeklyRecapSchema = z.object({
@@ -66,7 +85,7 @@ export const WeeklyRecapSchema = z.object({
weekStart: z.string(),
weekEnd: z.string(),
totalLogs: z.number().int().nonnegative(),
- averageRating: z.number().min(0).max(5),
+ averageRating: z.number().min(0).max(6),
topAlbums: z.array(RecapAlbumSchema),
shareText: z.string(),
deepLink: z.string(),
@@ -93,6 +112,8 @@ export const DeviceTokenSchema = z.object({
export type Album = z.infer;
export type Rating = z.infer;
+export type Track = z.infer;
+export type TrackRating = z.infer;
export type Review = z.infer;
export type UserProfile = z.infer;
export type SoundScoreList = z.infer;
diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts
new file mode 100644
index 0000000..4b43071
--- /dev/null
+++ b/packages/contracts/src/provider.ts
@@ -0,0 +1,59 @@
+import { z } from "zod";
+
+/** Supported music provider names. */
+export const ProviderName = z.enum(["spotify", "apple_music", "musicbrainz"]);
+export type ProviderName = z.infer;
+
+/** Typed error codes for provider-related failures. */
+export const ProviderErrorCode = z.enum([
+ "PROVIDER_NOT_SUPPORTED",
+ "OAUTH_STATE_MISMATCH",
+ "OAUTH_EXCHANGE_FAILED",
+ "TOKEN_EXPIRED",
+ "TOKEN_REFRESH_FAILED",
+ "ALREADY_CONNECTED",
+ "NOT_CONNECTED",
+ "RATE_LIMITED",
+]);
+export type ProviderErrorCode = z.infer;
+
+/** Request to initiate an OAuth connect flow with a provider. */
+export const ConnectProviderRequestSchema = z.object({
+ provider: ProviderName,
+ redirectUri: z.string().url(),
+});
+
+/** OAuth callback payload returned by the provider redirect. */
+export const OAuthCallbackRequestSchema = z.object({
+ provider: ProviderName,
+ code: z.string(),
+ state: z.string(),
+});
+
+/** Persistent record of a user's connection to an external provider. */
+export const ProviderConnectionSchema = z.object({
+ id: z.string(),
+ userId: z.string(),
+ provider: ProviderName,
+ connected: z.boolean(),
+ connectedAt: z.string().datetime(),
+ scopes: z.array(z.string()),
+ tokenExpiresAt: z.string().datetime().optional(),
+});
+
+/** Response containing the current provider connection status. */
+export const ProviderStatusResponseSchema = z.object({
+ connection: ProviderConnectionSchema.nullable(),
+});
+
+/** Request to disconnect a provider and optionally purge imported data. */
+export const DisconnectProviderRequestSchema = z.object({
+ provider: ProviderName,
+ purgeData: z.boolean().default(false),
+});
+
+export type ConnectProviderRequest = z.infer;
+export type OAuthCallbackRequest = z.infer;
+export type ProviderConnection = z.infer;
+export type ProviderStatusResponse = z.infer;
+export type DisconnectProviderRequest = z.infer;
diff --git a/packages/contracts/src/sync.ts b/packages/contracts/src/sync.ts
new file mode 100644
index 0000000..8bf7a85
--- /dev/null
+++ b/packages/contracts/src/sync.ts
@@ -0,0 +1,68 @@
+import { z } from "zod";
+import { ProviderName } from "./provider";
+
+/** Whether a sync pulls all data or only changes since the last cursor. */
+export const SyncType = z.enum(["full", "incremental"]);
+export type SyncType = z.infer;
+
+/** Lifecycle states of a sync job. */
+export const SyncStatus = z.enum(["queued", "running", "completed", "failed", "cancelled"]);
+export type SyncStatus = z.infer;
+
+/** Request to start a new sync job for a connected provider. */
+export const SyncTriggerRequestSchema = z.object({
+ provider: ProviderName,
+ syncType: SyncType,
+});
+
+/** Persistent representation of a sync job and its progress. */
+export const SyncJobSchema = z.object({
+ id: z.string(),
+ userId: z.string(),
+ provider: ProviderName,
+ syncType: SyncType,
+ status: SyncStatus,
+ progress: z.number().int().min(0).max(100),
+ itemsProcessed: z.number().int().nonnegative(),
+ itemsTotal: z.number().int().nonnegative().optional(),
+ error: z.string().optional(),
+ startedAt: z.string().datetime().optional(),
+ completedAt: z.string().datetime().optional(),
+ createdAt: z.string().datetime(),
+});
+
+/** Response containing the current state of a sync job. */
+export const SyncStatusResponseSchema = z.object({
+ job: SyncJobSchema,
+});
+
+/** Cursor bookmark for incremental syncs — tracks where the last sync left off. */
+export const SyncCursorSchema = z.object({
+ userId: z.string(),
+ provider: ProviderName,
+ cursorValue: z.string().optional(),
+ lastSyncAt: z.string().datetime().optional(),
+});
+
+/** A single listening event ingested from a provider or entered manually (canonical-aware). */
+export const SyncListeningEventSchema = z.object({
+ id: z.string(),
+ userId: z.string(),
+ canonicalAlbumId: z.string(),
+ playedAt: z.string().datetime(),
+ source: z.union([ProviderName, z.literal("manual")]),
+ sourceRef: z.record(z.string()).optional(),
+ dedupKey: z.string(),
+});
+
+/** Request to cancel a running or queued sync job. */
+export const CancelSyncRequestSchema = z.object({
+ syncId: z.string(),
+});
+
+export type SyncTriggerRequest = z.infer;
+export type SyncJob = z.infer;
+export type SyncStatusResponse = z.infer;
+export type SyncCursor = z.infer;
+export type SyncListeningEvent = z.infer;
+export type CancelSyncRequest = z.infer;