diff --git a/app/README.md b/app/README.md index 7b14cf9d7..f8ede93eb 100644 --- a/app/README.md +++ b/app/README.md @@ -26,6 +26,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - NPS survey UI (`surveys/`) applies `NpsSurveyBranching` after each answer so detractor / passive / promoter open-text paths end instead of falling through into the next score band (PostHog open-question `end` branching is not reliably persisted via API). - `BoxLoreNavHost` owns app route registration and delegates screen bodies to feature modules; stack-slide transitions follow the visible Home → Explore → Library → Lore order, and Home’s first committed content frame unlocks the floating Lore launch animation. - `NavGraphPodcastEpisodeDestinations` registers episode and podcast detail routes; episode full-path routes define `episodeDescription` as nullable with a default null value to safely handle empty, omitted, or special-character descriptions without argument bundle verification failures. +- `NavGraphSettingsDestinations` registers the settings hub, category pages, download settings, and the dedicated `"feedback"` full-page destination wired to `:feature:settings:feedback:FeedbackScreen`. - Cold-start destination precedence (see `StartDestinationResolver`): incomplete onboarding → offline Downloads (no deep link) → Appearance **Open app to** Subscriptions or Downloads (no deep link) → Home. Deep links and push `target_route` still win over Open app to. When cold start opens Subscriptions or Downloads via that pref, `openedToLandingOnLaunch` makes Back navigate to Home (not Library hub); Library-tile entry still pops to Library. Offline-forced Downloads without that pref still exits on Back. That Home navigation is owned by `PredictiveBackWrapper` (NavHost has nothing to pop), so peek scale must restore after the gesture — see `:core:designsystem`. - Process latest-episode sync (`SubscriptionForegroundSync`) and the versioned legacy RSS repair are started from `BoxLoreAppRoot` after onboarding. Repair is enabled only for upgraded installs running `0.0.18` through `0.0.30`, except `0.0.20`, starts only while online with at least one eligible pure `rss:` subscription, and admits at most one pass per app process. A fresh install on an allowed version writes the completed marker without inspecting or changing catalog rows, so updating a fresh `0.0.18` install to `0.0.19` cannot enable repair later. Unsupported versions and invalid package metadata fail closed. App foreground/screen-on events do not request another pass. While an eligible pass is active, the shell shows a persistent top popup with the boxlore loader; it cannot be dismissed and disappears when the pass settles. Returning to the app still requests normal subscription refresh (`SubscriptionResumeRefreshLogic` skips the first `ON_START` so Home keeps its 2s first-paint delay). Subscriptions-first launches still fetch because Library calls `requestRefresh` as soon as that screen is visible. Identified FCM payloads (guid/enclosure) that do not match a local episode no longer fall back to an unrelated newest tip. - Tab destinations in `NavGraphTabDestinations` wire Home / Explore / Learn / Library / player entry points to feature screens. Home’s Offline Mix “View all downloads” action navigates directly to Library Downloads through this shell-owned wiring. Lore history deep-links use `entryPoint=learn` (canonical glossary; legacy `learn_history` still normalizes to `learn`). Explore and Subscriptions apply Appearance **Default tabs** when the route does not already pick a tab (genre Explore, `tab=trending` / `for_you`, and `library/subscriptions?tab=0|1` still win). diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt index 91b2fbbea..06b1bdce4 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -340,6 +340,9 @@ class AppContainer( override fun getPendingEmail(): String? = prefs.getPendingAuthEmail() override fun setPendingEmail(email: String?) = prefs.setPendingAuthEmail(email) }, + onPreDeleteAccount = { + userSyncCoordinator.deleteCloudSyncData().getOrThrow() + }, ) }.getOrElse { object : cx.aswin.boxlore.core.auth.AuthRepository { diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt index 1f0aac59b..59d0106ec 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.launch internal fun androidx.navigation.NavGraphBuilder.addSettingsDestination(w: NavGraphWiring) { addMainSettingsRoute(w) addDownloadSettingsRoutes(w) + addFeedbackRoute(w) } private fun androidx.navigation.NavGraphBuilder.addMainSettingsRoute(w: NavGraphWiring) { @@ -98,6 +99,7 @@ private fun androidx.navigation.NavGraphBuilder.addMainSettingsRoute(w: NavGraph appInstanceId = appInstanceId, initialPage = settingsPage, isOnboarding = isFromOnboarding, + onSendFeedback = { navController.navigate("feedback") }, ), regionSettings = RegionSettings( currentRegion = settingsState.currentRegion, @@ -303,6 +305,19 @@ private fun androidx.navigation.NavGraphBuilder.addDownloadSettingsRoutes(w: Nav } } +private fun androidx.navigation.NavGraphBuilder.addFeedbackRoute(w: NavGraphWiring) { + val navController = w.navController + val container = w.container + + composable("feedback") { + cx.aswin.boxlore.feature.settings.feedback.FeedbackScreen( + podcastRepository = container.podcastRepository, + boxcastPrefs = container.boxcastPrefs, + onBack = { navController.popBackStack() }, + ) + } +} + private suspend fun runLibraryExport( application: android.app.Application, uri: android.net.Uri, diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt index dac61c3c1..afe6842f9 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt @@ -117,6 +117,7 @@ internal fun androidx.navigation.NavGraphBuilder.addHomeDestination(w: NavGraphW onNavigateToSettings = { navController.navigate("settings?page=hub") }, onNavigateToPlayStoreReview = { launchInAppReview(context) }, onSubmitFeedback = onSubmitFeedback, + onFeedbackClick = { w.actions.onShowFeedbackSheet() }, onNavigateToDebug = { navController.navigate("debug") }, onImportClick = { opmlCallbacks.onSourceChange("home_import_banner") diff --git a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt index fcbcc38e3..81f9bd771 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt @@ -66,7 +66,6 @@ import cx.aswin.boxlore.core.playback.stopAndClearQueue import cx.aswin.boxlore.core.prefs.PrefsFileMigrator import cx.aswin.boxlore.fcm.FcmTopicHelper import cx.aswin.boxlore.feature.home.ModeSwitchState -import cx.aswin.boxlore.feature.home.components.FeedbackSheet import cx.aswin.boxlore.feature.onboarding.generateRecommendationsFromOpml import cx.aswin.boxlore.feature.onboarding.markOnboardingCompletedSilent import cx.aswin.boxlore.feature.player.v2.PlayerSheetActions @@ -183,7 +182,6 @@ fun BoxLoreAppRoot( val smartDownloadManager = container.smartDownloadManager val installReferrerManager = container.installReferrerManager - var showFeedbackSheet by remember { mutableStateOf(false) } val onSubmitFeedback: suspend (String, String, String, String) -> Boolean = remember(podcastRepository) { { category, message, version, email -> @@ -631,7 +629,7 @@ fun BoxLoreAppRoot( NavHostActions( onLoreQueueConflictEpisode = { loreQueueConflictEpisode = it }, queueLoreEpisode = queueLoreEpisode, - onShowFeedbackSheet = { showFeedbackSheet = true }, + onShowFeedbackSheet = { navController.navigate("feedback") }, onSubmitFeedback = onSubmitFeedback, ), settingsState = @@ -910,43 +908,6 @@ fun BoxLoreAppRoot( }, ), ) - - if (showFeedbackSheet) { - val versionStr = - remember { - try { - activity.packageManager.getPackageInfo(activity.packageName, 0).versionName - ?: "unknown" - } catch (_: Exception) { - "unknown" - } - } - FeedbackSheet( - appVersion = versionStr, - onSubmit = onSubmitFeedback, - onRateInstead = { - showFeedbackSheet = false - try { - activity.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse("market://details?id=${activity.packageName}"), - ), - ) - } catch (_: Exception) { - activity.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse( - "https://play.google.com/store/apps/details?id=${activity.packageName}", - ), - ), - ) - } - }, - onDismissRequest = { showFeedbackSheet = false }, - ) - } } } } diff --git a/core/analytics/README.md b/core/analytics/README.md index 32a1166eb..01563508d 100644 --- a/core/analytics/README.md +++ b/core/analytics/README.md @@ -61,6 +61,7 @@ src/main/java/cx/aswin/boxlore/core/analytics/ - Unit tests live under `src/test/java/cx/aswin/boxlore/core/analytics`. - Use `RecordingAnalytics` when testing classes that accept `Analytics`. +- Use `AnalyticsHelper.installRecordingSink(recorder)` when testing classes or callbacks that interact directly with the singleton `AnalyticsHelper`. - `GlossaryAllEventsEmissionTest` covers every `emission:` inventory row; `LifecycleSdkMappingTest` / `InstallChannelAttributionTest` assert no dual open/install emits. - Architecture guards in `:core:testing` enforce CSV ↔ allowlist ↔ inventory parity. diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt index 52ae3c9f8..60d58b127 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt @@ -13,6 +13,10 @@ object AnalyticsHelper : Analytics { @Volatile private var activePauseReason: String = "user_voluntary" + @androidx.annotation.VisibleForTesting + fun installRecordingSink(recorder: MutableList>>): () -> Unit = + AnalyticsEmit.installRecordingSink(recorder) + fun setSeekSource(source: String) { activeSeekSource = source } diff --git a/core/auth/README.md b/core/auth/README.md index 7cd7770d3..f1eddf2ac 100644 --- a/core/auth/README.md +++ b/core/auth/README.md @@ -8,7 +8,7 @@ Provides authentication abstractions and implementations (Firebase). - `AuthRepository` (authentication operations, Google, Magic Link, Email/Password, `sendEmailVerification()`, `reloadUser()`) - `AuthException`, `RecentLoginRequiredException` -- `FirebaseAuthRepository` +- `FirebaseAuthRepository` (supports optional `onPreDeleteAccount` callback invoked before Firebase account deletion) ## Internal structure diff --git a/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt b/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt index c3e8f2b15..f7b645af3 100644 --- a/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt +++ b/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt @@ -19,6 +19,7 @@ class FirebaseAuthRepository( private val pendingEmailStore: PendingEmailStore? = null, private val magicLinkUrl: String = MAGIC_LINK_DEFAULT_URL, private val packageName: String = PACKAGE_NAME_DEFAULT, + private val onPreDeleteAccount: (suspend () -> Unit)? = null, ) : AuthRepository { private val _currentUser = MutableStateFlow(auth.currentUser.toBoxLoreUser()) @@ -98,6 +99,7 @@ class FirebaseAuthRepository( override suspend fun deleteAccount(): Result = runCatching { val user = auth.currentUser ?: error("No authenticated user to delete") try { + onPreDeleteAccount?.invoke() user.delete().awaitTask() pendingEmailStore?.setPendingEmail(null) } catch (e: Exception) { @@ -126,6 +128,7 @@ class FirebaseAuthRepository( "recent-login", "requires-recent-login", "credential_too_old", + "ERROR_REQUIRES_RECENT_LOGIN", ) } } diff --git a/core/catalog/README.md b/core/catalog/README.md index 1d79d6b6e..d0f9ecd79 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -6,7 +6,7 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su ## Public API -- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. `loadPiEpisodesForBaseline` remains for **first** catalog persist rematch only. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). +- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to proxy), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. `loadPiEpisodesForBaseline` remains for **first** catalog persist rematch only. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). - `syncSubscriptions` (`POST /sync`) is PI-only for shows that are **not** catalog-ready and **not** still on extras opt-in. Ready ids contribute the Room newest tip; opted-in not-ready ids contribute the cached extras tip. Live feed freshen stays on `SubscriptionForegroundSync`. `getEpisodeWindow` is the bounded Smart Queue / Auto entry (never `getAllNewest`). - Show search: legacy `searchPodcasts` / `searchPodcastsWithCorrection` keep calling `GET /search` (hybrid). New clients use `searchPodcastsTypeahead` (`GET /search/typeahead` / Meili) and `searchPodcastsGrouped` (parallel typeahead + hybrid → Matches / Also found). Concept search uses `searchSemanticGrouped` / `searchEpisodesSemantic` (`GET /search/semantic`: one CF embed → Qdrant podcasts + episodes; additive `feeds` + legacy `items`). - `logic.mergeShowSearchResults` dedupes Meili vs hybrid by id / itunes / feed URL. @@ -22,7 +22,7 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su - `InstallReferrerManager` parses Play Install Referrer deep links and exposes optional `onInstallReferrerResolved` (channel + raw referrer). `:app` wires that callback into analytics; catalog must not depend on `:core:analytics`. - `RoomLocalCatalog` implements `LocalCatalogPort`; its metadata enrichment transaction updates only an existing subscribed row, so a late Podcast Info response cannot create or restore a subscription after unsubscribe. `RoomEpisodeOfflineLookup` implements `EpisodeOfflineLookupPort`. - `ports.ListeningHistoryBackupPort`, `ports.SmartDownloadSyncPort`, `ports.QueueSyncPort`, `ports.ActivePlaybackSyncPort`, and `ports.PodcastNotificationSyncPort` keep backup, download, notification topic bindings, and playback seams cleanly decoupled without introducing forbidden `:core:catalog` to `:core:playback` dependency cycles. -- `sync.UserSyncCoordinator` orchestrates Boxlore Realtime Cloud Sync: batch-bounded dirty push with multi-column optimistic concurrency flag clearing, delta pull, account switch local data purge and active playback session teardown via `activePlaybackSyncPort.stopAndClearActiveSession()`, and coordination across `SubscriptionSyncResolver` (LWW subscription and tombstone resolution, RSS ingestion), `HistorySyncResolver` (active playing episode shield, progress LWW, independent like status LWW), and `QueueSyncResolver` (monotonic versioning, 3-way tombstone merge, head-position retention of active episode, and 5-tier metadata hydration). Results are communicated via `sync.SyncResult`, with reactive UI state modelled via `sync.CloudSyncUiStatus`. +- `sync.UserSyncCoordinator` orchestrates Boxlore Realtime Cloud Sync: batch-bounded dirty push with multi-column optimistic concurrency flag clearing, delta pull, cloud account deletion (`deleteCloudSyncData`), account switch local data purge and active playback session teardown via `activePlaybackSyncPort.stopAndClearActiveSession()`, and coordination across `SubscriptionSyncResolver` (LWW subscription and tombstone resolution, RSS ingestion), `HistorySyncResolver` (active playing episode shield, progress LWW, independent like status LWW), and `QueueSyncResolver` (monotonic versioning, 3-way tombstone merge, head-position retention of active episode, and 5-tier metadata hydration). Results are communicated via `sync.SyncResult`, with reactive UI state modelled via `sync.CloudSyncUiStatus`. - `FolderRepository` and `RoomFolderRepository` manage library subscription folders: creating iconless or icon-assigned folders with optional `showPodcastGrid` 1×1 cover style, updating display sizes, restoring folders from JSON backups (`restoreFolder`), auto-organizing subscribed shows into genre folders (with selectable display sizes and cover styles, while preserving custom and existing folders), auto-syncing subscribed shows matching `linkedGenre` (with canonical genre token matching and Tech/Technology synonym normalization), moving shows between folders on genre customization, adding/removing podcasts from folders, and cascading folder deletions without unsubscribing shows. - `:core:rss`, `:core:domain`, `:core:database`, and `:core:prefs` are re-exported where existing public signatures require those types. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt index 5d57f594a..7c831a69e 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt @@ -679,11 +679,24 @@ class PodcastRepository( } } - suspend fun submitFeedback(category: String, message: String, appVersion: String, email: String? = null,): Boolean = withContext(ioDispatcher) { + suspend fun submitFeedback( + category: String, + message: String, + appVersion: String, + email: String? = null, + diagnostics: String? = null, + logs: String? = null, + ): Boolean = withContext(ioDispatcher) { try { val request = - cx.aswin.boxlore.core.network.model - .FeedbackRequest(category, message, appVersion, email) + cx.aswin.boxlore.core.network.model.FeedbackRequest( + category = category, + message = message, + appVersion = appVersion, + email = email, + diagnostics = diagnostics, + logs = logs, + ) val response = api.submitFeedback(publicKey, request).execute() response.isSuccessful && response.body()?.success == true } catch (e: Exception) { diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt index ceb433479..083f80620 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt @@ -451,6 +451,29 @@ open class UserSyncCoordinator( requireBoxcastPrefs.setLastSyncTimestamp(syncedAt) } + open suspend fun deleteCloudSyncData(): Result = withContext(ioDispatcher) { + syncMutex.withLock { + runCatching { + val userId = authUserIdProvider() + if (userId.isNullOrBlank()) { + return@runCatching + } + val token = tokenProvider() ?: error("Missing authentication token for cloud sync data deletion") + val call = requireBoxLoreApi.deleteSyncAccount( + publicKey = publicKey, + authorization = "Bearer $token", + ) + val response = call.execute() + val code = response.code() + if (!response.isSuccessful && code != 404) { + error("Delete cloud sync data failed with HTTP $code: ${response.errorBody()?.string()}") + } + requireBoxcastPrefs.setLastSyncTimestamp(0L) + requireBoxcastPrefs.setLastSyncedUserId(null) + } + } + } + private suspend fun handoffIdleMiniplayerIfNewer(history: List) { val newestRemoteHistory = history.maxByOrNull { it.lastPlayedAt } if (newestRemoteHistory != null && newestRemoteHistory.lastPlayedAt > 0L) { diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt index df4415ed6..bbc787684 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt @@ -16,6 +16,7 @@ import cx.aswin.boxlore.core.network.BoxLoreApi import cx.aswin.boxlore.core.network.model.ListeningHistorySyncDto import cx.aswin.boxlore.core.network.model.QueueItemSyncDto import cx.aswin.boxlore.core.network.model.QueueSyncDto +import cx.aswin.boxlore.core.network.model.SyncDeleteAccountResponse import cx.aswin.boxlore.core.network.model.SyncPullRequest import cx.aswin.boxlore.core.network.model.SyncPullResponse import cx.aswin.boxlore.core.network.model.SyncPushRequest @@ -27,7 +28,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Request +import okhttp3.ResponseBody.Companion.toResponseBody import okio.Timeout import org.junit.After import org.junit.Assert.assertEquals @@ -58,6 +61,7 @@ class UserSyncCoordinatorTest { private var syncPushHandler: ((publicKey: String, auth: String?, request: SyncPushRequest) -> Call)? = null private var syncPullHandler: ((publicKey: String, auth: String?, request: SyncPullRequest) -> Call)? = null + private var deleteSyncAccountHandler: ((publicKey: String, auth: String?) -> Call)? = null private lateinit var subscriptionSyncResolver: SubscriptionSyncResolver private lateinit var historySyncResolver: HistorySyncResolver @@ -83,6 +87,7 @@ class UserSyncCoordinatorTest { fakeQueueSyncPort = FakeQueueSyncPort() syncPushHandler = null syncPullHandler = null + deleteSyncAccountHandler = null fakeBoxLoreApi = Proxy.newProxyInstance( BoxLoreApi::class.java.classLoader, @@ -91,6 +96,7 @@ class UserSyncCoordinatorTest { when (method.name) { "syncPush" -> syncPushHandler?.invoke(args[0] as String, args[1] as? String, args[2] as SyncPushRequest) "syncPull" -> syncPullHandler?.invoke(args[0] as String, args[1] as? String, args[2] as SyncPullRequest) + "deleteSyncAccount" -> deleteSyncAccountHandler?.invoke(args[0] as String, args[1] as? String) "hashCode" -> 42 "equals" -> false "toString" -> "FakeBoxLoreApi" @@ -901,4 +907,67 @@ class UserSyncCoordinatorTest { assertEquals("account-b", prefs.getLastSyncedUserId()) assertEquals(2000L, prefs.getLastSyncTimestamp()) } + + @Test + fun deleteCloudSyncData_callsDeleteSyncAccountAndClearsPrefs() = runTest(testDispatcher) { + prefs.setLastSyncedUserId("test-user-123") + prefs.setLastSyncTimestamp(12345L) + + var capturedPublicKey: String? = null + var capturedAuth: String? = null + + deleteSyncAccountHandler = { pubKey, auth -> + capturedPublicKey = pubKey + capturedAuth = auth + FakeCall(Response.success(SyncDeleteAccountResponse(status = "ok"))) + } + + val result = coordinator.deleteCloudSyncData() + assertTrue(result.isSuccess) + assertEquals("test-public-key", capturedPublicKey) + assertEquals("Bearer mock-jwt-token", capturedAuth) + assertEquals(null, prefs.getLastSyncedUserId()) + assertEquals(0L, prefs.getLastSyncTimestamp()) + } + + @Test + fun deleteCloudSyncData_treats404AsSuccessAndClearsPrefs() = runTest(testDispatcher) { + prefs.setLastSyncedUserId("test-user-123") + prefs.setLastSyncTimestamp(12345L) + + deleteSyncAccountHandler = { _, _ -> + FakeCall( + Response.error( + 404, + "{\"error\":\"Not Found\"}".toResponseBody("application/json".toMediaTypeOrNull()), + ), + ) + } + + val result = coordinator.deleteCloudSyncData() + assertTrue(result.isSuccess) + assertEquals(null, prefs.getLastSyncedUserId()) + assertEquals(0L, prefs.getLastSyncTimestamp()) + } + + @Test + fun deleteCloudSyncData_serverErrorReturnsFailure() = runTest(testDispatcher) { + prefs.setLastSyncedUserId("test-user-123") + prefs.setLastSyncTimestamp(12345L) + + deleteSyncAccountHandler = { _, _ -> + FakeCall( + Response.error( + 500, + "{\"error\":\"Database connection failed\"}".toResponseBody("application/json".toMediaTypeOrNull()), + ), + ) + } + + val result = coordinator.deleteCloudSyncData() + assertTrue(result.isFailure) + // Ensure prefs are NOT wiped if the server deletion failed + assertEquals("test-user-123", prefs.getLastSyncedUserId()) + assertEquals(12345L, prefs.getLastSyncTimestamp()) + } } diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SyncModels.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SyncModels.kt index 3361133c5..927978c52 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SyncModels.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SyncModels.kt @@ -14,7 +14,14 @@ data class SyncResponse(val items: List = emptyList(), val error: Stri data class SyncItem(val id: String, val latestEpisode: EpisodeItem? = null) @Serializable -data class FeedbackRequest(val category: String, val message: String, val appVersion: String, val email: String? = null) +data class FeedbackRequest( + val category: String, + val message: String, + val appVersion: String, + val email: String? = null, + val diagnostics: String? = null, + val logs: String? = null, +) @Serializable data class FeedbackResponse(val success: Boolean) diff --git a/core/playback/README.md b/core/playback/README.md index 994dedec9..68bf8278b 100644 --- a/core/playback/README.md +++ b/core/playback/README.md @@ -23,6 +23,7 @@ Owns playback session control, queue orchestration, smart queue logic, Media3 pl - `HistoryRecommendationLogic`, `AutoVoiceSearchLogic`, `SmartQueueRefillPolicy`, `MixtapeResumePolicy`, `PlaybackEntryPointResolve`, `NightWindowLogic`, and `ListeningHistoryUpsertLogic` are JVM-testable playback helpers. `PlaybackControllerStatePolicy` keeps an empty/unprepared MediaController from erasing Room-restored progress while preserving policy-selected zero-position restarts, `PlaybackProgressPersistencePolicy` rejects out-of-order asynchronous snapshots while preserving intentional restarts/backward seeks and determining when `lastPlayedAt` must update on short plays or intentional replays, `PlaybackHistorySeedPolicy` resolves ordered non-blank service metadata into missing history rows with fallback enrichment from podcast and catalog repositories, `PlaybackSessionRestoreHelper` coordinates cold-start and controller session restoration without reverting to unrelated sessions while healing blank podcast metadata into Room, and `PlaybackTaskRemovalPolicy` requires a final save before stopping an inactive service. `PlaybackEntryPointResolve` maps fine-grained source-context `entry_point` strings (`home_mixtape`, `learn`/`learn_history`, `briefing`) to coarse `PlaybackEntryPoint` for queue/mixtape policy while the raw string still attributes `playback_*`. - `AutoMediaResolver` and `AutoMediaResolutionPolicy` resolve playable items and metadata for Android Auto, enriching missing fields independently while preserving local non-blank metadata and falling back to catalog sources. - `AutoPlaybackResumptionPolicy` and `AutoPlaybackResumptionHandler` coordinate Android Auto and media button reconnection playback resumption (`onPlaybackResumption`), managing live player continuation, active miniplayer restoration, dismissed/inactive incomplete episode resumption, and playlist queue alignment. +- `AutoBrowseControllerMatcher` filters MediaSession controllers using Media3 companion/automotive inspection APIs and known automotive package identifiers (`com.google.android.projection.gearhead`, `com.google.android.apps.automotive.media`, `com.android.car.media`) so Android Auto connection and browsing analytics are strictly isolated from standard Bluetooth AVRCP (`com.android.bluetooth`) and system notifications. - `AutoArtworkFetchLogic`, `AutoCollageFreshnessLogic`, and `AutoArtworkPipeLogic` encode Android Auto artwork fetch, cache policy, and safe pipe streaming for hermetic tests. - `AutoCollagePrewarmPolicy` and `AutoCollageFolderLogic` encode prewarm throttle and aligned image/key folder inputs for hermetic tests. - `PlaybackIntroOutroController` manages intro-skip and outro-trim playback lifecycle. Natural completion remains event-driven; ending-trim polling runs only during active playback with a real trim, clears completed monitor jobs, and uses a 500 ms cadence. diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseControllerMatcher.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseControllerMatcher.kt new file mode 100644 index 000000000..f922425e7 --- /dev/null +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseControllerMatcher.kt @@ -0,0 +1,18 @@ +package cx.aswin.boxlore.core.playback.service.auto + +import androidx.media3.session.MediaSession + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +internal object AutoBrowseControllerMatcher { + fun isAndroidAuto(session: MediaSession, controller: MediaSession.ControllerInfo): Boolean { + val isMedia3Auto = + runCatching { + session.isAutoCompanionController(controller) || session.isAutomotiveController(controller) + }.getOrDefault(false) + + return isMedia3Auto || + controller.packageName == "com.google.android.projection.gearhead" || + controller.packageName == "com.google.android.apps.automotive.media" || + controller.packageName == "com.android.car.media" + } +} diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt index 2b2f0c4b9..0b9640350 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.guava.future import kotlinx.coroutines.launch @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +@Suppress("LargeClass") internal class AutoBrowseLibraryCallback( private val host: AutoBrowseLibraryHost, mediaResolver: AutoMediaResolver? = null, @@ -71,9 +72,14 @@ internal class AutoBrowseLibraryCallback( Bundle.EMPTY, ) - override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo,): MediaSession.ConnectionResult { - connectedAtMs[controller] = System.currentTimeMillis() - AnalyticsHelper.trackAndroidAutoConnected(sessionId = controller.packageName) + internal fun isAndroidAuto(session: MediaSession, controller: MediaSession.ControllerInfo): Boolean = + AutoBrowseControllerMatcher.isAndroidAuto(session, controller) + + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + if (isAndroidAuto(session, controller)) { + connectedAtMs[controller] = System.currentTimeMillis() + AnalyticsHelper.trackAndroidAutoConnected(sessionId = controller.packageName) + } val defaultResult = super.onConnect(session, controller) val sessionCommands = defaultResult.availableSessionCommands @@ -93,14 +99,16 @@ internal class AutoBrowseLibraryCallback( .build() } - override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo,) { + override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo) { val started = connectedAtMs.remove(controller) - val durationSeconds = - started?.let { ((System.currentTimeMillis() - it) / 1000L).toInt().coerceAtLeast(0) } - AnalyticsHelper.trackAndroidAutoDisconnected( - sessionId = controller.packageName, - durationSeconds = durationSeconds, - ) + if (started != null) { + val durationSeconds = + ((System.currentTimeMillis() - started) / 1000L).toInt().coerceAtLeast(0) + AnalyticsHelper.trackAndroidAutoDisconnected( + sessionId = controller.packageName, + durationSeconds = durationSeconds, + ) + } rootChildLimits.remove(controller) super.onDisconnected(session, controller) } @@ -338,7 +346,9 @@ internal class AutoBrowseLibraryCallback( params: LibraryParams?, ): ListenableFuture>> { android.util.Log.d("AutoBrowse", "onGetChildren: parentId=$parentId, page=$page") - AnalyticsHelper.trackAndroidAutoBrowse(node = parentId, action = "get_children") + if (isAndroidAuto(session, browser)) { + AnalyticsHelper.trackAndroidAutoBrowse(node = parentId, action = "get_children") + } return host.serviceScope.future { try { diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallbackTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallbackTest.kt index 2e5593dc7..bf651c702 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallbackTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallbackTest.kt @@ -4,15 +4,18 @@ import android.content.Context import android.net.Uri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.analytics.AnalyticsHelper import cx.aswin.boxlore.core.database.BoxLoreDatabase import cx.aswin.boxlore.core.database.PodcastDao import cx.aswin.boxlore.core.database.PodcastEntity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -45,9 +48,13 @@ class AutoBrowseLibraryCallbackTest { private lateinit var treeBuilder: AutoBrowseTreeBuilder private lateinit var resumptionHandler: AutoPlaybackResumptionHandler private lateinit var callback: AutoBrowseLibraryCallback + private val recordedEvents = mutableListOf>>() + private lateinit var restoreAnalytics: () -> Unit @Before fun setUp() = runBlocking { + recordedEvents.clear() + restoreAnalytics = AnalyticsHelper.installRecordingSink(recordedEvents) context = ApplicationProvider.getApplicationContext() host = mock(AutoBrowseLibraryHost::class.java) database = mock(BoxLoreDatabase::class.java) @@ -62,6 +69,10 @@ class AutoBrowseLibraryCallbackTest { `when`(database.podcastDao()).thenReturn(podcastDao) `when`(host.getString(cx.aswin.boxlore.core.catalog.R.string.auto_app_name)).thenReturn("boxlore") `when`(host.getString(cx.aswin.boxlore.core.catalog.R.string.auto_group_subscriptions)).thenReturn("Subscriptions") + val commandButton = mock(androidx.media3.session.CommandButton::class.java) + `when`(host.seekBackAction).thenReturn(commandButton) + `when`(host.seekForwardAction).thenReturn(commandButton) + `when`(host.markCompleteAction).thenReturn(commandButton) `when`(treeBuilder.getRootChildren()).thenReturn(emptyList()) `when`(treeBuilder.getHomeChildren()).thenReturn(emptyList()) @@ -76,6 +87,11 @@ class AutoBrowseLibraryCallbackTest { ) } + @After + fun tearDown() { + restoreAnalytics() + } + @Test fun `resolveGetItem resolves known subscription to browsable podcast folder`() = runBlocking { val podcast = PodcastEntity( @@ -217,4 +233,95 @@ class AutoBrowseLibraryCallbackTest { assertEquals("subscription:pod-contract", result.value?.mediaId) assertEquals("Contract Show", result.value?.mediaMetadata?.title?.toString()) } + + @Test + fun `isAndroidAuto identifies Auto and Automotive controllers correctly`() { + val session = mock(MediaSession::class.java) + + fun createController(packageName: String): MediaSession.ControllerInfo { + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn(packageName) + return controller + } + + val bluetooth = createController("com.android.bluetooth") + val systemUi = createController("com.android.systemui") + val appSelf = createController("cx.aswin.boxlore") + val autoCompanion = createController("com.google.android.projection.gearhead") + val automotiveGoogle = createController("com.google.android.apps.automotive.media") + val automotiveAosp = createController("com.android.car.media") + + assertEquals(false, callback.isAndroidAuto(session, bluetooth)) + assertEquals(false, callback.isAndroidAuto(session, systemUi)) + assertEquals(false, callback.isAndroidAuto(session, appSelf)) + assertEquals(true, callback.isAndroidAuto(session, autoCompanion)) + assertEquals(true, callback.isAndroidAuto(session, automotiveGoogle)) + assertEquals(true, callback.isAndroidAuto(session, automotiveAosp)) + } + + @Test + fun `isAndroidAuto returns true when MediaSession indicates companion or automotive`() { + val session = mock(MediaSession::class.java) + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn("com.custom.auto.oem") + + `when`(session.isAutoCompanionController(controller)).thenReturn(true) + assertEquals(true, callback.isAndroidAuto(session, controller)) + + `when`(session.isAutoCompanionController(controller)).thenReturn(false) + `when`(session.isAutomotiveController(controller)).thenReturn(true) + assertEquals(true, callback.isAndroidAuto(session, controller)) + } + + @Test + fun `onConnect and onDisconnected do not emit analytics for Bluetooth controller`() { + val session = mock(MediaSession::class.java) + val player = mock(Player::class.java) + `when`(player.availableCommands).thenReturn(Player.Commands.EMPTY) + `when`(session.player).thenReturn(player) + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn("com.android.bluetooth") + + callback.onConnect(session, controller) + assertTrue(recordedEvents.none { it.first == "android_auto_connected" }) + + callback.onDisconnected(session, controller) + assertTrue(recordedEvents.none { it.first == "android_auto_disconnected" }) + } + + @Test + fun `onConnect and onDisconnected emit analytics for Android Auto controller`() { + val session = mock(MediaSession::class.java) + val player = mock(Player::class.java) + `when`(player.availableCommands).thenReturn(Player.Commands.EMPTY) + `when`(session.player).thenReturn(player) + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn("com.google.android.projection.gearhead") + + callback.onConnect(session, controller) + assertTrue(recordedEvents.any { it.first == "android_auto_connected" }) + + callback.onDisconnected(session, controller) + assertTrue(recordedEvents.any { it.first == "android_auto_disconnected" }) + } + + @Test + fun `onGetChildren does not emit android_auto_browse for non-Auto controller`() { + val session = mock(MediaLibrarySession::class.java) + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn("com.android.bluetooth") + + callback.onGetChildren(session, controller, AutoBrowseContract.ROOT_ID, 0, 10, null) + assertTrue(recordedEvents.none { it.first == "android_auto_browse" }) + } + + @Test + fun `onGetChildren emits android_auto_browse for Android Auto controller`() { + val session = mock(MediaLibrarySession::class.java) + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn("com.google.android.projection.gearhead") + + callback.onGetChildren(session, controller, AutoBrowseContract.ROOT_ID, 0, 10, null) + assertTrue(recordedEvents.any { it.first == "android_auto_browse" }) + } } diff --git a/core/prefs/README.md b/core/prefs/README.md index c369a017a..5d5027ce3 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -16,7 +16,7 @@ Owns user preference persistence and migration helpers: DataStore-backed user pr - `SubscriptionsTabStyle` defines and sanitizes Subscriptions tab layout choices: `top` (default) vs `floating` (FAB style). - `BoxcastPrefs` stores the permanent Home video-showcase dismissal in the canonical `boxlore_prefs` file (`featured_video_showcase_dismissed`). The showcase asks for confirmation before writing it and does not reappear afterward. - `Context.userPreferencesDataStore` defines the `user_preferences` DataStore delegate. -- `BoxcastPrefs` is the typed facade for `boxlore_prefs` values such as onboarding, genres, recommendation caches, time-of-day rotation slot keys (`cached_byl_slot`), Learn history, learner-log gates, pending magic link authentication email (`pending_auth_email`), the stable sync installation ID (`sync_device_id` via `getOrCreateSyncDeviceId()`), sync cursor state (`sync_last_timestamp`, `sync_last_user_id`, `sync_metadata_version`), and the notification-permission prompt gate (`has_requested_notification_permission`). `clearBylCacheIfPodcastId` invalidates a Because-you-like cache (including its cached slot key) when its seed show adopts a new catalog id. +- `BoxcastPrefs` is the typed facade for `boxlore_prefs` values such as onboarding, genres, recommendation caches, time-of-day rotation slot keys (`cached_byl_slot`), Learn history, learner-log gates, pending magic link authentication email (`pending_auth_email`), sticky feedback drafts (`FeedbackDraft` via `getFeedbackDraft()`, `saveFeedbackDraft()`, and `clearFeedbackDraft()`), the stable sync installation ID (`sync_device_id` via `getOrCreateSyncDeviceId()`), sync cursor state (`sync_last_timestamp`, `sync_last_user_id`, `sync_metadata_version`), and the notification-permission prompt gate (`has_requested_notification_permission`). `clearBylCacheIfPodcastId` invalidates a Because-you-like cache (including its cached slot key) when its seed show adopts a new catalog id. - `resolveLearnerLogEnabled(isDebugBuild)`: debug defaults on when unset; **release is always off** unless the user explicitly persisted `true` via the debug-screen toggle. - `UserPreferenceKeys` centralizes DataStore preference keys. - `PrefsFileMigrator` opens canonical SharedPreferences files and migrates from legacy file names. diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt index d45a3e9a6..cf66bbd13 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt @@ -177,6 +177,11 @@ class BoxcastPrefs(context: Context) { const val KEY_LAST_SYNCED_USER_ID = "sync_last_user_id" const val KEY_SYNC_METADATA_VERSION = "sync_metadata_version" const val KEY_HAS_REQUESTED_NOTIFICATION_PERMISSION = "has_requested_notification_permission" + const val KEY_FEEDBACK_DRAFT_CATEGORY = "feedback_draft_category" + const val KEY_FEEDBACK_DRAFT_MESSAGE = "feedback_draft_message" + const val KEY_FEEDBACK_DRAFT_EMAIL = "feedback_draft_email" + const val KEY_FEEDBACK_DRAFT_STEPS = "feedback_draft_steps" + const val KEY_FEEDBACK_DRAFT_ATTACH_DIAGNOSTICS = "feedback_draft_attach_diagnostics" } // ── Auth / Magic Link ─────────────────────────────────────────────────── @@ -236,4 +241,53 @@ class BoxcastPrefs(context: Context) { fun setHasRequestedNotificationPermission(requested: Boolean = true) { prefs.edit().putBoolean(KEY_HAS_REQUESTED_NOTIFICATION_PERMISSION, requested).apply() } + + // ── Feedback Draft ──────────────────────────────────────────────────────── + + fun getFeedbackDraft(): FeedbackDraft? { + val message = prefs.getString(KEY_FEEDBACK_DRAFT_MESSAGE, null).orEmpty() + val steps = prefs.getString(KEY_FEEDBACK_DRAFT_STEPS, null).orEmpty() + if (message.isBlank() && steps.isBlank()) return null + val category = prefs.getString(KEY_FEEDBACK_DRAFT_CATEGORY, "feature") ?: "feature" + val email = prefs.getString(KEY_FEEDBACK_DRAFT_EMAIL, "") ?: "" + val attachDiagnostics = prefs.getBoolean(KEY_FEEDBACK_DRAFT_ATTACH_DIAGNOSTICS, true) + return FeedbackDraft( + category = category, + message = message, + email = email, + stepsToReproduce = steps, + attachDiagnostics = attachDiagnostics, + ) + } + + fun saveFeedbackDraft(draft: FeedbackDraft) { + prefs.edit() + .putString(KEY_FEEDBACK_DRAFT_CATEGORY, draft.category) + .putString(KEY_FEEDBACK_DRAFT_MESSAGE, draft.message) + .putString(KEY_FEEDBACK_DRAFT_EMAIL, draft.email) + .putString(KEY_FEEDBACK_DRAFT_STEPS, draft.stepsToReproduce) + .putBoolean(KEY_FEEDBACK_DRAFT_ATTACH_DIAGNOSTICS, draft.attachDiagnostics) + .apply() + } + + fun clearFeedbackDraft() { + prefs.edit() + .remove(KEY_FEEDBACK_DRAFT_CATEGORY) + .remove(KEY_FEEDBACK_DRAFT_MESSAGE) + .remove(KEY_FEEDBACK_DRAFT_EMAIL) + .remove(KEY_FEEDBACK_DRAFT_STEPS) + .remove(KEY_FEEDBACK_DRAFT_ATTACH_DIAGNOSTICS) + .apply() + } } + +/** + * Persisted draft for the user feedback screen to prevent data loss on accidental dismissal. + */ +data class FeedbackDraft( + val category: String, + val message: String, + val email: String = "", + val stepsToReproduce: String = "", + val attachDiagnostics: Boolean = true, +) diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt index 11f8ef1b8..239efc580 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -172,4 +173,57 @@ class BoxcastPrefsTest { prefs.setHasRequestedNotificationPermission(false) assertFalse(prefs.hasRequestedNotificationPermission()) } + + @Test + fun feedbackDraft_defaultsToNullAndRoundTripsAndClears() { + assertNull(prefs.getFeedbackDraft()) + + val draft = FeedbackDraft( + category = "bug", + message = "Playback stops after 5 minutes", + email = "listener@example.com", + stepsToReproduce = "1. Play episode\n2. Wait 5 min", + attachDiagnostics = true, + ) + prefs.saveFeedbackDraft(draft) + + val retrieved = prefs.getFeedbackDraft() + assertEquals(draft, retrieved) + + prefs.clearFeedbackDraft() + assertNull(prefs.getFeedbackDraft()) + } + + @Test + fun feedbackDraft_returnsDraft_whenOnlyStepsArePresent() { + val draft = FeedbackDraft( + category = "bug", + message = "", + email = "tester@example.com", + stepsToReproduce = "1. Crash on click", + attachDiagnostics = true, + ) + prefs.saveFeedbackDraft(draft) + + val retrieved = prefs.getFeedbackDraft() + assertNotNull(retrieved) + assertEquals("", retrieved?.message) + assertEquals("1. Crash on click", retrieved?.stepsToReproduce) + assertEquals("tester@example.com", retrieved?.email) + } + + @Test + fun feedbackDraft_returnsNull_whenBothMessageAndStepsAreBlank() { + val draft = FeedbackDraft( + category = "bug", + message = " ", + email = "tester@example.com", + stepsToReproduce = " ", + attachDiagnostics = true, + ) + prefs.saveFeedbackDraft(draft) + + val retrieved = prefs.getFeedbackDraft() + assertNull(retrieved) + } } diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt index 8b01f5f9d..c9dd44b9a 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt @@ -190,6 +190,7 @@ fun HomeRoute( onNavigateToSettings: (() -> Unit)? = null, onNavigateToPlayStoreReview: () -> Unit = {}, onSubmitFeedback: suspend (String, String, String, String) -> Boolean = { _, _, _, _ -> false }, + onFeedbackClick: (() -> Unit)? = null, onNavigateToDebug: () -> Unit = {}, onImportClick: () -> Unit = {}, onAiOnboardingClick: () -> Unit = {}, @@ -312,6 +313,7 @@ fun HomeRoute( onImportClick, onAiOnboardingClick, onBriefingClick, + onFeedbackClick, onNavigateToLatestEpisodes, ) { HomeScreenCallbacks( @@ -342,7 +344,7 @@ fun HomeRoute( onBriefingClick = onBriefingClick, onDismissBriefing = viewModel::dismissBriefingForToday, onDismissBriefingForever = viewModel::dismissBriefingForever, - onFeedbackClick = viewModel::triggerFeedback, + onFeedbackClick = onFeedbackClick ?: viewModel::triggerFeedback, onToggleHomePin = viewModel::toggleHomePin, onDismissFeaturedVideoShowcaseForever = viewModel::dismissFeaturedVideoShowcaseForever, ), diff --git a/feature/settings/README.md b/feature/settings/README.md index 369fa1aec..448cddd32 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -2,15 +2,16 @@ ## Purpose -Owns the unified Settings hub, category pages (Account, Appearance, Playback, Privacy, Library, Downloads, About), dialogs (Accent color, Add RSS, Reset analytics), authentication credential management (Google One Tap, Magic Link, Password), and download preference screens (`AutoDownloadSettingsScreen`, `SmartDownloadsSettingsScreen`). It presents data from injected core dependencies and does not own catalog engines, ranking persistence, playback services, download workers, or Room schemas. +Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearance, Playback, Privacy, Library, Downloads, About), dialogs (Accent color, Add RSS, Reset analytics), authentication credential management (Google One Tap, Magic Link, Password), and download preference screens (`AutoDownloadSettingsScreen`, `SmartDownloadsSettingsScreen`). It presents data from injected core dependencies and does not own catalog engines, ranking persistence, playback services, download workers, or Room schemas. ## Public API - `SettingsScreen`, `SettingsViewModel`, `SettingsViewModelAssembler`, and `ProfileSettingsDestination` for the settings hub and sub-pages. -- `SettingsScreenConfig` includes `isOnboarding: Boolean` (used by app navigation to keep onboarding mode active and bypass the hub on return) and `resolveSettingsBackAction` for deterministic back-handler actions. +- `SettingsScreenConfig` includes `isOnboarding: Boolean` (used by app navigation to keep onboarding mode active and bypass the hub on return), `onSendFeedback: (() -> Unit)?` to route to feedback, and `resolveSettingsBackAction` for deterministic back-handler actions. +- `FeedbackScreen`, `FeedbackViewModel`, `DiagnosticCollector`, and `LogcatCollector` under `feedback/` for sticky draft feedback submissions, in-process sanitized logcat extraction, device diagnostics, and GitHub issue reporting. - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen` under `downloads/` for download management. -- `AccountSettingsPage`, `AppearanceSettingsPage`, `PlaybackSettingsPage`, `PrivacySettingsPage`, `LibrarySettingsPage`, `DownloadsSettingsPage`, `AboutSettingsPage`. -- Dialogs: `AccentColorPickerDialog`, `AddRssFeedDialog`, `ResetAnalyticsDialog`. +- `AccountSettingsPage`, `SyncAndBackupsPage`, `AppearanceSettingsPage`, `PlaybackSettingsPage`, `PrivacySettingsPage`, `LibrarySettingsPage`, `DownloadsSettingsPage`, `AboutSettingsPage`. +- Dialogs: `AccentColorPickerDialog`, `AddRssFeedDialog`, `ResetAnalyticsDialog`, `LogsPreviewDialog`. ## Internal structure @@ -30,6 +31,13 @@ src/main/java/cx/aswin/boxlore/feature/settings/ downloads/ AutoDownloadSettingsScreen.kt SmartDownloadsSettingsScreen.kt + feedback/ + DiagnosticCollector.kt + FeedbackDialogs.kt + FeedbackScreen.kt + FeedbackSuccessView.kt + FeedbackViewModel.kt + LogcatCollector.kt pages/ AboutSettingsPage.kt AccountAuthHelpers.kt @@ -38,6 +46,11 @@ src/main/java/cx/aswin/boxlore/feature/settings/ AccountSettingsPage.kt AccountSignedOutContent.kt AnimatedBlobAvatar.kt + BlobAvatarAccessories.kt + BlobAvatarCharacter.kt + BlobAvatarEnvironments.kt + BlobAvatarGeometry.kt + BlobAvatarGenreMood.kt AppearanceSettingsPage.kt DownloadsSettingsPage.kt EmailVerificationPendingSection.kt @@ -45,6 +58,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ PlaybackSettingsPage.kt PrivacySettingsPage.kt SettingsHub.kt + SyncAndBackupsPage.kt ``` ## Dependencies diff --git a/feature/settings/src/main/AndroidManifest.xml b/feature/settings/src/main/AndroidManifest.xml new file mode 100644 index 000000000..15fea438b --- /dev/null +++ b/feature/settings/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt index 395f7a806..fde67aa54 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt @@ -5,6 +5,7 @@ internal enum class ProfileSettingsDestination( ) { Hub("Settings"), Account("Account"), + SyncAndBackups("Cloud Sync & Backups"), Library("Library"), Appearance("Appearance"), Playback("Playback"), diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt index 5e2a232b2..2bfeb33f1 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt @@ -53,6 +53,7 @@ import cx.aswin.boxlore.feature.settings.pages.PlaybackUiState import cx.aswin.boxlore.feature.settings.pages.PrivacySettingsActions import cx.aswin.boxlore.feature.settings.pages.PrivacySettingsPage import cx.aswin.boxlore.feature.settings.pages.SettingsHub +import cx.aswin.boxlore.feature.settings.pages.SyncAndBackupsPage import kotlinx.coroutines.flow.StateFlow /** Where to send the user for the two Downloads settings sub-screens. */ @@ -98,6 +99,7 @@ data class SettingsScreenConfig( /** Optional deep-link page: "library", "appearance", etc. */ val initialPage: String? = null, val isOnboarding: Boolean = false, + val onSendFeedback: (() -> Unit)? = null, ) /** Appearance sub-page state paired with its actions, so [SettingsScreen] can pass both as one. */ @@ -242,8 +244,8 @@ fun SettingsScreen( val actions = SettingsPagesActions( onNavigate = { destination = it }, onReturnToHub = returnToHub, - onNavigateToAccountFromLibrary = { - previousDestination = ProfileSettingsDestination.Library + onNavigateToAccountFromSync = { + previousDestination = ProfileSettingsDestination.SyncAndBackups destination = ProfileSettingsDestination.Account }, onDeletionExpandedChange = { isDeletionExpanded = it }, @@ -301,7 +303,7 @@ internal data class SettingsPagesUiData( internal data class SettingsPagesActions( val onNavigate: (ProfileSettingsDestination) -> Unit, val onReturnToHub: () -> Unit, - val onNavigateToAccountFromLibrary: () -> Unit, + val onNavigateToAccountFromSync: () -> Unit, val onDeletionExpandedChange: (Boolean) -> Unit, val onShowResetDialog: () -> Unit, val backupActions: LibraryBackupActions, @@ -343,6 +345,15 @@ private fun SettingsAnimatedPages( ) } + ProfileSettingsDestination.SyncAndBackups -> + SyncAndBackupsPage( + accountStatus = uiData.accountStatus, + syncStatus = syncStatus, + backupActions = actions.backupActions, + onBack = actions.onReturnToHub, + onAccountClick = actions.onNavigateToAccountFromSync, + ) + ProfileSettingsDestination.Library -> LibrarySettingsPage( discoveryPreferences = LibraryDiscoveryPreferences( @@ -361,11 +372,7 @@ private fun SettingsAnimatedPages( }, ), onAddRssClick = { contentBundle.settingsViewModel.openAddRssDialog() }, - backupActions = actions.backupActions, onBack = actions.onReturnToHub, - onAccountClick = actions.onNavigateToAccountFromLibrary, - accountStatus = uiData.accountStatus, - syncStatus = syncStatus, ) ProfileSettingsDestination.Appearance -> @@ -411,6 +418,7 @@ private fun SettingsAnimatedPages( appInfo = uiData.appInfo, onVisitPodcastIndex = { visitPodcastIndexHomepage(contentBundle.context) }, onOpenChangelog = { openChangelog(contentBundle.context) }, + onSendFeedback = { config.onSendFeedback?.invoke() }, onBack = actions.onReturnToHub, ) } @@ -638,6 +646,7 @@ private fun openChangelog(context: Context) { internal fun String?.toSettingsDestination(): ProfileSettingsDestination = when (this?.trim()?.lowercase()) { "account" -> ProfileSettingsDestination.Account + "sync", "sync_and_backups", "sync-and-backups", "backups" -> ProfileSettingsDestination.SyncAndBackups "library" -> ProfileSettingsDestination.Library "appearance" -> ProfileSettingsDestination.Appearance "playback" -> ProfileSettingsDestination.Playback diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollector.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollector.kt new file mode 100644 index 000000000..ef09514bc --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollector.kt @@ -0,0 +1,131 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.annotation.SuppressLint +import android.content.Context +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import androidx.core.content.pm.PackageInfoCompat +import java.util.Locale + +/** + * Diagnostic information about the device and runtime environment. + */ +data class DiagnosticInfo( + val appVersion: String, + val buildCode: Long, + val packageName: String, + val androidRelease: String, + val sdkInt: Int, + val manufacturer: String, + val model: String, + val networkType: String, + val audioRoute: String, + val locale: String, +) { + /** + * Compact summary designed to fit comfortably inside the in-app feedback body (~120 chars). + */ + fun toCondensedSummary(): String = "App: v$appVersion ($buildCode) | OS: Android $androidRelease (API $sdkInt) | Device: $manufacturer $model | Net: $networkType | Audio: $audioRoute" + + /** + * Full GitHub / Sharesheet Markdown report. + */ + fun toMarkdownReport(sanitizedLogcat: String? = null): String = buildString { + append("### System Diagnostics\n") + append("- **App**: boxlore `$appVersion` (build `$buildCode`)\n") + append("- **Package**: `$packageName`\n") + append("- **Android**: `$androidRelease` (API `$sdkInt`)\n") + append("- **Device**: `$manufacturer $model`\n") + append("- **Network**: `$networkType`\n") + append("- **Audio Route**: `$audioRoute`\n") + append("- **Locale**: `$locale`\n\n") + + if (!sanitizedLogcat.isNullOrBlank()) { + append("
\nSanitized App Logs\n\n") + append("```\n") + append(sanitizedLogcat.trim()) + append("\n```\n
\n") + } + } +} + +object DiagnosticCollector { + + /** + * Extracts non-sensitive diagnostic parameters from the Android environment. + */ + fun collect(context: Context): DiagnosticInfo { + val pm = context.packageManager + val pkgName = context.packageName + val packageInfo = try { + pm.getPackageInfo(pkgName, 0) + } catch (_: Exception) { + null + } + + val versionName = packageInfo?.versionName ?: "unknown" + val versionCode = packageInfo?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L + + val networkType = determineNetworkType(context) + val audioRoute = determineAudioRoute(context) + val locale = Locale.getDefault().toLanguageTag() + + return DiagnosticInfo( + appVersion = versionName, + buildCode = versionCode, + packageName = pkgName, + androidRelease = Build.VERSION.RELEASE ?: "unknown", + sdkInt = Build.VERSION.SDK_INT, + manufacturer = Build.MANUFACTURER.replaceFirstChar { it.uppercase() }, + model = Build.MODEL, + networkType = networkType, + audioRoute = audioRoute, + locale = locale, + ) + } + + @SuppressLint("MissingPermission") + private fun determineNetworkType(context: Context): String { + return try { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return "Unknown" + val network = cm.activeNetwork ?: return "Offline" + val caps = cm.getNetworkCapabilities(network) ?: return "Offline" + + when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi" + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Cellular" + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet" + caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN" + else -> "Connected" + } + } catch (_: SecurityException) { + "Unknown" + } + } + + private fun determineAudioRoute(context: Context): String { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + ?: return "Default" + + val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + val activeDeviceTypes = devices.map { it.type } + + return when { + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP) || + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_BLUETOOTH_SCO) || + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_BLE_HEADSET) || + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_BLE_SPEAKER) -> "Bluetooth" + + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_WIRED_HEADPHONES) || + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_WIRED_HEADSET) || + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_USB_HEADSET) -> "Headphones" + + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) -> "Speaker" + else -> "Default" + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt new file mode 100644 index 000000000..1f728e254 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt @@ -0,0 +1,203 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Notes +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight + +@Composable +fun LogsPreviewDialog( + logs: String?, + isLoading: Boolean, + diagnosticInfo: DiagnosticInfo?, + onShare: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + modifier = modifier + .padding(horizontal = 16.dp) + .widthIn(max = 520.dp) + .fillMaxWidth(), + properties = DialogProperties(usePlatformDefaultWidth = false), + icon = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Notes, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = "Diagnostics & Logs Preview", + style = MaterialTheme.typography.titleLarge, + fontWeight = GoogleSansWeight.semiBold, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "These diagnostics and sanitized logs will be included with your submission when diagnostics are enabled.\nTip: Reproduce the issue first so relevant error logs appear.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LogsTextSurface(logs = logs, isLoading = isLoading) + LogsActionButtons( + logs = logs, + diagnosticInfo = diagnosticInfo, + isLoading = isLoading, + onShare = onShare, + ) + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text("Close") + } + }, + shape = MaterialTheme.shapes.extraLarge, + containerColor = MaterialTheme.colorScheme.surface, + ) +} + +@Composable +private fun LogsTextSurface( + logs: String?, + isLoading: Boolean, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 140.dp, max = 280.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + if (isLoading) { + Box( + modifier = Modifier.fillMaxWidth().height(140.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + } + } else { + val verticalScroll = rememberScrollState() + val horizontalScroll = rememberScrollState() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .verticalScroll(verticalScroll) + .horizontalScroll(horizontalScroll), + ) { + Text( + text = logs ?: "No logs captured.", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 15.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +@Composable +private fun LogsActionButtons( + logs: String?, + diagnosticInfo: DiagnosticInfo?, + isLoading: Boolean, + onShare: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val isActionEnabled = !isLoading + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { + val report = diagnosticInfo?.toMarkdownReport(logs) ?: (logs ?: "") + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("boxlore diagnostics", report)) + Toast.makeText(context, "Copied diagnostics & logs to clipboard", Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.weight(1f), + enabled = isActionEnabled, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.medium, + ) { + Icon(Icons.Rounded.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = "Copy text", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + FilledTonalButton( + onClick = onShare, + modifier = Modifier.weight(1f), + enabled = isActionEnabled, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.medium, + ) { + Icon(Icons.Rounded.Share, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = "Share text", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt new file mode 100644 index 000000000..de676a518 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt @@ -0,0 +1,738 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Launch +import androidx.compose.material.icons.automirrored.rounded.Message +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Headphones +import androidx.compose.material.icons.rounded.Lightbulb +import androidx.compose.material.icons.rounded.Restore +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +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 cx.aswin.boxlore.core.catalog.PodcastRepository +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +import cx.aswin.boxlore.core.prefs.BoxcastPrefs + +private val FEEDBACK_CONTENT_BOTTOM_PADDING = 240.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FeedbackScreen( + podcastRepository: PodcastRepository, + boxcastPrefs: BoxcastPrefs, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val feedbackViewModel: FeedbackViewModel = viewModel( + factory = FeedbackViewModel.Factory(podcastRepository, boxcastPrefs, context), + ) + val uiState by feedbackViewModel.uiState.collectAsStateWithLifecycle() + + BackHandler(onBack = onBack) + + Scaffold( + modifier = modifier.imePadding(), + topBar = { + TopAppBar( + title = { + Text( + text = "Share your thoughts", + style = MaterialTheme.typography.titleLarge, + fontWeight = GoogleSansWeight.semiBold, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Navigate back", + ) + } + }, + actions = { + if (uiState.message.isNotBlank() || uiState.stepsToReproduce.isNotBlank()) { + IconButton(onClick = { feedbackViewModel.onDiscardDraft() }) { + Icon( + imageVector = Icons.Rounded.DeleteOutline, + contentDescription = "Discard draft", + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + ) { paddingValues -> + if (uiState.isSuccess) { + FeedbackSuccessView( + uiState = uiState, + onDone = onBack, + bottomPadding = FEEDBACK_CONTENT_BOTTOM_PADDING, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp), + ) + } else { + FeedbackFormView( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding( + start = 20.dp, + top = 12.dp, + end = 20.dp, + bottom = FEEDBACK_CONTENT_BOTTOM_PADDING, + ), + uiState = uiState, + viewModel = feedbackViewModel, + context = context, + ) + } + + if (uiState.logsPreview != null || uiState.isLoadingLogs) { + LogsPreviewDialog( + logs = uiState.logsPreview, + isLoading = uiState.isLoadingLogs, + diagnosticInfo = uiState.diagnosticInfo, + onShare = { shareFeedbackDiagnostics(context, uiState) }, + onDismissRequest = { feedbackViewModel.clearLogsPreview() }, + ) + } + } +} + +@Composable +private fun FeedbackFormView( + modifier: Modifier = Modifier, + uiState: FeedbackUiState, + viewModel: FeedbackViewModel, + context: Context, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + FeedbackDraftBanner( + visible = uiState.isDraftRestored, + onDiscard = { viewModel.onDiscardDraft() }, + onDismiss = { viewModel.onDismissDraftBanner() }, + ) + + FeedbackCategorySelector( + selectedCategory = uiState.category, + onSelectCategory = { viewModel.onCategorySelected(it) }, + ) + + FeedbackMessageInput( + category = uiState.category, + message = uiState.message, + enabled = !uiState.isSubmitting, + onMessageChanged = { viewModel.onMessageChanged(it) }, + ) + + if (uiState.category.isBugReport) { + FeedbackStepsInput( + steps = uiState.stepsToReproduce, + enabled = !uiState.isSubmitting, + onStepsChanged = { viewModel.onStepsChanged(it) }, + ) + } + + FeedbackEmailInput( + category = uiState.category, + email = uiState.email, + enabled = !uiState.isSubmitting, + onEmailChanged = { viewModel.onEmailChanged(it) }, + ) + + FeedbackDiagnosticsSection( + attachDiagnostics = uiState.attachDiagnostics, + diagnosticInfo = uiState.diagnosticInfo, + onToggleAttach = { viewModel.onAttachDiagnosticsChanged(it) }, + onPreviewLogs = { viewModel.loadLogsForPreview() }, + ) + + FeedbackCommunityRow( + gitHubUrl = buildFeedbackGitHubIssueUrl(uiState), + context = context, + ) + + FeedbackSubmitButton( + isSubmitting = uiState.isSubmitting, + message = uiState.message, + errorMessage = uiState.errorMessage, + onSubmit = { viewModel.onSubmit() }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun FeedbackDraftBanner( + visible: Boolean, + onDiscard: () -> Unit, + onDismiss: () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Restore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Restored draft from last session", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDiscard) { + Text( + text = "Discard", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + IconButton(onClick = onDismiss, modifier = Modifier.size(24.dp)) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = "Dismiss banner", + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(16.dp), + ) + } + } + } + } +} + +@Composable +private fun FeedbackCategorySelector( + selectedCategory: FeedbackCategory, + onSelectCategory: (FeedbackCategory) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Category", + style = MaterialTheme.typography.labelLarge, + fontWeight = GoogleSansWeight.medium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FeedbackCategory.entries.forEach { cat -> + val selected = selectedCategory == cat + FilterChip( + selected = selected, + onClick = { onSelectCategory(cat) }, + leadingIcon = { + val icon = when (cat) { + FeedbackCategory.FEATURE -> Icons.Rounded.Lightbulb + FeedbackCategory.BUG -> Icons.Rounded.BugReport + FeedbackCategory.AUDIO -> Icons.Rounded.Headphones + FeedbackCategory.OTHER -> Icons.AutoMirrored.Rounded.Message + } + Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp)) + }, + label = { Text(cat.label) }, + shape = MaterialTheme.shapes.medium, + colors = FilterChipDefaults.filterChipColors( + containerColor = MaterialTheme.colorScheme.surface, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } + } +} + +@Composable +private fun FeedbackMessageInput( + category: FeedbackCategory, + message: String, + enabled: Boolean = true, + onMessageChanged: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedTextField( + value = message, + onValueChange = onMessageChanged, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + placeholder = { + Text( + text = category.placeholder, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + }, + shape = MaterialTheme.shapes.large, + textStyle = MaterialTheme.typography.bodyMedium, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + ), + ) + + Text( + text = "${message.length}/2000", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.End, + ) + } +} + +@Composable +private fun FeedbackStepsInput( + steps: String, + enabled: Boolean = true, + onStepsChanged: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = "Steps to Reproduce (Optional)", + style = MaterialTheme.typography.labelLarge, + fontWeight = GoogleSansWeight.medium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Rounded.Lightbulb, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + text = "Tip: Reproduce the issue right before sending so recent logs capture the error.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + OutlinedTextField( + value = steps, + onValueChange = onStepsChanged, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + placeholder = { + Text( + text = "1. Play episode X\n2. At 02:15 audio stops\n3. Happens on both Wi-Fi and Cellular", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + }, + shape = MaterialTheme.shapes.large, + textStyle = MaterialTheme.typography.bodyMedium, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + ), + ) + } +} + +private fun getEmailSupportingText(category: FeedbackCategory): String = when (category) { + FeedbackCategory.BUG, FeedbackCategory.AUDIO -> + "We obsess over getting boxlore right. Rather than applying a quick band-aid, we would like to chat more about the issue to ensure that it is truly solved. So please, if possible, share your email — we promise to respect your inbox with no marketing, only direct help on this issue." + FeedbackCategory.FEATURE -> + "We love shaping boxlore alongside our listeners. If your idea sparks something great, we’d love to reach out to brainstorm, discuss designs, or have you test it early — strictly product chats, zero marketing." + FeedbackCategory.OTHER -> + "Every note helps shape where boxlore goes next. Leave your email if you'd like us to write back — we read every single message and promise to respect your inbox with zero marketing." +} + +@Composable +private fun FeedbackEmailInput( + category: FeedbackCategory, + email: String, + enabled: Boolean = true, + onEmailChanged: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Contact Email (Optional)", + style = MaterialTheme.typography.labelLarge, + fontWeight = GoogleSansWeight.medium, + color = MaterialTheme.colorScheme.onSurface, + ) + + OutlinedTextField( + value = email, + onValueChange = onEmailChanged, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = "you@example.com", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + }, + supportingText = { + Text( + text = getEmailSupportingText(category), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + }, + singleLine = true, + shape = MaterialTheme.shapes.large, + textStyle = MaterialTheme.typography.bodyMedium, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + ), + ) + } +} + +@Composable +private fun FeedbackDiagnosticsSection( + attachDiagnostics: Boolean, + diagnosticInfo: DiagnosticInfo?, + onToggleAttach: (Boolean) -> Unit, + onPreviewLogs: () -> Unit, +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + shape = MaterialTheme.shapes.large, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Include device diagnostics", + style = MaterialTheme.typography.bodyLarge, + fontWeight = GoogleSansWeight.medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Helps troubleshoot hardware, OS, and network issues", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = attachDiagnostics, + onCheckedChange = onToggleAttach, + ) + } + + if (attachDiagnostics && diagnosticInfo != null) { + DiagnosticsDetailsContent( + diagnosticInfo = diagnosticInfo, + onPreviewLogs = onPreviewLogs, + ) + } + } + } +} + +@Composable +private fun DiagnosticsDetailsContent( + diagnosticInfo: DiagnosticInfo, + onPreviewLogs: () -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "boxlore v${diagnosticInfo.appVersion} (${diagnosticInfo.buildCode}) • Android ${diagnosticInfo.androidRelease} (API ${diagnosticInfo.sdkInt})", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${diagnosticInfo.manufacturer} ${diagnosticInfo.model} • ${diagnosticInfo.networkType} • ${diagnosticInfo.audioRoute}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(15.dp), + ) + Text( + text = "Included automatically with report", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + TextButton( + onClick = onPreviewLogs, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Icon( + imageVector = Icons.Rounded.Visibility, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Preview logs", + style = MaterialTheme.typography.labelMedium, + ) + } + } +} + +@Composable +private fun FeedbackCommunityRow( + gitHubUrl: String, + context: Context, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(gitHubUrl)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + // Ignored if no browser/app can handle the intent + } + }, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.large, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Launch, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("GitHub Issue", maxLines = 1) + } + + OutlinedButton( + onClick = { + val pkgName = context.packageName + val marketUri = try { + Uri.parse("market://details?id=$pkgName") + } catch (_: Exception) { + Uri.parse("https://play.google.com/store/apps/details?id=$pkgName") + } + try { + val intent = Intent(Intent.ACTION_VIEW, marketUri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + try { + val webIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$pkgName"), + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(webIntent) + } catch (_: ActivityNotFoundException) { + // Ignored if neither store nor browser is available + } + } + }, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.large, + ) { + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Rate on Play", maxLines = 1) + } + } +} + +@Composable +private fun FeedbackSubmitButton( + isSubmitting: Boolean, + message: String, + errorMessage: String?, + onSubmit: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } + + Button( + onClick = onSubmit, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + enabled = !isSubmitting && message.isNotBlank(), + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + if (isSubmitting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text( + text = "Send Feedback", + style = MaterialTheme.typography.labelLarge, + fontWeight = GoogleSansWeight.semiBold, + ) + } + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt new file mode 100644 index 000000000..14e61b228 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt @@ -0,0 +1,431 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Message +import androidx.compose.material.icons.rounded.Analytics +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.Email +import androidx.compose.material.icons.rounded.Headphones +import androidx.compose.material.icons.rounded.Lightbulb +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight + +private const val STATUS_TITLE_CONTACT = "Reach out within 24 hours" +private const val STATUS_TITLE_ANONYMOUS = "Submitted anonymously" + +internal data class FeedbackSuccessContent( + val title: String, + val subtitle: String, + val statusTitle: String, + val statusDescription: String, + val isEmailAttached: Boolean, +) + +internal fun getFeedbackSuccessContent( + category: FeedbackCategory, + email: String, +): FeedbackSuccessContent { + val trimmedEmail = email.trim() + val hasEmail = trimmedEmail.isNotBlank() + val statusTitle = if (hasEmail) STATUS_TITLE_CONTACT else STATUS_TITLE_ANONYMOUS + + return when (category) { + FeedbackCategory.BUG -> FeedbackSuccessContent( + title = "Bug report submitted", + subtitle = "Thank you for helping us squash this bug. We obsess over getting boxlore right and fixing issues at the source.", + statusTitle = statusTitle, + statusDescription = if (hasEmail) { + "Rather than applying a quick band-aid, we would like to chat more about the issue to ensure it is truly solved. We will review your diagnostics and reach out to $trimmedEmail within 24 hours." + } else { + "Your report has been logged in our debugging queue. We will investigate the root cause for upcoming boxlore updates." + }, + isEmailAttached = hasEmail, + ) + FeedbackCategory.AUDIO -> FeedbackSuccessContent( + title = "Audio report submitted", + subtitle = "Playback reliability is core to boxlore. Thank you for reporting this stream behavior so we can investigate and resolve it.", + statusTitle = statusTitle, + statusDescription = if (hasEmail) { + "We obsess over getting boxlore right. We'll analyze your playback diagnostics and reach out to $trimmedEmail within 24 hours to help troubleshoot." + } else { + "Your audio playback report and stream diagnostics have been queued for investigation in our next release." + }, + isEmailAttached = hasEmail, + ) + FeedbackCategory.FEATURE -> FeedbackSuccessContent( + title = "Feature idea submitted", + subtitle = "We love shaping boxlore alongside our listeners. Your suggestion helps steer what we build next.", + statusTitle = statusTitle, + statusDescription = if (hasEmail) { + "We discuss community ideas regularly. If we explore this feature further or need your perspective, we'll reach out to $trimmedEmail within 24 hours." + } else { + "Your idea has been added to our product design roadmap for upcoming boxlore planning." + }, + isEmailAttached = hasEmail, + ) + FeedbackCategory.OTHER -> FeedbackSuccessContent( + title = "Feedback received", + subtitle = "Thank you for taking the time to write to us. Every note and perspective helps make boxlore better.", + statusTitle = statusTitle, + statusDescription = if (hasEmail) { + "We personally review every note. If your message requires a follow-up or reply, we'll reach out to $trimmedEmail within 24 hours." + } else { + "Your feedback has been safely shared with the boxlore team. Thank you for listening!" + }, + isEmailAttached = hasEmail, + ) + } +} + +@Composable +fun FeedbackSuccessView( + uiState: FeedbackUiState, + onDone: () -> Unit, + modifier: Modifier = Modifier, + bottomPadding: Dp = 240.dp, +) { + val content = remember(uiState.category, uiState.email) { + getFeedbackSuccessContent(uiState.category, uiState.email) + } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(16.dp)) + FeedbackSuccessHero(title = content.title, subtitle = content.subtitle) + Spacer(modifier = Modifier.height(28.dp)) + FeedbackStatusCard(content = content) + Spacer(modifier = Modifier.height(16.dp)) + FeedbackSummaryCard(uiState = uiState, isEmailAttached = content.isEmailAttached) + Spacer(modifier = Modifier.height(32.dp)) + Button( + onClick = onDone, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = MaterialTheme.shapes.large, + ) { + Text("Done") + } + Spacer(modifier = Modifier.height(bottomPadding)) + } +} + +@Composable +private fun FeedbackSuccessHero( + title: String, + subtitle: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(80.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + modifier = Modifier.size(44.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = GoogleSansWeight.semiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } +} + +private val FeedbackCategory.icon: ImageVector + get() = when (this) { + FeedbackCategory.BUG -> Icons.Rounded.BugReport + FeedbackCategory.AUDIO -> Icons.Rounded.Headphones + FeedbackCategory.FEATURE -> Icons.Rounded.Lightbulb + FeedbackCategory.OTHER -> Icons.AutoMirrored.Rounded.Message + } + +@Composable +private fun FeedbackStatusCard( + content: FeedbackSuccessContent, + modifier: Modifier = Modifier, +) { + val isAttached = content.isEmailAttached + val cardColor = if (isAttached) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + MaterialTheme.colorScheme.surfaceContainer + } + val iconContainerColor = if (isAttached) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.secondaryContainer + } + val iconTint = if (isAttached) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSecondaryContainer + } + val iconVector = if (isAttached) Icons.Rounded.Schedule else Icons.Rounded.DoneAll + + Surface( + shape = MaterialTheme.shapes.large, + color = cardColor, + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = CircleShape, + color = iconContainerColor, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = iconVector, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(22.dp), + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = content.statusTitle, + style = MaterialTheme.typography.titleSmall, + fontWeight = GoogleSansWeight.semiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = content.statusDescription, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp, + ) + } + } + } +} + +@Composable +private fun FeedbackSummaryCard( + uiState: FeedbackUiState, + isEmailAttached: Boolean, + modifier: Modifier = Modifier, +) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SummaryCategoryRow(category = uiState.category) + + if (isEmailAttached) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) + SummaryEmailRow(email = uiState.email.trim()) + } + + if (uiState.attachDiagnostics) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) + SummaryDiagnosticsRow() + } + } + } +} + +@Composable +private fun SummaryCategoryRow(category: FeedbackCategory) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = category.icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Category", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = category.label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } +} + +@Composable +private fun SummaryEmailRow(email: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f, fill = false), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Rounded.Email, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Contact email", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = GoogleSansWeight.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + textAlign = TextAlign.End, + ) + } +} + +@Composable +private fun SummaryDiagnosticsRow() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Rounded.Analytics, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Diagnostics & logs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text( + text = "Included", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = GoogleSansWeight.medium, + ) + } + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt new file mode 100644 index 000000000..96941f8bb --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt @@ -0,0 +1,371 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import cx.aswin.boxlore.core.catalog.PodcastRepository +import cx.aswin.boxlore.core.prefs.BoxcastPrefs +import cx.aswin.boxlore.core.prefs.FeedbackDraft +import java.net.URLEncoder +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +enum class FeedbackCategory( + val id: String, + val label: String, + val placeholder: String, +) { + FEATURE("feature", "Feature idea", "Describe the feature or improvement you'd like to see..."), + BUG("bug", "Bug report", "What happened and what did you expect to happen?"), + AUDIO("audio", "Audio / Stream", "Describe the playback issue (buffering, cut-off, audio distortion)..."), + OTHER("other", "General", "Tell us what's on your mind..."), +} + +val FeedbackCategory.isBugReport: Boolean + get() = this == FeedbackCategory.BUG || this == FeedbackCategory.AUDIO + +data class FeedbackUiState( + val category: FeedbackCategory = FeedbackCategory.FEATURE, + val message: String = "", + val stepsToReproduce: String = "", + val email: String = "", + val attachDiagnostics: Boolean = false, + val isDraftRestored: Boolean = false, + val isSubmitting: Boolean = false, + val isSuccess: Boolean = false, + val errorMessage: String? = null, + val diagnosticInfo: DiagnosticInfo? = null, + val logsPreview: String? = null, + val isLoadingLogs: Boolean = false, +) + +class FeedbackViewModel( + private val podcastRepository: PodcastRepository, + private val boxcastPrefs: BoxcastPrefs, + private val context: Context, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val submitFeedbackAction: ( + suspend ( + category: String, + message: String, + appVersion: String, + email: String?, + diagnostics: String?, + logs: String?, + ) -> Boolean + )? = null, +) : ViewModel() { + + private val _uiState = MutableStateFlow(FeedbackUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadInitialState() + } + + private fun loadInitialState() { + val diagnostics = DiagnosticCollector.collect(context) + val draft = boxcastPrefs.getFeedbackDraft() + + if (draft != null && (draft.message.isNotBlank() || draft.stepsToReproduce.isNotBlank())) { + val matchingCategory = FeedbackCategory.entries.find { it.id == draft.category } + ?: FeedbackCategory.FEATURE + + _uiState.update { + it.copy( + category = matchingCategory, + message = draft.message, + stepsToReproduce = draft.stepsToReproduce, + email = draft.email, + attachDiagnostics = draft.attachDiagnostics, + isDraftRestored = true, + diagnosticInfo = diagnostics, + ) + } + } else { + _uiState.update { + it.copy(diagnosticInfo = diagnostics) + } + } + } + + fun onCategorySelected(category: FeedbackCategory) { + _uiState.update { + it.copy( + category = category, + attachDiagnostics = category.isBugReport, + errorMessage = null, + ) + } + saveCurrentDraft() + } + + fun onMessageChanged(message: String) { + val trimmed = if (message.length > 2000) message.take(2000) else message + _uiState.update { it.copy(message = trimmed, errorMessage = null) } + saveCurrentDraft() + } + + fun onStepsChanged(steps: String) { + val trimmed = if (steps.length > 1000) steps.take(1000) else steps + _uiState.update { it.copy(stepsToReproduce = trimmed) } + saveCurrentDraft() + } + + fun onEmailChanged(email: String) { + _uiState.update { it.copy(email = email) } + saveCurrentDraft() + } + + fun onAttachDiagnosticsChanged(attach: Boolean) { + _uiState.update { it.copy(attachDiagnostics = attach) } + saveCurrentDraft() + } + + fun onDismissDraftBanner() { + _uiState.update { it.copy(isDraftRestored = false) } + } + + fun onDiscardDraft() { + boxcastPrefs.clearFeedbackDraft() + _uiState.update { + it.copy( + category = FeedbackCategory.FEATURE, + message = "", + stepsToReproduce = "", + email = "", + attachDiagnostics = false, + isDraftRestored = false, + errorMessage = null, + ) + } + } + + private fun saveCurrentDraft() { + val current = _uiState.value + if (current.message.isBlank() && current.stepsToReproduce.isBlank()) { + boxcastPrefs.clearFeedbackDraft() + return + } + val draft = FeedbackDraft( + category = current.category.id, + message = current.message, + email = current.email, + stepsToReproduce = current.stepsToReproduce, + attachDiagnostics = current.attachDiagnostics, + ) + boxcastPrefs.saveFeedbackDraft(draft) + } + + fun loadLogsForPreview() { + _uiState.update { it.copy(isLoadingLogs = true) } + viewModelScope.launch { + val logs = withContext(ioDispatcher) { + LogcatCollector.collectSanitizedLogcat(context = context, maxLines = 150) + } + _uiState.update { + it.copy( + logsPreview = logs, + isLoadingLogs = false, + ) + } + } + } + + fun clearLogsPreview() { + _uiState.update { it.copy(logsPreview = null, isLoadingLogs = false) } + } + + fun onSubmit() { + val state = _uiState.value + if (state.isSubmitting) return + + val trimmedMessage = state.message.trim() + if (trimmedMessage.isBlank()) { + _uiState.update { it.copy(errorMessage = "Please enter your feedback before submitting.") } + return + } + + _uiState.update { it.copy(isSubmitting = true, errorMessage = null) } + + viewModelScope.launch { + val wireCategory = mapWireCategory(state.category) + val appVersion = state.diagnosticInfo?.appVersion ?: "unknown" + val diagnosticsInfo = state.diagnosticInfo ?: DiagnosticCollector.collect(context) + + val fullMessage = buildFullFeedbackMessage(state, trimmedMessage, diagnosticsInfo) + val (diagnosticsReport, sanitizedLogs) = prepareDiagnosticPayload(state, diagnosticsInfo) + + val submit = submitFeedbackAction ?: { cat, msg, ver, mail, diag, logs -> + podcastRepository.submitFeedback(cat, msg, ver, mail, diag, logs) + } + + val success = submit( + wireCategory, + fullMessage, + appVersion, + state.email.trim().ifBlank { null }, + diagnosticsReport, + sanitizedLogs, + ) + + handleSubmissionResult(success) + } + } + + private suspend fun prepareDiagnosticPayload( + state: FeedbackUiState, + diagnosticsInfo: DiagnosticInfo, + ): Pair { + if (!state.attachDiagnostics) return null to null + + val diagnosticsReport = diagnosticsInfo.toMarkdownReport() + val sanitizedLogs = state.logsPreview ?: withContext(ioDispatcher) { + LogcatCollector.collectSanitizedLogcat(context = context, maxLines = 100) + } + return diagnosticsReport to sanitizedLogs + } + + private fun handleSubmissionResult(success: Boolean) { + if (success) { + boxcastPrefs.clearFeedbackDraft() + _uiState.update { + it.copy( + isSubmitting = false, + isSuccess = true, + isDraftRestored = false, + ) + } + } else { + _uiState.update { + it.copy( + isSubmitting = false, + errorMessage = "Couldn't send feedback right now. Please check your connection and try again.", + ) + } + } + } + + class Factory( + private val podcastRepository: PodcastRepository, + private val boxcastPrefs: BoxcastPrefs, + private val context: Context, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = FeedbackViewModel(podcastRepository, boxcastPrefs, context) as T + } +} + +private fun mapWireCategory(category: FeedbackCategory): String = when (category) { + FeedbackCategory.FEATURE -> "feature" + FeedbackCategory.BUG, FeedbackCategory.AUDIO -> "bug" + FeedbackCategory.OTHER -> "other" +} + +private fun buildFullFeedbackMessage( + state: FeedbackUiState, + trimmedMessage: String, + diagnosticsInfo: DiagnosticInfo, +): String { + val prefix = if (state.category == FeedbackCategory.AUDIO) { + "[Category: Audio / Stream Issue]\n\n" + } else { + "" + } + val steps = if (state.category.isBugReport && state.stepsToReproduce.isNotBlank()) { + "\n\nSteps to reproduce:\n${state.stepsToReproduce.trim()}" + } else { + "" + } + val diagnostics = if (state.attachDiagnostics) { + "\n\n---\nDiagnostics: ${diagnosticsInfo.toCondensedSummary()}" + } else { + "" + } + val overhead = prefix.length + steps.length + diagnostics.length + val availableForMessage = (2000 - overhead).coerceAtLeast(100) + val cappedMessage = trimmedMessage.take(availableForMessage) + return "$prefix$cappedMessage$steps$diagnostics".take(2000) +} + +fun buildFeedbackGitHubIssueUrl(state: FeedbackUiState): String { + val title = "[${state.category.label}]: " + state.message.take(60).replace("\n", " ").trim() + val body = buildString { + append("### Description\n") + append(state.message.ifBlank { "Describe the issue or feature request here." }) + append("\n\n") + if (state.category.isBugReport && state.stepsToReproduce.isNotBlank()) { + append("### Steps to Reproduce\n") + append(state.stepsToReproduce) + append("\n\n") + } + if (state.attachDiagnostics && state.diagnosticInfo != null) { + append(state.diagnosticInfo.toMarkdownReport()) + append("\n*Note: To attach app logs, please copy them from the app's diagnostic preview and paste here.*\n") + } + }.take(2000) + + return try { + "https://github.com/boxcreate/boxlore/issues/new?title=" + + URLEncoder.encode(title, "UTF-8") + + "&body=" + + URLEncoder.encode(body, "UTF-8") + } catch (_: Exception) { + "https://github.com/boxcreate/boxlore/issues/new" + } +} + +fun shareFeedbackDiagnostics(context: Context, state: FeedbackUiState) { + val report = state.diagnosticInfo?.toMarkdownReport(state.logsPreview) + ?: "No diagnostics available." + + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "boxlore Diagnostics & Logs") + putExtra(Intent.EXTRA_TEXT, report) + } + val shareIntent = Intent.createChooser(sendIntent, "Share Diagnostics & Logs") + shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(shareIntent) +} + +fun sendFeedbackEmail(context: Context, state: FeedbackUiState) { + val report = buildString { + if (state.message.isNotBlank()) { + append("Message:\n") + append(state.message.trim()) + append("\n\n") + } + if (state.stepsToReproduce.isNotBlank()) { + append("Steps to reproduce:\n") + append(state.stepsToReproduce.trim()) + append("\n\n") + } + append(state.diagnosticInfo?.toMarkdownReport(state.logsPreview) ?: "No diagnostics available.") + } + + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:feedback@aswin.cx") + putExtra( + Intent.EXTRA_SUBJECT, + "[boxlore] ${state.category.label} & Diagnostics (v${state.diagnosticInfo?.appVersion ?: ""})", + ) + putExtra(Intent.EXTRA_TEXT, report) + } + try { + val chooser = Intent.createChooser(emailIntent, "Send feedback email") + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooser) + } catch (_: Exception) { + shareFeedbackDiagnostics(context, state) + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt new file mode 100644 index 000000000..be0cc93be --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt @@ -0,0 +1,148 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.content.Context +import android.os.Process +import androidx.annotation.WorkerThread +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.regex.Pattern + +/** + * Collects and sanitizes in-process logcat entries for diagnostic feedback. + * + * Scrubbing ensures that no bearer tokens, API keys, passwords, email addresses, + * private API URLs, or internal header names leak into feedback or diagnostic dumps. + */ +object LogcatCollector { + + private const val SCRUBBED_API_URL = "https://api.boxlore.app" + private const val SCRUBBED_API_HOST = "api.boxlore.app" + private const val SCRUBBED_APP_KEY_LABEL = "public key" + + private val AUTH_HEADER_PATTERN = Pattern.compile( + """(?i)(["']?authorization["']?\s*:\s*)?(Bearer|Basic)\s+([a-z0-9\-_./+=]{4,})""", + ) + + private val FIREBASE_API_KEY_PATTERN = Pattern.compile( + """AIza[0-9A-Za-z\-_]{35}""", + ) + + private val URL_QUERY_PARAM_PATTERN = Pattern.compile( + """([?&](?:token|key|auth|api[-_]?key|secret)=)[^&\s]+""", + Pattern.CASE_INSENSITIVE, + ) + + private val X_APP_KEY_VALUE_PATTERN = Pattern.compile( + """(?i)["']?[a-z0-9_-]+(?:app|public)[-_ ]key["']?\s*[:=]\s*(?:\[REDACTED\]|["']?[^\s"']+)""", + ) + + private val X_APP_KEY_NAME_PATTERN = Pattern.compile( + """(?i)\b(?:x[-_]app[-_]key|boxlore[-_]public[-_]key|boxcast[-_]public[-_]key|x\s+app\s+key)\b""", + ) + + private val SENSITIVE_KEY_VALUE_PATTERN = Pattern.compile( + """(?i)(["']?[a-z0-9_]*(?:auth|token|secret|password|key)["']?\s*[:=]\s*["']?)([^"'\s,;&?\[]+)(["']?)""", + ) + + private val EMAIL_ADDRESS_PATTERN = Pattern.compile( + """\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b""", + ) + + private val API_URL_PATTERN = Pattern.compile( + """(?i)https?://[a-z0-9.-]*aswin\.cx(:[0-9]+)?(/[^\s"']*)?""", + ) + + private val ASWIN_HOST_PATTERN = Pattern.compile( + """(?i)\b[a-z0-9.-]*aswin\.cx(:[0-9]+)?\b""", + ) + + private val API_CONFIG_URL_PATTERN = Pattern.compile( + """(?i)(["']?(?:api|base)[-_a-z]*url["']?\s*[:=]\s*["']?)https?://[^"'\s]+(["']?)""", + ) + + /** + * Captures the most recent logcat entries for the current application process. + * Must be called on a background thread. + */ + @WorkerThread + fun collectSanitizedLogcat(context: Context? = null, maxLines: Int = 150): String = try { + val process = startLogcatProcess(maxLines) + val rawLog = readProcessOutput(process) + + if (rawLog.isBlank()) { + "[No logcat entries recorded for current session]" + } else { + val (apiBaseUrl, publicKey) = readApiConfig(context) + sanitizeLogcatOutput(rawLog, apiBaseUrl, publicKey) + } + } catch (e: Exception) { + "[Logcat collection unavailable: ${e.javaClass.simpleName}]" + } + + private fun startLogcatProcess(maxLines: Int): java.lang.Process { + val pid = Process.myPid() + return try { + ProcessBuilder("logcat", "--pid=$pid", "-d", "-v", "time", "-t", maxLines.toString()) + .redirectErrorStream(true) + .start() + } catch (_: Exception) { + ProcessBuilder("logcat", "-d", "-v", "time", "-t", maxLines.toString()) + .redirectErrorStream(true) + .start() + } + } + + private fun readProcessOutput(process: java.lang.Process): String { + val output = StringBuilder() + try { + BufferedReader(InputStreamReader(process.inputStream)).use { reader -> + reader.lineSequence().forEach { line -> + output.append(line).append('\n') + } + } + process.waitFor() + } finally { + process.destroy() + } + return output.toString() + } + + private fun readApiConfig(context: Context?): Pair { + if (context == null) return null to null + val prefs = context.getSharedPreferences("boxlore_api_config", Context.MODE_PRIVATE) + return prefs.getString("base_url", null) to prefs.getString("public_key", null) + } + + /** + * Sanitizes sensitive information from raw logcat text. + */ + fun sanitizeLogcatOutput( + raw: String, + knownApiBaseUrl: String? = null, + knownPublicKey: String? = null, + ): String { + if (raw.isBlank()) return raw + + var sanitized = raw + + if (!knownPublicKey.isNullOrBlank() && knownPublicKey.length >= 4) { + sanitized = sanitized.replace(knownPublicKey, "[REDACTED]") + } + if (!knownApiBaseUrl.isNullOrBlank() && knownApiBaseUrl.startsWith("http")) { + sanitized = sanitized.replace(knownApiBaseUrl, SCRUBBED_API_URL) + } + + sanitized = URL_QUERY_PARAM_PATTERN.matcher(sanitized).replaceAll("$1[REDACTED]") + sanitized = AUTH_HEADER_PATTERN.matcher(sanitized).replaceAll("$1$2 [REDACTED]") + sanitized = FIREBASE_API_KEY_PATTERN.matcher(sanitized).replaceAll("[REDACTED_API_KEY]") + sanitized = X_APP_KEY_VALUE_PATTERN.matcher(sanitized).replaceAll("$SCRUBBED_APP_KEY_LABEL: [REDACTED]") + sanitized = X_APP_KEY_NAME_PATTERN.matcher(sanitized).replaceAll(SCRUBBED_APP_KEY_LABEL) + sanitized = SENSITIVE_KEY_VALUE_PATTERN.matcher(sanitized).replaceAll("$1[REDACTED]$3") + sanitized = EMAIL_ADDRESS_PATTERN.matcher(sanitized).replaceAll("[REDACTED_EMAIL]") + sanitized = API_CONFIG_URL_PATTERN.matcher(sanitized).replaceAll("$1$SCRUBBED_API_URL$2") + sanitized = API_URL_PATTERN.matcher(sanitized).replaceAll("$SCRUBBED_API_URL$2") + sanitized = ASWIN_HOST_PATTERN.matcher(sanitized).replaceAll(SCRUBBED_API_HOST) + + return sanitized + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AboutSettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AboutSettingsPage.kt index 430a74747..da26ad05b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AboutSettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AboutSettingsPage.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Launch +import androidx.compose.material.icons.rounded.Feedback import androidx.compose.material.icons.rounded.HistoryEdu import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -52,6 +53,7 @@ internal fun AboutSettingsPage( appInfo: AppInfo, onVisitPodcastIndex: () -> Unit, onOpenChangelog: () -> Unit, + onSendFeedback: () -> Unit = {}, onBack: () -> Unit, ) { SettingsScaffold( @@ -132,6 +134,12 @@ internal fun AboutSettingsPage( SpecRow(label = "Android", value = "${appInfo.androidRelease} (API ${appInfo.sdkInt})") } } + SettingsNavigationRow( + title = "Send feedback", + supportingText = "Ideas, bug reports, and logs", + icon = Icons.Rounded.Feedback, + onClick = onSendFeedback, + ) SettingsNavigationRow( title = "Changelog", supportingText = "What’s new on GitHub", diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsDialogs.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsDialogs.kt index de8dccb23..146cb3cc1 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsDialogs.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsDialogs.kt @@ -2,6 +2,10 @@ package cx.aswin.boxlore.feature.settings.pages import android.content.Context import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -9,6 +13,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import cx.aswin.boxlore.core.auth.AuthRepository import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import kotlinx.coroutines.CoroutineScope @@ -61,11 +67,13 @@ internal fun AccountSettingsDialogs( onConfirmDelete: () -> Unit, onDismissReauthRequired: () -> Unit, onSignOutToReauth: () -> Unit, + onOpenAccountDeletionInfo: () -> Unit = {}, ) { if (showDeleteConfirmation) { DeleteAccountConfirmationDialog( onDismiss = onDismissDeleteConfirmation, onConfirmDelete = onConfirmDelete, + onOpenAccountDeletionInfo = onOpenAccountDeletionInfo, ) } @@ -81,16 +89,27 @@ internal fun AccountSettingsDialogs( private fun DeleteAccountConfirmationDialog( onDismiss: () -> Unit, onConfirmDelete: () -> Unit, + onOpenAccountDeletionInfo: () -> Unit = {}, ) { AlertDialog( onDismissRequest = onDismiss, title = { Text("Delete Account", fontWeight = GoogleSansWeight.bold) }, text = { - Text( - "Are you sure you want to delete your boxlore account? " + - "This permanently removes your cloud profile and cross-device sync data. " + - "Your local downloads and podcast catalog on this device will not be erased.", - ) + Column { + Text( + "Are you sure you want to delete your boxlore account? " + + "This permanently removes your cloud profile and cross-device sync data. " + + "Your local downloads and podcast catalog on this device will not be erased.", + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "Learn how your data is handled after deletion", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + fontWeight = GoogleSansWeight.medium, + modifier = Modifier.clickable(onClick = onOpenAccountDeletionInfo), + ) + } }, confirmButton = { Button( diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt index 62c7255b1..5f72fbdbe 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt @@ -1,8 +1,11 @@ package cx.aswin.boxlore.feature.settings.pages import android.content.Context +import android.content.Intent +import android.net.Uri import android.widget.Toast import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,6 +27,7 @@ import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.DeleteForever import androidx.compose.material.icons.rounded.Email +import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Key import androidx.compose.material.icons.rounded.Lock import androidx.compose.material3.Button @@ -46,6 +50,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -62,6 +68,55 @@ import kotlinx.coroutines.launch private const val PROVIDER_GOOGLE = "google.com" private const val PROVIDER_EMAIL_LINK = "emailLink" +private const val ACCOUNT_DELETION_URL = "https://aswin.cx/boxlore/account-deletion/" + +@Composable +private fun AccountStateEffects( + currentUser: BoxLoreUser?, + authState: AccountAuthState, + context: Context, + scrollState: ScrollState, +) { + var previousUser by remember { mutableStateOf(null) } + LaunchedEffect(currentUser) { + val user = currentUser + val prev = previousUser + previousUser = user + if (prev != null && user == null) { + authState.resetToNewEmail() + } else if (user?.isEmailVerified == true) { + authState.isAwaitingVerification = false + AccountVerificationStorage.setPref(context, false) + } + } + + LaunchedEffect(authState.isAwaitingVerification, authState.magicLinkSent) { + if (authState.isAwaitingVerification || authState.magicLinkSent) { + scrollState.animateScrollTo(0) + } + } +} + +private fun openAccountDeletionInfo(context: Context, uriHandler: UriHandler) { + val opened = runCatching { + uriHandler.openUri(ACCOUNT_DELETION_URL) + true + }.getOrDefault(false) + if (!opened) { + val fallbackOpened = runCatching { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ACCOUNT_DELETION_URL)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + true + }.getOrDefault(false) + if (!fallbackOpened) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager + clipboard?.setPrimaryClip(android.content.ClipData.newPlainText("Account Deletion URL", ACCOUNT_DELETION_URL)) + android.widget.Toast.makeText(context, "Link copied to clipboard", android.widget.Toast.LENGTH_SHORT).show() + } + } +} @Composable internal fun AccountSettingsPage( @@ -72,8 +127,10 @@ internal fun AccountSettingsPage( isOnboarding: Boolean = false, ) { val context = LocalContext.current + val uriHandler = LocalUriHandler.current val scope = rememberCoroutineScope() val scrollState = rememberScrollState() + val onOpenAccountDeletionInfo = { openAccountDeletionInfo(context, uriHandler) } val currentUser by ( authRepository?.currentUser?.collectAsState() ?: remember { mutableStateOf(null) } @@ -88,24 +145,12 @@ internal fun AccountSettingsPage( scope = scope, ) - var previousUser by remember { mutableStateOf(null) } - LaunchedEffect(currentUser) { - val user = currentUser - val prev = previousUser - previousUser = user - if (prev != null && user == null) { - authState.resetToNewEmail() - } else if (user?.isEmailVerified == true) { - authState.isAwaitingVerification = false - AccountVerificationStorage.setPref(context, false) - } - } - - LaunchedEffect(authState.isAwaitingVerification, authState.magicLinkSent) { - if (authState.isAwaitingVerification || authState.magicLinkSent) { - scrollState.animateScrollTo(0) - } - } + AccountStateEffects( + currentUser = currentUser, + authState = authState, + context = context, + scrollState = scrollState, + ) SettingsScaffold( title = "Account", @@ -127,6 +172,7 @@ internal fun AccountSettingsPage( handleSignOut(authRepository, authState, context) }, onDeleteAccountClick = { showDeleteConfirmation = true }, + onOpenAccountDeletionInfo = onOpenAccountDeletionInfo, onSyncNow = onSyncNow, onContinueToHome = onBack, ), @@ -158,6 +204,7 @@ internal fun AccountSettingsPage( showReauthRequiredDialog = false handleSignOut(authRepository, authState, context) }, + onOpenAccountDeletionInfo = onOpenAccountDeletionInfo, ) } @@ -201,6 +248,7 @@ private data class SignedInActions( val onResetPassword: () -> Unit, val onSignOut: () -> Unit, val onDeleteAccountClick: () -> Unit, + val onOpenAccountDeletionInfo: () -> Unit, val onSyncNow: () -> Unit, val onContinueToHome: () -> Unit = {}, ) @@ -225,6 +273,7 @@ private fun ColumnScope.SignedInContent( onResetPassword = actions.onResetPassword, onSignOut = actions.onSignOut, onDeleteAccountClick = actions.onDeleteAccountClick, + onOpenAccountDeletionInfo = actions.onOpenAccountDeletionInfo, ) } @@ -426,7 +475,7 @@ private fun UserProfileCard( horizontalAlignment = Alignment.CenterHorizontally, ) { AnimatedBlobAvatar( - size = 76.dp, + size = 92.dp, ) Spacer(Modifier.height(14.dp)) @@ -466,6 +515,7 @@ private fun AccountManagementGroup( onResetPassword: () -> Unit, onSignOut: () -> Unit, onDeleteAccountClick: () -> Unit, + onOpenAccountDeletionInfo: () -> Unit, ) { val isPasswordAccount = user.providerId != PROVIDER_GOOGLE && !user.email.isNullOrBlank() SettingsGroup(title = "Account Management") { @@ -492,5 +542,12 @@ private fun AccountManagementGroup( destructive = true, onClick = onDeleteAccountClick, ) + SettingsDivider() + SettingsActionRow( + title = "Account & Data Deletion Info", + supportingText = "Learn how your data is handled after deletion or request partial data removal", + icon = Icons.Rounded.Info, + onClick = onOpenAccountDeletionInfo, + ) } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt index 3b07c46a3..92db02368 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt @@ -1,36 +1,54 @@ package cx.aswin.boxlore.feature.settings.pages +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlin.math.sin +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch internal data class BlobAvatarAnimationState( val floatProgress: State, @@ -40,13 +58,15 @@ internal data class BlobAvatarAnimationState( val headBobAngle: State, val pupilLookRatioX: State, val pupilLookRatioY: State, + val soundwaveProgress: State, + val particleProgress: State, + val bassPulseScale: State, ) @Composable internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { val infiniteTransition = rememberInfiniteTransition(label = "BlobAvatarTransition") - // Gentle, calming float val floatProgress = infiniteTransition.animateFloat( initialValue = -1f, targetValue = 1f, @@ -57,7 +77,6 @@ internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { label = "BlobFloatProgress", ) - // Soft squishy breathing val breatheScaleX = infiniteTransition.animateFloat( initialValue = 0.985f, targetValue = 1.015f, @@ -77,7 +96,6 @@ internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { label = "BlobBreatheY", ) - // Sweet subtle head groove to the music val headBobAngle = infiniteTransition.animateFloat( initialValue = -2.5f, targetValue = 2.5f, @@ -88,7 +106,6 @@ internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { label = "BlobHeadBob", ) - // Natural, cute blink with double-blink val eyeOpenScale = infiniteTransition.animateFloat( initialValue = 1f, targetValue = 1f, @@ -108,6 +125,36 @@ internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { label = "BlobEyeOpen", ) + val soundwaveProgress = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1800, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "BlobSoundwave", + ) + + val particleProgress = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 3200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "BlobParticles", + ) + + val bassPulseScale = infiniteTransition.animateFloat( + initialValue = 1.0f, + targetValue = 1.05f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "BlobBassPulse", + ) + val (pupilLookRatioX, pupilLookRatioY) = rememberPupilLookAnimations(infiniteTransition) return BlobAvatarAnimationState( @@ -118,6 +165,9 @@ internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { headBobAngle = headBobAngle, pupilLookRatioX = pupilLookRatioX, pupilLookRatioY = pupilLookRatioY, + soundwaveProgress = soundwaveProgress, + particleProgress = particleProgress, + bassPulseScale = bassPulseScale, ) } @@ -166,134 +216,269 @@ private fun rememberPupilLookAnimations( return Pair(pupilLookRatioX, pupilLookRatioY) } +internal data class BlobAvatarPalette( + val bodyBase: Color, + val bodyShadow: Color, + val bodyOutline: Color, + val facialColor: Color, + val blushColor: Color, + val headbandColor: Color, + val cushionColor: Color, + val headphoneCup: Color, + val headphoneAccent: Color, + val shadowAlpha: Float, +) + +@Composable +internal fun resolveAvatarPalette( + genreMood: BlobAvatarGenreMood, + isDark: Boolean, +): BlobAvatarPalette { + val bodyBase = if (isDark) { + Color(0xFFF1F5F9) + } else { + Color(0xFFF8FAFC) + } + + val bodyShadow = Color(0xFFCBD5E1) + + val bodyOutline = if (isDark) { + Color.White.copy(alpha = 0.18f) + } else { + Color(0xFF94A3B8).copy(alpha = 0.45f) + } + + val facialColor = Color(0xFF0F172A) + val blushColor = Color(0xFFFF5277).copy(alpha = if (isDark) 0.50f else 0.40f) + + val headbandColor = if (isDark) Color(0xFF1E293B) else Color(0xFF334155) + val cushionColor = if (isDark) Color(0xFF0F172A) else Color(0xFF1E293B) + val headphoneCup = genreMood.keyLightColor + val headphoneAccent = genreMood.rimLightColor + val shadowAlpha = if (isDark) 0.22f else 0.26f + + return BlobAvatarPalette( + bodyBase = bodyBase, + bodyShadow = bodyShadow, + bodyOutline = bodyOutline, + facialColor = facialColor, + blushColor = blushColor, + headbandColor = headbandColor, + cushionColor = cushionColor, + headphoneCup = headphoneCup, + headphoneAccent = headphoneAccent, + shadowAlpha = shadowAlpha, + ) +} + +internal data class BlobFacialExpression( + val isWinking: Boolean, + val isClosed: Boolean, + val isLaughing: Boolean, +) + +internal data class BlobAvatarRenderContext( + val mood: BlobAvatarGenreMood, + val palette: BlobAvatarPalette, + val animState: BlobAvatarAnimationState, + val expression: BlobFacialExpression, + val squishY: Float, + val isDark: Boolean, +) + /** - * An ultra-cute, 3D clay-shaded mochi blob wearing cozy studio headphones. - * Features warm rosy cheeks, glossy kawaii button eyes with bright catchlights, - * a sweet delicate smile, and a gentle music groove. + * An ultra-expressive, tactile 3D mochi mascot avatar with dual studio spotlights, + * concentric headphone soundwaves, spring-physics squish on tap, and podcast genre moods. */ @Composable internal fun AnimatedBlobAvatar( modifier: Modifier = Modifier, - size: Dp = 82.dp, + size: Dp = 88.dp, + initialMood: BlobAvatarGenreMood? = null, ) { val animState = rememberBlobAvatarAnimationState() - val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f - val bodyBaseColor = if (isDark) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.95f) - } else { - MaterialTheme.colorScheme.primaryContainer + var activeMood by rememberSaveable { + mutableStateOf(initialMood ?: BlobAvatarGenreMood.random()) } - val bodyShadowColor = if (isDark) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.70f) - } else { - MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) - } - val bodyBounceColor = if (isDark) { - Color.White.copy(alpha = 0.22f) - } else { - MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) - } - - val facialColor = Color(0xFF1E222A) - val blushColor = if (isDark) { - Color(0xFFFF7597).copy(alpha = 0.45f) - } else { - Color(0xFFFF7B95).copy(alpha = 0.38f) + val palette = resolveAvatarPalette(activeMood, isDark) + val haptic = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + + val squishScaleY = remember { Animatable(1f) } + var isWinking by remember { mutableStateOf(false) } + var winkJob by remember { mutableStateOf(null) } + + val onTapAvatar = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + activeMood = BlobAvatarGenreMood.next(activeMood) + coroutineScope.launch { + squishScaleY.snapTo(0.80f) + squishScaleY.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ) + } + winkJob?.cancel() + winkJob = coroutineScope.launch { + isWinking = true + delay(650) + isWinking = false + } } - val headbandColor = if (isDark) Color(0xFF282C34) else Color(0xFF3C4048) - val cushionColor = if (isDark) Color(0xFF1C1F26) else Color(0xFF2E323A) - val headphoneCupColor = MaterialTheme.colorScheme.tertiary - val headphoneAccentColor = if (isDark) Color.White.copy(alpha = 0.90f) else MaterialTheme.colorScheme.primary - val shadowBaseAlpha = if (isDark) 0.10f else 0.18f + val expression = BlobFacialExpression( + isWinking = isWinking, + isClosed = activeMood == BlobAvatarGenreMood.ChillMusic, + isLaughing = activeMood == BlobAvatarGenreMood.ComedyTalk, + ) + + val renderContext = BlobAvatarRenderContext( + mood = activeMood, + palette = palette, + animState = animState, + expression = expression, + squishY = squishScaleY.value, + isDark = isDark, + ) Box( - modifier = modifier.size(size), + modifier = modifier + .size(size) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Button, + onClickLabel = "Cycle avatar mood", + onClick = onTapAvatar, + ) + .semantics { + contentDescription = "Avatar mood: ${activeMood.displayName}" + }, contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.fillMaxSize()) { - val w = this.size.width - val h = this.size.height + drawAvatarLayers(renderContext, this.size.width, this.size.height) + } + } +} - val floatOffsetPx = BlobAvatarGeometry.computeFloatOffset(h, animState.floatProgress.value) - val center = Offset(w / 2f, (h / 2f) + floatOffsetPx) +private fun DrawScope.drawAvatarLayers( + renderContext: BlobAvatarRenderContext, + w: Float, + h: Float, +) { + val mood = renderContext.mood + val palette = renderContext.palette + val animState = renderContext.animState + + val floatOffsetPx = BlobAvatarGeometry.computeFloatOffset(h, animState.floatProgress.value) + val center = Offset(w / 2f, (h / 2f) + floatOffsetPx) + + drawDualSpotlights(mood = mood, w = w, h = h, isDark = renderContext.isDark) + + BlobAvatarEnvironments.drawEnvironment( + drawScope = this, + environmentType = mood.environmentType, + center = center, + w = w, + h = h, + mood = mood, + progress = animState.particleProgress.value, + ) - // 1. Soft 3D Ground Drop Shadow - drawGroundShadow( + drawGroundShadow( + center = center, + w = w, + h = h, + floatProgress = animState.floatProgress.value, + baseAlpha = palette.shadowAlpha, + ) + + drawSoundwaveRipples( + center = center, + w = w, + h = h, + animProgress = animState.soundwaveProgress.value, + waveColor = mood.rimLightColor, + ) + + drawGenreParticles( + center = center, + w = w, + mood = mood, + progress = animState.particleProgress.value, + ) + + val squishX = BlobAvatarGeometry.computeSquishScaleX(renderContext.squishY) + + scale(scaleX = squishX, scaleY = renderContext.squishY, pivot = center) { + rotate(degrees = animState.headBobAngle.value, pivot = center) { + val bodyWidth = BlobAvatarGeometry.computeBodyWidth(w, animState.breatheScaleX.value) + val bodyHeight = BlobAvatarGeometry.computeBodyHeight(h, animState.breatheScaleY.value) + val dimensions = CharacterDimensions( center = center, + bodyWidth = bodyWidth, + bodyHeight = bodyHeight, w = w, - h = h, - floatProgress = animState.floatProgress.value, - baseAlpha = shadowBaseAlpha, ) + BlobAvatarCharacter.drawCharacter(this, dimensions, renderContext) + } + } +} - // 2. Groovy Head-Bobbing Container - rotate(degrees = animState.headBobAngle.value, pivot = center) { - val bodyWidth = BlobAvatarGeometry.computeBodyWidth(w, animState.breatheScaleX.value) - val bodyHeight = BlobAvatarGeometry.computeBodyHeight(h, animState.breatheScaleY.value) - - // 2A. Headband Arch Over the Head - drawHeadband( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - bandColor = headbandColor, - w = w, - ) - - // 2B. 3D Volumetric Clay Mochi Body - drawClayMochiBody( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - baseColor = bodyBaseColor, - shadowColor = bodyShadowColor, - bounceColor = bodyBounceColor, - ) - - // 2C. Soft Rosy Blush Cheeks - drawRosyBlush( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - blushColor = blushColor, - w = w, - ) +private fun DrawScope.drawDualSpotlights( + mood: BlobAvatarGenreMood, + w: Float, + h: Float, + isDark: Boolean, +) { + val auraAlpha = if (isDark) 0.22f else 0.38f + val keyAlpha = if (isDark) 0.35f else 0.42f + val rimAlpha = if (isDark) 0.30f else 0.38f - // 2D. Kawaii Glossy Button Eyes (No creepy sclera!) - drawKawaiiEyes( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - animState = animState, - eyeColor = facialColor, - w = w, - ) + // Ambient backlight halo behind the mascot to create sharp contrast and luminous definition against light surfaces + val auraOrigin = Offset(w / 2f, h / 2f) + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + mood.keyLightColor.copy(alpha = auraAlpha), + mood.rimLightColor.copy(alpha = auraAlpha * 0.65f), + Color.Transparent, + ), + center = auraOrigin, + radius = w * 0.52f, + ), + radius = w * 0.52f, + center = auraOrigin, + ) - // 2E. Sweet Delicate Smile - drawSweetSmile( - center = center, - bodyHeight = bodyHeight, - smileColor = facialColor, - w = w, - ) + val keyLightOrigin = Offset(w * 0.15f, h * 0.10f) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(mood.keyLightColor.copy(alpha = keyAlpha), Color.Transparent), + center = keyLightOrigin, + radius = w * 0.65f, + ), + radius = w * 0.65f, + center = keyLightOrigin, + ) - // 2F. Cozy Padded Over-Ear Headphones - drawCozyHeadphones( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - cupColor = headphoneCupColor, - cushionColor = cushionColor, - accentColor = headphoneAccentColor, - w = w, - ) - } - } - } + val rimLightOrigin = Offset(w * 0.85f, h * 0.90f) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(mood.rimLightColor.copy(alpha = rimAlpha), Color.Transparent), + center = rimLightOrigin, + radius = w * 0.60f, + ), + radius = w * 0.60f, + center = rimLightOrigin, + ) } private fun DrawScope.drawGroundShadow( @@ -323,312 +508,87 @@ private fun DrawScope.drawGroundShadow( ) } -private fun DrawScope.drawClayMochiBody( +private fun DrawScope.drawSoundwaveRipples( center: Offset, - bodyWidth: Float, - bodyHeight: Float, - baseColor: Color, - shadowColor: Color, - bounceColor: Color, -) { - val cornerRadius = CornerRadius(bodyWidth * 0.46f, bodyHeight * 0.46f) - val bodyRect = RoundRect( - left = center.x - (bodyWidth / 2f), - top = center.y - (bodyHeight / 2f), - right = center.x + (bodyWidth / 2f), - bottom = center.y + (bodyHeight / 2f), - cornerRadius = cornerRadius, - ) - val bodyPath = Path().apply { addRoundRect(bodyRect) } - - // Soft 3D volumetric radial gradient (light from top-left) - val lightSource = Offset(center.x - (bodyWidth * 0.20f), center.y - (bodyHeight * 0.22f)) - val bodyBrush = Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = 0.50f), - baseColor, - shadowColor, - ), - center = lightSource, - radius = bodyWidth * 0.85f, - ) - drawPath(path = bodyPath, brush = bodyBrush) - - // Soft subsurface bounce light on bottom curve - val bounceBrush = Brush.linearGradient( - colors = listOf(Color.Transparent, bounceColor), - start = Offset(center.x, center.y), - end = Offset(center.x, center.y + (bodyHeight / 2f)), - ) - drawPath(path = bodyPath, brush = bounceBrush) - - // Gentle glossy shine on top-left - val specWidth = bodyWidth * 0.34f - val specHeight = bodyHeight * 0.14f - val specCenter = Offset(center.x - (bodyWidth * 0.18f), center.y - (bodyHeight * 0.26f)) - drawOval( - brush = Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = 0.55f), - Color.White.copy(alpha = 0.15f), - Color.Transparent, - ), - center = specCenter, - radius = specWidth / 2f, - ), - topLeft = Offset(specCenter.x - (specWidth / 2f), specCenter.y - (specHeight / 2f)), - size = Size(specWidth, specHeight), - ) -} - -private fun DrawScope.drawHeadband( - center: Offset, - bodyWidth: Float, - bodyHeight: Float, - bandColor: Color, w: Float, + h: Float, + animProgress: Float, + waveColor: Color, ) { - val bandTopY = BlobAvatarGeometry.computeHeadbandTopY(center.y, bodyHeight) - val bandLeftX = center.x - (bodyWidth * 0.44f) - val bandRightX = center.x + (bodyWidth * 0.44f) - val bandStartY = center.y - (bodyHeight * 0.10f) - - val bandPath = Path().apply { - moveTo(bandLeftX, bandStartY) - cubicTo( - bandLeftX, - bandTopY, - bandRightX, - bandTopY, - bandRightX, - bandStartY, + val earSpacing = w * 0.42f + val earY = center.y - (h * 0.01f) + + val waves = listOf(animProgress, (animProgress + 0.5f) % 1f) + + for (p in waves) { + val radius = BlobAvatarGeometry.computeSoundwaveRadius(w, p) + val alpha = BlobAvatarGeometry.computeSoundwaveAlpha(p) + val stroke = (w * 0.016f * (1f - (p * 0.4f))).coerceAtLeast(1f) + + // Left ear wave arc + drawArc( + color = waveColor.copy(alpha = alpha), + startAngle = 110f, + sweepAngle = 140f, + useCenter = false, + topLeft = Offset(center.x - earSpacing - radius, earY - radius), + size = Size(radius * 2f, radius * 2f), + style = Stroke(width = stroke, cap = StrokeCap.Round), ) - } - - val bandStroke = BlobAvatarGeometry.computeHeadbandStroke(w) - - drawPath( - path = bandPath, - color = bandColor, - style = Stroke( - width = bandStroke, - cap = StrokeCap.Round, - join = StrokeJoin.Round, - ), - ) - drawPath( - path = bandPath, - color = Color.White.copy(alpha = 0.30f), - style = Stroke( - width = bandStroke * 0.32f, - cap = StrokeCap.Round, - join = StrokeJoin.Round, - ), - ) -} - -private fun DrawScope.drawCozyHeadphones( - center: Offset, - bodyWidth: Float, - bodyHeight: Float, - cupColor: Color, - cushionColor: Color, - accentColor: Color, - w: Float, -) { - val cupCenterY = center.y - (bodyHeight * 0.02f) - val cupSpacingX = BlobAvatarGeometry.computeCupSpacing(bodyWidth) - - val cupWidth = BlobAvatarGeometry.computeCupWidth(w) - val cupHeight = w * BlobAvatarGeometry.CUP_HEIGHT_RATIO - - val ears = listOf( - Pair(center.x - cupSpacingX, -4f), - Pair(center.x + cupSpacingX, 4f), - ) - - for ((cupX, tilt) in ears) { - rotate(degrees = tilt, pivot = Offset(cupX, cupCenterY)) { - // Soft inner cushion pill - val cushionRect = RoundRect( - left = cupX - (cupWidth * 0.48f), - top = cupCenterY - (cupHeight * 0.48f), - right = cupX + (cupWidth * 0.48f), - bottom = cupCenterY + (cupHeight * 0.48f), - cornerRadius = CornerRadius(cupWidth * 0.44f, cupWidth * 0.44f), - ) - drawRoundRect( - color = cushionColor, - topLeft = Offset(cushionRect.left, cushionRect.top), - size = Size(cushionRect.width, cushionRect.height), - cornerRadius = CornerRadius(cupWidth * 0.44f, cupWidth * 0.44f), - ) - - // Outer pastel dome shell - val shellWidth = cupWidth * 0.76f - val shellHeight = cupHeight * 0.86f - val shellLeft = if (cupX < center.x) cupX - (cupWidth * 0.50f) else cupX - (shellWidth * 0.46f) - val shellTop = cupCenterY - (shellHeight * 0.50f) - val shellCenter = Offset(shellLeft + (shellWidth / 2f), shellTop + (shellHeight / 2f)) - - val shellBrush = Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = 0.45f), - cupColor, - cupColor.copy(alpha = 0.80f), - ), - center = Offset(shellCenter.x - (shellWidth * 0.18f), shellCenter.y - (shellHeight * 0.18f)), - radius = shellWidth * 0.72f, - ) - - drawRoundRect( - brush = shellBrush, - topLeft = Offset(shellLeft, shellTop), - size = Size(shellWidth, shellHeight), - cornerRadius = CornerRadius(shellWidth * 0.44f, shellWidth * 0.44f), - ) - - // Metallic rim line - drawRoundRect( - color = Color.White.copy(alpha = 0.38f), - topLeft = Offset(shellLeft, shellTop), - size = Size(shellWidth, shellHeight), - cornerRadius = CornerRadius(shellWidth * 0.44f, shellWidth * 0.44f), - style = Stroke(width = (w * 0.014f).coerceAtLeast(1f)), - ) - - // Center audio dot - val dotRadius = shellWidth * 0.20f - drawCircle( - color = accentColor, - radius = dotRadius, - center = shellCenter, - ) - drawCircle( - color = Color.White.copy(alpha = 0.65f), - radius = dotRadius * 0.45f, - center = shellCenter, - ) - } - } -} - -private fun DrawScope.drawRosyBlush( - center: Offset, - bodyWidth: Float, - bodyHeight: Float, - blushColor: Color, - w: Float, -) { - val blushY = center.y + (bodyHeight * 0.10f) - val blushSpacing = BlobAvatarGeometry.computeBlushSpacing(bodyWidth) - val blushRadius = BlobAvatarGeometry.computeBlushRadius(w) - - for (sign in listOf(-1f, 1f)) { - val blushCenter = Offset(center.x + (sign * blushSpacing), blushY) - drawCircle( - brush = Brush.radialGradient( - colors = listOf(blushColor, blushColor.copy(alpha = 0.22f), Color.Transparent), - center = blushCenter, - radius = blushRadius, - ), - radius = blushRadius, - center = blushCenter, + // Right ear wave arc + drawArc( + color = waveColor.copy(alpha = alpha), + startAngle = -70f, + sweepAngle = 140f, + useCenter = false, + topLeft = Offset(center.x + earSpacing - radius, earY - radius), + size = Size(radius * 2f, radius * 2f), + style = Stroke(width = stroke, cap = StrokeCap.Round), ) } } -private fun DrawScope.drawKawaiiEyes( +private fun DrawScope.drawGenreParticles( center: Offset, - bodyWidth: Float, - bodyHeight: Float, - animState: BlobAvatarAnimationState, - eyeColor: Color, w: Float, + mood: BlobAvatarGenreMood, + progress: Float, ) { - val eyeSpacing = bodyWidth * BlobAvatarGeometry.EYE_SPACING_RATIO - val eyeCenterY = center.y - (bodyHeight * 0.02f) - val (eyeWidth, eyeHeight) = BlobAvatarGeometry.computeEyeDimensions(w) - val strokeWidth = BlobAvatarGeometry.computeStrokeWidth(w) - val eyeOpenScale = animState.eyeOpenScale.value - - val (maxShiftX, maxShiftY) = BlobAvatarGeometry.computeEyeMaxShifts(eyeWidth, eyeHeight) - val shiftX = maxShiftX * animState.pupilLookRatioX.value - val shiftY = maxShiftY * animState.pupilLookRatioY.value - - val eyes = listOf( - Offset(center.x - eyeSpacing, eyeCenterY), - Offset(center.x + eyeSpacing, eyeCenterY), - ) - - for (eyeCenter in eyes) { - if (eyeOpenScale > 0.25f) { - val currentEyeHeight = eyeHeight * eyeOpenScale - val currentEyeTop = eyeCenter.y - (currentEyeHeight / 2f) + (shiftY * eyeOpenScale) - val currentEyeLeft = eyeCenter.x - (eyeWidth / 2f) + shiftX - - // Glossy Dark Button Eye Oval (Super friendly, like Kirby/Sanrio) - drawOval( - color = eyeColor, - topLeft = Offset(currentEyeLeft, currentEyeTop), - size = Size(eyeWidth, currentEyeHeight), - ) - - // Main Sweet White Catchlight Sparkle (top-right) - val mainGlintRadius = eyeWidth * 0.36f * eyeOpenScale - drawCircle( - color = Color.White, - radius = mainGlintRadius, - center = Offset( - currentEyeLeft + (eyeWidth * 0.65f), - currentEyeTop + (currentEyeHeight * 0.32f), - ), - ) - - // Secondary Tiny Sparkle (bottom-left) - val secGlintRadius = eyeWidth * 0.18f * eyeOpenScale - drawCircle( - color = Color.White.copy(alpha = 0.90f), - radius = secGlintRadius, - center = Offset( - currentEyeLeft + (eyeWidth * 0.32f), - currentEyeTop + (currentEyeHeight * 0.72f), - ), - ) - } else { - // Sweet happy closed eyes (^_^) during blink - drawArc( - color = eyeColor, - startAngle = 200f, - sweepAngle = 140f, - useCenter = false, - topLeft = Offset(eyeCenter.x - (eyeWidth * 0.90f), eyeCenter.y - (eyeHeight * 0.30f)), - size = Size(eyeWidth * 1.8f, eyeHeight * 0.75f), - style = Stroke(width = strokeWidth, cap = StrokeCap.Round), - ) + val color = mood.keyLightColor + val count = 3 + for (i in 0 until count) { + val phase = (progress + (i.toFloat() / count)) % 1f + val particleY = center.y - (w * 0.15f) - (phase * w * 0.35f) + val sway = sin((phase * 2f * Math.PI) + i).toFloat() * (w * 0.08f) + val particleX = if (i % 2 == 0) center.x - (w * 0.30f) + sway else center.x + (w * 0.30f) + sway + val alpha = (sin(phase * Math.PI).toFloat() * 0.85f).coerceIn(0f, 1f) + val size = w * 0.040f + + when (mood.particleType) { + BlobAvatarGenreMood.ParticleType.MusicNote -> { + drawCircle(color = color.copy(alpha = alpha), radius = size * 0.5f, center = Offset(particleX, particleY)) + drawLine( + color = color.copy(alpha = alpha), + start = Offset(particleX + (size * 0.5f), particleY), + end = Offset(particleX + (size * 0.5f), particleY - (size * 1.4f)), + strokeWidth = (w * 0.012f).coerceAtLeast(1f), + cap = StrokeCap.Round, + ) + } + BlobAvatarGenreMood.ParticleType.EqualizerBar -> { + val barHeight = size * (1f + sin(phase * Math.PI * 4).toFloat().coerceAtLeast(0f)) + drawLine( + color = color.copy(alpha = alpha), + start = Offset(particleX, particleY - barHeight), + end = Offset(particleX, particleY + barHeight), + strokeWidth = (w * 0.015f).coerceAtLeast(1.5f), + cap = StrokeCap.Round, + ) + } + else -> { + drawCircle(color = color.copy(alpha = alpha), radius = size * 0.55f, center = Offset(particleX, particleY)) + } } } } - -private fun DrawScope.drawSweetSmile( - center: Offset, - bodyHeight: Float, - smileColor: Color, - w: Float, -) { - val smileWidth = w * 0.12f - val smileHeight = w * 0.065f - val strokeWidth = (w * 0.026f).coerceAtLeast(1.5f) - - // Sweet, gentle, friendly smile arc (◡) - drawArc( - color = smileColor, - startAngle = 20f, - sweepAngle = 140f, - useCenter = false, - topLeft = Offset(center.x - (smileWidth / 2f), center.y + (bodyHeight * 0.10f)), - size = Size(smileWidth, smileHeight), - style = Stroke(width = strokeWidth, cap = StrokeCap.Round), - ) -} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarAccessories.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarAccessories.kt new file mode 100644 index 000000000..30a7b4b93 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarAccessories.kt @@ -0,0 +1,415 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate + +internal object BlobAvatarAccessories { + + fun drawAccessory( + drawScope: DrawScope, + accessoryType: BlobAvatarGenreMood.AccessoryType, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + mood: BlobAvatarGenreMood, + ) { + when (accessoryType) { + BlobAvatarGenreMood.AccessoryType.None -> { + // No accessory - clean headphones & mochi body + } + BlobAvatarGenreMood.AccessoryType.DetectiveFedora -> + drawDetectiveFedora(drawScope, center, bodyWidth, bodyHeight, w) + BlobAvatarGenreMood.AccessoryType.CyberVisor -> + drawCyberVisor(drawScope, center, bodyWidth, bodyHeight, w, mood.keyLightColor) + BlobAvatarGenreMood.AccessoryType.AthleticSweatband -> + drawAthleticSweatband(drawScope, center, bodyWidth, bodyHeight, w) + BlobAvatarGenreMood.AccessoryType.BroadcastBoomMic -> + drawBroadcastBoomMic(drawScope, center, bodyWidth, bodyHeight, w) + BlobAvatarGenreMood.AccessoryType.ComedianBowtie -> + drawComedianBowtie(drawScope, center, bodyWidth, bodyHeight, w) + BlobAvatarGenreMood.AccessoryType.WizardHat -> + drawWizardHat(drawScope, center, bodyWidth, bodyHeight, w) + } + } + + private fun drawDetectiveFedora( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + ) { + val hatCenterY = center.y - (bodyHeight * 0.46f) + val hatWidth = bodyWidth * 0.88f + val hatHeight = bodyHeight * 0.30f + + drawScope.rotate(degrees = -6f, pivot = Offset(center.x, hatCenterY)) { + // Fedora Brim + val brimRect = RoundRect( + left = center.x - (hatWidth / 2f), + top = hatCenterY + (hatHeight * 0.20f), + right = center.x + (hatWidth / 2f), + bottom = hatCenterY + (hatHeight * 0.48f), + cornerRadius = CornerRadius(hatHeight * 0.20f, hatHeight * 0.20f), + ) + drawRoundRect( + color = Color(0xFF262E3B), + topLeft = Offset(brimRect.left, brimRect.top), + size = Size(brimRect.width, brimRect.height), + cornerRadius = CornerRadius(hatHeight * 0.20f, hatHeight * 0.20f), + ) + + // Fedora Crown + val crownPath = Path().apply { + moveTo(center.x - (hatWidth * 0.30f), hatCenterY + (hatHeight * 0.25f)) + cubicTo( + center.x - (hatWidth * 0.28f), + hatCenterY - (hatHeight * 0.60f), + center.x - (hatWidth * 0.05f), + hatCenterY - (hatHeight * 0.40f), + center.x, + hatCenterY - (hatHeight * 0.50f), + ) + cubicTo( + center.x + (hatWidth * 0.05f), + hatCenterY - (hatHeight * 0.40f), + center.x + (hatWidth * 0.28f), + hatCenterY - (hatHeight * 0.60f), + center.x + (hatWidth * 0.30f), + hatCenterY + (hatHeight * 0.25f), + ) + close() + } + drawPath(path = crownPath, color = Color(0xFF333E4F)) + + // Dark Ribbon Hatband + drawRoundRect( + color = Color(0xFF161A22), + topLeft = Offset(center.x - (hatWidth * 0.29f), hatCenterY + (hatHeight * 0.12f)), + size = Size(hatWidth * 0.58f, hatHeight * 0.16f), + cornerRadius = CornerRadius(2f, 2f), + ) + + // Ribbon Gold Buckle + drawRect( + color = Color(0xFFFFC107), + topLeft = Offset(center.x + (hatWidth * 0.10f), hatCenterY + (hatHeight * 0.13f)), + size = Size(hatWidth * 0.06f, hatHeight * 0.14f), + style = Stroke(width = (w * 0.012f).coerceAtLeast(1f)), + ) + + // Magnifying Glass + val glassX = center.x + (bodyWidth * 0.38f) + val glassY = center.y + (bodyHeight * 0.15f) + val glassRadius = w * 0.09f + drawCircle(color = Color(0xFF80DEEA).copy(alpha = 0.35f), radius = glassRadius, center = Offset(glassX, glassY)) + drawCircle( + color = Color(0xFFE2E8F0), + radius = glassRadius, + center = Offset(glassX, glassY), + style = Stroke(width = (w * 0.016f).coerceAtLeast(1.5f)), + ) + drawLine( + color = Color(0xFF8D6E63), + start = Offset(glassX + (glassRadius * 0.70f), glassY + (glassRadius * 0.70f)), + end = Offset(glassX + (glassRadius * 1.50f), glassY + (glassRadius * 1.50f)), + strokeWidth = (w * 0.024f).coerceAtLeast(2f), + cap = StrokeCap.Round, + ) + } + } + + private fun drawCyberVisor( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + neonColor: Color, + ) { + val visorY = center.y - (bodyHeight * 0.02f) + val visorWidth = bodyWidth * 0.72f + val visorHeight = bodyHeight * 0.18f + + // Visor glowing base + val visorRect = RoundRect( + left = center.x - (visorWidth / 2f), + top = visorY - (visorHeight / 2f), + right = center.x + (visorWidth / 2f), + bottom = visorY + (visorHeight / 2f), + cornerRadius = CornerRadius(visorHeight * 0.44f, visorHeight * 0.44f), + ) + + drawScope.drawRoundRect( + brush = Brush.horizontalGradient( + colors = listOf( + neonColor.copy(alpha = 0.85f), + Color(0xFF00E5FF).copy(alpha = 0.90f), + neonColor.copy(alpha = 0.85f), + ), + ), + topLeft = Offset(visorRect.left, visorRect.top), + size = Size(visorRect.width, visorRect.height), + cornerRadius = CornerRadius(visorHeight * 0.44f, visorHeight * 0.44f), + ) + + // Visor White Center Scanline + drawScope.drawLine( + color = Color.White.copy(alpha = 0.85f), + start = Offset(center.x - (visorWidth * 0.40f), visorY), + end = Offset(center.x + (visorWidth * 0.40f), visorY), + strokeWidth = (w * 0.012f).coerceAtLeast(1f), + cap = StrokeCap.Round, + ) + + // Headphone Antenna with Beacon + val antennaX = center.x - (bodyWidth * 0.46f) + val antennaTopY = center.y - (bodyHeight * 0.50f) + drawScope.drawLine( + color = Color(0xFF94A3B8), + start = Offset(antennaX, center.y - (bodyHeight * 0.20f)), + end = Offset(antennaX, antennaTopY), + strokeWidth = (w * 0.015f).coerceAtLeast(1.5f), + cap = StrokeCap.Round, + ) + drawScope.drawCircle(color = neonColor, radius = w * 0.035f, center = Offset(antennaX, antennaTopY)) + drawScope.drawCircle(color = Color.White, radius = w * 0.016f, center = Offset(antennaX, antennaTopY)) + } + + private fun drawAthleticSweatband( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + ) { + val bandY = center.y - (bodyHeight * 0.22f) + val bandWidth = bodyWidth * 0.86f + val bandHeight = bodyHeight * 0.14f + + val sweatbandRect = RoundRect( + left = center.x - (bandWidth / 2f), + top = bandY - (bandHeight / 2f), + right = center.x + (bandWidth / 2f), + bottom = bandY + (bandHeight / 2f), + cornerRadius = CornerRadius(bandHeight * 0.35f, bandHeight * 0.35f), + ) + + // Terrycloth Base + drawScope.drawRoundRect( + color = Color(0xFFF8FAFC), + topLeft = Offset(sweatbandRect.left, sweatbandRect.top), + size = Size(sweatbandRect.width, sweatbandRect.height), + cornerRadius = CornerRadius(bandHeight * 0.35f, bandHeight * 0.35f), + ) + + // Athletic Stripes (Navy + Crimson) + drawScope.drawLine( + color = Color(0xFF1E3A8A), + start = Offset(center.x - (bandWidth * 0.42f), bandY - (bandHeight * 0.20f)), + end = Offset(center.x + (bandWidth * 0.42f), bandY - (bandHeight * 0.20f)), + strokeWidth = (w * 0.014f).coerceAtLeast(1f), + ) + drawScope.drawLine( + color = Color(0xFFEF4444), + start = Offset(center.x - (bandWidth * 0.42f), bandY + (bandHeight * 0.20f)), + end = Offset(center.x + (bandWidth * 0.42f), bandY + (bandHeight * 0.20f)), + strokeWidth = (w * 0.014f).coerceAtLeast(1f), + ) + + // Cute Sweat Drop on Forehead + val dropX = center.x + (bodyWidth * 0.30f) + val dropY = center.y - (bodyHeight * 0.12f) + drawScope.drawCircle(color = Color(0xFF38BDF8), radius = w * 0.024f, center = Offset(dropX, dropY)) + drawScope.drawCircle(color = Color.White, radius = w * 0.010f, center = Offset(dropX - (w * 0.006f), dropY - (w * 0.006f))) + } + + private fun drawBroadcastBoomMic( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + ) { + val earCupLeft = center.x - (bodyWidth * 0.46f) + val earCupY = center.y - (bodyHeight * 0.02f) + val mouthX = center.x - (bodyWidth * 0.08f) + val mouthY = center.y + (bodyHeight * 0.12f) + + // Gooseneck Boom Arm + val boomPath = Path().apply { + moveTo(earCupLeft, earCupY) + cubicTo( + earCupLeft + (bodyWidth * 0.10f), + earCupY + (bodyHeight * 0.22f), + mouthX - (bodyWidth * 0.15f), + mouthY + (bodyHeight * 0.08f), + mouthX, + mouthY, + ) + } + drawScope.drawPath( + path = boomPath, + color = Color(0xFF475569), + style = Stroke(width = (w * 0.020f).coerceAtLeast(1.5f), cap = StrokeCap.Round), + ) + + // Foam Windscreen Capsule + val capsuleWidth = w * 0.08f + val capsuleHeight = w * 0.055f + drawScope.drawRoundRect( + color = Color(0xFF1E293B), + topLeft = Offset(mouthX - (capsuleWidth / 2f), mouthY - (capsuleHeight / 2f)), + size = Size(capsuleWidth, capsuleHeight), + cornerRadius = CornerRadius(capsuleHeight * 0.40f, capsuleHeight * 0.40f), + ) + // Red Audio Ring on Mic Tip + drawScope.drawLine( + color = Color(0xFFEF4444), + start = Offset(mouthX + (capsuleWidth * 0.20f), mouthY - (capsuleHeight * 0.40f)), + end = Offset(mouthX + (capsuleWidth * 0.20f), mouthY + (capsuleHeight * 0.40f)), + strokeWidth = (w * 0.010f).coerceAtLeast(1f), + ) + } + + private fun drawComedianBowtie( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + ) { + val tieY = center.y + (bodyHeight * 0.42f) + val knotWidth = bodyWidth * 0.12f + val knotHeight = bodyHeight * 0.12f + val wingWidth = bodyWidth * 0.18f + val wingHeight = bodyHeight * 0.18f + val bowtieColor = Color(0xFFFF4081) + + // Left wing + val leftWing = Path().apply { + moveTo(center.x - (knotWidth * 0.40f), tieY) + lineTo(center.x - (knotWidth * 0.40f) - wingWidth, tieY - (wingHeight * 0.48f)) + lineTo(center.x - (knotWidth * 0.40f) - (wingWidth * 0.85f), tieY) + lineTo(center.x - (knotWidth * 0.40f) - wingWidth, tieY + (wingHeight * 0.48f)) + close() + } + drawScope.drawPath(path = leftWing, color = bowtieColor) + drawScope.drawPath( + path = leftWing, + color = Color.White.copy(alpha = 0.50f), + style = Stroke(width = (w * 0.010f).coerceAtLeast(1f)), + ) + + // Right wing + val rightWing = Path().apply { + moveTo(center.x + (knotWidth * 0.40f), tieY) + lineTo(center.x + (knotWidth * 0.40f) + wingWidth, tieY - (wingHeight * 0.48f)) + lineTo(center.x + (knotWidth * 0.40f) + (wingWidth * 0.85f), tieY) + lineTo(center.x + (knotWidth * 0.40f) + wingWidth, tieY + (wingHeight * 0.48f)) + close() + } + drawScope.drawPath(path = rightWing, color = bowtieColor) + drawScope.drawPath( + path = rightWing, + color = Color.White.copy(alpha = 0.50f), + style = Stroke(width = (w * 0.010f).coerceAtLeast(1f)), + ) + + // Center knot (soft rounded button) + val knotRect = RoundRect( + left = center.x - (knotWidth / 2f), + top = tieY - (knotHeight / 2f), + right = center.x + (knotWidth / 2f), + bottom = tieY + (knotHeight / 2f), + cornerRadius = CornerRadius(knotHeight * 0.35f, knotHeight * 0.35f), + ) + drawScope.drawRoundRect( + color = Color(0xFFFFD54F), + topLeft = Offset(knotRect.left, knotRect.top), + size = Size(knotRect.width, knotRect.height), + cornerRadius = CornerRadius(knotHeight * 0.35f, knotHeight * 0.35f), + ) + drawScope.drawRoundRect( + color = Color.White.copy(alpha = 0.70f), + topLeft = Offset(knotRect.left, knotRect.top), + size = Size(knotRect.width, knotRect.height), + cornerRadius = CornerRadius(knotHeight * 0.35f, knotHeight * 0.35f), + style = Stroke(width = (w * 0.012f).coerceAtLeast(1f)), + ) + } + + private fun drawWizardHat( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + ) { + val hatCenterY = center.y - (bodyHeight * 0.40f) + val hatWidth = bodyWidth * 0.90f + val hatHeight = bodyHeight * 0.55f + + drawScope.rotate(degrees = 8f, pivot = Offset(center.x, hatCenterY)) { + // Hat Wide Brim + val brimRect = RoundRect( + left = center.x - (hatWidth / 2f), + top = hatCenterY + (hatHeight * 0.15f), + right = center.x + (hatWidth / 2f), + bottom = hatCenterY + (hatHeight * 0.38f), + cornerRadius = CornerRadius(hatHeight * 0.12f, hatHeight * 0.12f), + ) + drawRoundRect( + color = Color(0xFF4C1D95), + topLeft = Offset(brimRect.left, brimRect.top), + size = Size(brimRect.width, brimRect.height), + cornerRadius = CornerRadius(hatHeight * 0.12f, hatHeight * 0.12f), + ) + + // Hat Pointed Floppy Cone + val conePath = Path().apply { + moveTo(center.x - (hatWidth * 0.32f), hatCenterY + (hatHeight * 0.20f)) + cubicTo( + center.x - (hatWidth * 0.10f), + hatCenterY - (hatHeight * 0.30f), + center.x + (hatWidth * 0.10f), + hatCenterY - (hatHeight * 0.70f), + center.x + (hatWidth * 0.35f), + hatCenterY - (hatHeight * 0.85f), + ) + cubicTo( + center.x + (hatWidth * 0.20f), + hatCenterY - (hatHeight * 0.50f), + center.x + (hatWidth * 0.25f), + hatCenterY - (hatHeight * 0.20f), + center.x + (hatWidth * 0.32f), + hatCenterY + (hatHeight * 0.20f), + ) + close() + } + drawPath(path = conePath, color = Color(0xFF5B21B6)) + + // Starry Gold Hatband + drawRoundRect( + color = Color(0xFFF59E0B), + topLeft = Offset(center.x - (hatWidth * 0.30f), hatCenterY + (hatHeight * 0.10f)), + size = Size(hatWidth * 0.60f, hatHeight * 0.12f), + cornerRadius = CornerRadius(2f, 2f), + ) + // Golden Star Sparkle on Tip + val tipX = center.x + (hatWidth * 0.35f) + val tipY = hatCenterY - (hatHeight * 0.85f) + drawCircle(color = Color(0xFFFDE047), radius = w * 0.038f, center = Offset(tipX, tipY)) + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt new file mode 100644 index 000000000..abd47b39b --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt @@ -0,0 +1,361 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate + +internal data class CharacterDimensions( + val center: Offset, + val bodyWidth: Float, + val bodyHeight: Float, + val w: Float, +) + +internal object BlobAvatarCharacter { + + fun drawCharacter( + drawScope: DrawScope, + dimensions: CharacterDimensions, + renderContext: BlobAvatarRenderContext, + ) { + val palette = renderContext.palette + drawHeadband(drawScope, dimensions, palette.headbandColor) + drawClayMochiBody(drawScope, dimensions, palette) + drawRosyBlush(drawScope, dimensions, palette.blushColor) + drawKawaiiEyes(drawScope, dimensions, renderContext.animState, palette.facialColor, renderContext.expression) + drawSweetSmile(drawScope, dimensions, palette.facialColor, renderContext.expression.isLaughing) + drawCozyHeadphones(drawScope, dimensions, palette, renderContext.animState.bassPulseScale.value) + BlobAvatarAccessories.drawAccessory( + drawScope = drawScope, + accessoryType = renderContext.mood.accessoryType, + center = dimensions.center, + bodyWidth = dimensions.bodyWidth, + bodyHeight = dimensions.bodyHeight, + w = dimensions.w, + mood = renderContext.mood, + ) + } + + private fun drawClayMochiBody( + drawScope: DrawScope, + dimensions: CharacterDimensions, + palette: BlobAvatarPalette, + ) { + val center = dimensions.center + val bodyWidth = dimensions.bodyWidth + val bodyHeight = dimensions.bodyHeight + val cornerRadius = CornerRadius(bodyWidth * 0.46f, bodyHeight * 0.46f) + val bodyRect = RoundRect( + left = center.x - (bodyWidth / 2f), + top = center.y - (bodyHeight / 2f), + right = center.x + (bodyWidth / 2f), + bottom = center.y + (bodyHeight / 2f), + cornerRadius = cornerRadius, + ) + val bodyPath = Path().apply { addRoundRect(bodyRect) } + + val lightSource = Offset(center.x - (bodyWidth * 0.20f), center.y - (bodyHeight * 0.22f)) + val bodyBrush = Brush.radialGradient( + colors = listOf( + Color.White, + palette.bodyBase, + palette.bodyShadow, + ), + center = lightSource, + radius = bodyWidth * 0.85f, + ) + drawScope.drawPath(path = bodyPath, brush = bodyBrush) + + drawScope.drawPath( + path = bodyPath, + color = palette.bodyOutline, + style = Stroke(width = (dimensions.w * 0.008f).coerceAtLeast(1f)), + ) + + val specWidth = bodyWidth * 0.34f + val specHeight = bodyHeight * 0.14f + val specCenter = Offset(center.x - (bodyWidth * 0.18f), center.y - (bodyHeight * 0.26f)) + drawScope.drawOval( + brush = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.70f), + Color.White.copy(alpha = 0.25f), + Color.Transparent, + ), + center = specCenter, + radius = specWidth / 2f, + ), + topLeft = Offset(specCenter.x - (specWidth / 2f), specCenter.y - (specHeight / 2f)), + size = Size(specWidth, specHeight), + ) + } + + private fun drawHeadband( + drawScope: DrawScope, + dimensions: CharacterDimensions, + bandColor: Color, + ) { + val center = dimensions.center + val bodyWidth = dimensions.bodyWidth + val bodyHeight = dimensions.bodyHeight + val w = dimensions.w + val bandTopY = BlobAvatarGeometry.computeHeadbandTopY(center.y, bodyHeight) + val bandLeftX = center.x - (bodyWidth * 0.44f) + val bandRightX = center.x + (bodyWidth * 0.44f) + val bandStartY = center.y - (bodyHeight * 0.10f) + + val bandPath = Path().apply { + moveTo(bandLeftX, bandStartY) + cubicTo(bandLeftX, bandTopY, bandRightX, bandTopY, bandRightX, bandStartY) + } + + val bandStroke = BlobAvatarGeometry.computeHeadbandStroke(w) + + drawScope.drawPath( + path = bandPath, + color = bandColor, + style = Stroke(width = bandStroke, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + + drawScope.drawPath( + path = bandPath, + color = Color.White.copy(alpha = 0.45f), + style = Stroke(width = bandStroke * 0.30f, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + } + + private fun drawCozyHeadphones( + drawScope: DrawScope, + dimensions: CharacterDimensions, + palette: BlobAvatarPalette, + bassScale: Float, + ) { + val center = dimensions.center + val bodyWidth = dimensions.bodyWidth + val bodyHeight = dimensions.bodyHeight + val w = dimensions.w + val cupCenterY = center.y - (bodyHeight * 0.02f) + val (cupSpacingX, baseCupWidth) = BlobAvatarGeometry.computeCupParams(w, bodyWidth, bassScale) + val cupHeight = w * BlobAvatarGeometry.CUP_HEIGHT_RATIO * bassScale + + val ears = listOf( + Pair(center.x - cupSpacingX, -4f), + Pair(center.x + cupSpacingX, 4f), + ) + + for ((cupX, tilt) in ears) { + drawScope.rotate(degrees = tilt, pivot = Offset(cupX, cupCenterY)) { + val cushionRect = RoundRect( + left = cupX - (baseCupWidth * 0.48f), + top = cupCenterY - (cupHeight * 0.48f), + right = cupX + (baseCupWidth * 0.48f), + bottom = cupCenterY + (cupHeight * 0.48f), + cornerRadius = CornerRadius(baseCupWidth * 0.44f, baseCupWidth * 0.44f), + ) + drawRoundRect( + color = palette.cushionColor, + topLeft = Offset(cushionRect.left, cushionRect.top), + size = Size(cushionRect.width, cushionRect.height), + cornerRadius = CornerRadius(baseCupWidth * 0.44f, baseCupWidth * 0.44f), + ) + + val shellWidth = baseCupWidth * 0.78f + val shellHeight = cupHeight * 0.88f + val shellLeft = if (cupX < center.x) cupX - (baseCupWidth * 0.50f) else cupX - (shellWidth * 0.46f) + val shellTop = cupCenterY - (shellHeight * 0.50f) + val shellCenter = Offset(shellLeft + (shellWidth / 2f), shellTop + (shellHeight / 2f)) + + val shellBrush = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.60f), + palette.headphoneCup, + palette.headphoneCup.copy(alpha = 0.85f), + ), + center = Offset(shellCenter.x - (shellWidth * 0.18f), shellCenter.y - (shellHeight * 0.18f)), + radius = shellWidth * 0.75f, + ) + + drawRoundRect( + brush = shellBrush, + topLeft = Offset(shellLeft, shellTop), + size = Size(shellWidth, shellHeight), + cornerRadius = CornerRadius(shellWidth * 0.44f, shellWidth * 0.44f), + ) + + drawRoundRect( + color = Color.White.copy(alpha = 0.50f), + topLeft = Offset(shellLeft, shellTop), + size = Size(shellWidth, shellHeight), + cornerRadius = CornerRadius(shellWidth * 0.44f, shellWidth * 0.44f), + style = Stroke(width = (w * 0.015f).coerceAtLeast(1f)), + ) + + val dotRadius = shellWidth * 0.22f + drawCircle(color = palette.headphoneAccent, radius = dotRadius, center = shellCenter) + drawCircle(color = Color.White.copy(alpha = 0.75f), radius = dotRadius * 0.45f, center = shellCenter) + } + } + } + + private fun drawRosyBlush( + drawScope: DrawScope, + dimensions: CharacterDimensions, + blushColor: Color, + ) { + val center = dimensions.center + val bodyWidth = dimensions.bodyWidth + val bodyHeight = dimensions.bodyHeight + val w = dimensions.w + val blushY = center.y + (bodyHeight * 0.10f) + val (blushSpacing, blushRadius) = BlobAvatarGeometry.computeBlushParams(w, bodyWidth) + + for (sign in listOf(-1f, 1f)) { + val blushCenter = Offset(center.x + (sign * blushSpacing), blushY) + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf(blushColor, blushColor.copy(alpha = 0.22f), Color.Transparent), + center = blushCenter, + radius = blushRadius, + ), + radius = blushRadius, + center = blushCenter, + ) + } + } + + private fun drawKawaiiEyes( + drawScope: DrawScope, + dimensions: CharacterDimensions, + animState: BlobAvatarAnimationState, + eyeColor: Color, + expression: BlobFacialExpression, + ) { + val center = dimensions.center + val bodyWidth = dimensions.bodyWidth + val bodyHeight = dimensions.bodyHeight + val w = dimensions.w + val eyeSpacing = bodyWidth * BlobAvatarGeometry.EYE_SPACING_RATIO + val eyeCenterY = center.y - (bodyHeight * 0.02f) + val (eyeWidth, eyeHeight) = BlobAvatarGeometry.computeEyeDimensions(w) + val strokeWidth = BlobAvatarGeometry.computeStrokeWidth(w) + val eyeOpenScale = if (expression.isWinking) 0.08f else animState.eyeOpenScale.value + + val (maxShiftX, maxShiftY) = BlobAvatarGeometry.computeEyeMaxShifts(eyeWidth, eyeHeight) + val shiftX = maxShiftX * animState.pupilLookRatioX.value + val shiftY = maxShiftY * animState.pupilLookRatioY.value + + val isClosedOrLaughing = expression.isClosed || expression.isLaughing + val eyes = listOf( + Pair(Offset(center.x - eyeSpacing, eyeCenterY), isClosedOrLaughing), + Pair(Offset(center.x + eyeSpacing, eyeCenterY), isClosedOrLaughing || expression.isWinking), + ) + + for ((eyeCenter, isThisClosedOrWinking) in eyes) { + val currentScale = if (isThisClosedOrWinking) 0.08f else eyeOpenScale + if (currentScale > 0.25f) { + val currentEyeHeight = eyeHeight * currentScale + val currentEyeTop = eyeCenter.y - (currentEyeHeight / 2f) + (shiftY * currentScale) + val currentEyeLeft = eyeCenter.x - (eyeWidth / 2f) + shiftX + + drawScope.drawOval( + color = eyeColor, + topLeft = Offset(currentEyeLeft, currentEyeTop), + size = Size(eyeWidth, currentEyeHeight), + ) + + val mainGlintRadius = eyeWidth * 0.36f * currentScale + drawScope.drawCircle( + color = Color.White, + radius = mainGlintRadius, + center = Offset( + currentEyeLeft + (eyeWidth * 0.65f), + currentEyeTop + (currentEyeHeight * 0.32f), + ), + ) + + val secGlintRadius = eyeWidth * 0.18f * currentScale + drawScope.drawCircle( + color = Color.White.copy(alpha = 0.90f), + radius = secGlintRadius, + center = Offset( + currentEyeLeft + (eyeWidth * 0.32f), + currentEyeTop + (currentEyeHeight * 0.72f), + ), + ) + } else { + drawScope.drawArc( + color = eyeColor, + startAngle = 200f, + sweepAngle = 140f, + useCenter = false, + topLeft = Offset(eyeCenter.x - (eyeWidth * 0.90f), eyeCenter.y - (eyeHeight * 0.30f)), + size = Size(eyeWidth * 1.8f, eyeHeight * 0.75f), + style = Stroke(width = strokeWidth, cap = StrokeCap.Round), + ) + } + } + } + + private fun drawSweetSmile( + drawScope: DrawScope, + dimensions: CharacterDimensions, + smileColor: Color, + isLaughing: Boolean, + ) { + val center = dimensions.center + val bodyHeight = dimensions.bodyHeight + val w = dimensions.w + val smileWidth = w * (if (isLaughing) 0.15f else 0.12f) + val smileHeight = w * (if (isLaughing) 0.08f else 0.065f) + val strokeWidth = (w * 0.026f).coerceAtLeast(1.5f) + + if (isLaughing) { + val mouthTopY = center.y + (bodyHeight * 0.09f) + val mouthPath = Path().apply { + moveTo(center.x - (smileWidth / 2f), mouthTopY) + cubicTo( + center.x - (smileWidth * 0.35f), + mouthTopY + smileHeight, + center.x + (smileWidth * 0.35f), + mouthTopY + smileHeight, + center.x + (smileWidth / 2f), + mouthTopY, + ) + close() + } + drawScope.drawPath(path = mouthPath, color = Color(0xFFFF5277)) + drawScope.drawPath( + path = mouthPath, + color = smileColor, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + val glintWidth = smileWidth * 0.35f + drawScope.drawLine( + color = Color.White.copy(alpha = 0.90f), + start = Offset(center.x - (glintWidth / 2f), mouthTopY + (strokeWidth * 0.6f)), + end = Offset(center.x + (glintWidth / 2f), mouthTopY + (strokeWidth * 0.6f)), + strokeWidth = (w * 0.014f).coerceAtLeast(1f), + cap = StrokeCap.Round, + ) + } else { + drawScope.drawArc( + color = smileColor, + startAngle = 20f, + sweepAngle = 140f, + useCenter = false, + topLeft = Offset(center.x - (smileWidth / 2f), center.y + (bodyHeight * 0.10f)), + size = Size(smileWidth, smileHeight), + style = Stroke(width = strokeWidth, cap = StrokeCap.Round), + ) + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarEnvironments.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarEnvironments.kt new file mode 100644 index 000000000..83cc75013 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarEnvironments.kt @@ -0,0 +1,411 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import kotlin.math.cos +import kotlin.math.sin + +internal object BlobAvatarEnvironments { + + fun drawEnvironment( + drawScope: DrawScope, + environmentType: BlobAvatarGenreMood.EnvironmentType, + center: Offset, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + progress: Float, + ) { + when (environmentType) { + BlobAvatarGenreMood.EnvironmentType.VenetianBlinds -> + drawVenetianBlinds(drawScope, w, h, mood) + BlobAvatarGenreMood.EnvironmentType.CyberMatrix -> + drawCyberMatrix(drawScope, w, h, mood, progress) + BlobAvatarGenreMood.EnvironmentType.StadiumLights -> + drawStadiumLights(drawScope, w, h, mood) + BlobAvatarGenreMood.EnvironmentType.OnAirSign -> + drawOnAirSign(drawScope, w, h, mood) + BlobAvatarGenreMood.EnvironmentType.LofiBokeh -> + drawLofiBokeh(drawScope, w, h, mood, progress) + BlobAvatarGenreMood.EnvironmentType.ComedyStage -> + drawComedyStage(drawScope, center, w, h, mood) + BlobAvatarGenreMood.EnvironmentType.MysticRunes -> + drawMysticRunes(drawScope, center, w, h, mood, progress) + } + } + + private fun drawVenetianBlinds( + drawScope: DrawScope, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + ) { + // Flashlight beam from upper right + val lightOrigin = Offset(w * 0.92f, h * 0.08f) + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf( + mood.keyLightColor.copy(alpha = 0.26f), + mood.keyLightColor.copy(alpha = 0.08f), + Color.Transparent, + ), + center = lightOrigin, + radius = w * 0.85f, + ), + radius = w * 0.85f, + center = lightOrigin, + ) + + // Film-noir venetian blind shadow slats + val slatCount = 5 + val slatHeight = h * 0.06f + val slatSpacing = h * 0.18f + for (i in 0 until slatCount) { + val y = (i * slatSpacing) + (h * 0.04f) + drawScope.drawRoundRect( + color = Color.Black.copy(alpha = 0.22f), + topLeft = Offset(0f, y), + size = Size(w, slatHeight), + cornerRadius = CornerRadius(2f, 2f), + ) + } + } + + private fun drawCyberMatrix( + drawScope: DrawScope, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + progress: Float, + ) { + // Digital grid dots + val cols = 5 + val rows = 5 + val stepX = w / (cols + 1) + val stepY = h / (rows + 1) + for (r in 1..rows) { + for (c in 1..cols) { + drawScope.drawCircle( + color = mood.keyLightColor.copy(alpha = 0.16f), + radius = (w * 0.009f).coerceAtLeast(1.2f), + center = Offset(c * stepX, r * stepY), + ) + } + } + + // Horizontal audio laser scanline + val scanY = h * progress + drawScope.drawLine( + brush = Brush.horizontalGradient( + colors = listOf( + Color.Transparent, + mood.rimLightColor.copy(alpha = 0.55f), + Color.White.copy(alpha = 0.80f), + mood.rimLightColor.copy(alpha = 0.55f), + Color.Transparent, + ), + ), + start = Offset(0f, scanY), + end = Offset(w, scanY), + strokeWidth = (w * 0.012f).coerceAtLeast(1f), + ) + + // Tech HUD corner brackets + val bracketSize = w * 0.10f + val bracketStroke = (w * 0.012f).coerceAtLeast(1f) + val bracketColor = mood.keyLightColor.copy(alpha = 0.40f) + drawScope.drawLine(bracketColor, Offset(w * 0.06f, w * 0.06f), Offset(w * 0.06f + bracketSize, w * 0.06f), bracketStroke) + drawScope.drawLine(bracketColor, Offset(w * 0.06f, w * 0.06f), Offset(w * 0.06f, w * 0.06f + bracketSize), bracketStroke) + drawScope.drawLine(bracketColor, Offset(w * 0.94f, h * 0.94f), Offset(w * 0.94f - bracketSize, h * 0.94f), bracketStroke) + drawScope.drawLine(bracketColor, Offset(w * 0.94f, h * 0.94f), Offset(w * 0.94f, h * 0.94f - bracketSize), bracketStroke) + } + + private fun drawStadiumLights( + drawScope: DrawScope, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + ) { + val lightRadius = w * 0.024f + val lightColor = mood.keyLightColor + + // Top-left and Top-right stadium floodlight clusters + val towers = listOf(Offset(w * 0.12f, h * 0.10f), Offset(w * 0.88f, h * 0.10f)) + for (tower in towers) { + // Glow halo + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf(lightColor.copy(alpha = 0.35f), Color.Transparent), + center = tower, + radius = w * 0.35f, + ), + radius = w * 0.35f, + center = tower, + ) + // Cluster of 3 bulbs + for (dx in listOf(-lightRadius * 1.5f, 0f, lightRadius * 1.5f)) { + drawScope.drawCircle(color = Color.White, radius = lightRadius, center = Offset(tower.x + dx, tower.y)) + drawScope.drawCircle( + color = lightColor, + radius = lightRadius * 1.25f, + center = Offset(tower.x + dx, tower.y), + style = Stroke(width = (w * 0.008f).coerceAtLeast(1f)), + ) + } + } + + // Floating victory confetti + val confettiColors = listOf(Color(0xFFFFC107), Color(0xFF2979FF), Color(0xFFEF4444), Color(0xFF10B981)) + val confettiPositions = listOf( + Offset(w * 0.20f, h * 0.35f), + Offset(w * 0.80f, h * 0.30f), + Offset(w * 0.25f, h * 0.70f), + Offset(w * 0.75f, h * 0.65f), + ) + for ((idx, pos) in confettiPositions.withIndex()) { + drawScope.rotate(degrees = (idx * 35f) + 15f, pivot = pos) { + drawRoundRect( + color = confettiColors[idx % confettiColors.size].copy(alpha = 0.75f), + topLeft = Offset(pos.x - (w * 0.02f), pos.y - (w * 0.01f)), + size = Size(w * 0.04f, w * 0.02f), + cornerRadius = CornerRadius(2f, 2f), + ) + } + } + } + + private fun drawOnAirSign( + drawScope: DrawScope, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + ) { + // Acoustic damping tile mesh pattern in corners + val meshColor = mood.rimLightColor.copy(alpha = 0.18f) + val meshStroke = (w * 0.010f).coerceAtLeast(1f) + for (i in 1..3) { + val offset = i * (w * 0.06f) + drawScope.drawLine(meshColor, Offset(0f, offset), Offset(offset, 0f), meshStroke) + drawScope.drawLine(meshColor, Offset(w - offset, 0f), Offset(w, offset), meshStroke) + } + + // Glowing "ON AIR" studio pill badge at top center + val badgeWidth = w * 0.46f + val badgeHeight = h * 0.12f + val badgeLeft = (w - badgeWidth) / 2f + val badgeTop = h * 0.04f + val badgeCenter = Offset(w / 2f, badgeTop + (badgeHeight / 2f)) + + // Neon Red Halo + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf(Color(0xFFEF4444).copy(alpha = 0.35f), Color.Transparent), + center = badgeCenter, + radius = badgeWidth * 0.80f, + ), + radius = badgeWidth * 0.80f, + center = badgeCenter, + ) + + // Badge pill body + drawScope.drawRoundRect( + color = Color(0xFF1E293B), + topLeft = Offset(badgeLeft, badgeTop), + size = Size(badgeWidth, badgeHeight), + cornerRadius = CornerRadius(badgeHeight * 0.35f, badgeHeight * 0.35f), + ) + // Red glowing border + drawScope.drawRoundRect( + color = Color(0xFFEF4444), + topLeft = Offset(badgeLeft, badgeTop), + size = Size(badgeWidth, badgeHeight), + cornerRadius = CornerRadius(badgeHeight * 0.35f, badgeHeight * 0.35f), + style = Stroke(width = (w * 0.015f).coerceAtLeast(1.5f)), + ) + + // Studio Live Dot + "ON AIR" Indicator bar + val liveDotRadius = badgeHeight * 0.22f + val dotCenter = Offset(badgeLeft + (badgeWidth * 0.22f), badgeCenter.y) + drawScope.drawCircle(color = Color(0xFFEF4444), radius = liveDotRadius, center = dotCenter) + drawScope.drawCircle(color = Color.White, radius = liveDotRadius * 0.45f, center = dotCenter) + + // Broadcast horizontal status wave + drawScope.drawLine( + color = Color.White.copy(alpha = 0.90f), + start = Offset(badgeLeft + (badgeWidth * 0.40f), badgeCenter.y), + end = Offset(badgeLeft + (badgeWidth * 0.82f), badgeCenter.y), + strokeWidth = (w * 0.016f).coerceAtLeast(1.5f), + cap = StrokeCap.Round, + ) + } + + private fun drawLofiBokeh( + drawScope: DrawScope, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + progress: Float, + ) { + // Floating fairy light bokeh orbs + val orbs = listOf( + Triple(0.20f, 0.25f, 0.12f), + Triple(0.80f, 0.20f, 0.15f), + Triple(0.15f, 0.75f, 0.14f), + Triple(0.85f, 0.70f, 0.11f), + Triple(0.50f, 0.88f, 0.13f), + ) + for ((idx, orb) in orbs.withIndex()) { + val (baseX, baseY, radiusRatio) = orb + val floatY = (baseY * h) + (sin((progress * 2f * Math.PI) + idx).toFloat() * (h * 0.03f)) + val center = Offset(baseX * w, floatY) + val orbRadius = w * radiusRatio + val orbColor = if (idx % 2 == 0) mood.keyLightColor else mood.rimLightColor + + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf(orbColor.copy(alpha = 0.28f), orbColor.copy(alpha = 0.08f), Color.Transparent), + center = center, + radius = orbRadius, + ), + radius = orbRadius, + center = center, + ) + } + + // Cozy angled lofi rain streaks + val rainColor = Color.White.copy(alpha = 0.18f) + val rainStroke = (w * 0.010f).coerceAtLeast(1f) + val rainX = listOf(w * 0.25f, w * 0.45f, w * 0.70f) + for (rx in rainX) { + drawScope.drawLine( + color = rainColor, + start = Offset(rx, h * 0.15f), + end = Offset(rx - (w * 0.06f), h * 0.35f), + strokeWidth = rainStroke, + cap = StrokeCap.Round, + ) + } + } + + private fun drawComedyStage( + drawScope: DrawScope, + center: Offset, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + ) { + // Stage warm spotlight circle on the mascot + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf( + mood.keyLightColor.copy(alpha = 0.35f), + mood.keyLightColor.copy(alpha = 0.12f), + Color.Transparent, + ), + center = center, + radius = w * 0.55f, + ), + radius = w * 0.55f, + center = center, + ) + + // Top comedy curtain swag curves (Left & Right in warm golden amber) + val curtainColor = Color(0xFFF59E0B).copy(alpha = 0.35f) + val leftDrape = Path().apply { + moveTo(0f, 0f) + lineTo(w * 0.30f, 0f) + cubicTo(w * 0.20f, h * 0.14f, w * 0.05f, h * 0.12f, 0f, h * 0.09f) + close() + } + val rightDrape = Path().apply { + moveTo(w, 0f) + lineTo(w * 0.70f, 0f) + cubicTo(w * 0.80f, h * 0.14f, w * 0.95f, h * 0.12f, w, h * 0.09f) + close() + } + drawScope.drawPath(path = leftDrape, color = curtainColor) + drawScope.drawPath(path = rightDrape, color = curtainColor) + + // Laugh sparkles + val sparkleX = listOf(w * 0.20f, w * 0.82f) + val sparkleY = listOf(h * 0.30f, h * 0.28f) + for (i in sparkleX.indices) { + drawScope.drawCircle(color = Color(0xFFFFD700), radius = w * 0.022f, center = Offset(sparkleX[i], sparkleY[i])) + drawScope.drawCircle(color = Color.White, radius = w * 0.010f, center = Offset(sparkleX[i], sparkleY[i])) + } + } + + private fun drawMysticRunes( + drawScope: DrawScope, + center: Offset, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + progress: Float, + ) { + // Rotating arcane rune ring around the mascot + val runeRadius = w * 0.44f + val rotationAngle = progress * 360f + + drawScope.rotate(degrees = rotationAngle, pivot = center) { + drawCircle( + color = mood.rimLightColor.copy(alpha = 0.30f), + radius = runeRadius, + center = center, + style = Stroke(width = (w * 0.010f).coerceAtLeast(1f)), + ) + + // Arcane rune tick marks + val tickCount = 8 + val tickAngle = 360f / tickCount + for (i in 0 until tickCount) { + val rad = Math.toRadians((i * tickAngle).toDouble()) + val innerR = runeRadius - (w * 0.035f) + val outerR = runeRadius + (w * 0.035f) + val start = Offset(center.x + (innerR * cos(rad)).toFloat(), center.y + (innerR * sin(rad)).toFloat()) + val end = Offset(center.x + (outerR * cos(rad)).toFloat(), center.y + (outerR * sin(rad)).toFloat()) + drawLine( + color = mood.rimLightColor.copy(alpha = 0.50f), + start = start, + end = end, + strokeWidth = (w * 0.012f).coerceAtLeast(1f), + cap = StrokeCap.Round, + ) + } + } + + // Floating campfire embers + val embers = listOf( + Triple(0.25f, 0.80f, 0.40f), + Triple(0.72f, 0.75f, 0.65f), + Triple(0.35f, 0.60f, 0.85f), + Triple(0.65f, 0.55f, 0.20f), + ) + for ((idx, ember) in embers.withIndex()) { + val (baseX, startY, phaseOffset) = ember + val phase = (progress + phaseOffset) % 1f + val emberY = (startY * h) - (phase * h * 0.40f) + val emberX = (baseX * w) + (sin((phase * 2f * Math.PI) + idx).toFloat() * (w * 0.04f)) + val emberAlpha = (sin(phase * Math.PI).toFloat() * 0.85f).coerceIn(0f, 1f) + + drawScope.drawCircle( + color = mood.keyLightColor.copy(alpha = emberAlpha), + radius = (w * 0.020f).coerceAtLeast(1.5f), + center = Offset(emberX, emberY), + ) + drawScope.drawCircle( + color = Color.White.copy(alpha = emberAlpha), + radius = (w * 0.008f).coerceAtLeast(1f), + center = Offset(emberX, emberY), + ) + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt new file mode 100644 index 000000000..30b1882db --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt @@ -0,0 +1,141 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.ui.graphics.Color +import kotlin.random.Random + +/** + * Visual themes, physical accessories, and environmental backdrops representing classic podcast genres. + * Designed to be randomized or cycled interactively, and later bound to a user's top subscribed genre. + */ +internal enum class BlobAvatarGenreMood( + val id: String, + val displayName: String, + val emoji: String, + val keyLightColor: Color, + val rimLightColor: Color, + val particleType: ParticleType, + val accessoryType: AccessoryType, + val environmentType: EnvironmentType, +) { + ChillMusic( + id = "chill_music", + displayName = "Chill Music", + emoji = "🎧", + keyLightColor = Color(0xFF8A5CF6), // Neon Violet + rimLightColor = Color(0xFF00E5FF), // Electric Cyan + particleType = ParticleType.MusicNote, + accessoryType = AccessoryType.None, + environmentType = EnvironmentType.LofiBokeh, + ), + TrueCrime( + id = "true_crime", + displayName = "True Crime", + emoji = "🕵️", + keyLightColor = Color(0xFF00E5FF), // Mystery Cyan + rimLightColor = Color(0xFF1E40AF), // Deep Sapphire + particleType = ParticleType.RadarPulse, + accessoryType = AccessoryType.DetectiveFedora, + environmentType = EnvironmentType.VenetianBlinds, + ), + TechSciFi( + id = "tech_scifi", + displayName = "Tech & Sci-Fi", + emoji = "🤖", + keyLightColor = Color(0xFF00F5D4), // Aqua Mint + rimLightColor = Color(0xFF00E676), // Neon Emerald + particleType = ParticleType.EqualizerBar, + accessoryType = AccessoryType.CyberVisor, + environmentType = EnvironmentType.CyberMatrix, + ), + ComedyTalk( + id = "comedy_talk", + displayName = "Comedy & Talk", + emoji = "😂", + keyLightColor = Color(0xFFFFB300), // Warm Amber + rimLightColor = Color(0xFFFF4081), // Candy Coral + particleType = ParticleType.LaughSparkle, + accessoryType = AccessoryType.ComedianBowtie, + environmentType = EnvironmentType.ComedyStage, + ), + LoreStories( + id = "lore_stories", + displayName = "Lore & Stories", + emoji = "🧙", + keyLightColor = Color(0xFFFF9E00), // Campfire Gold + rimLightColor = Color(0xFF7C3AED), // Starry Violet + particleType = ParticleType.StoryEmber, + accessoryType = AccessoryType.WizardHat, + environmentType = EnvironmentType.MysticRunes, + ), + NewsBriefing( + id = "news_briefing", + displayName = "News & Briefing", + emoji = "🎙️", + keyLightColor = Color(0xFFFF3366), // Studio Crimson + rimLightColor = Color(0xFF80DEEA), // Ice Cyan + particleType = ParticleType.BroadcastArc, + accessoryType = AccessoryType.BroadcastBoomMic, + environmentType = EnvironmentType.OnAirSign, + ), + SportsAthletics( + id = "sports_athletics", + displayName = "Sports & Athletics", + emoji = "🏃", + keyLightColor = Color(0xFFFFC107), // Stadium Amber + rimLightColor = Color(0xFF2979FF), // Electric Blue + particleType = ParticleType.EnergySpark, + accessoryType = AccessoryType.AthleticSweatband, + environmentType = EnvironmentType.StadiumLights, + ); + + enum class ParticleType { + MusicNote, + RadarPulse, + EqualizerBar, + LaughSparkle, + StoryEmber, + BroadcastArc, + EnergySpark, + } + + enum class AccessoryType { + None, + DetectiveFedora, + CyberVisor, + AthleticSweatband, + BroadcastBoomMic, + ComedianBowtie, + WizardHat, + } + + enum class EnvironmentType { + VenetianBlinds, + CyberMatrix, + StadiumLights, + OnAirSign, + LofiBokeh, + ComedyStage, + MysticRunes, + } + + companion object { + fun random( + exclude: BlobAvatarGenreMood? = null, + random: Random = Random, + ): BlobAvatarGenreMood { + val all = entries + val candidates = if (all.size > 1 && exclude != null) { + all.filter { it != exclude } + } else { + all + } + return candidates[random.nextInt(candidates.size)] + } + + fun next(current: BlobAvatarGenreMood): BlobAvatarGenreMood { + val all = entries + val nextIndex = (current.ordinal + 1) % all.size + return all[nextIndex] + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGeometry.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGeometry.kt index 3ea84eb5f..b44295478 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGeometry.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGeometry.kt @@ -1,5 +1,7 @@ package cx.aswin.boxlore.feature.settings.pages +import kotlin.math.sqrt + internal object BlobAvatarGeometry { const val BODY_WIDTH_RATIO = 0.72f const val BODY_HEIGHT_RATIO = 0.68f @@ -25,6 +27,9 @@ internal object BlobAvatarGeometry { const val EYE_MAX_SHIFT_X_RATIO = 0.28f const val EYE_MAX_SHIFT_Y_RATIO = 0.18f + const val SOUNDWAVE_MAX_RADIUS_RATIO = 0.20f + const val SPOTLIGHT_BACKDROP_RADIUS_RATIO = 0.45f + fun computeBodyWidth(w: Float, breatheX: Float = 1f): Float = w * BODY_WIDTH_RATIO * breatheX @@ -43,27 +48,24 @@ internal object BlobAvatarGeometry { fun computeStrokeWidth(w: Float): Float = (w * STROKE_WIDTH_RATIO).coerceAtLeast(MIN_STROKE_WIDTH) - fun computeCupSpacing(bodyWidth: Float): Float = - bodyWidth * CUP_SPACING_RATIO - - fun computeCupWidth(w: Float, pulse: Float = 1f): Float = - w * CUP_WIDTH_RATIO * pulse - - fun computeOuterEarCupExtent(w: Float, bodyWidth: Float, pulse: Float = 1f): Float { - val spacing = computeCupSpacing(bodyWidth) - val cupW = computeCupWidth(w, pulse) - return spacing + (cupW * 0.50f) - } + fun computeCupParams(w: Float, bodyWidth: Float, pulse: Float = 1f): Pair = + Pair(bodyWidth * CUP_SPACING_RATIO, w * CUP_WIDTH_RATIO * pulse) - fun computeBlushSpacing(bodyWidth: Float): Float = - bodyWidth * BLUSH_SPACING_RATIO - - fun computeBlushRadius(w: Float): Float = - w * BLUSH_RADIUS_RATIO + fun computeBlushParams(w: Float, bodyWidth: Float): Pair = + Pair(bodyWidth * BLUSH_SPACING_RATIO, w * BLUSH_RADIUS_RATIO) fun computeEyeDimensions(w: Float): Pair = Pair(w * EYE_WIDTH_RATIO, w * EYE_HEIGHT_RATIO) fun computeEyeMaxShifts(eyeWidth: Float, eyeHeight: Float): Pair = Pair(eyeWidth * EYE_MAX_SHIFT_X_RATIO, eyeHeight * EYE_MAX_SHIFT_Y_RATIO) + + fun computeSoundwaveRadius(w: Float, progress: Float): Float = + w * (0.07f + (progress.coerceIn(0f, 1f) * SOUNDWAVE_MAX_RADIUS_RATIO)) + + fun computeSoundwaveAlpha(progress: Float): Float = + ((1f - progress.coerceIn(0f, 1f)) * 0.65f).coerceIn(0f, 1f) + + fun computeSquishScaleX(squishY: Float): Float = + if (squishY <= 0f) 1f else (1f / sqrt(squishY)).coerceIn(0.80f, 1.25f) } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt index 8254a893e..d3e69c4ab 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt @@ -5,30 +5,18 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowForward -import androidx.compose.material.icons.rounded.CloudDone -import androidx.compose.material.icons.rounded.CloudOff -import androidx.compose.material.icons.rounded.CloudSync -import androidx.compose.material.icons.rounded.FileDownload -import androidx.compose.material.icons.rounded.FileUpload import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material.icons.rounded.RssFeed import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -37,10 +25,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp -import cx.aswin.boxlore.core.catalog.sync.CloudSyncUiStatus import cx.aswin.boxlore.core.designsystem.components.ContentRegionLanguagePicker import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable @@ -60,26 +45,11 @@ data class LibraryDiscoveryPreferences( val onSetContentLanguages: (List) -> Unit, ) -/** - * Export/import callbacks for [LibrarySettingsPage], grouped to keep its parameter count small. - * Also used by [cx.aswin.boxlore.feature.settings.SettingsScreen]. - */ -data class LibraryBackupActions( - val onExportJson: () -> Unit, - val onExportOpml: () -> Unit, - val onImportJson: () -> Unit, - val onImportOpml: () -> Unit, -) - @Composable internal fun LibrarySettingsPage( discoveryPreferences: LibraryDiscoveryPreferences, onAddRssClick: () -> Unit, - backupActions: LibraryBackupActions, onBack: () -> Unit, - onAccountClick: () -> Unit, - accountStatus: String? = null, - syncStatus: CloudSyncUiStatus = CloudSyncUiStatus.Idle, ) { var isCountryFaqExpanded by rememberSaveable { mutableStateOf(false) } val collapseCountryFaq = { isCountryFaqExpanded = false } @@ -89,14 +59,6 @@ internal fun LibrarySettingsPage( onBack = onBack, onUnconsumedTap = if (isCountryFaqExpanded) collapseCountryFaq else null, ) { - LibraryAccountSyncGroup( - accountStatus = accountStatus, - syncStatus = syncStatus, - onAccountClick = { - collapseCountryFaq() - onAccountClick() - }, - ) SettingsGroup(title = "Discovery") { SettingsContent { ContentRegionLanguagePicker( @@ -131,227 +93,6 @@ internal fun LibrarySettingsPage( }, ) } - - LibraryBackupGroups( - backupActions = backupActions, - onAction = collapseCountryFaq, - ) - } -} - -@Composable -private fun LibraryAccountSyncGroup( - accountStatus: String?, - syncStatus: CloudSyncUiStatus, - onAccountClick: () -> Unit, -) { - SettingsGroup(title = "Account & Cloud Sync") { - LibraryAccountSyncButton( - accountStatus = accountStatus, - syncStatus = syncStatus, - onClick = onAccountClick, - ) - } -} - -private data class LibrarySyncVisualState( - val icon: ImageVector, - val iconTint: Color, - val iconBackground: Color, -) - -@Composable -private fun resolveLibrarySyncVisual( - isSignedIn: Boolean, - syncStatus: CloudSyncUiStatus, -): LibrarySyncVisualState { - val isSyncError = syncStatus is CloudSyncUiStatus.Error - val isSyncing = syncStatus is CloudSyncUiStatus.Syncing - - return when { - !isSignedIn || isSyncing -> LibrarySyncVisualState( - icon = Icons.Rounded.CloudSync, - iconTint = MaterialTheme.colorScheme.primary, - iconBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - ) - isSyncError -> LibrarySyncVisualState( - icon = Icons.Rounded.CloudOff, - iconTint = MaterialTheme.colorScheme.error, - iconBackground = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f), - ) - else -> LibrarySyncVisualState( - icon = Icons.Rounded.CloudDone, - iconTint = MaterialTheme.colorScheme.primary, - iconBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - ) - } -} - -@Composable -private fun LibrarySyncStatusBadge( - syncStatus: CloudSyncUiStatus, - modifier: Modifier = Modifier, -) { - val isSyncError = syncStatus is CloudSyncUiStatus.Error - val isSyncing = syncStatus is CloudSyncUiStatus.Syncing - - val statusDotColor = if (isSyncError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - } - val statusText = when { - isSyncError -> "Sync issue" - isSyncing -> "Syncing..." - else -> "Cloud sync active" - } - - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - Box( - modifier = Modifier - .size(6.dp) - .background(statusDotColor, CircleShape), - ) - Text( - text = statusText, - style = MaterialTheme.typography.labelSmall, - color = statusDotColor, - fontWeight = GoogleSansWeight.medium, - ) - } -} - -@Composable -private fun LibraryAccountSyncButton( - accountStatus: String?, - syncStatus: CloudSyncUiStatus, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val isSignedIn = accountStatus != null - val visual = resolveLibrarySyncVisual(isSignedIn, syncStatus) - - Row( - modifier = modifier - .fillMaxWidth() - .expressiveClickable( - shape = MaterialTheme.shapes.large, - onClick = onClick, - ) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp), - ) { - Surface( - modifier = Modifier.size(44.dp), - shape = MaterialTheme.shapes.medium, - color = visual.iconBackground, - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = visual.icon, - contentDescription = null, - tint = visual.iconTint, - modifier = Modifier.size(24.dp), - ) - } - } - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(3.dp), - ) { - Text( - text = "Cloud sync with account", - style = MaterialTheme.typography.titleMedium, - fontWeight = GoogleSansWeight.bold, - color = MaterialTheme.colorScheme.onSurface, - ) - - Text( - text = if (isSignedIn) { - "Signed in as $accountStatus" - } else { - "Sign in to backup and sync across devices" - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - if (isSignedIn) { - Spacer(Modifier.height(1.dp)) - LibrarySyncStatusBadge(syncStatus = syncStatus) - } - } - - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceContainerHighest, - modifier = Modifier.size(32.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowForward, - contentDescription = "Open account settings", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun LibraryBackupGroups( - backupActions: LibraryBackupActions, - onAction: () -> Unit, -) { - SettingsGroup(title = "Export") { - SettingsNavigationRow( - title = "Full library backup", - supportingText = "Subscriptions, history, likes, settings, and catalog shows with Missing episodes? (JSON)", - icon = Icons.Rounded.FileUpload, - onClick = { - onAction() - backupActions.onExportJson() - }, - ) - SettingsDivider() - SettingsNavigationRow( - title = "Subscriptions only", - supportingText = "OPML file", - icon = Icons.Rounded.FileUpload, - onClick = { - onAction() - backupActions.onExportOpml() - }, - ) - } - - SettingsGroup(title = "Import") { - SettingsNavigationRow( - title = "Full library backup", - supportingText = "Restores Missing episodes? opt-ins and refreshes those shows", - icon = Icons.Rounded.FileDownload, - onClick = { - onAction() - backupActions.onImportJson() - }, - ) - SettingsDivider() - SettingsNavigationRow( - title = "Subscriptions only", - supportingText = "Import from an OPML file", - icon = Icons.Rounded.FileDownload, - onClick = { - onAction() - backupActions.onImportOpml() - }, - ) } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SettingsHub.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SettingsHub.kt index d27ee1de6..76a97ac1b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SettingsHub.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SettingsHub.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.feature.settings.pages import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.LibraryBooks +import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.DownloadForOffline import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Palette @@ -23,11 +24,19 @@ internal fun SettingsHub( onBack = onBack, ) { SettingsCategoryCard( - title = "Library & Account", - description = "Cloud sync, region, RSS feeds, import and export", - icon = Icons.AutoMirrored.Rounded.LibraryBooks, + title = "Cloud Sync & Backups", + description = "Account sync, OPML feeds, and library backups", + icon = Icons.Rounded.CloudSync, containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + onClick = { onNavigate(ProfileSettingsDestination.SyncAndBackups) }, + ) + SettingsCategoryCard( + title = "Library", + description = "Discovery region, content languages, and RSS feeds", + icon = Icons.AutoMirrored.Rounded.LibraryBooks, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, onClick = { onNavigate(ProfileSettingsDestination.Library) }, ) SettingsCategoryCard( diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPage.kt new file mode 100644 index 000000000..6ea5db030 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPage.kt @@ -0,0 +1,282 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.CloudDone +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.FileDownload +import androidx.compose.material.icons.rounded.FileUpload +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.catalog.sync.CloudSyncUiStatus +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.feature.settings.components.SettingsDivider +import cx.aswin.boxlore.feature.settings.components.SettingsGroup +import cx.aswin.boxlore.feature.settings.components.SettingsNavigationRow +import cx.aswin.boxlore.feature.settings.components.SettingsScaffold + +/** + * Export/import callbacks for [SyncAndBackupsPage], grouped to keep its parameter count small. + * Also used by [cx.aswin.boxlore.feature.settings.SettingsScreen]. + */ +data class LibraryBackupActions( + val onExportJson: () -> Unit, + val onExportOpml: () -> Unit, + val onImportJson: () -> Unit, + val onImportOpml: () -> Unit, +) + +@Composable +internal fun SyncAndBackupsPage( + accountStatus: String?, + syncStatus: CloudSyncUiStatus, + backupActions: LibraryBackupActions, + onBack: () -> Unit, + onAccountClick: () -> Unit, +) { + SettingsScaffold( + title = "Cloud Sync & Backups", + onBack = onBack, + ) { + SyncAccountSection( + accountStatus = accountStatus, + syncStatus = syncStatus, + onAccountClick = onAccountClick, + ) + SyncBackupExportGroup(backupActions = backupActions) + SyncBackupImportGroup(backupActions = backupActions) + } +} + +@Composable +private fun SyncAccountSection( + accountStatus: String?, + syncStatus: CloudSyncUiStatus, + onAccountClick: () -> Unit, +) { + SettingsGroup(title = "Cloud Sync") { + CloudSyncCard( + accountStatus = accountStatus, + syncStatus = syncStatus, + onClick = onAccountClick, + ) + } +} + +private data class SyncVisualState( + val icon: ImageVector, + val iconTint: Color, + val iconBackground: Color, +) + +@Composable +private fun resolveSyncVisual( + isSignedIn: Boolean, + syncStatus: CloudSyncUiStatus, +): SyncVisualState { + val isSyncError = syncStatus is CloudSyncUiStatus.Error + val isSyncing = syncStatus is CloudSyncUiStatus.Syncing + + return when { + !isSignedIn || isSyncing -> SyncVisualState( + icon = Icons.Rounded.CloudSync, + iconTint = MaterialTheme.colorScheme.primary, + iconBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + ) + isSyncError -> SyncVisualState( + icon = Icons.Rounded.CloudOff, + iconTint = MaterialTheme.colorScheme.error, + iconBackground = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f), + ) + else -> SyncVisualState( + icon = Icons.Rounded.CloudDone, + iconTint = MaterialTheme.colorScheme.primary, + iconBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + ) + } +} + +internal fun resolveSyncBadgeText(syncStatus: CloudSyncUiStatus): String = when { + syncStatus is CloudSyncUiStatus.Error -> "Sync issue" + syncStatus is CloudSyncUiStatus.Syncing -> "Syncing..." + else -> "Cloud sync active" +} + +internal fun resolveCloudSyncSubtitle(accountStatus: String?): String = + if (accountStatus != null) { + "Signed in as $accountStatus" + } else { + "Sign in to backup and sync across devices" + } + +@Composable +private fun SyncStatusBadge( + syncStatus: CloudSyncUiStatus, + modifier: Modifier = Modifier, +) { + val isSyncError = syncStatus is CloudSyncUiStatus.Error + + val statusDotColor = if (isSyncError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + val statusText = resolveSyncBadgeText(syncStatus) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + modifier = Modifier + .size(6.dp) + .background(statusDotColor, CircleShape), + ) + Text( + text = statusText, + style = MaterialTheme.typography.labelSmall, + color = statusDotColor, + fontWeight = GoogleSansWeight.medium, + ) + } +} + +@Composable +private fun CloudSyncCard( + accountStatus: String?, + syncStatus: CloudSyncUiStatus, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isSignedIn = accountStatus != null + val visual = resolveSyncVisual(isSignedIn, syncStatus) + + Row( + modifier = modifier + .fillMaxWidth() + .expressiveClickable( + shape = MaterialTheme.shapes.large, + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface( + modifier = Modifier.size(44.dp), + shape = MaterialTheme.shapes.medium, + color = visual.iconBackground, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = visual.icon, + contentDescription = null, + tint = visual.iconTint, + modifier = Modifier.size(24.dp), + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = "Cloud sync", + style = MaterialTheme.typography.titleMedium, + fontWeight = GoogleSansWeight.bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + Text( + text = resolveCloudSyncSubtitle(accountStatus), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (isSignedIn) { + Spacer(Modifier.height(1.dp)) + SyncStatusBadge(syncStatus = syncStatus) + } + } + + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.size(32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = "Open account settings", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun SyncBackupExportGroup( + backupActions: LibraryBackupActions, +) { + SettingsGroup(title = "Export") { + SettingsNavigationRow( + title = "Full library backup", + supportingText = "Subscriptions, history, likes, settings, and catalog shows with Missing episodes? (JSON)", + icon = Icons.Rounded.FileUpload, + onClick = backupActions.onExportJson, + ) + SettingsDivider() + SettingsNavigationRow( + title = "Subscriptions only", + supportingText = "OPML file", + icon = Icons.Rounded.FileUpload, + onClick = backupActions.onExportOpml, + ) + } +} + +@Composable +private fun SyncBackupImportGroup( + backupActions: LibraryBackupActions, +) { + SettingsGroup(title = "Import") { + SettingsNavigationRow( + title = "Full library backup", + supportingText = "Restores Missing episodes? opt-ins and refreshes those shows", + icon = Icons.Rounded.FileDownload, + onClick = backupActions.onImportJson, + ) + SettingsDivider() + SettingsNavigationRow( + title = "Subscriptions only", + supportingText = "Import from an OPML file", + icon = Icons.Rounded.FileDownload, + onClick = backupActions.onImportOpml, + ) + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt index 18eb96732..4c8f28874 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt @@ -66,4 +66,28 @@ class SettingsBackNavigationTest { actionNull, ) } + + @Test + fun whenNotOnboarding_withSyncAndBackupsPreviousDestination_navigatesToSyncAndBackups() { + val action = resolveSettingsBackAction( + isOnboarding = false, + previousDestination = ProfileSettingsDestination.SyncAndBackups, + initialPage = null, + ) + assertEquals( + SettingsBackAction.NavigateTo(ProfileSettingsDestination.SyncAndBackups), + action, + ) + } + + @Test + fun toSettingsDestination_resolvesSyncAndBackupsVariants() { + assertEquals(ProfileSettingsDestination.SyncAndBackups, "sync".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.SyncAndBackups, "sync_and_backups".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.SyncAndBackups, "sync-and-backups".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.SyncAndBackups, "backups".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.Library, "library".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.Account, "account".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.Hub, "unknown".toSettingsDestination()) + } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollectorTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollectorTest.kt new file mode 100644 index 000000000..015139e90 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollectorTest.kt @@ -0,0 +1,58 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DiagnosticCollectorTest { + + private val sampleInfo = DiagnosticInfo( + appVersion = "1.2.3", + buildCode = 42L, + packageName = "cx.aswin.boxlore", + androidRelease = "15", + sdkInt = 35, + manufacturer = "Google", + model = "Pixel 9", + networkType = "Wi-Fi", + audioRoute = "Bluetooth", + locale = "en-US", + ) + + @Test + fun `toCondensedSummary formats compact diagnostics correctly`() { + val summary = sampleInfo.toCondensedSummary() + + assertTrue(summary.contains("App: v1.2.3 (42)")) + assertTrue(summary.contains("Android 15 (API 35)")) + assertTrue(summary.contains("Google Pixel 9")) + assertTrue(summary.contains("Net: Wi-Fi")) + assertTrue(summary.contains("Audio: Bluetooth")) + assertTrue(summary.length < 150) + } + + @Test + fun `toMarkdownReport includes headers and fields`() { + val report = sampleInfo.toMarkdownReport(sanitizedLogcat = "Sample logcat content") + + assertTrue(report.contains("### System Diagnostics")) + assertTrue(report.contains("- **App**: boxlore `1.2.3` (build `42`)")) + assertTrue(report.contains("- **Package**: `cx.aswin.boxlore`")) + assertTrue(report.contains("- **Android**: `15` (API `35`)")) + assertTrue(report.contains("- **Device**: `Google Pixel 9`")) + assertTrue(report.contains("- **Network**: `Wi-Fi`")) + assertTrue(report.contains("- **Audio Route**: `Bluetooth`")) + assertTrue(report.contains("- **Locale**: `en-US`")) + assertTrue(report.contains("
")) + assertTrue(report.contains("Sanitized App Logs")) + assertTrue(report.contains("Sample logcat content")) + } + + @Test + fun `toMarkdownReport without logs omits details block`() { + val report = sampleInfo.toMarkdownReport(sanitizedLogcat = null) + + assertTrue(report.contains("### System Diagnostics")) + assertFalse(report.contains("
")) + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessContentTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessContentTest.kt new file mode 100644 index 000000000..36f697083 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessContentTest.kt @@ -0,0 +1,99 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FeedbackSuccessContentTest { + + @Test + fun getFeedbackSuccessContent_bugWithEmail_includes24HrTatAndEmail() { + val content = getFeedbackSuccessContent(FeedbackCategory.BUG, "listener@example.com") + + assertEquals("Bug report submitted", content.title) + assertTrue(content.subtitle.contains("boxlore")) + assertTrue(content.isEmailAttached) + assertEquals("Reach out within 24 hours", content.statusTitle) + assertTrue(content.statusDescription.contains("24 hours")) + assertTrue(content.statusDescription.contains("listener@example.com")) + assertTrue(content.statusDescription.contains("truly solved")) + } + + @Test + fun getFeedbackSuccessContent_bugWithoutEmail_showsAnonymousLogging() { + val content = getFeedbackSuccessContent(FeedbackCategory.BUG, " ") + + assertEquals("Bug report submitted", content.title) + assertFalse(content.isEmailAttached) + assertEquals("Submitted anonymously", content.statusTitle) + assertTrue(content.statusDescription.contains("debugging queue")) + } + + @Test + fun getFeedbackSuccessContent_audioWithEmail_includesAudioSpecificCopyAndTat() { + val content = getFeedbackSuccessContent(FeedbackCategory.AUDIO, "audiofan@example.com") + + assertEquals("Audio report submitted", content.title) + assertTrue(content.subtitle.contains("Playback reliability")) + assertTrue(content.isEmailAttached) + assertEquals("Reach out within 24 hours", content.statusTitle) + assertTrue(content.statusDescription.contains("24 hours")) + assertTrue(content.statusDescription.contains("audiofan@example.com")) + assertTrue(content.statusDescription.contains("playback diagnostics")) + } + + @Test + fun getFeedbackSuccessContent_audioWithoutEmail_showsAnonymousQueue() { + val content = getFeedbackSuccessContent(FeedbackCategory.AUDIO, "") + + assertEquals("Audio report submitted", content.title) + assertFalse(content.isEmailAttached) + assertEquals("Submitted anonymously", content.statusTitle) + assertTrue(content.statusDescription.contains("audio playback report")) + } + + @Test + fun getFeedbackSuccessContent_featureWithEmail_includesFeatureCopyAndTat() { + val content = getFeedbackSuccessContent(FeedbackCategory.FEATURE, "builder@example.com") + + assertEquals("Feature idea submitted", content.title) + assertTrue(content.subtitle.contains("boxlore")) + assertTrue(content.isEmailAttached) + assertEquals("Reach out within 24 hours", content.statusTitle) + assertTrue(content.statusDescription.contains("24 hours")) + assertTrue(content.statusDescription.contains("builder@example.com")) + } + + @Test + fun getFeedbackSuccessContent_featureWithoutEmail_showsAnonymousRoadmap() { + val content = getFeedbackSuccessContent(FeedbackCategory.FEATURE, "") + + assertEquals("Feature idea submitted", content.title) + assertFalse(content.isEmailAttached) + assertEquals("Submitted anonymously", content.statusTitle) + assertTrue(content.statusDescription.contains("product design roadmap")) + } + + @Test + fun getFeedbackSuccessContent_otherWithEmail_includesGeneralCopyAndTat() { + val content = getFeedbackSuccessContent(FeedbackCategory.OTHER, "friend@example.com") + + assertEquals("Feedback received", content.title) + assertTrue(content.subtitle.contains("boxlore")) + assertTrue(content.isEmailAttached) + assertEquals("Reach out within 24 hours", content.statusTitle) + assertTrue(content.statusDescription.contains("24 hours")) + assertTrue(content.statusDescription.contains("friend@example.com")) + } + + @Test + fun getFeedbackSuccessContent_otherWithoutEmail_showsAnonymousDelivery() { + val content = getFeedbackSuccessContent(FeedbackCategory.OTHER, "") + + assertEquals("Feedback received", content.title) + assertFalse(content.isEmailAttached) + assertEquals("Submitted anonymously", content.statusTitle) + assertTrue(content.statusDescription.contains("safely shared")) + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModelTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModelTest.kt new file mode 100644 index 000000000..ad7b52f72 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModelTest.kt @@ -0,0 +1,293 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.catalog.PodcastRepository +import cx.aswin.boxlore.core.prefs.BoxcastPrefs +import cx.aswin.boxlore.core.prefs.FeedbackDraft +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class FeedbackViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var context: Context + private lateinit var prefs: BoxcastPrefs + private lateinit var dummyPodcastRepository: PodcastRepository + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + context = ApplicationProvider.getApplicationContext() + context + .getSharedPreferences(BoxcastPrefs.PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + prefs = BoxcastPrefs(context) + dummyPodcastRepository = mock(PodcastRepository::class.java) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun initial_state_restores_draft_from_BoxcastPrefs_when_present() { + prefs.saveFeedbackDraft( + FeedbackDraft( + category = "bug", + message = "Player stops after 3 minutes", + email = "listener@example.com", + stepsToReproduce = "1. Play\n2. Wait", + attachDiagnostics = true, + ), + ) + + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + val state = vm.uiState.value + + assertEquals(FeedbackCategory.BUG, state.category) + assertEquals("Player stops after 3 minutes", state.message) + assertEquals("listener@example.com", state.email) + assertEquals("1. Play\n2. Wait", state.stepsToReproduce) + assertTrue(state.attachDiagnostics) + assertTrue(state.isDraftRestored) + } + + @Test + fun editing_fields_updates_state_and_persists_draft() { + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + + vm.onCategorySelected(FeedbackCategory.FEATURE) + vm.onMessageChanged("Add dark AMOLED theme") + vm.onEmailChanged("fan@example.com") + vm.onAttachDiagnosticsChanged(false) + + val state = vm.uiState.value + assertEquals(FeedbackCategory.FEATURE, state.category) + assertEquals("Add dark AMOLED theme", state.message) + assertEquals("fan@example.com", state.email) + assertFalse(state.attachDiagnostics) + + val savedDraft = prefs.getFeedbackDraft() + assertNotNull(savedDraft) + assertEquals("feature", savedDraft?.category) + assertEquals("Add dark AMOLED theme", savedDraft?.message) + assertEquals("fan@example.com", savedDraft?.email) + assertEquals(false, savedDraft?.attachDiagnostics) + } + + @Test + fun onDiscardDraft_resets_state_and_clears_draft_from_BoxcastPrefs() { + prefs.saveFeedbackDraft( + FeedbackDraft( + category = "other", + message = "Something random", + stepsToReproduce = "step 1", + email = "user@example.com", + ), + ) + + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + assertTrue(vm.uiState.value.isDraftRestored) + assertEquals("user@example.com", vm.uiState.value.email) + + vm.onDiscardDraft() + + val state = vm.uiState.value + assertEquals("", state.message) + assertEquals("", state.stepsToReproduce) + assertEquals("", state.email) + assertFalse(state.isDraftRestored) + assertNull(prefs.getFeedbackDraft()) + } + + @Test + fun initial_state_restores_draft_when_only_steps_are_present() { + prefs.saveFeedbackDraft( + FeedbackDraft( + category = "bug", + message = "", + email = "listener@example.com", + stepsToReproduce = "1. Play\n2. Wait", + attachDiagnostics = true, + ), + ) + + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + val state = vm.uiState.value + + assertEquals(FeedbackCategory.BUG, state.category) + assertEquals("", state.message) + assertEquals("listener@example.com", state.email) + assertEquals("1. Play\n2. Wait", state.stepsToReproduce) + assertTrue(state.attachDiagnostics) + assertTrue(state.isDraftRestored) + } + + @Test + fun onSubmit_with_blank_message_sets_error_and_does_not_call_repository() = runTest(testDispatcher) { + var called = false + val vm = FeedbackViewModel( + dummyPodcastRepository, + prefs, + context, + submitFeedbackAction = { _, _, _, _, _, _ -> + called = true + true + }, + ) + vm.onMessageChanged(" ") + + vm.onSubmit() + + val state = vm.uiState.value + assertNotNull(state.errorMessage) + assertFalse(state.isSubmitting) + assertFalse(called) + } + + @Test + fun onSubmit_success_clears_draft_and_marks_success() = runTest(testDispatcher) { + var capturedCategory: String? = null + var capturedEmail: String? = null + + val vm = FeedbackViewModel( + dummyPodcastRepository, + prefs, + context, + submitFeedbackAction = { cat, _, _, mail, _, _ -> + capturedCategory = cat + capturedEmail = mail + true + }, + ) + vm.onCategorySelected(FeedbackCategory.FEATURE) + vm.onMessageChanged("Please support OPML tagging") + vm.onEmailChanged("dev@example.com") + + vm.onSubmit() + advanceUntilIdle() + + val state = vm.uiState.value + assertFalse(state.isSubmitting) + assertTrue(state.isSuccess) + assertEquals("feature", capturedCategory) + assertEquals("dev@example.com", capturedEmail) + assertNull(prefs.getFeedbackDraft()) + } + + @Test + fun onSubmit_with_audio_category_maps_to_bug_on_wire() = runTest(testDispatcher) { + var capturedCategory: String? = null + + val vm = FeedbackViewModel( + dummyPodcastRepository, + prefs, + context, + ioDispatcher = testDispatcher, + submitFeedbackAction = { cat, _, _, _, _, _ -> + capturedCategory = cat + true + }, + ) + vm.onCategorySelected(FeedbackCategory.AUDIO) + vm.onMessageChanged("Stream cuts off at end of track") + + vm.onSubmit() + advanceUntilIdle() + + val state = vm.uiState.value + assertTrue(state.isSuccess) + assertEquals("bug", capturedCategory) + } + + @Test + fun onSubmit_with_diagnostics_attached_passes_diagnostics_and_logs() = runTest(testDispatcher) { + var capturedDiag: String? = null + + val vm = FeedbackViewModel( + dummyPodcastRepository, + prefs, + context, + ioDispatcher = testDispatcher, + submitFeedbackAction = { _, _, _, _, diag, _ -> + capturedDiag = diag + true + }, + ) + vm.onCategorySelected(FeedbackCategory.BUG) + vm.onMessageChanged("Crash when clicking download button") + + vm.onSubmit() + advanceUntilIdle() + + val state = vm.uiState.value + assertTrue(state.isSuccess) + assertNotNull(capturedDiag) + assertTrue(capturedDiag!!.contains("System Diagnostics")) + } + + @Test + fun buildGitHubIssueUrl_creates_valid_URL_with_encoded_title() { + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + vm.onCategorySelected(FeedbackCategory.BUG) + vm.onMessageChanged("Crash when clicking download button") + vm.onStepsChanged("1. Tap download\n2. Observe crash") + + val url = buildFeedbackGitHubIssueUrl(vm.uiState.value) + assertTrue(url.startsWith("https://github.com/boxcreate/boxlore/issues/new?title=")) + assertTrue(url.contains("Crash")) + assertTrue(url.contains("body=")) + } + + @Test + fun category_selection_toggles_attachDiagnostics_default_for_bug_reports() { + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + // Default category is FEATURE -> attachDiagnostics should be false + assertEquals(FeedbackCategory.FEATURE, vm.uiState.value.category) + assertFalse(vm.uiState.value.attachDiagnostics) + + // Switching to BUG should default attachDiagnostics to true + vm.onCategorySelected(FeedbackCategory.BUG) + assertEquals(FeedbackCategory.BUG, vm.uiState.value.category) + assertTrue(vm.uiState.value.attachDiagnostics) + + // Switching to AUDIO should keep attachDiagnostics true + vm.onCategorySelected(FeedbackCategory.AUDIO) + assertEquals(FeedbackCategory.AUDIO, vm.uiState.value.category) + assertTrue(vm.uiState.value.attachDiagnostics) + + // Switching back to FEATURE should set attachDiagnostics to false + vm.onCategorySelected(FeedbackCategory.FEATURE) + assertEquals(FeedbackCategory.FEATURE, vm.uiState.value.category) + assertFalse(vm.uiState.value.attachDiagnostics) + + // Switching to OTHER should set attachDiagnostics to false + vm.onCategorySelected(FeedbackCategory.OTHER) + assertEquals(FeedbackCategory.OTHER, vm.uiState.value.category) + assertFalse(vm.uiState.value.attachDiagnostics) + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt new file mode 100644 index 000000000..f200e9582 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt @@ -0,0 +1,115 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class LogcatCollectorTest { + + @Test + fun `sanitizeLogcatOutput scrubs sensitive bearer tokens`() { + val raw = "12-10 10:20:30.123 1000 1000 D OkHttp: Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xyz" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xyz")) + assertTrue(sanitized.contains("Bearer [REDACTED]")) + } + + @Test + fun `sanitizeLogcatOutput scrubs sensitive basic auth credentials`() { + val raw = "12-10 10:20:30.123 1000 1000 D OkHttp: Authorization: Basic dXNlcjpwYXNz" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("dXNlcjpwYXNz")) + assertTrue(sanitized.contains("Basic [REDACTED]")) + } + + @Test + fun `sanitizeLogcatOutput scrubs Firebase API keys`() { + val fakeKey = "AIza" + "SyD1234567890abcdefghijklmnopqrstuv" + val raw = "Loaded Firebase options with key $fakeKey" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains(fakeKey)) + assertTrue(sanitized.contains("[REDACTED_API_KEY]")) + } + + @Test + fun `sanitizeLogcatOutput scrubs key-value credentials`() { + val fakeApiKey = "mock" + "_api_key_12345678" + val fakeToken = "mock" + "_token_12345678" + val raw = """{"api_key": "$fakeApiKey", "token": "$fakeToken"}""" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains(fakeApiKey)) + assertFalse(sanitized.contains(fakeToken)) + assertTrue(sanitized.contains("[REDACTED]")) + } + + @Test + fun `sanitizeLogcatOutput scrubs short key-value credentials`() { + val raw = """{"token": "abc12", "key": "123"}""" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("abc12")) + assertFalse(sanitized.contains("123")) + assertTrue(sanitized.contains("[REDACTED]")) + } + + @Test + fun `sanitizeLogcatOutput scrubs sensitive query parameters`() { + val raw = "GET https://api.boxlore.cx/query?token=xyz123456&category=news HTTP/1.1" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("token=xyz123456")) + assertTrue(sanitized.contains("token=[REDACTED]")) + assertTrue(sanitized.contains("category=news")) + } + + @Test + fun `sanitizeLogcatOutput scrubs email addresses`() { + val raw = "User signed in as user.test@example.com from device" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("user.test@example.com")) + assertTrue(sanitized.contains("[REDACTED_EMAIL]")) + } + + @Test + fun `sanitizeLogcatOutput preserves non-sensitive content`() { + val raw = "12-10 10:20:30.123 1000 1000 I PlaybackService: State changed to STATE_PLAYING position=12400" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertEquals(raw, sanitized) + } + + @Test + fun `sanitizeLogcatOutput scrubs API URLs and replaces them with simple text`() { + val domain = "aswin" + ".cx" + val raw = "--> GET https://api.$domain/podcasts/search?q=news HTTP/1.1\nHost: api.$domain" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains(domain)) + assertTrue(sanitized.contains("https://api.boxlore.app/podcasts/search?q=news")) + assertTrue(sanitized.contains("Host: api.boxlore.app")) + } + + @Test + fun `sanitizeLogcatOutput scrubs X-App-Key header and replaces param name with public key`() { + val fakeKey = "mock" + "_key_value_89012" + val raw = "--> X-App-Key: $fakeKey\nAttaching x-app-key to outgoing request" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("X-App-Key")) + assertFalse(sanitized.contains("x-app-key")) + assertFalse(sanitized.contains(fakeKey)) + assertTrue(sanitized.contains("public key: [REDACTED]")) + assertTrue(sanitized.contains("Attaching public key to outgoing request")) + } + + @Test + fun `sanitizeLogcatOutput handles empty text safely`() { + assertEquals("", LogcatCollector.sanitizeLogcatOutput("")) + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt index 763bd1cc2..e3e546978 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt @@ -60,8 +60,7 @@ class AnimatedBlobAvatarTest { fun blobAvatar_blushSpacing_staysWithinBodyWidth() { val width = 250f val bodyWidth = BlobAvatarGeometry.computeBodyWidth(width) - val blushSpacing = BlobAvatarGeometry.computeBlushSpacing(bodyWidth) - val blushRadius = BlobAvatarGeometry.computeBlushRadius(width) + val (blushSpacing, blushRadius) = BlobAvatarGeometry.computeBlushParams(width, bodyWidth) val blushOuterEdge = blushSpacing + blushRadius val bodyHalfWidth = bodyWidth / 2f @@ -100,7 +99,8 @@ class AnimatedBlobAvatarTest { val bodyWidth = BlobAvatarGeometry.computeBodyWidth(width, maxBreatheX) val bodyHeight = BlobAvatarGeometry.computeBodyHeight(height, maxBreatheY) - val outerEarcupExtent = BlobAvatarGeometry.computeOuterEarCupExtent(width, bodyWidth, maxPulse) + val (cupSpacing, cupWidth) = BlobAvatarGeometry.computeCupParams(width, bodyWidth, maxPulse) + val outerEarcupExtent = cupSpacing + (cupWidth * 0.50f) // Outer earcup edge must remain safely within half the canvas width assertTrue( @@ -117,4 +117,84 @@ class AnimatedBlobAvatarTest { // Headband top must stay above zero (not clipped at top) assertTrue("Top edge of headband ($topBandEdge) must stay above 0", topBandEdge > 0f) } + + @Test + fun blobAvatar_soundwaveRadius_andAlpha_behaveDeterministically() { + val width = 200f + val r0 = BlobAvatarGeometry.computeSoundwaveRadius(width, 0f) + val r1 = BlobAvatarGeometry.computeSoundwaveRadius(width, 1f) + assertTrue("Soundwave radius at 1f ($r1) must exceed radius at 0f ($r0)", r1 > r0) + + val a0 = BlobAvatarGeometry.computeSoundwaveAlpha(0f) + val a1 = BlobAvatarGeometry.computeSoundwaveAlpha(1f) + assertEquals(0.65f, a0, 0.001f) + assertEquals(0.0f, a1, 0.001f) + + // Middle progress + val rMid = BlobAvatarGeometry.computeSoundwaveRadius(width, 0.5f) + assertTrue(rMid > r0 && rMid < r1) + } + + @Test + fun blobAvatar_squishScaleX_preservesVolume() { + val normalX = BlobAvatarGeometry.computeSquishScaleX(1.0f) + assertEquals(1.0f, normalX, 0.001f) + + val squishedX = BlobAvatarGeometry.computeSquishScaleX(0.80f) + assertTrue("When squished vertically (0.8f), scaleX must expand (> 1.0f)", squishedX > 1.0f) + + val stretchedX = BlobAvatarGeometry.computeSquishScaleX(1.15f) + assertTrue("When stretched vertically (1.15f), scaleX must contract (< 1.0f)", stretchedX < 1.0f) + } + + @Test + fun blobAvatar_genreMoods_provideDistinctPalettesAndCycleCleanly() { + val moods = BlobAvatarGenreMood.entries + assertEquals("Must support 7 distinct podcast genre moods", 7, moods.size) + + for (mood in moods) { + assertTrue("Mood ID must not be blank", mood.id.isNotBlank()) + assertTrue("Display name must not be blank", mood.displayName.isNotBlank()) + assertTrue("Emoji must not be blank", mood.emoji.isNotBlank()) + val nextMood = BlobAvatarGenreMood.next(mood) + assertTrue("Next mood must be valid", moods.contains(nextMood)) + } + + // Verify full cycle returns to original + var current = moods.first() + repeat(moods.size) { + current = BlobAvatarGenreMood.next(current) + } + assertEquals("Full cycle must return to start", moods.first(), current) + } + + @Test + fun blobAvatar_moodAccessoriesAndEnvironments_areUniqueAndConfigured() { + val moods = BlobAvatarGenreMood.entries + val accessories = moods.map { it.accessoryType }.toSet() + val environments = moods.map { it.environmentType }.toSet() + + assertEquals("Each of the 7 moods must have a unique accessory", 7, accessories.size) + assertEquals("Each of the 7 moods must have a unique environment backdrop", 7, environments.size) + + assertEquals(7, BlobAvatarGenreMood.AccessoryType.entries.size) + assertEquals(7, BlobAvatarGenreMood.EnvironmentType.entries.size) + } + + @Test + fun blobAvatar_randomMood_avoidsConsecutiveDuplicates() { + var lastMood = BlobAvatarGenreMood.random(random = kotlin.random.Random(42)) + repeat(20) { index -> + val nextMood = BlobAvatarGenreMood.random( + exclude = lastMood, + random = kotlin.random.Random(42 + index), + ) + org.junit.Assert.assertNotEquals( + "Consecutive random moods must not duplicate", + lastMood, + nextMood, + ) + lastMood = nextMood + } + } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPageTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPageTest.kt new file mode 100644 index 000000000..edeb9b9f3 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPageTest.kt @@ -0,0 +1,50 @@ +package cx.aswin.boxlore.feature.settings.pages + +import cx.aswin.boxlore.core.catalog.sync.CloudSyncUiStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncAndBackupsPageTest { + + @Test + fun resolveSyncBadgeText_returnsExpectedStrings() { + assertEquals("Sync issue", resolveSyncBadgeText(CloudSyncUiStatus.Error("Network failure"))) + assertEquals("Syncing...", resolveSyncBadgeText(CloudSyncUiStatus.Syncing)) + assertEquals("Cloud sync active", resolveSyncBadgeText(CloudSyncUiStatus.Idle)) + assertEquals("Cloud sync active", resolveSyncBadgeText(CloudSyncUiStatus.Success(12345678L))) + } + + @Test + fun resolveCloudSyncSubtitle_returnsExpectedStrings() { + assertEquals("Signed in as listener@boxlore.cx", resolveCloudSyncSubtitle("listener@boxlore.cx")) + assertEquals("Sign in to backup and sync across devices", resolveCloudSyncSubtitle(null)) + } + + @Test + fun libraryBackupActions_triggersCallbacksCorrectly() { + var exportJsonCalled = false + var exportOpmlCalled = false + var importJsonCalled = false + var importOpmlCalled = false + + val actions = LibraryBackupActions( + onExportJson = { exportJsonCalled = true }, + onExportOpml = { exportOpmlCalled = true }, + onImportJson = { importJsonCalled = true }, + onImportOpml = { importOpmlCalled = true }, + ) + + actions.onExportJson() + assertTrue(exportJsonCalled) + + actions.onExportOpml() + assertTrue(exportOpmlCalled) + + actions.onImportJson() + assertTrue(importJsonCalled) + + actions.onImportOpml() + assertTrue(importOpmlCalled) + } +}