diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..0c4e282
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,21 @@
+# https://editorconfig.org
+root = true
+
+[*.{kt,kts}]
+charset = utf-8
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+ktlint_function_naming_ignore_when_annotated_with = Composable
+
+[*.xml]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+# Disable copyright headers for ALL XML files
+[app/src/main/res/**/*.xml]
+ij_formatter_off = true
diff --git a/.kotlin/sessions/kotlin-compiler-6938267065353854363.salive b/.kotlin/sessions/kotlin-compiler-6938267065353854363.salive
deleted file mode 100644
index e69de29..0000000
diff --git a/README.md b/README.md
index 2ddd298..45cbeb2 100644
--- a/README.md
+++ b/README.md
@@ -11,14 +11,14 @@
-
+
-
# 🎵 Napify
+ # 🎵 Blankee
-Napify is a **sound-based productivity and relaxation app** designed to help you:
+Blankee is a **sound-based productivity and relaxation app** designed to help you:
- Focus better
- Boost productivity
- Fall asleep in noisy environments
@@ -32,11 +32,11 @@ Napify is a **sound-based productivity and relaxation app** designed to help you
## 📥 Download Now
**📲 Available on Google Play!**
-[](https://play.google.com/store/apps/details?id=com.pronaycoding.blanket_mobile)
+[](https://play.google.com/store/apps/details?id=com.pronaycoding.blankee)
Or download the latest APK from GitHub:
-1. **Go to the [GitHub Actions page](https://github.com/itsPronay/napify/actions)**
+1. **Go to the [GitHub Actions page](https://github.com/itsPronay/blankee/actions)**
2. Click on the **latest workflow run** under the **"Build Check"** section
3. Scroll down to the **Artifacts** section
4. Download **app-debug.apk** or **app-release.apk**
@@ -52,17 +52,17 @@ We welcome open-source contributions! Feel free to:
- Report **issues**
- Suggest **new features**
-Join us in making Napify better!
+Join us in making Blankee better!
## 💡 Inspiration
-Napify is inspired by **[Blanket](https://github.com/rafaelmardojai/blanket)**, a GNOME application developed by **Rafael Mardojai**.
+Blankee is inspired by **[Blanket](https://github.com/rafaelmardojai/blanket)**, a GNOME application developed by **Rafael Mardojai**.
This project aims to bring a **similar experience to Android devices**.
> \[!Note]
> The sounds used in this app are **not original**. They have been **sourced from others**.
> Special thanks to the **original creators** of these sounds for making them available.
-> Please see [SOUNDS_LICENSING.md](https://github.com/itsPronay/napify/blob/play_store/SOUNDS_LICENSING.md) for more info.
+> Please see [SOUNDS_LICENSING.md](https://github.com/itsPronay/blankee/blob/play_store/SOUNDS_LICENSING.md) for more info.
-🌙✨ **Enjoy Napify!**
+🌙✨ **Enjoy Blankee!**
📬 *Have feedback? Open an issue or contribute!*
diff --git a/app/.gitignore b/app/.gitignore
index 25ee264..1aa2b7e 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -1,3 +1,4 @@
/build
.kotlin/
- local.properties
\ No newline at end of file
+ local.properties
+google-services.json
\ No newline at end of file
diff --git a/app/AppModule.kt b/app/AppModule.kt
deleted file mode 100644
index ee83e3c..0000000
--- a/app/AppModule.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * Created by Pronay Sarker on 12/01/2025 (11:00 AM)
- */
-
-
-object AppModule {
-}
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 687f935..2454fa1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,22 +1,18 @@
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
-
- id("kotlin-kapt")
- id("com.google.dagger.hilt.android")
+ alias(libs.plugins.google.ksp)
alias(libs.plugins.compose.compiler)
-
- //hilt
-// id ("kotlin-kapt")
-// id("com.google.dagger.hilt.android")
+ alias(libs.plugins.google.gms.google.services)
+ alias(libs.plugins.google.firebase.crashlytics)
}
android {
- namespace = "com.pronaycoding.blanket_mobile"
+ namespace = "com.pronaycoding.blankee"
compileSdk = 35
defaultConfig {
- applicationId = "com.pronaycoding.blanket_mobile"
+ applicationId = "com.pronaycoding.blankee"
minSdk = 24
targetSdk = 34
versionCode = 3
@@ -29,12 +25,16 @@ android {
}
buildTypes {
+ debug {
+ buildConfigField("boolean", "CUSTOM_SOUNDS_PREMIUM_LOCKED", "false")
+ }
release {
isShrinkResources = true
isMinifyEnabled = true
+ buildConfigField("boolean", "CUSTOM_SOUNDS_PREMIUM_LOCKED", "true")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
+ "proguard-rules.pro",
)
}
}
@@ -47,6 +47,7 @@ android {
}
buildFeatures {
compose = true
+ buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
@@ -56,11 +57,20 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
+
+ // Lint is currently hanging during `lintAnalyzeDebug` / `lintVitalAnalyzeRelease`.
+ // Keep CI/builds unblocked while we investigate root cause.
+ lint {
+ checkReleaseBuilds = false
+ abortOnError = false
+ }
}
dependencies {
implementation(libs.androidx.core.ktx)
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
+ implementation("androidx.media:media:1.7.0")
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
@@ -70,6 +80,7 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose.android)
+ implementation(libs.firebase.crashlytics)
// implementation(libs.androidx.material3.android)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
@@ -79,34 +90,32 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
- //navigation
- val nav_version = "2.7.7"
- implementation("androidx.navigation:navigation-compose:$nav_version")
-
- //Exoplayer
- implementation("androidx.media3:media3-exoplayer:1.3.1")
- implementation("androidx.media3:media3-exoplayer-dash:1.3.1")
- implementation("androidx.media3:media3-ui:1.3.1")
+ // navigation
+ implementation(libs.androidx.navigation.compose)
+ // Exoplayer
+ implementation(libs.androidx.media3.exoplayer)
+ implementation(libs.androidx.media3.exoplayer.dash)
+ implementation(libs.androidx.media3.ui)
- //await
- implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2")
- implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
- implementation ("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0")
+ // await
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
// implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.4.0")
-// //hilt
- implementation("com.google.dagger:hilt-android:2.54")
- kapt("com.google.dagger:hilt-android-compiler:2.54")
- implementation(libs.androidx.hilt.navigation.compose)
+ // Koin
+ implementation(libs.koin.android)
+ implementation(libs.koin.androidx.compose)
// extra icons
- implementation("androidx.compose.material:material-icons-extended:1.5.1")
+ implementation(libs.androidx.compose.material.icons.extended)
-// implementation("com.google.dagger:hilt-android:2.44")
-// kapt("com.google.dagger:hilt-android-compiler:2.44")
-}
+ // Room Database (KSP avoids Kotlin 2.x + kapt processor issues)
+ implementation(libs.androidx.room.runtime)
+ ksp(libs.androidx.room.compiler)
+ implementation(libs.androidx.room.ktx)
-//kapt {
-// correctErrorTypes = true
-//}
\ No newline at end of file
+ implementation(libs.billing)
+ implementation(libs.billing.ktx)
+}
diff --git a/app/src/androidTest/java/com/pronaycoding/blanket_mobile/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/pronaycoding/blankee/ExampleInstrumentedTest.kt
similarity index 77%
rename from app/src/androidTest/java/com/pronaycoding/blanket_mobile/ExampleInstrumentedTest.kt
rename to app/src/androidTest/java/com/pronaycoding/blankee/ExampleInstrumentedTest.kt
index 48ed33f..4bc12a5 100644
--- a/app/src/androidTest/java/com/pronaycoding/blanket_mobile/ExampleInstrumentedTest.kt
+++ b/app/src/androidTest/java/com/pronaycoding/blankee/ExampleInstrumentedTest.kt
@@ -1,13 +1,11 @@
-package com.pronaycoding.blanket_mobile
+package com.pronaycoding.blankee
-import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
-
+import androidx.test.platform.app.InstrumentationRegistry
+import junit.framework.TestCase.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
-import org.junit.Assert.*
-
/**
* Instrumented test, which will execute on an Android device.
*
@@ -19,6 +17,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.pronaycoding.blanket_mobile", appContext.packageName)
+ assertEquals("com.pronaycoding.blankee", appContext.packageName)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7dee6ee..d219272 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,7 +1,11 @@
-
+
+
+
+
+
+ tools:targetApi="35">
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
index e3b5ae6..06d3f09 100644
Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/pronaycoding/blankee/App.kt b/app/src/main/java/com/pronaycoding/blankee/App.kt
new file mode 100644
index 0000000..4019b5c
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/App.kt
@@ -0,0 +1,57 @@
+package com.pronaycoding.blankee
+
+import android.app.Application
+import com.google.firebase.crashlytics.FirebaseCrashlytics
+import com.pronaycoding.blankee.core.service.billing.PlayBillingManager
+import org.koin.android.ext.koin.androidContext
+import org.koin.android.ext.koin.androidLogger
+import org.koin.core.context.GlobalContext
+import org.koin.core.context.startKoin
+import org.koin.core.logger.Level
+
+/**
+ * Blankee Application class.
+ *
+ * This is the entry point for the entire application. It initializes:
+ * - Firebase Crashlytics for error tracking and reporting
+ * - Koin dependency injection framework with all modules
+ * - Play Billing Manager for in-app purchase handling
+ *
+ * Extends [Application] to provide application-level lifecycle methods.
+ * Called once when the app process is created.
+ *
+ * @see KoinModule for dependency injection configuration
+ * @see PlayBillingManager for billing initialization
+ */
+class App : Application() {
+ /**
+ * Called when the application is starting.
+ *
+ * This method:
+ * 1. Initializes Firebase Crashlytics (enabled only in release builds for privacy)
+ * 2. Starts Koin with all configured modules
+ * 3. Initializes PlayBillingManager for handling in-app purchases
+ *
+ * Debug logging for Koin is enabled in debug builds to help diagnose DI issues.
+ */
+ override fun onCreate() {
+ super.onCreate()
+
+ // Initialize Firebase Crashlytics
+ FirebaseCrashlytics
+ .getInstance()
+ .setCrashlyticsCollectionEnabled(BuildConfig.BUILD_TYPE == "release")
+
+ // Start Koin dependency injection framework
+ startKoin {
+ if (BuildConfig.DEBUG) {
+ androidLogger(Level.DEBUG) // Enable debug logging only in debug builds
+ }
+ androidContext(this@App)
+ modules(KoinModule.allModules)
+ }
+
+ // Initialize Play Billing for premium features
+ GlobalContext.get().get().start()
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/KoinModule.kt b/app/src/main/java/com/pronaycoding/blankee/KoinModule.kt
new file mode 100644
index 0000000..9b38f9a
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/KoinModule.kt
@@ -0,0 +1,134 @@
+package com.pronaycoding.blankee
+
+import com.pronaycoding.blankee.core.data.repository.CustomSoundRepository
+import com.pronaycoding.blankee.core.data.repository.PresetRepository
+import com.pronaycoding.blankee.core.data.repositoryImpl.CustomSoundRepositoryImpl
+import com.pronaycoding.blankee.core.data.repositoryImpl.PresetRepositoryImpl
+import com.pronaycoding.blankee.core.database.BlankeeDatabase
+import com.pronaycoding.blankee.core.database.dao.CustomSoundDao
+import com.pronaycoding.blankee.core.database.dao.PresetDao
+import com.pronaycoding.blankee.core.datastore.PreferenceManagerRepository
+import com.pronaycoding.blankee.core.datastore.PreferenceManagerRepositoryImpl
+import com.pronaycoding.blankee.core.service.billing.PlayBillingManager
+import com.pronaycoding.blankee.core.service.playback.GlobalPlaybackState
+import com.pronaycoding.blankee.core.service.playback.MediaPlaybackNotifications
+import com.pronaycoding.blankee.feature.home.HomeViewmodel
+import com.pronaycoding.blankee.feature.home.SoundManager
+import com.pronaycoding.blankee.feature.settings.SettingsViewModel
+import org.koin.androidx.viewmodel.dsl.viewModelOf
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.bind
+import org.koin.dsl.module
+
+/**
+ * Koin Dependency Injection Module for the Blankee application.
+ *
+ * This object configures and provides all dependencies for the app using Koin,
+ * a lightweight Kotlin dependency injection framework.
+ *
+ * Module Organization:
+ * - **Repository Module**: Data access layers (repositories and preference manager)
+ * - **ViewModel Module**: Lifecycle-aware screen state management
+ * - **Database Module**: Room database and Data Access Objects (DAOs)
+ * - **Helper Classes Module**: Services and managers (audio, playback, notifications, billing)
+ *
+ * All modules are combined in [allModules] which is loaded during app initialization.
+ *
+ * @see org.koin.core.module.Module for Koin module documentation
+ * @see allModules for the complete combined module
+ */
+object KoinModule {
+ /**
+ * Repository Module - Data Access Layer.
+ *
+ * Provides singleton instances of repository implementations:
+ * - [PreferenceManagerRepository]: App preferences and settings
+ * - [PresetRepository]: Preset persistence operations
+ * - [CustomSoundRepository]: Custom sound file management
+ * - [PlayBillingManager]: Google Play billing and premium features
+ *
+ * Each repository is bound to its interface for dependency injection.
+ */
+ private val repositoryModule =
+ module {
+ singleOf(::PreferenceManagerRepositoryImpl) bind PreferenceManagerRepository::class
+ singleOf(::PresetRepositoryImpl) bind PresetRepository::class
+ singleOf(::CustomSoundRepositoryImpl) bind CustomSoundRepository::class
+ single { PlayBillingManager(get(), get()) }
+ }
+
+ /**
+ * ViewModel Module - UI State Management.
+ *
+ * Provides ViewModels for screens:
+ * - [HomeViewmodel]: Home screen state (sounds, presets, playback)
+ * - [SettingsViewModel]: Settings screen state (theme, language, premium)
+ *
+ * ViewModels are created with viewModelOf() which automatically handles
+ * lifecycle awareness and dependency injection.
+ */
+ private val viewmodelModule =
+ module {
+ viewModelOf(::HomeViewmodel)
+ viewModelOf(::SettingsViewModel)
+ }
+
+ /**
+ * Database Module - Persistence Layer.
+ *
+ * Provides:
+ * - [BlankeeDatabase]: Singleton Room database instance
+ * - [CustomSoundDao]: Custom sound database access
+ * - [PresetDao]: Preset database access
+ *
+ * DAOs are extracted from the database singleton to avoid creating multiple instances.
+ */
+ private val databaseModule =
+ module {
+ single { BlankeeDatabase.getDatabase(get()) }
+ single { get().customSoundDao() }
+ single { get().presetDao() }
+ }
+
+ /**
+ * Helper Classes Module - Services and Managers.
+ *
+ * Provides singleton instances of core services:
+ * - [SoundManager]: Audio playback control and MediaPlayer management
+ * - [GlobalPlaybackState]: Global play/pause state and coordination
+ * - [MediaPlaybackNotifications]: System notification management for playback
+ *
+ * These services are singletons to maintain consistent state across the app.
+ */
+ private val helperClassModules =
+ module {
+ single { SoundManager(get()) }
+ single { GlobalPlaybackState(get()) }
+ single { MediaPlaybackNotifications(get()) }
+ }
+
+ /**
+ * Complete Koin Module including all sub-modules.
+ *
+ * This module combines all dependency configurations and is passed to Koin during
+ * application initialization (in App.kt). It includes:
+ * - Repository implementations
+ * - ViewModels
+ * - Database and DAOs
+ * - Services and managers
+ *
+ * @see repositoryModule
+ * @see viewmodelModule
+ * @see databaseModule
+ * @see helperClassModules
+ */
+ val allModules =
+ module {
+ includes(
+ repositoryModule,
+ viewmodelModule,
+ databaseModule,
+ helperClassModules,
+ )
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/MainActivity.kt b/app/src/main/java/com/pronaycoding/blankee/MainActivity.kt
new file mode 100644
index 0000000..f87021b
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/MainActivity.kt
@@ -0,0 +1,111 @@
+package com.pronaycoding.blankee
+
+import android.Manifest
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.SystemBarStyle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.pronaycoding.blankee.core.common.Constants
+import com.pronaycoding.blankee.core.datastore.PreferenceManagerRepositoryImpl
+import com.pronaycoding.blankee.core.ui.components.EnjoyBlankeePrompt
+import com.pronaycoding.blankee.core.ui.theme.BlankeeAppTheme
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+
+class MainActivity : ComponentActivity() {
+ private val requestNotificationPermission =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
+
+ private val shouldShowEnjoyPromptFlow = MutableStateFlow(false)
+ private val showNotificationPrimerFlow = MutableStateFlow(false)
+
+ override fun attachBaseContext(newBase: Context) {
+ super.attachBaseContext(PreferenceManagerRepositoryImpl.wrapContextWithStoredLanguage(newBase))
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val preferenceManager =
+ PreferenceManagerRepositoryImpl(
+ this,
+ )
+ lifecycleScope.launch {
+ val launchCount = preferenceManager.incrementLaunchCount()
+ val shouldShowEnjoyPrompt =
+ launchCount == Constants.ENJOY_PROMPT_TRIGGER_LAUNCH &&
+ !preferenceManager.isEnjoyPromptShown()
+ shouldShowEnjoyPromptFlow.value = shouldShowEnjoyPrompt
+ }
+
+ lifecycleScope.launch {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ !preferenceManager.isNotificationPrimerShown()
+ ) {
+ showNotificationPrimerFlow.value = true
+ }
+ }
+
+ enableEdgeToEdge(
+ statusBarStyle =
+ SystemBarStyle.dark(
+ scrim = android.graphics.Color.TRANSPARENT,
+ ),
+ )
+
+ setContent {
+ BlankeeAppTheme {
+ Surface {
+ val shouldShowEnjoyPrompt =
+ shouldShowEnjoyPromptFlow.collectAsStateWithLifecycle().value
+ val showNotificationPrimer =
+ showNotificationPrimerFlow.collectAsStateWithLifecycle().value
+ Navigation()
+ EnjoyBlankeePrompt(shouldShow = shouldShowEnjoyPrompt)
+
+ if (showNotificationPrimer) {
+ AlertDialog(
+ onDismissRequest = { },
+ title = { Text(getString(R.string.notification_primer_title)) },
+ text = { Text(getString(R.string.notification_primer_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showNotificationPrimerFlow.value = false
+ lifecycleScope.launch {
+ preferenceManager.setNotificationPrimerShown(true)
+ }
+ requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ },
+ ) {
+ Text(getString(R.string.notification_primer_grant))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ showNotificationPrimerFlow.value = false
+ lifecycleScope.launch {
+ preferenceManager.setNotificationPrimerShown(true)
+ }
+ },
+ ) {
+ Text(getString(R.string.notification_primer_continue_anyway))
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/Navigation.kt b/app/src/main/java/com/pronaycoding/blankee/Navigation.kt
new file mode 100644
index 0000000..1c30f4d
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/Navigation.kt
@@ -0,0 +1,71 @@
+package com.pronaycoding.blankee
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.pronaycoding.blankee.feature.home.HomeScreenRoute
+import com.pronaycoding.blankee.feature.settings.SettingsScreenRoute
+
+/**
+ * Navigation routes for the Blankee application.
+ *
+ * This enum defines all available destinations in the app's navigation graph.
+ * Routes are used by Jetpack Compose Navigation to manage screen transitions.
+ *
+ * @see Navigation for the NavHost setup that uses these routes
+ */
+enum class Routes {
+ /**
+ * Home screen route - main screen showing sound controls and presets.
+ * Entry point for the app.
+ */
+ Home,
+
+ /**
+ * Settings screen route - app preferences, theme, language, and premium features.
+ * Accessible from the home screen.
+ */
+ Settings,
+}
+
+/**
+ * Sets up the navigation graph for the Blankee application.
+ *
+ * This composable creates a NavHost with all app routes and their associated screen composables.
+ * It manages navigation between:
+ * - Home screen (default/start destination)
+ * - Settings screen
+ *
+ * Uses Jetpack Compose Navigation (androidx.navigation.compose) for type-safe navigation.
+ * The NavController is created with rememberNavController() and managed internally.
+ *
+ * @see Routes for available navigation destinations
+ * @see HomeScreenRoute for the home screen composable
+ * @see SettingsScreenRoute for the settings screen composable
+ */
+@Composable
+fun Navigation() {
+ val navController: NavHostController = rememberNavController()
+
+ NavHost(navController = navController, startDestination = Routes.Home.name) {
+ /**
+ * Home screen - displays sound selection, volume controls, and presets.
+ */
+ composable(Routes.Home.name) {
+ HomeScreenRoute(
+ navigateToSettings = { navController.navigate(Routes.Settings.name) },
+ )
+ }
+
+ /**
+ * Settings screen - app configuration including theme, language, and premium.
+ */
+ composable(Routes.Settings.name) {
+ SettingsScreenRoute(
+ onBackPressed = { navController.navigateUp() },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/common/Constants.kt b/app/src/main/java/com/pronaycoding/blankee/core/common/Constants.kt
new file mode 100644
index 0000000..49c2471
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/common/Constants.kt
@@ -0,0 +1,22 @@
+package com.pronaycoding.blankee.core.common
+
+/**
+ * Application-wide constants for the Blankee app.
+ *
+ * This object centralizes all constant values used throughout the application,
+ * including URLs, theme modes, language tags, and configuration thresholds.
+ */
+object Constants {
+ const val GITHUB_REPO = "https://github.com/itsPronay/napify"
+ const val RAFAEL_MARDOJAI_GITHUB = "https://github.com/rafaelmardojai/"
+ const val PRONAY_GITHUB = "https://github.com/itsPronay/"
+ const val ENJOY_PROMPT_TRIGGER_LAUNCH = 3
+ const val MODE_LIGHT = "light"
+ const val MODE_DARK = "dark"
+ const val MODE_SYSTEM = "system"
+ const val LANGUAGE_TAG_SYSTEM = "system"
+ const val LANGUAGE_TAG_ENGLISH = "en"
+ const val LANGUAGE_TAG_HINDI = "hi"
+ const val LANGUAGE_TAG_BENGALI = "bn"
+ const val LANGUAGE_TAG_SPANISH = "es"
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/common/PresetJson.kt b/app/src/main/java/com/pronaycoding/blankee/core/common/PresetJson.kt
new file mode 100644
index 0000000..f66aba3
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/common/PresetJson.kt
@@ -0,0 +1,68 @@
+package com.pronaycoding.blankee.core.common
+
+import org.json.JSONObject
+
+/**
+ * Utility object for converting between volume maps and JSON strings.
+ *
+ * Used for serializing/deserializing preset volume settings for storage in the database.
+ * Presets contain volume levels for multiple sounds, which are stored as JSON strings
+ * in [PresetEntity] fields (builtInVolumesJson and customVolumesJson).
+ *
+ * The JSON format only stores sounds with volume > 0 to minimize storage.
+ *
+ * Example JSON: `{"0":0.5,"2":0.75,"5":1.0}`
+ * Maps sound indices/IDs to their volume levels (0.0 to 1.0).
+ */
+internal object PresetJson {
+ /**
+ * Converts a map of sound indices/IDs to volumes into a JSON string.
+ *
+ * Only sound entries with volume > 0 are included in the output to reduce storage size.
+ *
+ * Example:
+ * ```kotlin
+ * mapToJson(mapOf(0 to 0.5f, 1 to 0f, 2 to 0.75f))
+ * // Returns: {"0":0.5,"2":0.75}
+ * ```
+ *
+ * @param map A map where keys are sound indices/IDs and values are volume levels (0.0 to 1.0)
+ * @return JSON string representation of the volume map
+ */
+ fun mapToJson(map: Map): String {
+ val o = JSONObject()
+ map.forEach { (key, value) ->
+ if (value > 0f) {
+ o.put(key.toString(), value.toDouble())
+ }
+ }
+ return o.toString()
+ }
+
+ /**
+ * Converts a JSON string back into a map of sound indices/IDs and volumes.
+ *
+ * Handles blank/empty JSON strings by returning an empty map.
+ *
+ * Example:
+ * ```kotlin
+ * jsonToMap("{\"0\":0.5,\"2\":0.75}")
+ * // Returns: mapOf(0 to 0.5f, 2 to 0.75f)
+ * ```
+ *
+ * @param json JSON string containing volume mappings
+ * @return Map of sound indices/IDs to their volume levels, or empty map if json is blank
+ * @throws JSONException if the JSON is malformed
+ */
+ fun jsonToMap(json: String): Map {
+ if (json.isBlank()) return emptyMap()
+ val o = JSONObject(json)
+ val out = mutableMapOf()
+ val keys = o.keys()
+ while (keys.hasNext()) {
+ val k = keys.next()
+ out[k.toInt()] = o.getDouble(k).toFloat()
+ }
+ return out
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/common/util/ContextActivity.kt b/app/src/main/java/com/pronaycoding/blankee/core/common/util/ContextActivity.kt
new file mode 100644
index 0000000..532531d
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/common/util/ContextActivity.kt
@@ -0,0 +1,34 @@
+package com.pronaycoding.blankee.core.common.util
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+
+/**
+ * Extension function to find the Activity from a given Context.
+ *
+ * This utility recursively traverses the context chain to locate an Activity instance.
+ * Useful in Compose composables and other contexts where you need access to the Activity
+ * but only have a Context reference.
+ *
+ * Implementation uses tail recursion for efficient unwrapping of ContextWrapper instances.
+ *
+ * Usage example:
+ * ```kotlin
+ * val activity = context.findActivity()
+ * if (activity != null) {
+ * // Use the activity for operations like:
+ * // - Requesting permissions
+ * // - Launching intents
+ * // - Accessing activity-specific APIs
+ * }
+ * ```
+ *
+ * @return The Activity if found, null if the context doesn't wrap an Activity
+ */
+tailrec fun Context.findActivity(): Activity? =
+ when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+ }
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/common/util/ExternalIntents.kt b/app/src/main/java/com/pronaycoding/blankee/core/common/util/ExternalIntents.kt
new file mode 100644
index 0000000..8db6d81
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/common/util/ExternalIntents.kt
@@ -0,0 +1,49 @@
+package com.pronaycoding.blankee.core.common.util
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.widget.Toast
+import com.pronaycoding.blankee.R
+
+/**
+ * Opens an external URL in the device's default browser.
+ *
+ * This utility function safely handles opening URLs with graceful error handling:
+ * - If a browser is not available: Shows a "No browser installed" toast
+ * - On other exceptions: Shows a generic error toast
+ *
+ * The function catches [ActivityNotFoundException] separately to provide specific user feedback
+ * when no browser app is available on the device.
+ *
+ * Usage example:
+ * ```kotlin
+ * openExternalUrl(context, "https://github.com/itsPronay/blankee")
+ * ```
+ *
+ * @param context The context used to start the activity and show toasts
+ * @param url The full URL to open (should start with http:// or https://)
+ */
+fun openExternalUrl(
+ context: Context,
+ url: String,
+) {
+ try {
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ } catch (e: ActivityNotFoundException) {
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.no_browser_installed),
+ Toast.LENGTH_LONG,
+ ).show()
+ } catch (e: Exception) {
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.error_unexpected),
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/data/repository/CustomSoundRepository.kt b/app/src/main/java/com/pronaycoding/blankee/core/data/repository/CustomSoundRepository.kt
new file mode 100644
index 0000000..0de765e
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/data/repository/CustomSoundRepository.kt
@@ -0,0 +1,75 @@
+package com.pronaycoding.blankee.core.data.repository
+
+import com.pronaycoding.blankee.core.database.entities.CustomSoundEntity
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Repository interface for custom sound data access operations.
+ *
+ * This interface defines a contract for custom sound persistence, abstracting the underlying database implementation.
+ * It provides high-level operations for managing custom sound metadata without exposing database details.
+ *
+ * @see CustomSoundRepositoryImpl for the concrete implementation
+ * @see CustomSoundEntity for the data model
+ * @see CustomSoundDao for lower-level database access
+ */
+interface CustomSoundRepository {
+ /**
+ * Observes all custom sounds as a continuous Flow.
+ *
+ * The returned Flow emits a new list of custom sounds whenever changes occur in the database.
+ * This enables reactive UI updates when custom sounds are added or deleted.
+ *
+ * @return A Flow of lists containing all custom sounds
+ */
+ fun getAllCustomSounds(): Flow>
+
+ /**
+ * Adds a new custom sound to the database.
+ *
+ * @param displayName User-friendly name for the custom sound
+ * @param filePath Full path or URI to the audio file
+ * @throws Exception if the add operation fails
+ */
+ suspend fun addCustomSound(
+ displayName: String,
+ filePath: String,
+ )
+
+ /**
+ * Removes a custom sound by its ID.
+ *
+ * @param id The ID of the custom sound to remove
+ * @throws Exception if the remove operation fails
+ */
+ suspend fun removeCustomSound(id: Int)
+
+ /**
+ * Removes a custom sound from the database.
+ *
+ * @param sound The [CustomSoundEntity] to remove
+ * @throws Exception if the remove operation fails
+ */
+ suspend fun removeCustomSound(sound: CustomSoundEntity)
+
+ /**
+ * Retrieves a specific custom sound by its ID.
+ *
+ * @param id The ID of the custom sound to retrieve
+ * @return The [CustomSoundEntity] if found, null otherwise
+ * @throws Exception if the retrieval operation fails
+ */
+ suspend fun getCustomSoundById(id: Int): CustomSoundEntity?
+
+ /**
+ * Updates the display name of a custom sound.
+ *
+ * @param id The ID of the custom sound to update
+ * @param displayName The new display name for the custom sound
+ * @throws Exception if the update operation fails
+ */
+ suspend fun updateCustomSoundDisplayName(
+ id: Int,
+ displayName: String,
+ )
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/data/repository/PresetRepository.kt b/app/src/main/java/com/pronaycoding/blankee/core/data/repository/PresetRepository.kt
new file mode 100644
index 0000000..37af1c5
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/data/repository/PresetRepository.kt
@@ -0,0 +1,44 @@
+package com.pronaycoding.blankee.core.data.repository
+
+import com.pronaycoding.blankee.core.database.entities.PresetEntity
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Repository interface for preset data access operations.
+ *
+ * This interface defines a contract for preset persistence, abstracting the underlying database implementation.
+ * It provides high-level operations for managing presets without exposing database details to the caller.
+ *
+ * @see PresetRepositoryImpl for the concrete implementation
+ * @see PresetEntity for the data model
+ * @see PresetDao for lower-level database access
+ */
+interface PresetRepository {
+ /**
+ * Observes all presets as a continuous Flow.
+ *
+ * The returned Flow emits a new list of presets whenever changes occur in the database.
+ * This enables reactive UI updates when presets are added, modified, or deleted.
+ * The Flow is a cold stream that starts emitting when collected.
+ *
+ * @return A Flow of lists containing all presets
+ */
+ fun observePresets(): Flow>
+
+ /**
+ * Saves a new preset to the database.
+ *
+ * @param entity The [PresetEntity] to save
+ * @return The ID of the newly created preset (auto-generated by the database)
+ * @throws Exception if the save operation fails
+ */
+ suspend fun savePreset(entity: PresetEntity): Long
+
+ /**
+ * Deletes a preset by its ID.
+ *
+ * @param id The ID of the preset to delete
+ * @throws Exception if the delete operation fails
+ */
+ suspend fun deletePreset(id: Long)
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/data/repositoryImpl/CustomSoundRepositoryImpl.kt b/app/src/main/java/com/pronaycoding/blankee/core/data/repositoryImpl/CustomSoundRepositoryImpl.kt
new file mode 100644
index 0000000..6d632d7
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/data/repositoryImpl/CustomSoundRepositoryImpl.kt
@@ -0,0 +1,88 @@
+package com.pronaycoding.blankee.core.data.repositoryImpl
+
+import com.pronaycoding.blankee.core.database.dao.CustomSoundDao
+import com.pronaycoding.blankee.core.database.entities.CustomSoundEntity
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Implementation of the [CustomSoundRepository] interface.
+ *
+ * This class provides concrete implementations for all custom sound database operations.
+ * It acts as a bridge between the UI/business logic layer and the Room database DAO,
+ * delegating all operations to [CustomSoundDao] while maintaining the repository abstraction.
+ *
+ * @param customSoundDao The Data Access Object used for database operations
+ *
+ * @see CustomSoundRepository for the interface contract
+ * @see CustomSoundDao for lower-level database access
+ */
+class CustomSoundRepositoryImpl(
+ private val customSoundDao: CustomSoundDao,
+) : com.pronaycoding.blankee.core.data.repository.CustomSoundRepository {
+ /**
+ * Retrieves all custom sounds from the database as a Flow.
+ *
+ * @return A Flow from the DAO that emits updated custom sound lists on database changes
+ */
+ override fun getAllCustomSounds(): Flow> = customSoundDao.getAllCustomSounds()
+
+ /**
+ * Adds a new custom sound to the database.
+ *
+ * Creates a new [CustomSoundEntity] with the provided display name and file path,
+ * then inserts it into the database.
+ *
+ * @param displayName User-friendly name for the custom sound
+ * @param filePath Full path or URI to the audio file
+ */
+ override suspend fun addCustomSound(
+ displayName: String,
+ filePath: String,
+ ) {
+ val entity =
+ CustomSoundEntity(
+ displayName = displayName,
+ filePath = filePath,
+ )
+ customSoundDao.insertCustomSound(entity)
+ }
+
+ /**
+ * Removes a custom sound from the database by its ID.
+ *
+ * @param id The ID of the custom sound to remove
+ */
+ override suspend fun removeCustomSound(id: Int) {
+ customSoundDao.deleteCustomSoundById(id)
+ }
+
+ /**
+ * Removes a custom sound from the database.
+ *
+ * @param sound The [CustomSoundEntity] to remove
+ */
+ override suspend fun removeCustomSound(sound: CustomSoundEntity) {
+ customSoundDao.deleteCustomSound(sound)
+ }
+
+ /**
+ * Retrieves a specific custom sound by its ID.
+ *
+ * @param id The ID of the custom sound to retrieve
+ * @return The [CustomSoundEntity] if found, null otherwise
+ */
+ override suspend fun getCustomSoundById(id: Int): CustomSoundEntity? = customSoundDao.getCustomSoundById(id)
+
+ /**
+ * Updates the display name of an existing custom sound.
+ *
+ * @param id The ID of the custom sound to update
+ * @param displayName The new display name for the custom sound
+ */
+ override suspend fun updateCustomSoundDisplayName(
+ id: Int,
+ displayName: String,
+ ) {
+ customSoundDao.updateCustomSoundDisplayName(id, displayName)
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/data/repositoryImpl/PresetRepositoryImpl.kt b/app/src/main/java/com/pronaycoding/blankee/core/data/repositoryImpl/PresetRepositoryImpl.kt
new file mode 100644
index 0000000..c697f7e
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/data/repositoryImpl/PresetRepositoryImpl.kt
@@ -0,0 +1,44 @@
+package com.pronaycoding.blankee.core.data.repositoryImpl
+
+import com.pronaycoding.blankee.core.data.repository.PresetRepository
+import com.pronaycoding.blankee.core.database.dao.PresetDao
+import com.pronaycoding.blankee.core.database.entities.PresetEntity
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Implementation of the [PresetRepository] interface.
+ *
+ * This class provides concrete implementations for all preset database operations.
+ * It acts as a bridge between the UI/business logic layer and the Room database DAO,
+ * delegating all operations to [PresetDao] while maintaining the repository abstraction.
+ *
+ * @param presetDao The Data Access Object used for database operations
+ *
+ * @see PresetRepository for the interface contract
+ * @see PresetDao for lower-level database access
+ */
+class PresetRepositoryImpl(
+ private val presetDao: PresetDao,
+) : PresetRepository {
+ /**
+ * Observes all presets from the database.
+ *
+ * @return A Flow from the DAO that emits updated preset lists on database changes
+ */
+ override fun observePresets(): Flow> = presetDao.observeAll()
+
+ /**
+ * Saves a preset to the database.
+ *
+ * @param entity The preset to save
+ * @return The ID assigned to the new preset
+ */
+ override suspend fun savePreset(entity: PresetEntity): Long = presetDao.insertPreset(entity)
+
+ /**
+ * Deletes a preset from the database.
+ *
+ * @param id The ID of the preset to delete
+ */
+ override suspend fun deletePreset(id: Long) = presetDao.deleteById(id)
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/database/BlankeeDatabase.kt b/app/src/main/java/com/pronaycoding/blankee/core/database/BlankeeDatabase.kt
new file mode 100644
index 0000000..211a29c
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/database/BlankeeDatabase.kt
@@ -0,0 +1,57 @@
+package com.pronaycoding.blankee.core.database
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import com.pronaycoding.blankee.core.database.dao.CustomSoundDao
+import com.pronaycoding.blankee.core.database.dao.PresetDao
+import com.pronaycoding.blankee.core.database.entities.CustomSoundEntity
+import com.pronaycoding.blankee.core.database.entities.PresetEntity
+
+/**
+ * Room database for the Blankee application.
+ *
+ * This database manages persistent storage for:
+ * - Custom sound files added by users ([CustomSoundEntity])
+ * - Sound presets (combinations of sounds with specific volumes) ([PresetEntity])
+ *
+ * The database uses the Singleton pattern to ensure only one instance exists throughout the app lifecycle.
+ * All database access is performed through Data Access Objects (DAOs) which provide type-safe database queries.
+ *
+ * @see CustomSoundDao for custom sound database operations
+ * @see PresetDao for preset database operations
+ * @see CustomSoundEntity for custom sound data model
+ * @see PresetEntity for preset data model
+ */
+@Database(
+ entities = [
+ CustomSoundEntity::class,
+ PresetEntity::class,
+ ],
+ version = 1,
+ exportSchema = false,
+)
+abstract class BlankeeDatabase : RoomDatabase() {
+ abstract fun customSoundDao(): CustomSoundDao
+
+ abstract fun presetDao(): PresetDao
+
+ companion object {
+ @Volatile
+ private var instance: BlankeeDatabase? = null
+
+ fun getDatabase(context: Context): BlankeeDatabase =
+ instance ?: synchronized(this) {
+ val db =
+ Room
+ .databaseBuilder(
+ context.applicationContext,
+ BlankeeDatabase::class.java,
+ "blankee_database",
+ ).build()
+ instance = db
+ db
+ }
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/database/dao/CustomSoundDao.kt b/app/src/main/java/com/pronaycoding/blankee/core/database/dao/CustomSoundDao.kt
new file mode 100644
index 0000000..3a6026d
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/database/dao/CustomSoundDao.kt
@@ -0,0 +1,41 @@
+package com.pronaycoding.blankee.core.database.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
+import com.pronaycoding.blankee.core.database.entities.CustomSoundEntity
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Data Access Object (DAO) for managing [CustomSoundEntity] database operations.
+ *
+ * Custom sounds are audio files uploaded or imported by users to extend the built-in sound library.
+ * This DAO provides type-safe database queries for all custom sound CRUD (Create, Read, Update, Delete) operations.
+ *
+ * @see CustomSoundEntity for the data model
+ * @see BlankeeDatabase for the database definition
+ */
+@Dao
+interface CustomSoundDao {
+ @Insert
+ suspend fun insertCustomSound(sound: CustomSoundEntity)
+
+ @Delete
+ suspend fun deleteCustomSound(sound: CustomSoundEntity)
+
+ @Query("SELECT * FROM custom_sounds ORDER BY createdAt ASC")
+ fun getAllCustomSounds(): Flow>
+
+ @Query("SELECT * FROM custom_sounds WHERE id = :id")
+ suspend fun getCustomSoundById(id: Int): CustomSoundEntity?
+
+ @Query("DELETE FROM custom_sounds WHERE id = :id")
+ suspend fun deleteCustomSoundById(id: Int)
+
+ @Query("UPDATE custom_sounds SET displayName = :displayName WHERE id = :id")
+ suspend fun updateCustomSoundDisplayName(
+ id: Int,
+ displayName: String,
+ )
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/database/dao/PresetDao.kt b/app/src/main/java/com/pronaycoding/blankee/core/database/dao/PresetDao.kt
new file mode 100644
index 0000000..934145a
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/database/dao/PresetDao.kt
@@ -0,0 +1,33 @@
+package com.pronaycoding.blankee.core.database.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Update
+import com.pronaycoding.blankee.core.database.entities.PresetEntity
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Data Access Object (DAO) for managing [PresetEntity] database operations.
+ *
+ * A preset is a saved combination of sounds with their respective volumes, allowing users to save
+ * and restore their favorite audio mixes. This DAO provides type-safe database queries for all
+ * preset CRUD (Create, Read, Update, Delete) operations.
+ *
+ * @see PresetEntity for the data model
+ * @see BlankeeDatabase for the database definition
+ */
+@Dao
+interface PresetDao {
+ @Insert
+ suspend fun insertPreset(preset: PresetEntity): Long
+
+ @Query("SELECT * FROM presets ORDER BY createdAt ASC")
+ fun observeAll(): Flow>
+
+ @Query("DELETE FROM presets WHERE id = :id")
+ suspend fun deleteById(id: Long)
+
+ @Update
+ suspend fun updatePreset(preset: PresetEntity)
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/database/entities/CustomSoundEntity.kt b/app/src/main/java/com/pronaycoding/blankee/core/database/entities/CustomSoundEntity.kt
new file mode 100644
index 0000000..d64c520
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/database/entities/CustomSoundEntity.kt
@@ -0,0 +1,28 @@
+package com.pronaycoding.blankee.core.database.entities
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * Represents a custom sound file uploaded by the user in the database.
+ *
+ * Custom sounds extend the built-in sound library, allowing users to import their own audio files
+ * (e.g., personal recordings, nature sounds) and use them in sound presets. The audio file itself
+ * is stored on the file system, while this entity stores metadata and the file path reference.
+ *
+ * @property id Unique identifier for the custom sound (auto-generated by the database)
+ * @property displayName User-friendly name for the custom sound as it appears in the UI
+ * @property filePath Full path or URI to the audio file on the device (e.g., "/data/user/0/.../sound.mp3")
+ * @property createdAt Timestamp (in milliseconds) when the custom sound was added, defaults to current system time
+ *
+ * @see CustomSoundDao for database access operations
+ * @see BlankeeDatabase for the database definition
+ */
+@Entity(tableName = "custom_sounds")
+data class CustomSoundEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: Int = 0,
+ val displayName: String,
+ val filePath: String,
+ val createdAt: Long = System.currentTimeMillis(),
+)
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/database/entities/PresetEntity.kt b/app/src/main/java/com/pronaycoding/blankee/core/database/entities/PresetEntity.kt
new file mode 100644
index 0000000..a643584
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/database/entities/PresetEntity.kt
@@ -0,0 +1,30 @@
+package com.pronaycoding.blankee.core.database.entities
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * Represents a saved sound preset in the database.
+ *
+ * A preset is a combination of built-in sounds and/or custom sounds, each with specific volume levels,
+ * that users can save and restore for quick playback. For example, a user might create a "Relaxing Evening"
+ * preset with rain at 60% volume and birds at 40% volume.
+ *
+ * @property id Unique identifier for the preset (auto-generated by the database)
+ * @property name User-friendly name for the preset (e.g., "Relaxing Evening")
+ * @property builtInVolumesJson JSON string mapping built-in sound indices to their volume levels (0.0 - 1.0)
+ * @property customVolumesJson JSON string mapping custom sound IDs to their volume levels (0.0 - 1.0)
+ * @property createdAt Timestamp (in milliseconds) when the preset was created, defaults to current system time
+ *
+ * @see PresetDao for database access operations
+ * @see BlankeeDatabase for the database definition
+ */
+@Entity(tableName = "presets")
+data class PresetEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: Long = 0,
+ val name: String,
+ val builtInVolumesJson: String,
+ val customVolumesJson: String,
+ val createdAt: Long = System.currentTimeMillis(),
+)
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/datastore/PreferenceManagerRepository.kt b/app/src/main/java/com/pronaycoding/blankee/core/datastore/PreferenceManagerRepository.kt
new file mode 100644
index 0000000..6f33f04
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/datastore/PreferenceManagerRepository.kt
@@ -0,0 +1,121 @@
+package com.pronaycoding.blankee.core.datastore
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Repository interface for managing app preferences and settings.
+ *
+ * This interface provides access to user preferences stored in DataStore, including:
+ * - Theme mode (light/dark/system)
+ * - Language/locale settings
+ * - App usage tracking (launch count)
+ * - UI prompt display state
+ * - Premium/billing status
+ *
+ * The implementation uses Android DataStore as the backing storage for type-safe, encrypted preference management.
+ *
+ * @see PreferenceManagerRepositoryImpl for the concrete implementation
+ */
+interface PreferenceManagerRepository {
+ /**
+ * Retrieves the current theme mode synchronously (blocking operation).
+ *
+ * Should only be used when synchronous access is necessary, such as in Activity.onCreate().
+ * Prefer [PreferenceManagerRepositoryImpl.premiumUnlockedFlow] for reactive theme changes.
+ *
+ * @param defaultValue The value to return if theme mode has not been set
+ * @return The stored theme mode (e.g., "light", "dark", "system")
+ */
+ fun getThemeModeBlocking(defaultValue: String): String
+
+ /**
+ * Sets the theme mode preference.
+ *
+ * @param mode The theme mode to set (e.g., "light", "dark", "system")
+ */
+ suspend fun setThemeMode(mode: String)
+
+ /**
+ * Retrieves the current language tag synchronously (blocking operation).
+ *
+ * The stored value is a BCP 47 language tag (e.g., "en", "hi", "es").
+ * Special value: use Constants.LANGUAGE_TAG_SYSTEM for system language.
+ *
+ * Should only be used when synchronous access is necessary, such as in Activity.attachBaseContext().
+ *
+ * @param defaultValue The value to return if language tag has not been set
+ * @return The stored BCP 47 language tag
+ */
+ fun getLanguageTagBlocking(defaultValue: String): String
+
+ /**
+ * Sets the language tag preference.
+ *
+ * @param tag The BCP 47 language tag to set
+ */
+ suspend fun setLanguageTag(tag: String)
+
+ /**
+ * Increments and returns the app launch count.
+ *
+ * This counter tracks how many times the app has been launched, useful for
+ * showing prompts or analytics after N launches.
+ *
+ * @return The new launch count after incrementing
+ */
+ suspend fun incrementLaunchCount(): Int
+
+ /**
+ * Retrieves the current app launch count.
+ *
+ * @return The number of times the app has been launched
+ */
+ suspend fun getLaunchCount(): Int
+
+ /**
+ * Checks if the "Enjoy Blankee" prompt has been shown to the user.
+ *
+ * @return true if the prompt has been shown, false otherwise
+ */
+ suspend fun isEnjoyPromptShown(): Boolean
+
+ /**
+ * Sets the state of whether the "Enjoy Blankee" prompt has been shown.
+ *
+ * @param shown true to mark the prompt as shown
+ */
+ suspend fun setEnjoyPromptShown(shown: Boolean)
+
+ /**
+ * Checks if the notification permission primer has been shown to the user.
+ *
+ * @return true if the primer has been shown, false otherwise
+ */
+ suspend fun isNotificationPrimerShown(): Boolean
+
+ /**
+ * Sets the state of whether the notification permission primer has been shown.
+ *
+ * @param shown true to mark the primer as shown
+ */
+ suspend fun setNotificationPrimerShown(shown: Boolean)
+
+ /**
+ * Observes the premium unlock status as a reactive Flow.
+ *
+ * Emits true if the user has an active premium/paid subscription, false otherwise.
+ * This flow is reactive and emits new values when the premium status changes (e.g., after purchase or restore).
+ *
+ * @return A Flow emitting the premium unlock status
+ */
+ fun premiumUnlockedFlow(): Flow
+
+ /**
+ * Sets the premium unlock status.
+ *
+ * Typically called after a successful purchase or restoration from Play Billing.
+ *
+ * @param unlocked true if premium is unlocked, false otherwise
+ */
+ suspend fun setPremiumUnlocked(unlocked: Boolean)
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/datastore/PreferenceManagerRepositoryImpl.kt b/app/src/main/java/com/pronaycoding/blankee/core/datastore/PreferenceManagerRepositoryImpl.kt
new file mode 100644
index 0000000..a2de3bf
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/datastore/PreferenceManagerRepositoryImpl.kt
@@ -0,0 +1,220 @@
+package com.pronaycoding.blankee.core.datastore
+
+import android.content.Context
+import android.content.res.Configuration
+import android.os.Build
+import android.os.LocaleList
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.pronaycoding.blankee.core.common.Constants.LANGUAGE_TAG_SYSTEM
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.runBlocking
+import java.util.Locale
+
+/**
+ * Implementation of [PreferenceManagerRepository] using Android DataStore.
+ *
+ * DataStore is a modern replacement for SharedPreferences, providing:
+ * - Type-safe access to preferences
+ * - Encryption of sensitive data
+ * - Coroutine-based async API
+ * - Reactive Flows for preference changes
+ *
+ * This implementation stores preferences in a datastore file named "blankee_preferences".
+ * All preference keys and their types are defined in the companion object's Keys class.
+ *
+ * Thread-safety: All methods are thread-safe and can be called from any thread.
+ *
+ * @param context The application context used to access DataStore
+ *
+ * @see PreferenceManagerRepository for the interface contract
+ */
+class PreferenceManagerRepositoryImpl(
+ private val context: Context,
+) : PreferenceManagerRepository {
+ private val appContext = context.applicationContext
+
+ /**
+ * Retrieves the stored theme mode using synchronous blocking.
+ *
+ * Uses runBlocking internally - should only be called from contexts where blocking is acceptable.
+ *
+ * @param defaultValue Default value if not set
+ * @return The stored theme mode or defaultValue
+ */
+ override fun getThemeModeBlocking(defaultValue: String): String =
+ runBlocking {
+ appContext.dataStore.data.first()[Keys.themeMode] ?: defaultValue
+ }
+
+ /**
+ * Stores the theme mode preference.
+ *
+ * @param mode Theme mode to store
+ */
+ override suspend fun setThemeMode(mode: String) {
+ appContext.dataStore.edit { prefs ->
+ prefs[Keys.themeMode] = mode
+ }
+ }
+
+ /**
+ * Retrieves the stored language tag using synchronous blocking.
+ *
+ * The language tag is a BCP 47 locale identifier (e.g., "en", "hi", "es-MX").
+ * Uses runBlocking internally - should only be called from contexts where blocking is acceptable.
+ *
+ * @param defaultValue Default value if not set
+ * @return The stored language tag or defaultValue
+ */
+ override fun getLanguageTagBlocking(defaultValue: String): String =
+ runBlocking {
+ appContext.dataStore.data.first()[Keys.languageTag] ?: defaultValue
+ }
+
+ /**
+ * Stores the language tag preference.
+ *
+ * @param tag BCP 47 language tag to store
+ */
+ override suspend fun setLanguageTag(tag: String) {
+ appContext.dataStore.edit { prefs ->
+ prefs[Keys.languageTag] = tag
+ }
+ }
+
+ /**
+ * Atomically increments the launch count by 1 and returns the new value.
+ *
+ * @return The new launch count after incrementing
+ */
+ override suspend fun incrementLaunchCount(): Int {
+ var next = 1
+ appContext.dataStore.edit { prefs ->
+ next = (prefs[Keys.launchCount] ?: 0) + 1
+ prefs[Keys.launchCount] = next
+ }
+ return next
+ }
+
+ /**
+ * Retrieves the current launch count.
+ *
+ * @return The launch count, or 0 if not yet set
+ */
+ override suspend fun getLaunchCount(): Int = appContext.dataStore.data.first()[Keys.launchCount] ?: 0
+
+ /**
+ * Checks if the "Enjoy Blankee" prompt has been displayed.
+ *
+ * @return true if shown, false otherwise
+ */
+ override suspend fun isEnjoyPromptShown(): Boolean = appContext.dataStore.data.first()[Keys.enjoyPromptShown] ?: false
+
+ /**
+ * Marks the "Enjoy Blankee" prompt as shown/hidden.
+ *
+ * @param shown true to mark as shown
+ */
+ override suspend fun setEnjoyPromptShown(shown: Boolean) {
+ appContext.dataStore.edit { prefs ->
+ prefs[Keys.enjoyPromptShown] = shown
+ }
+ }
+
+ /**
+ * Checks if the notification permission primer has been displayed.
+ *
+ * @return true if shown, false otherwise
+ */
+ override suspend fun isNotificationPrimerShown(): Boolean = appContext.dataStore.data.first()[Keys.notificationPrimerShown] ?: false
+
+ /**
+ * Marks the notification permission primer as shown/hidden.
+ *
+ * @param shown true to mark as shown
+ */
+ override suspend fun setNotificationPrimerShown(shown: Boolean) {
+ appContext.dataStore.edit { prefs ->
+ prefs[Keys.notificationPrimerShown] = shown
+ }
+ }
+
+ /**
+ * Observes the premium unlock status as a reactive Flow.
+ *
+ * @return Flow emitting premium status updates
+ */
+ override fun premiumUnlockedFlow(): Flow =
+ appContext.dataStore.data.map { prefs ->
+ prefs[Keys.premiumUnlocked] ?: false
+ }
+
+ /**
+ * Sets the premium unlock status.
+ *
+ * @param unlocked true if premium is unlocked
+ */
+ override suspend fun setPremiumUnlocked(unlocked: Boolean) {
+ appContext.dataStore.edit { prefs ->
+ prefs[Keys.premiumUnlocked] = unlocked
+ }
+ }
+
+ companion object {
+ /**
+ * Wraps a Context to apply the stored language/locale settings.
+ *
+ * This method creates a new Context with Configuration modified to use the stored language tag.
+ * Intended for use in Activity.attachBaseContext() to ensure all resources resolve with the correct locale.
+ *
+ * Example usage:
+ * ```kotlin
+ * override fun attachBaseContext(newBase: Context) {
+ * super.attachBaseContext(PreferenceManagerRepositoryImpl.wrapContextWithStoredLanguage(newBase))
+ * }
+ * ```
+ *
+ * @param base The base Context to wrap
+ * @return A new Context configured with the stored language, or the original Context if system language is selected
+ */
+ fun wrapContextWithStoredLanguage(base: Context): Context {
+ val tag = PreferenceManagerRepositoryImpl(base).getLanguageTagBlocking(LANGUAGE_TAG_SYSTEM)
+ if (tag == LANGUAGE_TAG_SYSTEM) return base
+
+ val locale = Locale.forLanguageTag(tag)
+ Locale.setDefault(locale)
+ val config = Configuration(base.resources.configuration)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ config.setLocales(LocaleList(locale))
+ } else {
+ @Suppress("DEPRECATION")
+ config.locale = locale
+ }
+ return base.createConfigurationContext(config)
+ }
+ }
+
+ /**
+ * Private object containing all DataStore preference keys.
+ *
+ * Each key is typed and corresponds to a specific preference value.
+ */
+ private object Keys {
+ val themeMode: Preferences.Key = stringPreferencesKey("app_theme_mode")
+ val languageTag: Preferences.Key = stringPreferencesKey("app_language_tag")
+ val launchCount: Preferences.Key = intPreferencesKey("launch_count")
+ val enjoyPromptShown: Preferences.Key = booleanPreferencesKey("enjoy_prompt_shown")
+ val notificationPrimerShown: Preferences.Key =
+ booleanPreferencesKey("notification_primer_shown")
+ val premiumUnlocked: Preferences.Key = booleanPreferencesKey("premium_unlocked")
+ }
+}
+
+private val Context.dataStore by preferencesDataStore(name = "blankee_preferences")
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/model/CardItems.kt b/app/src/main/java/com/pronaycoding/blankee/core/model/CardItems.kt
new file mode 100644
index 0000000..df766f0
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/model/CardItems.kt
@@ -0,0 +1,260 @@
+package com.pronaycoding.blankee.core.model
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.pronaycoding.blankee.R
+
+/**
+ * Sealed class representing available sounds in the Blankee app.
+ *
+ * This sealed class defines all built-in ambient sounds and provides a framework for custom user-uploaded sounds.
+ * Sounds are organized into categories (Nature, Travel, Interiors, Noise, Custom) and each includes:
+ * - A localized title (from string resources)
+ * - An icon for UI display
+ * - An audio resource file (for built-in sounds)
+ * - Category information for UI grouping
+ *
+ * Built-in sounds are defined as data objects (singletons), while custom user sounds are represented
+ * as data class instances of [CustomCardItem].
+ *
+ * @property titleResId String resource ID for the sound's localized name
+ * @property icon Drawable resource ID for the sound's icon
+ * @property audioSource Raw audio resource ID (only for built-in sounds)
+ * @property type Category name (e.g., "Nature", "Travel", "Custom") for grouping
+ * @property firstInType Boolean indicating if this is the first sound in its category (used for UI section headers)
+ * @property customSoundId Unique ID for custom sounds (null for built-in sounds)
+ * @property filePath File path or URI to audio file (only for custom sounds)
+ *
+ * @see CustomCardItem for custom user-uploaded sound representation
+ * @see SoundManager for audio playback
+ */
+sealed class CardItems(
+ @StringRes val titleResId: Int,
+ var icon: Int,
+ val audioSource: Int,
+ val type: String = "",
+ val firstInType: Boolean = false,
+ val customSoundId: Int? = null,
+ val filePath: String? = null,
+) {
+ /**
+ * Returns the localized title string for this sound.
+ *
+ * For custom sounds, returns the user-provided display name.
+ * For built-in sounds, loads the localized string from resources.
+ *
+ * @return The localized title string
+ */
+ @Composable
+ fun localizedTitle(): String =
+ when (this) {
+ is CustomCardItem -> displayName
+ else -> stringResource(titleResId)
+ }
+
+ /**
+ * Rain sound - Nature category.
+ * Gentle rainfall sounds for a calm, natural atmosphere.
+ */
+ data object Rain : CardItems(
+ titleResId = R.string.sound_rain,
+ icon = R.drawable.rain,
+ audioSource = R.raw.nature_rain,
+ type = "Nature",
+ firstInType = true,
+ )
+
+ /**
+ * Summer Night sound - Nature category.
+ * Evening outdoor ambience with summer insects and breeze.
+ */
+ data object SummerNight : CardItems(
+ titleResId = R.string.sound_summer_night,
+ icon = R.drawable.moon,
+ audioSource = R.raw.nature_summernight,
+ type = "Nature",
+ )
+
+ /**
+ * Wind sound - Nature category.
+ * Gentle wind and rustling leaves for outdoor ambience.
+ */
+ data object Wind : CardItems(
+ titleResId = R.string.sound_wind,
+ icon = R.drawable.wind,
+ audioSource = R.raw.nature_wind,
+ type = "Nature",
+ )
+
+ /**
+ * Ocean Wave sound - Nature category.
+ * Soothing waves crashing on shore.
+ */
+ data object Wave : CardItems(
+ titleResId = R.string.sound_wave,
+ icon = R.drawable.wave,
+ audioSource = R.raw.nature_waves,
+ type = "Nature",
+ )
+
+ /**
+ * Stream sound - Nature category.
+ * Gentle flowing water and babbling brook.
+ */
+ data object Stream : CardItems(
+ titleResId = R.string.sound_stream,
+ icon = R.drawable.stream,
+ audioSource = R.raw.nature_stream,
+ type = "Nature",
+ )
+
+ /**
+ * Thunder Storm sound - Nature category.
+ * Thunderstorm with rain and distant lightning.
+ */
+ data object Storm : CardItems(
+ titleResId = R.string.sound_storm,
+ icon = R.drawable.storm,
+ audioSource = R.raw.nature_storm,
+ type = "Nature",
+ )
+
+ /**
+ * Bird Chirping sound - Nature category.
+ * Morning birds and forest ambience.
+ */
+ data object Birds : CardItems(
+ titleResId = R.string.sound_birds,
+ icon = R.drawable.birds,
+ audioSource = R.raw.nature_birds,
+ type = "Nature",
+ )
+
+ /**
+ * Train sound - Travel category.
+ * Moving train with rhythmic wheel sounds.
+ */
+ data object Train : CardItems(
+ titleResId = R.string.sound_train,
+ icon = R.drawable.train,
+ audioSource = R.raw.travel_train,
+ type = "Travel",
+ firstInType = true,
+ )
+
+ /**
+ * Boat/Sailing sound - Travel category.
+ * Boat engine and water ambience.
+ */
+ data object Boat : CardItems(
+ titleResId = R.string.sound_boat,
+ icon = R.drawable.sailboat,
+ audioSource = R.raw.travel_boat,
+ type = "Travel",
+ )
+
+ /**
+ * City Ambience sound - Travel category.
+ * Urban background with traffic and city sounds.
+ */
+ data object City : CardItems(
+ titleResId = R.string.sound_city,
+ icon = R.drawable.city,
+ audioSource = R.raw.travel_city,
+ type = "Travel",
+ )
+
+ /**
+ * Coffee Shop sound - Interiors category.
+ * Cozy coffee shop ambience with background chatter.
+ */
+ data object CoffeeShop : CardItems(
+ titleResId = R.string.sound_coffee_shop,
+ icon = R.drawable.coffee,
+ audioSource = R.raw.indoor_interior_coffeeshop,
+ type = "Interiors",
+ firstInType = true,
+ )
+
+ /**
+ * Fireplace sound - Interiors category.
+ * Crackling fireplace for warmth and comfort.
+ */
+ data object FirePlace : CardItems(
+ titleResId = R.string.sound_fireplace,
+ icon = R.drawable.fireplace,
+ audioSource = R.raw.indoor_interior_fireplace,
+ type = "Interiors",
+ )
+
+ /**
+ * Busy Restaurant sound - Interiors category.
+ * Lively restaurant ambience with background noise.
+ */
+ data object BusyRestaurant : CardItems(
+ titleResId = R.string.sound_busy_restaurant,
+ icon = R.drawable.food_delivery,
+ audioSource = R.raw.indoor_busy_restaurant,
+ type = "Interiors",
+ )
+
+ /**
+ * Pink Noise sound - Noise category.
+ * Pink noise (deeper than white noise) for masking and focus.
+ */
+ data object PinkNoise : CardItems(
+ titleResId = R.string.sound_pink_noise,
+ icon = R.drawable.pink_noise,
+ audioSource = R.raw.noise_pink_noise,
+ type = "Noise",
+ firstInType = true,
+ )
+
+ /**
+ * White Noise sound - Noise category.
+ * Classic white noise for sleep and masking ambient sounds.
+ */
+ data object WhiteNoise : CardItems(
+ titleResId = R.string.sound_white_noise,
+ icon = R.drawable.white_noise,
+ audioSource = R.raw.noise_white_noise,
+ type = "Noise",
+ )
+
+ /**
+ * Custom sounds category marker.
+ * Not a real sound, used as a placeholder for the Custom category.
+ */
+ data object Custom : CardItems(
+ titleResId = R.string.empty_string,
+ icon = R.drawable.city,
+ audioSource = R.raw.noise_white_noise,
+ type = "Custom",
+ )
+
+ /**
+ * Represents a custom user-uploaded sound file.
+ *
+ * Custom sounds are audio files imported by users, extending the built-in sound library.
+ * Each custom sound is tracked by a unique ID and its file path for persistent playback.
+ *
+ * @property id Unique database ID for this custom sound
+ * @property displayName User-provided name for the custom sound
+ * @property soundFilePath File path or content URI to the audio file
+ *
+ * @see CustomSoundRepository for custom sound management
+ */
+ data class CustomCardItem(
+ val id: Int,
+ val displayName: String,
+ val soundFilePath: String,
+ ) : CardItems(
+ titleResId = R.string.empty_string,
+ icon = R.drawable.white_noise,
+ audioSource = R.raw.noise_white_noise,
+ type = "Custom",
+ customSoundId = id,
+ filePath = soundFilePath,
+ )
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/service/billing/BillingConstants.kt b/app/src/main/java/com/pronaycoding/blankee/core/service/billing/BillingConstants.kt
new file mode 100644
index 0000000..cf00fdf
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/service/billing/BillingConstants.kt
@@ -0,0 +1,21 @@
+package com.pronaycoding.blankee.core.service.billing
+
+/**
+ * In-app product ID for the Blankee Premium subscription/product.
+ *
+ * This managed product ID is used to identify the premium offering in Google Play Billing.
+ * Premium unlocks features including:
+ * - Custom sound uploads
+ * - Additional preset slots
+ * - Ad-free experience (if applicable)
+ *
+ * **Configuration**: Create a **managed product** (one-time purchase) in Play Console with this exact ID:
+ * - Path: Monetize → In-app Products → Create Product
+ * - Product ID: "blankee_premium"
+ * - Product Type: Managed product (purchased once, owned forever)
+ *
+ * @see PlayBillingManager for billing implementation
+ * @see HomeViewmodel for checking premium status
+ * @see SettingsViewModel for premium purchase UI
+ */
+const val PREMIUM_INAPP_PRODUCT_ID: String = "blankee_premium"
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/service/billing/PlayBillingManager.kt b/app/src/main/java/com/pronaycoding/blankee/core/service/billing/PlayBillingManager.kt
new file mode 100644
index 0000000..84efb22
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/service/billing/PlayBillingManager.kt
@@ -0,0 +1,195 @@
+package com.pronaycoding.blankee.core.service.billing
+
+import android.app.Activity
+import android.content.Context
+import android.util.Log
+import android.widget.Toast
+import com.android.billingclient.api.AcknowledgePurchaseParams
+import com.android.billingclient.api.BillingClient
+import com.android.billingclient.api.BillingClientStateListener
+import com.android.billingclient.api.BillingFlowParams
+import com.android.billingclient.api.BillingResult
+import com.android.billingclient.api.PendingPurchasesParams
+import com.android.billingclient.api.ProductDetails
+import com.android.billingclient.api.Purchase
+import com.android.billingclient.api.PurchasesUpdatedListener
+import com.android.billingclient.api.QueryProductDetailsParams
+import com.android.billingclient.api.QueryPurchasesParams
+import com.pronaycoding.blankee.R
+import com.pronaycoding.blankee.core.datastore.PreferenceManagerRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+
+class PlayBillingManager(
+ private val appContext: Context,
+ private val prefs: PreferenceManagerRepository,
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+
+ private lateinit var billingClient: BillingClient
+
+ @Volatile
+ private var productDetails: ProductDetails? = null
+
+ private val purchasesUpdatedListener =
+ PurchasesUpdatedListener { billingResult, purchases ->
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
+ purchases.forEach { handlePurchase(it) }
+ } else if (billingResult.responseCode != BillingClient.BillingResponseCode.USER_CANCELED) {
+ Log.w(TAG, "Purchase update: ${billingResult.debugMessage}")
+ }
+ }
+
+ fun start() {
+ billingClient =
+ BillingClient
+ .newBuilder(appContext)
+ .setListener(purchasesUpdatedListener)
+ .enablePendingPurchases(
+ PendingPurchasesParams
+ .newBuilder()
+ .enableOneTimeProducts()
+ .build(),
+ ).build()
+
+ billingClient.startConnection(
+ object : BillingClientStateListener {
+ override fun onBillingSetupFinished(billingResult: BillingResult) {
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ scope.launch {
+ prefetchPremiumProduct()
+ syncPremiumFromPlay()
+ }
+ } else {
+ Log.w(TAG, "Billing setup failed: ${billingResult.debugMessage}")
+ }
+ }
+
+ override fun onBillingServiceDisconnected() {
+ Log.w(TAG, "Billing service disconnected")
+ }
+ },
+ )
+ }
+
+ suspend fun syncPremiumFromPlay(): Boolean {
+ if (!billingClient.isReady) return false
+ val purchases = queryInAppPurchases()
+ val hasPremium = purchases.any { isPremiumPurchase(it) }
+ prefs.setPremiumUnlocked(hasPremium)
+ purchases.filter { isPremiumPurchase(it) && !it.isAcknowledged }.forEach { acknowledge(it) }
+ return hasPremium
+ }
+
+ fun launchPremiumPurchase(activity: Activity) {
+ if (!billingClient.isReady) {
+ Toast.makeText(activity, appContext.getString(R.string.billing_not_ready), Toast.LENGTH_SHORT).show()
+ return
+ }
+ val details = productDetails
+ if (details == null) {
+ Toast.makeText(activity, appContext.getString(R.string.billing_product_not_loaded), Toast.LENGTH_SHORT).show()
+ scope.launch { prefetchPremiumProduct() }
+ return
+ }
+ val productDetailsParamsList =
+ listOf(
+ BillingFlowParams.ProductDetailsParams
+ .newBuilder()
+ .setProductDetails(details)
+ .build(),
+ )
+ val billingFlowParams =
+ BillingFlowParams
+ .newBuilder()
+ .setProductDetailsParamsList(productDetailsParamsList)
+ .build()
+ val result = billingClient.launchBillingFlow(activity, billingFlowParams)
+ if (result.responseCode != BillingClient.BillingResponseCode.OK) {
+ Toast.makeText(activity, appContext.getString(R.string.billing_launch_failed), Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private suspend fun prefetchPremiumProduct() {
+ if (!billingClient.isReady) return
+ val product =
+ QueryProductDetailsParams.Product
+ .newBuilder()
+ .setProductId(PREMIUM_INAPP_PRODUCT_ID)
+ .setProductType(BillingClient.ProductType.INAPP)
+ .build()
+ val params =
+ QueryProductDetailsParams
+ .newBuilder()
+ .setProductList(listOf(product))
+ .build()
+ val detailsList = queryProductDetails(params)
+ productDetails = detailsList.firstOrNull()
+ }
+
+ private suspend fun queryProductDetails(params: QueryProductDetailsParams): List =
+ suspendCancellableCoroutine { cont ->
+ billingClient.queryProductDetailsAsync(params) { billingResult, detailsResult ->
+ if (!cont.isActive) return@queryProductDetailsAsync
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ cont.resume(detailsResult.productDetailsList)
+ } else {
+ Log.w(TAG, "queryProductDetails: ${billingResult.debugMessage}")
+ cont.resume(emptyList())
+ }
+ }
+ }
+
+ private suspend fun queryInAppPurchases(): List =
+ suspendCancellableCoroutine { cont ->
+ billingClient.queryPurchasesAsync(
+ QueryPurchasesParams
+ .newBuilder()
+ .setProductType(BillingClient.ProductType.INAPP)
+ .build(),
+ ) { billingResult, purchases ->
+ if (!cont.isActive) return@queryPurchasesAsync
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ cont.resume(purchases ?: emptyList())
+ } else {
+ cont.resume(emptyList())
+ }
+ }
+ }
+
+ private fun isPremiumPurchase(purchase: Purchase): Boolean =
+ purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
+ purchase.products.any { it == PREMIUM_INAPP_PRODUCT_ID }
+
+ private fun handlePurchase(purchase: Purchase) {
+ if (!isPremiumPurchase(purchase)) return
+ if (purchase.isAcknowledged) {
+ scope.launch { prefs.setPremiumUnlocked(true) }
+ } else {
+ acknowledge(purchase)
+ }
+ }
+
+ private fun acknowledge(purchase: Purchase) {
+ val params =
+ AcknowledgePurchaseParams
+ .newBuilder()
+ .setPurchaseToken(purchase.purchaseToken)
+ .build()
+ billingClient.acknowledgePurchase(params) { result ->
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
+ scope.launch { prefs.setPremiumUnlocked(true) }
+ } else {
+ Log.w(TAG, "Acknowledge failed: ${result.debugMessage}")
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "PlayBilling"
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/service/playback/BlankeeMediaPlaybackService.kt b/app/src/main/java/com/pronaycoding/blankee/core/service/playback/BlankeeMediaPlaybackService.kt
new file mode 100644
index 0000000..0e12205
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/service/playback/BlankeeMediaPlaybackService.kt
@@ -0,0 +1,215 @@
+package com.pronaycoding.blankee.core.service.playback
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import androidx.core.app.NotificationCompat
+import androidx.media.app.NotificationCompat.MediaStyle
+import com.pronaycoding.blankee.MainActivity
+import com.pronaycoding.blankee.R
+import org.koin.android.ext.android.inject
+
+class BlankeeMediaPlaybackService : Service() {
+ private val globalPlaybackState: GlobalPlaybackState by inject()
+
+ private lateinit var mediaSession: MediaSessionCompat
+
+ override fun onCreate() {
+ super.onCreate()
+ createChannel()
+ mediaSession =
+ MediaSessionCompat(this, "BlankeePlayback").apply {
+ setCallback(
+ object : MediaSessionCompat.Callback() {
+ override fun onPlay() {
+ globalPlaybackState.setCanPlay(true)
+ refreshFromGlobals()
+ }
+
+ override fun onPause() {
+ globalPlaybackState.setCanPlay(false)
+ refreshFromGlobals()
+ }
+ },
+ )
+ isActive = true
+ }
+ }
+
+ private fun refreshFromGlobals() {
+ if (!globalPlaybackState.lastHasAudibleMix) return
+ startForeground(
+ NOTIFICATION_ID,
+ buildNotification(globalPlaybackState.canPlay.value, true),
+ )
+ }
+
+ override fun onStartCommand(
+ intent: Intent?,
+ flags: Int,
+ startId: Int,
+ ): Int {
+ when (intent?.action) {
+ ACTION_UPDATE -> {
+ val canPlay = intent.getBooleanExtra(EXTRA_CAN_PLAY, true)
+ val hasMix = intent.getBooleanExtra(EXTRA_HAS_MIX, false)
+ if (!hasMix) {
+ // Defensive fallback: if this instance was started via startForegroundService,
+ // Android expects startForeground() before we stop.
+ startForeground(NOTIFICATION_ID, buildNotification(canPlay = false, hasMix = false))
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } else {
+ @Suppress("DEPRECATION")
+ stopForeground(true)
+ }
+ stopSelf(startId)
+ return START_NOT_STICKY
+ }
+ startForeground(NOTIFICATION_ID, buildNotification(canPlay, hasMix))
+ }
+ }
+ return START_STICKY
+ }
+
+ private fun buildNotification(
+ canPlay: Boolean,
+ hasMix: Boolean,
+ ): Notification {
+ val openApp =
+ PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ },
+ pendingIntentFlags(),
+ )
+
+ val toggle =
+ PendingIntent.getBroadcast(
+ this,
+ 1,
+ Intent(this, MediaPlaybackActionReceiver::class.java).apply {
+ action = MediaPlaybackActionReceiver.ACTION_TOGGLE_PLAYBACK
+ },
+ pendingIntentFlags(),
+ )
+
+ val playPauseAction =
+ if (canPlay) {
+ NotificationCompat.Action
+ .Builder(
+ android.R.drawable.ic_media_pause,
+ getString(R.string.notification_action_pause),
+ toggle,
+ ).build()
+ } else {
+ NotificationCompat.Action
+ .Builder(
+ android.R.drawable.ic_media_play,
+ getString(R.string.notification_action_play),
+ toggle,
+ ).build()
+ }
+
+ val state =
+ if (canPlay) {
+ PlaybackStateCompat.STATE_PLAYING
+ } else {
+ PlaybackStateCompat.STATE_PAUSED
+ }
+ mediaSession.setPlaybackState(
+ PlaybackStateCompat
+ .Builder()
+ .setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
+ .setActions(
+ PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE,
+ ).build(),
+ )
+
+ val text =
+ if (canPlay) {
+ getString(R.string.notification_playing)
+ } else {
+ getString(R.string.notification_paused)
+ }
+
+ return NotificationCompat
+ .Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(text)
+ .setSmallIcon(R.drawable.ic_stat_blankee)
+ .setContentIntent(openApp)
+ .setOngoing(hasMix && canPlay)
+ .setOnlyAlertOnce(true)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .addAction(playPauseAction)
+ .setStyle(
+ MediaStyle()
+ .setMediaSession(mediaSession.sessionToken)
+ .setShowActionsInCompactView(0),
+ ).build()
+ }
+
+ private fun pendingIntentFlags(): Int {
+ var flags = PendingIntent.FLAG_UPDATE_CURRENT
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ flags = flags or PendingIntent.FLAG_IMMUTABLE
+ }
+ return flags
+ }
+
+ private fun createChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val mgr = getSystemService(NotificationManager::class.java)
+ val channel =
+ NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = getString(R.string.notification_channel_desc)
+ setShowBadge(false)
+ }
+ mgr?.createNotificationChannel(channel)
+ }
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onDestroy() {
+ if (::mediaSession.isInitialized) {
+ mediaSession.isActive = false
+ mediaSession.release()
+ }
+ super.onDestroy()
+ }
+
+ companion object {
+ const val CHANNEL_ID = "blankee_playback"
+ const val NOTIFICATION_ID = 7101
+ private const val ACTION_UPDATE = "com.pronaycoding.blankee.action.UPDATE_PLAYBACK_NOTIFICATION"
+ const val EXTRA_CAN_PLAY = "extra_can_play"
+ const val EXTRA_HAS_MIX = "extra_has_mix"
+
+ fun intentUpdate(
+ context: Context,
+ canPlay: Boolean,
+ hasAudibleMix: Boolean,
+ ): Intent =
+ Intent(context, BlankeeMediaPlaybackService::class.java).apply {
+ action = ACTION_UPDATE
+ putExtra(EXTRA_CAN_PLAY, canPlay)
+ putExtra(EXTRA_HAS_MIX, hasAudibleMix)
+ }
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/service/playback/GlobalPlaybackState.kt b/app/src/main/java/com/pronaycoding/blankee/core/service/playback/GlobalPlaybackState.kt
new file mode 100644
index 0000000..74ebe50
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/service/playback/GlobalPlaybackState.kt
@@ -0,0 +1,69 @@
+package com.pronaycoding.blankee.core.service.playback
+
+import com.pronaycoding.blankee.feature.home.SoundManager
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Manages the global playback state of all sounds in the Blankee application.
+ *
+ * This class maintains a single source of truth for whether audio playback is currently enabled
+ * across the entire app. It provides functionality to:
+ * - Query the current playback state via [canPlay] StateFlow
+ * - Set playback state and coordinate pause/resume of all sounds
+ * - Toggle between play and pause states
+ * - Track whether the current audio mix is audible (used for notification management)
+ *
+ * The playback state is backed by a [StateFlow] for reactive updates throughout the app.
+ * When playback is disabled, all sounds are paused; when enabled, all previously playing sounds resume.
+ *
+ * @param soundManager Reference to [SoundManager] for coordinating pause/resume of all loaded sounds
+ *
+ * @see SoundManager for audio playback control
+ * @see HomeViewmodel for ViewModel-level playback management
+ */
+class GlobalPlaybackState(
+ private val soundManager: SoundManager,
+) {
+ private val _canPlay = MutableStateFlow(true)
+
+ /**
+ * StateFlow representing the current global playback state.
+ *
+ * - true: Audio playback is enabled
+ * - false: Audio playback is paused (all sounds are paused)
+ */
+ val canPlay: StateFlow = _canPlay.asStateFlow()
+
+ @Volatile
+ var lastHasAudibleMix: Boolean = false
+
+ /**
+ * Sets the global playback state and updates all sounds accordingly.
+ *
+ * When set to true, all previously playing sounds resume.
+ * When set to false, all currently playing sounds are paused and can be resumed later.
+ *
+ * @param play true to enable playback, false to pause all sounds
+ * @see resumeAllSounds
+ * @see pauseAllSounds
+ */
+ fun setCanPlay(play: Boolean) {
+ _canPlay.value = play
+ if (play) {
+ soundManager.resumeAllSounds()
+ } else {
+ soundManager.pauseAllSounds()
+ }
+ }
+
+ /**
+ * Toggles the global playback state between play and pause.
+ *
+ * This is a convenience method that inverts the current [canPlay] value and calls [setCanPlay].
+ */
+ fun togglePlayPause() {
+ setCanPlay(!_canPlay.value)
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/service/playback/MediaPlaybackActionReceiver.kt b/app/src/main/java/com/pronaycoding/blankee/core/service/playback/MediaPlaybackActionReceiver.kt
new file mode 100644
index 0000000..99a4e81
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/service/playback/MediaPlaybackActionReceiver.kt
@@ -0,0 +1,64 @@
+package com.pronaycoding.blankee.core.service.playback
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import org.koin.core.context.GlobalContext
+
+/**
+ * Broadcast receiver for handling playback control actions from the system notification.
+ *
+ * This receiver intercepts the [ACTION_TOGGLE_PLAYBACK] broadcast and:
+ * 1. Toggles the global playback state (play/pause)
+ * 2. Syncs the notification with the new state
+ *
+ * The receiver can be triggered by:
+ * - System playback notification buttons
+ * - Voice commands
+ * - External media button events (headphones, car systems, etc.)
+ *
+ * Registration: This receiver should be registered in the manifest or via dynamic registration.
+ *
+ * @see GlobalPlaybackState for playback state toggling
+ * @see MediaPlaybackNotifications for notification updates
+ * @see BlankeeMediaPlaybackService for service integration
+ */
+class MediaPlaybackActionReceiver : BroadcastReceiver() {
+ /**
+ * Handles broadcast intents for playback control actions.
+ *
+ * When [ACTION_TOGGLE_PLAYBACK] is received:
+ * 1. Retrieves [GlobalPlaybackState] and [MediaPlaybackNotifications] from Koin
+ * 2. Toggles the playback state
+ * 3. Syncs the notification with the updated state
+ *
+ * @param context The context in which the receiver is running
+ * @param intent The intent being received (should have action [ACTION_TOGGLE_PLAYBACK])
+ */
+ override fun onReceive(
+ context: Context,
+ intent: Intent?,
+ ) {
+ if (intent?.action != ACTION_TOGGLE_PLAYBACK) return
+ val koin = GlobalContext.get()
+ val global = koin.get()
+ val notifications = koin.get()
+ global.togglePlayPause()
+ notifications.requestSync(
+ global.canPlay.value,
+ global.lastHasAudibleMix,
+ )
+ }
+
+ companion object {
+ /**
+ * Broadcast action for toggling playback state.
+ *
+ * This action is typically sent by:
+ * - Notification play/pause button
+ * - System media controls
+ * - Headphone button events
+ */
+ const val ACTION_TOGGLE_PLAYBACK = "com.pronaycoding.blankee.action.TOGGLE_PLAYBACK"
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/service/playback/MediaPlaybackNotifications.kt b/app/src/main/java/com/pronaycoding/blankee/core/service/playback/MediaPlaybackNotifications.kt
new file mode 100644
index 0000000..014cac0
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/service/playback/MediaPlaybackNotifications.kt
@@ -0,0 +1,53 @@
+package com.pronaycoding.blankee.core.service.playback
+
+import android.content.Context
+import androidx.core.content.ContextCompat
+
+/**
+ * Manages system playback notifications and foreground service for audio playback.
+ *
+ * This class coordinates with [BlankeeMediaPlaybackService] to:
+ * - Show playback controls in the system notification
+ * - Run as a foreground service when audio is playing (required for continuous playback)
+ * - Update notification state based on playback and mix status
+ *
+ * Foreground service status:
+ * - **Starts foreground**: When there's an audible mix to display
+ * - **Starts regular service**: When there's no audible mix (silent preset)
+ *
+ * @param appContext Application context used to start the service
+ *
+ * @see BlankeeMediaPlaybackService for the actual service implementation
+ * @see GlobalPlaybackState for playback state management
+ */
+class MediaPlaybackNotifications(
+ private val appContext: Context,
+) {
+ /**
+ * Requests a notification sync with the current playback state.
+ *
+ * This method starts or updates [BlankeeMediaPlaybackService] with the current
+ * playback state. The service will:
+ * - Start as a foreground service if [hasAudibleMix] is true
+ * - Start as a regular service if [hasAudibleMix] is false
+ *
+ * @param canPlay Whether audio playback is currently enabled (play/pause state)
+ * @param hasAudibleMix Whether there are sounds with volume > 0 (audible preset)
+ *
+ * @see BlankeeMediaPlaybackService.intentUpdate for intent creation
+ * @see ContextCompat.startForegroundService for foreground service requirements
+ */
+ fun requestSync(
+ canPlay: Boolean,
+ hasAudibleMix: Boolean,
+ ) {
+ val intent = BlankeeMediaPlaybackService.intentUpdate(appContext, canPlay, hasAudibleMix)
+ if (hasAudibleMix) {
+ // Start as foreground service when there's something to play/show
+ ContextCompat.startForegroundService(appContext, intent)
+ } else {
+ // Do not start as foreground when there's nothing to show/play.
+ appContext.startService(intent)
+ }
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/ui/components/BlankeeTopAppBar.kt b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/BlankeeTopAppBar.kt
new file mode 100644
index 0000000..0b192c2
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/BlankeeTopAppBar.kt
@@ -0,0 +1,461 @@
+package com.pronaycoding.blankee.core.ui.components
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Timer
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.pronaycoding.blankee.R
+import com.pronaycoding.blankee.feature.home.HomeViewmodel
+import com.pronaycoding.blankee.feature.home.getCardList
+import org.koin.androidx.compose.koinViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BlankeeTopAppBar(
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+ navigateToSettings: () -> Unit,
+) {
+ val viewModel: HomeViewmodel = koinViewModel()
+ val context = LocalContext.current
+ val canPlay by viewModel.canPlay.collectAsStateWithLifecycle()
+ val presets by viewModel.presets.collectAsStateWithLifecycle()
+ val builtinVolumes by viewModel.builtinVolumes.collectAsStateWithLifecycle()
+ val customVolumes by viewModel.customVolumes.collectAsStateWithLifecycle()
+ val sleepTimerRemainingMillis by viewModel.sleepTimerRemainingMillis.collectAsStateWithLifecycle()
+
+ val canSavePreset =
+ remember(builtinVolumes, customVolumes) {
+ val until = getCardList().size - 1
+ builtinVolumes.any { (i, v) -> i in 0 until until && v > 0f } ||
+ customVolumes.any { (_, v) -> v > 0f }
+ }
+
+ var showDropdown by rememberSaveable { mutableStateOf(false) }
+ var showSavePresetDialog by rememberSaveable { mutableStateOf(false) }
+ var presetNameInput by rememberSaveable { mutableStateOf("") }
+ var showTimerDialog by rememberSaveable { mutableStateOf(false) }
+ var selectedTimerOption by rememberSaveable { mutableIntStateOf(5) }
+ var customTimerInput by rememberSaveable { mutableStateOf("") }
+ var presetPendingDeleteId by rememberSaveable { mutableStateOf(null) }
+ var presetPendingDeleteName by rememberSaveable { mutableStateOf("") }
+
+// if (showSavePresetDialog) {
+// AlertDialog(
+// onDismissRequest = { showSavePresetDialog = false },
+// title = { Text(stringResource(R.string.save_preset_dialog_title)) },
+// text = {
+// OutlinedTextField(
+// value = presetNameInput,
+// onValueChange = { presetNameInput = it },
+// label = { Text(stringResource(R.string.preset_name_label)) },
+// singleLine = true,
+// modifier = Modifier.fillMaxWidth()
+// )
+// },
+// confirmButton = {
+// TextButton(
+// onClick = {
+// viewModel.savePreset(presetNameInput)
+// presetNameInput = ""
+// showSavePresetDialog = false
+// }
+// ) {
+// Text(stringResource(R.string.preset_dialog_save))
+// }
+// },
+// dismissButton = {
+// TextButton(onClick = { showSavePresetDialog = false }) {
+// Text(stringResource(R.string.dialog_cancel))
+// }
+// }
+// )
+// }
+
+ if (showTimerDialog) {
+ AlertDialog(
+ onDismissRequest = { showTimerDialog = false },
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.Timer,
+ contentDescription = null,
+ tint =
+ if (sleepTimerRemainingMillis != null) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.primary
+ },
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(stringResource(R.string.timer_dialog_title))
+ }
+ },
+ text = {
+ Column {
+ if (sleepTimerRemainingMillis != null) {
+ Text(
+ text =
+ stringResource(
+ R.string.timer_countdown_label,
+ formatRemainingTimerLabel(sleepTimerRemainingMillis ?: 0L),
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(bottom = 10.dp),
+ )
+ }
+
+ val timerOptions = listOf(1, 5, 10, 15, 30, -1)
+ timerOptions.forEach { option ->
+ val isSelected = selectedTimerOption == option
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .background(
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
+ },
+ shape = RoundedCornerShape(12.dp),
+ ).clickable { selectedTimerOption = option }
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ selected = isSelected,
+ onClick = { selectedTimerOption = option },
+ )
+ Text(
+ text =
+ if (option == -1) {
+ stringResource(R.string.timer_custom)
+ } else {
+ stringResource(R.string.timer_minutes_format, option)
+ },
+ )
+ }
+ }
+ if (selectedTimerOption == -1) {
+ OutlinedTextField(
+ value = customTimerInput,
+ onValueChange = { customTimerInput = it.filter(Char::isDigit) },
+ label = { Text(stringResource(R.string.timer_custom_label)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ val selectedMinutes =
+ if (selectedTimerOption == -1) {
+ customTimerInput.toLongOrNull()
+ } else {
+ selectedTimerOption.toLong()
+ }
+ if (selectedMinutes == null || selectedMinutes <= 0L) {
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.timer_invalid_input),
+ Toast.LENGTH_SHORT,
+ ).show()
+ return@TextButton
+ }
+ viewModel.startSleepTimer(selectedMinutes * 60_000L)
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.timer_set, selectedMinutes),
+ Toast.LENGTH_SHORT,
+ ).show()
+ showTimerDialog = false
+ },
+ ) {
+ Text(stringResource(R.string.timer_start))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showTimerDialog = false }) {
+ Text(stringResource(R.string.dialog_cancel))
+ }
+ },
+ )
+ }
+
+// if (presetPendingDeleteId != null) {
+// AlertDialog(
+// onDismissRequest = { presetPendingDeleteId = null },
+// title = { Text(stringResource(R.string.preset_delete_confirm_title)) },
+// text = {
+// Text(
+// stringResource(
+// R.string.preset_delete_confirm_message,
+// presetPendingDeleteName
+// )
+// )
+// },
+// confirmButton = {
+// TextButton(
+// onClick = {
+// viewModel.deletePreset(presetPendingDeleteId!!)
+// presetPendingDeleteId = null
+// }
+// ) {
+// Text(stringResource(R.string.delete_sound_confirm))
+// }
+// },
+// dismissButton = {
+// TextButton(onClick = { presetPendingDeleteId = null }) {
+// Text(stringResource(R.string.dialog_cancel))
+// }
+// }
+// )
+// }
+
+ TopAppBar(
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ ),
+ scrollBehavior = scrollBehavior,
+ title = {
+ Text(
+ text = stringResource(R.string.app_title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ },
+ actions = {
+ Row {
+ val btnColors =
+ IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer.copy(.1f),
+ contentColor = MaterialTheme.colorScheme.primaryContainer,
+ )
+ FilledTonalIconButton(
+ onClick = {
+ viewModel.resetAllSounds()
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.sounds_reset),
+ Toast.LENGTH_SHORT,
+ ).show()
+ },
+ colors = btnColors,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = stringResource(R.string.menu_reset),
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ FilledTonalIconButton(
+ onClick = { viewModel.handlePlayPause(!canPlay) },
+ shape = CircleShape,
+ colors = btnColors,
+ ) {
+ Icon(
+ imageVector =
+ when (canPlay) {
+ true -> Icons.Default.Pause
+ false -> Icons.Default.PlayArrow
+ },
+ contentDescription =
+ if (canPlay) {
+ stringResource(R.string.menu_pause)
+ } else {
+ stringResource(R.string.menu_play)
+ },
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ FilledTonalIconButton(
+ onClick = { showTimerDialog = true },
+ shape = CircleShape,
+ colors =
+ IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor =
+ if (sleepTimerRemainingMillis != null) {
+ MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.45f)
+ } else {
+ MaterialTheme.colorScheme.primaryContainer.copy(.1f)
+ },
+ contentColor =
+ if (sleepTimerRemainingMillis != null) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.primaryContainer
+ },
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Timer,
+ contentDescription = stringResource(R.string.menu_timer),
+ tint =
+ if (sleepTimerRemainingMillis != null) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.primaryContainer
+ },
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ Spacer(modifier = Modifier.size(8.dp))
+ FilledTonalIconButton(
+ onClick = { showDropdown = true },
+ shape = CircleShape,
+ colors = btnColors,
+ ) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = stringResource(R.string.content_desc_more_options),
+ modifier = Modifier.size(20.dp),
+ )
+ if (showDropdown) {
+ DropdownMenu(
+ expanded = showDropdown,
+ onDismissRequest = { showDropdown = false },
+ ) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_settings)) },
+ onClick = {
+ showDropdown = false
+ navigateToSettings()
+ },
+ )
+ HorizontalDivider()
+// DropdownMenuItem(
+// text = { Text(stringResource(R.string.presets_save_mix)) },
+// onClick = {
+// showDropdown = false
+// if (!canSavePreset) {
+// Toast.makeText(
+// context,
+// context.getString(R.string.preset_nothing_to_save),
+// Toast.LENGTH_SHORT
+// ).show()
+// } else {
+// presetNameInput = ""
+// showSavePresetDialog = true
+// }
+// }
+// )
+// HorizontalDivider()
+// if (presets.isEmpty()) {
+// DropdownMenuItem(
+// text = {
+// Text(
+// stringResource(R.string.presets_empty),
+// style = MaterialTheme.typography.bodySmall,
+// color = MaterialTheme.colorScheme.onSurfaceVariant
+// )
+// },
+// onClick = { },
+// enabled = false
+// )
+// } else {
+// presets.forEach { preset ->
+// DropdownMenuItem(
+// text = {
+// Text(
+// preset.name,
+// maxLines = 1,
+// overflow = TextOverflow.Ellipsis
+// )
+// },
+// onClick = {
+// showDropdown = false
+// viewModel.applyPreset(preset)
+// },
+// trailingIcon = {
+// IconButton(
+// onClick = {
+// showDropdown = false
+// presetPendingDeleteId = preset.id
+// presetPendingDeleteName = preset.name
+// }
+// ) {
+// Icon(
+// imageVector = Icons.Outlined.Delete,
+// contentDescription = stringResource(R.string.preset_delete_desc)
+// )
+// }
+// }
+// )
+// }
+// }
+ }
+ }
+ }
+ }
+ },
+ )
+}
+
+private fun formatRemainingTimerLabel(remainingMillis: Long): String {
+ val totalSeconds = (remainingMillis / 1000L).coerceAtLeast(0L)
+ val hours = totalSeconds / 3600L
+ val minutes = (totalSeconds % 3600L) / 60L
+ val seconds = totalSeconds % 60L
+ return if (hours > 0L) {
+ "%d:%02d:%02d".format(hours, minutes, seconds)
+ } else {
+ "%02d:%02d".format(minutes, seconds)
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/ui/components/BlanketTabRow.kt b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/BlanketTabRow.kt
new file mode 100644
index 0000000..a2f0be9
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/BlanketTabRow.kt
@@ -0,0 +1,85 @@
+package com.pronaycoding.blankee.core.ui.components
+
+/**
+ * Tab row component for navigating between app sections.
+ *
+ * This composable provides a Material Design tab row for switching between:
+ * - Home section (sound controls and presets)
+ * - Settings section (preferences and options)
+ *
+ * Features:
+ * - Smooth tab transitions
+ * - Customizable styling and colors
+ * - State management for selected tab
+ *
+ * @see androidx.compose.material3.TabRow for Material Design tab row documentation
+ */
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.pronaycoding.blankee.R
+
+@Composable
+fun BlanketTabRow(modifier: Modifier = Modifier) {
+ val titles =
+ listOf(
+ stringResource(R.string.tab_home),
+ stringResource(R.string.tab_settings),
+ )
+ var state by remember { mutableStateOf(0) }
+
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ TabRow(
+ selectedTabIndex = state,
+ divider = {},
+ indicator = {
+ },
+ ) {
+ titles.forEachIndexed { index, title ->
+ Tab(
+ selectedContentColor = Color(0xFF27a157).copy(alpha = .5f),
+ selected = (index == state),
+ onClick = { state = index },
+ text = {
+ Text(
+ text = title,
+ color =
+ if (state == index) {
+ Color(0xFF27a157)
+ } else {
+ MaterialTheme.colorScheme.inverseSurface.copy(
+ alpha = .7f,
+ )
+ },
+ )
+ },
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewBlanketTabRow() {
+ BlanketTabRow()
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/ui/components/CustomSoundCard.kt b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/CustomSoundCard.kt
new file mode 100644
index 0000000..a9fe6eb
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/CustomSoundCard.kt
@@ -0,0 +1,233 @@
+package com.pronaycoding.blankee.core.ui.components
+
+/**
+ * Custom sound card component for user-uploaded sounds.
+ *
+ * This file contains composable functions for displaying and controlling custom sounds
+ * uploaded by the user. Features:
+ * - Display custom sound with music icon
+ * - Volume control slider for individual custom sounds
+ * - Delete button with confirmation dialog
+ * - Play/pause state tracking
+ * - Visual feedback for playback status
+ *
+ * Custom sounds are distinct from built-in sounds and can be removed by the user.
+ *
+ * @see com.pronaycoding.blankee.core.model.CardItems.CustomCardItem for custom sound model
+ * @see androidx.compose.material3.Slider for volume control
+ */
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.LibraryMusic
+import androidx.compose.material.icons.filled.Remove
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.pronaycoding.blankee.R
+import com.pronaycoding.blankee.feature.home.HomeViewmodel
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun CustomSoundCard(
+ modifier: Modifier,
+ soundId: Int,
+ displayName: String,
+ playOrPause: Boolean,
+ onDeleteClick: (Int) -> Unit,
+ viewModel: HomeViewmodel = koinViewModel(),
+) {
+ val context = LocalContext.current
+ val customVolumes by viewModel.customVolumes.collectAsStateWithLifecycle()
+ val volume = customVolumes[soundId] ?: 0f
+ var localVolume by remember(soundId) { mutableFloatStateOf(volume) }
+ var isDragging by remember(soundId) { mutableStateOf(false) }
+ LaunchedEffect(volume, soundId) {
+ if (!isDragging) localVolume = volume
+ }
+ val displayVolume = localVolume
+ val shouldPlaySound = displayVolume > 0f
+ val shouldUseActiveColor = shouldPlaySound && playOrPause
+ var showDeleteConfirmDialog by rememberSaveable(soundId) { mutableStateOf(false) }
+ val interactionSource = remember(soundId) { MutableInteractionSource() }
+
+ Column(
+ modifier = modifier.padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box {
+ Icon(
+ modifier =
+ Modifier
+ .size(24.dp)
+ .align(Alignment.TopEnd)
+ .background(
+ color = MaterialTheme.colorScheme.error.copy(alpha = 0.1f),
+ shape = CircleShape,
+ ).clickable { showDeleteConfirmDialog = true },
+ imageVector = Icons.Filled.Remove,
+ contentDescription = stringResource(R.string.delete_sound),
+ tint = MaterialTheme.colorScheme.error,
+ )
+
+ Box(
+ contentAlignment = Alignment.TopEnd,
+ modifier =
+ Modifier
+ .size(100.dp)
+ .clip(CircleShape)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = {
+ viewModel.onCustomCardClick(soundId)
+ },
+ ).background(
+ if (shouldUseActiveColor) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
+ } else {
+ Color.Gray.copy(alpha = 0.1f)
+ },
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.LibraryMusic,
+ contentDescription = stringResource(R.string.content_desc_sound_icon),
+ modifier =
+ Modifier
+ .size(40.dp)
+ .align(Alignment.Center),
+ tint =
+ if (shouldUseActiveColor) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ Color.Gray.copy(
+ .5f,
+ )
+ },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = displayName,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Slider(
+ colors =
+ SliderDefaults.colors(
+ thumbColor =
+ when (playOrPause) {
+ true -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
+ else -> Color.Gray.copy(.5f)
+ },
+ activeTrackColor =
+ if (playOrPause) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ Color.Gray.copy(
+ .5f,
+ )
+ },
+ inactiveTrackColor = Color.Gray.copy(.5f),
+ disabledThumbColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
+ disabledActiveTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
+ disabledInactiveTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
+ ),
+ value = displayVolume,
+ onValueChange = { newValue ->
+ if (playOrPause) {
+ isDragging = true
+ localVolume = newValue
+ viewModel.previewCustomSoundVolume(soundId, newValue)
+ }
+ },
+ onValueChangeFinished = {
+ if (playOrPause) {
+ viewModel.setCustomSoundVolume(soundId, localVolume)
+ }
+ isDragging = false
+ if (!playOrPause) {
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.cant_play_sound_on_pause),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ },
+ valueRange = 0f..1f,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(6.dp),
+ )
+ }
+
+ if (showDeleteConfirmDialog) {
+ AlertDialog(
+ onDismissRequest = { showDeleteConfirmDialog = false },
+ title = { Text(text = stringResource(R.string.delete_sound_dialog_title)) },
+ text = {
+ Text(
+ text =
+ stringResource(
+ R.string.delete_sound_dialog_message,
+ displayName,
+ ),
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onDeleteClick(soundId)
+ showDeleteConfirmDialog = false
+ },
+ ) {
+ Text(text = stringResource(R.string.delete_sound_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteConfirmDialog = false }) {
+ Text(text = stringResource(R.string.dialog_cancel))
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/ui/components/EnjoyBlankeePrompt.kt b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/EnjoyBlankeePrompt.kt
new file mode 100644
index 0000000..fe9f0b8
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/EnjoyBlankeePrompt.kt
@@ -0,0 +1,105 @@
+package com.pronaycoding.blankee.core.ui.components
+
+/**
+ * "Enjoy Blankee" prompt dialog component.
+ *
+ * This composable displays a series of dialogs to users after a certain number of app launches
+ * (configured via [Constants.ENJOY_PROMPT_TRIGGER_LAUNCH]).
+ *
+ * Dialog sequence:
+ * 1. Initial prompt asking "Are you enjoying Blankee?"
+ * 2. GitHub star request if user clicks "Yes"
+ * 3. Links to GitHub repository for starring
+ *
+ * Features:
+ * - Shown only once per app installation (tracked via preferences)
+ * - Automatic dismissal with state management
+ * - Opens GitHub link in external browser
+ *
+ * @see Constants.ENJOY_PROMPT_TRIGGER_LAUNCH for trigger threshold
+ * @see PreferenceManagerRepository for preference persistence
+ */
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import com.pronaycoding.blankee.R
+import com.pronaycoding.blankee.core.common.Constants
+import com.pronaycoding.blankee.core.common.util.openExternalUrl
+import com.pronaycoding.blankee.core.datastore.PreferenceManagerRepositoryImpl
+
+@Composable
+fun EnjoyBlankeePrompt(shouldShow: Boolean) {
+ if (!shouldShow) return
+
+ val context = LocalContext.current
+ var showEnjoyDialog by rememberSaveable { mutableStateOf(false) }
+ var showStarDialog by rememberSaveable { mutableStateOf(false) }
+ val preferenceManager =
+ remember(context) {
+ PreferenceManagerRepositoryImpl(
+ context,
+ )
+ }
+
+ LaunchedEffect(Unit) {
+ // Mark as shown as soon as we decide to show, so it won't loop on restarts.
+ preferenceManager.setEnjoyPromptShown(true)
+ showEnjoyDialog = true
+ }
+
+ if (showEnjoyDialog) {
+ AlertDialog(
+ onDismissRequest = { showEnjoyDialog = false },
+ title = { Text(stringResource(R.string.enjoy_prompt_title)) },
+ text = { Text(stringResource(R.string.enjoy_prompt_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showEnjoyDialog = false
+ showStarDialog = true
+ },
+ ) {
+ Text(stringResource(R.string.enjoy_prompt_yes))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showEnjoyDialog = false }) {
+ Text(stringResource(R.string.enjoy_prompt_no))
+ }
+ },
+ )
+ }
+
+ if (showStarDialog) {
+ AlertDialog(
+ onDismissRequest = { showStarDialog = false },
+ title = { Text(stringResource(R.string.star_github_title)) },
+ text = { Text(stringResource(R.string.star_github_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showStarDialog = false
+ openExternalUrl(context, Constants.GITHUB_REPO)
+ },
+ ) {
+ Text(stringResource(R.string.star_github_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showStarDialog = false }) {
+ Text(stringResource(R.string.star_github_later))
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/ui/components/PrettyCardView.kt b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/PrettyCardView.kt
new file mode 100644
index 0000000..871522c
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/PrettyCardView.kt
@@ -0,0 +1,162 @@
+package com.pronaycoding.blankee.core.ui.components
+
+/**
+ * Sound card component for displaying and controlling individual sounds.
+ *
+ * This file contains UI components for:
+ * - Displaying a sound item with its icon and name
+ * - Showing volume control slider
+ * - Play/pause toggle for individual sounds
+ * - Visual feedback for playback status
+ *
+ * Each card represents a built-in or custom sound with interactive controls
+ * for volume adjustment and playback state.
+ *
+ * @see androidx.compose.material3.Slider for volume control
+ * @see androidx.compose.material3.Icon for sound icons
+ */
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.pronaycoding.blankee.R
+import com.pronaycoding.blankee.core.model.CardItems
+import com.pronaycoding.blankee.feature.home.HomeViewmodel
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun PrettyCardView(
+ modifier: Modifier = Modifier,
+ index: Int,
+ cardItem: CardItems,
+ playOrPause: Boolean,
+ viewModel: HomeViewmodel = koinViewModel(),
+) {
+ val context = LocalContext.current
+ val builtinVolumes by viewModel.builtinVolumes.collectAsStateWithLifecycle()
+ val volume = builtinVolumes[index] ?: 0f
+ var localVolume by remember(index) { mutableFloatStateOf(volume) }
+ var isDragging by remember(index) { mutableStateOf(false) }
+ LaunchedEffect(volume, index) {
+ if (!isDragging) localVolume = volume
+ }
+ val displayVolume = localVolume
+ val shouldPlaySound = displayVolume > 0f
+ val shouldUseActiveColor = shouldPlaySound && playOrPause
+ val interactionSource = remember { MutableInteractionSource() }
+
+ Column(
+ modifier = modifier.padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier =
+ Modifier
+ .size(100.dp)
+ .clip(CircleShape)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = {
+ viewModel.onBuiltInCardClick(index)
+ },
+ ).background(
+ if (shouldUseActiveColor) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
+ } else {
+ Color.Gray.copy(alpha = 0.1f)
+ },
+ ),
+ ) {
+ Icon(
+ painter = painterResource(id = cardItem.icon),
+ contentDescription = stringResource(R.string.content_desc_sound_icon),
+ modifier = Modifier.size(40.dp),
+ tint = if (shouldUseActiveColor) MaterialTheme.colorScheme.primary else Color.Gray.copy(.5f),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = cardItem.localizedTitle(),
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Slider(
+ colors =
+ SliderDefaults.colors(
+ thumbColor =
+ when (playOrPause) {
+ true -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
+ else -> Color.Gray.copy(.5f)
+ },
+ activeTrackColor = if (playOrPause) MaterialTheme.colorScheme.primary else Color.Gray.copy(.5f),
+ inactiveTrackColor = Color.Gray.copy(.5f),
+ disabledThumbColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
+ disabledActiveTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
+ disabledInactiveTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
+ ),
+ value = displayVolume,
+ onValueChange = { newValue ->
+ if (playOrPause) {
+ isDragging = true
+ localVolume = newValue
+ viewModel.previewBuiltinVolume(index, newValue)
+ }
+ },
+ onValueChangeFinished = {
+ if (playOrPause) {
+ viewModel.setBuiltinVolume(index, localVolume)
+ }
+ isDragging = false
+ if (!playOrPause) {
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.cant_play_sound_on_pause),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ },
+ valueRange = 0f..1f,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(6.dp),
+ )
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/ui/components/TitleCardView.kt b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/TitleCardView.kt
new file mode 100644
index 0000000..e1c7de8
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/ui/components/TitleCardView.kt
@@ -0,0 +1,88 @@
+package com.pronaycoding.blankee.core.ui.components
+
+/**
+ * Section title card component for sound categories.
+ *
+ * This file provides composable functions for displaying section headers in the sound list.
+ * Features:
+ * - Category title display with primary color
+ * - Horizontal divider line for visual separation
+ * - Proper spacing and typography
+ *
+ * Used to group sounds by category (Nature, Travel, Interiors, etc.)
+ *
+ * @see androidx.compose.material3.Text for text rendering
+ * @see androidx.compose.material3.HorizontalDivider for divider rendering
+ */
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun TitleCardView(text: String) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 20.dp, bottom = 12.dp, start = 4.dp),
+ ) {
+ Text(
+ text = text,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.titleLarge,
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(top = 8.dp),
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
+ thickness = 2.dp,
+ )
+ }
+}
+
+// @Preview (showBackground = true)
+@Composable
+fun TitleCardView(
+ modifier: Modifier = Modifier,
+ typeText: String,
+) {
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 10.dp, start = 8.dp)
+ .padding(8.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = Color.Transparent,
+ ),
+ ) {
+ Text(
+ text = typeText,
+ color = Color(0xFF27a157),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily.Monospace,
+ )
+ HorizontalDivider()
+ }
+}
+
+@Composable
+@Preview(showSystemUi = true)
+fun Preview(modifier: Modifier = Modifier) {
+ TitleCardView(text = "Title")
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/ui/theme/Color.kt b/app/src/main/java/com/pronaycoding/blankee/core/ui/theme/Color.kt
new file mode 100644
index 0000000..23c423f
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/ui/theme/Color.kt
@@ -0,0 +1,38 @@
+package com.pronaycoding.blankee.core.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Material Design 3 Color Palette for the Blankee application.
+ *
+ * The color scheme is designed to evoke calm and relaxation, fitting the app's sleep/ambient sound purpose.
+ * Colors are organized into:
+ * - Primary: Soft blues for main branding and interactive elements
+ * - Secondary: Cyan tones for secondary accents
+ * - Tertiary: Soft greens for tertiary accents
+ * - Neutral: Greys, whites, and background colors for light/dark modes
+ * - Status: Error, success, and warning colors for user feedback
+ *
+ * @see androidx.compose.material3.MaterialTheme.colorScheme for accessing these colors
+ */
+
+val Primary = Color(0xFF1E88E5)
+val PrimaryDark = Color(0xFF1565C0)
+val PrimaryLight = Color(0xFF64B5F6)
+val Secondary = Color(0xFF26C6DA)
+val SecondaryDark = Color(0xFF00ACC1)
+val SecondaryLight = Color(0xFF80DEEA)
+val Tertiary = Color(0xFF4CAF50)
+val TertiaryLight = Color(0xFF81C784)
+val DarkBackground = Color(0xFF0F0F0F)
+val DarkSurface = Color(0xFF1A1A1A)
+val DarkSurfaceVariant = Color(0xFF2D2D2D)
+val LightBackground = Color(0xFFFAFAFA)
+val LightSurface = Color(0xFFFFFFFF)
+val LightSurfaceVariant = Color(0xFFF5F5F5)
+val DarkText = Color(0xFF1C1B1F)
+val LightText = Color(0xFFFFFBFE)
+val TextSecondary = Color(0xFF79747E)
+val Error = Color(0xFFEF5350)
+val Success = Color(0xFF66BB6A)
+val Warning = Color(0xFFFFB74D)
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/ui/theme/Theme.kt b/app/src/main/java/com/pronaycoding/blankee/core/ui/theme/Theme.kt
new file mode 100644
index 0000000..42f75df
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/ui/theme/Theme.kt
@@ -0,0 +1,145 @@
+package com.pronaycoding.blankee.core.ui.theme
+
+/**
+ * Material Design 3 theme system for the Blankee application.
+ *
+ * This file defines:
+ * - Dark and Light color schemes
+ * - Theme composition with Material Design 3
+ * - Support for system theme following device settings
+ * - Dynamic color support on Android 12+
+ *
+ * The theme is designed to evoke calm and relaxation, fitting the app's sleep/ambient sound purpose.
+ *
+ * Color scheme selection logic:
+ * 1. Check user preference (light/dark/system)
+ * 2. If system mode, follow device settings
+ * 3. Apply dynamic colors if available (Android 12+) and enabled
+ * 4. Fall back to custom light/dark color schemes
+ *
+ * System UI integration:
+ * - Status bar color matches surface color
+ * - Status bar icons adapt to theme brightness
+ *
+ * @see androidx.compose.material3.MaterialTheme for Material Design theme
+ * @see PreferenceManagerRepository for theme preference storage
+ */
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+import com.pronaycoding.blankee.core.common.Constants
+import com.pronaycoding.blankee.core.datastore.PreferenceManagerRepositoryImpl
+
+// Dark theme - Calm and minimalist like Blanket app
+private val DarkColorScheme =
+ darkColorScheme(
+ primary = Primary, // Soft Blue
+ onPrimary = Color.White,
+ primaryContainer = PrimaryDark,
+ onPrimaryContainer = Color.White,
+ secondary = Secondary, // Cyan
+ onSecondary = Color.White,
+ secondaryContainer = SecondaryDark,
+ onSecondaryContainer = Color.White,
+ tertiary = Tertiary, // Soft Green
+ onTertiary = Color.White,
+ tertiaryContainer = TertiaryLight,
+ onTertiaryContainer = Color.White,
+ error = Error,
+ onError = Color.White,
+ errorContainer = Color(0xFFF9DEDC),
+ onErrorContainer = Color(0xFF410E0B),
+ background = DarkBackground, // Almost black
+ onBackground = LightText,
+ surface = DarkSurface, // Dark card background
+ onSurface = LightText,
+ surfaceVariant = DarkSurfaceVariant,
+ onSurfaceVariant = TextSecondary,
+ outline = Color(0xFF79747E),
+ outlineVariant = Color(0xFF49454E),
+ )
+
+// Light theme
+private val LightColorScheme =
+ lightColorScheme(
+ primary = Primary, // Soft Blue
+ onPrimary = Color.White,
+ primaryContainer = PrimaryLight,
+ onPrimaryContainer = Color(0xFF062D5E),
+ secondary = Secondary, // Cyan
+ onSecondary = Color.White,
+ secondaryContainer = SecondaryLight,
+ onSecondaryContainer = Color(0xFF003D47),
+ tertiary = Tertiary, // Soft Green
+ onTertiary = Color.White,
+ tertiaryContainer = TertiaryLight,
+ onTertiaryContainer = Color.White,
+ error = Error,
+ onError = Color.White,
+ errorContainer = Color(0xFFFCDEDB),
+ onErrorContainer = Color(0xFF370B1E),
+ background = LightBackground, // Off-white
+ onBackground = DarkText,
+ surface = LightSurface, // Pure white
+ onSurface = DarkText,
+ surfaceVariant = LightSurfaceVariant,
+ onSurfaceVariant = TextSecondary,
+ outline = Color(0xFF79747E),
+ outlineVariant = Color(0xFFCAC7D0),
+ )
+
+@Composable
+fun BlankeeAppTheme(
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = false, // Disabled for consistent branding
+ content: @Composable () -> Unit,
+) {
+ val context = LocalContext.current
+ val preferenceManager = PreferenceManagerRepositoryImpl(context)
+ val systemDark = isSystemInDarkTheme()
+ val darkTheme =
+ when (preferenceManager.getThemeModeBlocking(Constants.MODE_DARK)) {
+ Constants.MODE_LIGHT -> false
+ Constants.MODE_DARK -> true
+ Constants.MODE_SYSTEM -> systemDark
+ else -> true
+ }
+
+ val colorScheme =
+ when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ @Suppress("DEPRECATION")
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = colorScheme.surface.toArgb()
+ WindowCompat.getInsetsController(window, view)!!.isAppearanceLightStatusBars = !darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content,
+ )
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/core/ui/theme/Type.kt b/app/src/main/java/com/pronaycoding/blankee/core/ui/theme/Type.kt
new file mode 100644
index 0000000..6ad80ce
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/core/ui/theme/Type.kt
@@ -0,0 +1,159 @@
+package com.pronaycoding.blankee.core.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+/**
+ * Material Design 3 Typography system for the Blankee application.
+ *
+ * This file defines the complete typography scale used throughout the app's Compose UI.
+ * All text styles follow Material Design 3 guidelines with appropriate font weights,
+ * sizes, line heights, and letter spacing.
+ *
+ * Typography hierarchy:
+ * - Display: Large, decorative text for headlines
+ * - Title: For prominent section headings
+ * - Body: For body text and regular content
+ * - Label: For buttons, labels, and small text
+ *
+ * @see androidx.compose.material3.MaterialTheme.typography for accessing these styles
+ * @see androidx.compose.material3.Text for composable that uses these styles
+ */
+val Typography =
+ Typography(
+ /**
+ * Display Large: 32sp, Bold
+ * Used for the largest, most prominent headlines.
+ */
+ displayLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Bold,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ letterSpacing = 0.sp,
+ ),
+ /**
+ * Display Medium: 28sp, Bold
+ * Used for secondary-level display headlines.
+ */
+ displayMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Bold,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ letterSpacing = 0.sp,
+ ),
+ /**
+ * Title Large: 22sp, Semi-Bold
+ * Used for main section titles and primary headings.
+ */
+ titleLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp,
+ ),
+ /**
+ * Title Medium: 18sp, Semi-Bold
+ * Used for secondary titles and subsection headings.
+ */
+ titleMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 18.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ /**
+ * Title Small: 16sp, Medium
+ * Used for smaller titles and tertiary headings.
+ */
+ titleSmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 22.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ /**
+ * Body Large: 16sp, Normal
+ * Used for main body text and longer content passages.
+ */
+ bodyLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ /**
+ * Body Medium: 14sp, Normal
+ * Used for regular body text and content.
+ */
+ bodyMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.25.sp,
+ ),
+ /**
+ * Body Small: 12sp, Normal
+ * Used for smaller body text, captions, and secondary content.
+ */
+ bodySmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp,
+ ),
+ /**
+ * Label Large: 14sp, Semi-Bold
+ * Used for button text and prominent labels.
+ */
+ labelLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ /**
+ * Label Medium: 12sp, Semi-Bold
+ * Used for labels and secondary button text.
+ */
+ labelMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ /**
+ * Label Small: 10sp, Semi-Bold
+ * Used for small labels, badges, and tiny text elements.
+ */
+ labelSmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 10.sp,
+ lineHeight = 14.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ )
diff --git a/app/src/main/java/com/pronaycoding/blankee/feature/home/HomeScreen.kt b/app/src/main/java/com/pronaycoding/blankee/feature/home/HomeScreen.kt
new file mode 100644
index 0000000..0f37846
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/feature/home/HomeScreen.kt
@@ -0,0 +1,945 @@
+package com.pronaycoding.blankee.feature.home
+
+/**
+ * Home screen composable for the Blankee application.
+ *
+ * The main feature screen displaying:
+ * - Sound categories (Nature, Travel, Interiors, Noise)
+ * - Built-in ambient sounds with individual volume controls
+ * - Custom user-uploaded sounds
+ * - Sound selection and mixing interface
+ * - Integration with playback controls (play/pause, reset, timer)
+ *
+ * Features:
+ * - LazyColumn layout for efficient scrolling through sounds
+ * - Real-time volume adjustments
+ * - Custom sound upload capability
+ * - Preset management (save/load/delete)
+ * - Top app bar with media controls
+ *
+ * State management via [HomeViewmodel]:
+ * - Built-in sound volumes
+ * - Custom sound volumes
+ * - Playback state
+ * - Preset management
+ * - Sleep timer
+ *
+ * @see HomeViewmodel for screen state and business logic
+ * @see BlankeeTopAppBar for media controls
+ * @see PrettyCardView for sound card UI
+ * @see CustomSoundCard for custom sound card UI
+ */
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Context
+import android.net.Uri
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+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.lazy.LazyColumn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Timer
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.IconButtonDefaults.filledTonalIconButtonColors
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.pronaycoding.blankee.R
+import com.pronaycoding.blankee.core.common.util.findActivity
+import com.pronaycoding.blankee.core.database.entities.CustomSoundEntity
+import com.pronaycoding.blankee.core.database.entities.PresetEntity
+import com.pronaycoding.blankee.core.model.CardItems
+import com.pronaycoding.blankee.core.ui.components.BlankeeTopAppBar
+import com.pronaycoding.blankee.core.ui.components.CustomSoundCard
+import com.pronaycoding.blankee.core.ui.components.PrettyCardView
+import com.pronaycoding.blankee.core.ui.components.TitleCardView
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun HomeScreenRoute(
+ navigateToSettings: () -> Unit,
+ viewmodel: HomeViewmodel = koinViewModel(),
+) {
+ val canPlaySound by viewmodel.canPlay.collectAsStateWithLifecycle()
+ val customSounds by viewmodel.customSounds.collectAsStateWithLifecycle()
+ val customSoundsUnlocked by viewmodel.customSoundsUnlocked.collectAsStateWithLifecycle()
+ val sleepTimerRemainingMillis by viewmodel.sleepTimerRemainingMillis.collectAsStateWithLifecycle()
+ val canPlay by viewmodel.canPlay.collectAsStateWithLifecycle()
+// val canPlay by viewModel.canPlay.collectAsStateWithLifecycle()
+ val presets by viewmodel.presets.collectAsStateWithLifecycle()
+ val builtinVolumes by viewmodel.builtinVolumes.collectAsStateWithLifecycle()
+ val customVolumes by viewmodel.customVolumes.collectAsStateWithLifecycle()
+
+ HomeScreen(
+ canPlay = canPlay,
+ presets = presets,
+ builtinVolumes = builtinVolumes,
+ customVolumes = customVolumes,
+ navigateToSettings = navigateToSettings,
+ canPlaySound = canPlaySound,
+ savePreset = viewmodel::savePreset,
+ customSoundsUnlocked = customSoundsUnlocked,
+ customSounds = customSounds,
+ startSleepTimer = viewmodel::startSleepTimer,
+ applyPreset = viewmodel::applyPreset,
+ onAddCustomSound = { displayName, filePath ->
+ viewmodel.addCustomSound(
+ displayName,
+ filePath,
+ )
+ },
+ handlePlayPause = viewmodel::handlePlayPause,
+ onDeleteCustomSound = { soundId -> viewmodel.removeCustomSound(soundId) },
+ onLaunchPremiumPurchase = { activity -> viewmodel.launchPremiumPurchase(activity) },
+ sleepTimerRemainingMillis = sleepTimerRemainingMillis,
+ onCancelSleepTimer = { viewmodel.cancelSleepTimer() },
+ resetAllSounds = viewmodel::resetAllSounds,
+ deletePreset = viewmodel::deletePreset,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun HomeScreen(
+ canPlay: Boolean,
+ savePreset: (String) -> Unit,
+ presets: List,
+ builtinVolumes: Map,
+ customVolumes: Map,
+ navigateToSettings: () -> Unit,
+ startSleepTimer: (Long) -> Unit,
+ applyPreset: (PresetEntity) -> Unit,
+ canPlaySound: Boolean,
+ customSoundsUnlocked: Boolean,
+ deletePreset: (Long) -> Unit,
+ customSounds: List = emptyList(),
+ resetAllSounds: () -> Unit,
+ handlePlayPause: (Boolean) -> Unit,
+ onAddCustomSound: (String, String) -> Unit = { _, _ -> },
+ onDeleteCustomSound: (Int) -> Unit = {},
+ onLaunchPremiumPurchase: (Activity) -> Unit = {},
+ sleepTimerRemainingMillis: Long? = null,
+ onCancelSleepTimer: () -> Unit = {},
+) {
+ val context = LocalContext.current
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+ var showCancelTimerDialog by rememberSaveable { mutableStateOf(false) }
+ var showTimerDialog by rememberSaveable { mutableStateOf(false) }
+
+ val btnColors =
+ IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer.copy(.1f),
+ contentColor = MaterialTheme.colorScheme.primaryContainer,
+ )
+
+ var showDropdown by rememberSaveable { mutableStateOf(false) }
+ var showSavePresetDialog by rememberSaveable { mutableStateOf(false) }
+ var presetNameInput by rememberSaveable { mutableStateOf("") }
+// var showTimerDialog by rememberSaveable { mutableStateOf(false) }
+ var selectedTimerOption by rememberSaveable { mutableIntStateOf(5) }
+ var customTimerInput by rememberSaveable { mutableStateOf("") }
+ var presetPendingDeleteId by rememberSaveable { mutableStateOf(null) }
+ var presetPendingDeleteName by rememberSaveable { mutableStateOf("") }
+ var presetClicked by rememberSaveable { mutableStateOf(false) }
+
+ val canSavePreset =
+ remember(builtinVolumes, customVolumes) {
+ val until = getCardList().size - 1
+ builtinVolumes.any { (i, v) -> i in 0 until until && v > 0f } ||
+ customVolumes.any { (_, v) -> v > 0f }
+ }
+
+ PresetClicked(
+ context = context,
+ dropdownEnabled = presetClicked,
+ applyPreset = applyPreset,
+ presets = presets,
+ setDropdownFalse = { presetClicked = false },
+ setPresentPendingDeleteId = { presetPendingDeleteId = it },
+ setPresetPendingDeleteName = { presetPendingDeleteName = it },
+ canSavePreset = canSavePreset,
+ savePreset = savePreset,
+ )
+
+ DeletePresetDialog(
+ presetPendingDeleteName = presetPendingDeleteName,
+ presetPendingDeleteId = presetPendingDeleteId,
+ deletePreset = deletePreset,
+ setPresetPendingDeleteId = { presetPendingDeleteId = it },
+ )
+
+ TimerDialog(
+ context = context,
+ showTimerDialog = showTimerDialog,
+ sleepTimerRemainingMillis = sleepTimerRemainingMillis,
+ setTimerDialogFalse = { showTimerDialog = false },
+ startSleepTimer = startSleepTimer,
+ )
+
+ val audioPickerLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent(),
+ ) { uri: Uri? ->
+ uri?.let {
+ val defaultName =
+ context.getString(
+ R.string.custom_sound_default_name,
+ customSounds.size + 1,
+ )
+ val displayName = defaultName
+ val filePath = it.toString()
+ if (filePath.isNotEmpty()) {
+ onAddCustomSound(displayName, filePath)
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.sound_added, displayName),
+ Toast.LENGTH_SHORT,
+ ).show()
+ } else {
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.could_not_add_sound),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ }
+
+ Scaffold(
+ bottomBar = {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 20.dp)
+ .height(60.dp),
+ ) {
+ Row(
+ modifier = Modifier.align(Alignment.Center),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ FilledTonalIconButton(
+ onClick = {
+ presetClicked = true
+ },
+ colors = btnColors,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Save,
+ contentDescription = stringResource(R.string.content_desc_more_options),
+ modifier = Modifier.size(18.dp),
+ )
+ }
+
+ FilledTonalIconButton(
+ onClick = {
+ resetAllSounds()
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.sounds_reset),
+ Toast.LENGTH_SHORT,
+ ).show()
+ },
+ colors = btnColors,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = stringResource(R.string.menu_reset),
+ )
+ }
+
+ FilledTonalIconButton(
+ modifier =
+ Modifier
+ .size(60.dp),
+ onClick = { handlePlayPause(!canPlay) },
+ shape = CircleShape,
+ colors =
+ filledTonalIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.error.copy(.1f),
+ contentColor = MaterialTheme.colorScheme.error,
+ ),
+ ) {
+ Icon(
+ imageVector =
+ when (canPlay) {
+ true -> Icons.Default.Pause
+ false -> Icons.Default.PlayArrow
+ },
+ contentDescription =
+ if (canPlay) {
+ stringResource(R.string.menu_pause)
+ } else {
+ stringResource(R.string.menu_play)
+ },
+ modifier = Modifier.size(42.dp),
+ )
+ }
+
+ FilledTonalIconButton(
+ onClick = { showTimerDialog = true },
+ shape = CircleShape,
+ colors =
+ filledTonalIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer.copy(.1f)
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Timer,
+ contentDescription = stringResource(R.string.menu_timer),
+ tint = MaterialTheme.colorScheme.primaryContainer
+ )
+ }
+
+ FilledTonalIconButton(
+ onClick = {
+ navigateToSettings()
+ },
+ colors = btnColors,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = stringResource(R.string.content_desc_more_options),
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ }
+ },
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .padding(it)
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ ) {
+ LazyColumn(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 16.dp),
+ content = {
+ // responsible for sleep timer countdown showing on UI
+ if (sleepTimerRemainingMillis != null) {
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ SleepTimerActiveCard(
+ sleepTimerRemainingMillis = sleepTimerRemainingMillis,
+ setShowCancelTimerDialogTrue = {
+ showCancelTimerDialog = true
+ },
+ )
+ }
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ CustomCard {
+ SoundGrid(
+ items = homeGridCardItems(customSoundsUnlocked),
+ canPlaySound = canPlaySound,
+ )
+ }
+ }
+
+ if (customSoundsUnlocked && customSounds.isNotEmpty()) {
+ item {
+ TitleCardView(stringResource(R.string.category_custom))
+ CustomCard {
+ val chunks = customSounds.chunked(2)
+ chunks.forEach { chunk ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ chunk.forEach { customSound ->
+ CustomSoundCard(
+ modifier = Modifier.weight(1f),
+ soundId = customSound.id,
+ displayName = customSound.displayName,
+ playOrPause = canPlaySound,
+ onDeleteClick = onDeleteCustomSound,
+ )
+ }
+ // Fill empty slot if odd number
+ if (chunk.size == 1) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ if (!customSoundsUnlocked) {
+ Text(
+ text = stringResource(R.string.custom_sounds_premium_message),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Button(
+ onClick = {
+ context.findActivity()?.let { onLaunchPremiumPurchase(it) }
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ ) {
+ Text(
+ stringResource(R.string.buy_premium),
+ style = MaterialTheme.typography.titleSmall,
+ )
+ }
+ } else {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Button(
+ onClick = { audioPickerLauncher.launch("audio/*") },
+ modifier =
+ Modifier
+// .fillMaxWidth()
+ .height(56.dp)
+ .align(Alignment.Center),
+ shape = RoundedCornerShape(16.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Add,
+ contentDescription = stringResource(R.string.content_desc_add_custom_sound),
+ modifier = Modifier.size(24.dp),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ stringResource(R.string.add_custom_sound),
+ style = MaterialTheme.typography.titleSmall,
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ },
+ )
+ }
+ }
+
+ if (showCancelTimerDialog) {
+ AlertDialog(
+ onDismissRequest = { showCancelTimerDialog = false },
+ title = { Text(stringResource(R.string.timer_cancel_confirm_title)) },
+ text = { Text(stringResource(R.string.timer_cancel_confirm_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onCancelSleepTimer()
+ showCancelTimerDialog = false
+ },
+ ) {
+ Text(stringResource(R.string.timer_cancel))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showCancelTimerDialog = false }) {
+ Text(stringResource(R.string.dialog_cancel))
+ }
+ },
+ )
+ }
+}
+
+private fun formatTimer(remainingMillis: Long): String {
+ val totalSeconds = (remainingMillis / 1000L).coerceAtLeast(0L)
+ val hours = totalSeconds / 3600L
+ val minutes = (totalSeconds % 3600L) / 60L
+ val seconds = totalSeconds % 60L
+ return if (hours > 0L) {
+ "%d:%02d:%02d".format(hours, minutes, seconds)
+ } else {
+ "%02d:%02d".format(minutes, seconds)
+ }
+}
+
+@SuppressLint("UnusedBoxWithConstraintsScope")
+@Composable
+private fun SoundGrid(
+ items: List,
+ canPlaySound: Boolean,
+) {
+ BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
+ val itemMinWidth = 140.dp
+ val columns = ((maxWidth - 8.dp) / (itemMinWidth + 8.dp)).toInt().coerceAtLeast(1).coerceAtMost(5)
+
+ Column {
+ val chunks = items.chunked(columns)
+ chunks.forEach { chunk ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ chunk.forEach { cardItem ->
+ PrettyCardView(
+ modifier = Modifier.weight(1f),
+ index = getCardList().indexOf(cardItem),
+ cardItem = cardItem,
+ playOrPause = canPlaySound,
+ )
+ }
+ repeat(columns - chunk.size) {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ }
+ }
+}
+
+fun getCardList(): List =
+ listOf(
+ CardItems.Rain,
+ CardItems.Wind,
+ CardItems.Storm,
+ CardItems.Wave,
+ CardItems.Stream,
+ CardItems.Birds,
+ CardItems.SummerNight,
+ CardItems.Train,
+ CardItems.Boat,
+ CardItems.City,
+ CardItems.CoffeeShop,
+ CardItems.FirePlace,
+ CardItems.BusyRestaurant,
+ CardItems.PinkNoise,
+ CardItems.WhiteNoise,
+ )
+
+// TODO need to fix this logic
+private fun homeGridCardItems(customSoundsUnlocked: Boolean): List =
+ if (!customSoundsUnlocked) {
+ getCardList().filter { it != CardItems.Custom }
+ } else {
+ getCardList()
+ }
+
+@Composable
+fun CustomCard(content: @Composable () -> Unit) {
+ Card(
+ modifier = Modifier.padding(vertical = 8.dp),
+ shape = RoundedCornerShape(20.dp),
+ elevation = CardDefaults.cardElevation(0.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ content()
+ }
+ }
+}
+
+@Composable
+fun PresetClicked(
+ dropdownEnabled: Boolean,
+ applyPreset: (PresetEntity) -> Unit,
+ presets: List,
+ setDropdownFalse: () -> Unit,
+ setPresentPendingDeleteId: (Long) -> Unit,
+ setPresetPendingDeleteName: (String) -> Unit,
+ canSavePreset: Boolean,
+ savePreset: (String) -> Unit,
+ context: Context,
+) {
+ var presetNameInput by rememberSaveable { mutableStateOf("") }
+ var showSavePresetDialog by rememberSaveable { mutableStateOf(false) }
+
+ DropdownMenu(
+ expanded = dropdownEnabled,
+ onDismissRequest = setDropdownFalse,
+ ) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.presets_save_mix)) },
+ onClick = {
+ setDropdownFalse()
+ if (!canSavePreset) {
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.preset_nothing_to_save),
+ Toast.LENGTH_SHORT,
+ ).show()
+ } else {
+ presetNameInput = ""
+ showSavePresetDialog = true
+ }
+ },
+ )
+ HorizontalDivider()
+ if (presets.isEmpty()) {
+ DropdownMenuItem(
+ text = {
+ Text(
+ stringResource(R.string.presets_empty),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ onClick = { },
+ enabled = false,
+ )
+ } else {
+ presets.forEach { preset ->
+ DropdownMenuItem(
+ text = {
+ Text(
+ preset.name,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ onClick = {
+ setDropdownFalse()
+ applyPreset(preset)
+ },
+ trailingIcon = {
+ IconButton(
+ onClick = {
+ setDropdownFalse()
+ setPresentPendingDeleteId(preset.id)
+ setPresetPendingDeleteName(preset.name)
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Delete,
+ tint = MaterialTheme.colorScheme.error,
+ contentDescription = stringResource(R.string.preset_delete_desc),
+ )
+ }
+ },
+ )
+ }
+ }
+ }
+
+ if (showSavePresetDialog) {
+ AlertDialog(
+ onDismissRequest = { showSavePresetDialog = false },
+ title = { Text(stringResource(R.string.save_preset_dialog_title)) },
+ text = {
+ OutlinedTextField(
+ supportingText = { Text(stringResource(R.string.preset_name_hint)) },
+ value = presetNameInput,
+ onValueChange = { presetNameInput = it },
+ label = { Text(stringResource(R.string.preset_name_label)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ savePreset(presetNameInput)
+ presetNameInput = ""
+ showSavePresetDialog = false
+ },
+ ) {
+ Text(stringResource(R.string.preset_dialog_save))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showSavePresetDialog = false }) {
+ Text(stringResource(R.string.dialog_cancel))
+ }
+ },
+ )
+ }
+}
+
+@Composable
+fun DeletePresetDialog(
+ modifier: Modifier = Modifier,
+ presetPendingDeleteName: String,
+ presetPendingDeleteId: Long?,
+ deletePreset: (Long) -> Unit,
+ setPresetPendingDeleteId: (Long?) -> Unit,
+) {
+ if (presetPendingDeleteId != null) {
+ AlertDialog(
+ modifier = modifier,
+ onDismissRequest = { setPresetPendingDeleteId(null) },
+ title = { Text(stringResource(R.string.preset_delete_confirm_title)) },
+ text = {
+ Text(
+ stringResource(
+ R.string.preset_delete_confirm_message,
+ presetPendingDeleteName,
+ ),
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ deletePreset(presetPendingDeleteId!!)
+ setPresetPendingDeleteId(null)
+ },
+ ) {
+ Text(stringResource(R.string.delete_sound_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { setPresetPendingDeleteId.invoke(null) }) {
+ Text(stringResource(R.string.dialog_cancel))
+ }
+ },
+ )
+ }
+}
+
+@Composable
+fun TimerDialog(
+ context: Context,
+ showTimerDialog: Boolean,
+ sleepTimerRemainingMillis: Long?,
+ startSleepTimer: (Long) -> Unit,
+ setTimerDialogFalse: () -> Unit,
+) {
+ var selectedTimerOption by rememberSaveable { mutableIntStateOf(5) }
+ var customTimerInput by rememberSaveable { mutableStateOf("") }
+
+ if (showTimerDialog) {
+ AlertDialog(
+ onDismissRequest = { setTimerDialogFalse() },
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.Timer,
+ contentDescription = null,
+ tint =
+ if (sleepTimerRemainingMillis != null) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.primary
+ },
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(stringResource(R.string.timer_dialog_title))
+ }
+ },
+ text = {
+ Column {
+ if (sleepTimerRemainingMillis != null) {
+ Text(
+ text =
+ stringResource(
+ R.string.timer_countdown_label,
+ formatRemainingTimerLabel(sleepTimerRemainingMillis ?: 0L),
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(bottom = 10.dp),
+ )
+ }
+
+ val timerOptions = listOf(1, 5, 10, 15, 30, -1)
+ timerOptions.forEach { option ->
+ val isSelected = selectedTimerOption == option
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .background(
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
+ },
+ shape = RoundedCornerShape(12.dp),
+ )
+ .clickable { selectedTimerOption = option }
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ selected = isSelected,
+ onClick = { selectedTimerOption = option },
+ )
+ Text(
+ text =
+ if (option == -1) {
+ stringResource(R.string.timer_custom)
+ } else {
+ stringResource(R.string.timer_minutes_format, option)
+ },
+ )
+ }
+ }
+ if (selectedTimerOption == -1) {
+ OutlinedTextField(
+ value = customTimerInput,
+ onValueChange = { customTimerInput = it.filter(Char::isDigit) },
+ label = { Text(stringResource(R.string.timer_custom_label)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ val selectedMinutes =
+ if (selectedTimerOption == -1) {
+ customTimerInput.toLongOrNull()
+ } else {
+ selectedTimerOption.toLong()
+ }
+ if (selectedMinutes == null || selectedMinutes <= 0L) {
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.timer_invalid_input),
+ Toast.LENGTH_SHORT,
+ ).show()
+ return@TextButton
+ }
+ startSleepTimer(selectedMinutes * 60_000L)
+ Toast
+ .makeText(
+ context,
+ context.getString(R.string.timer_set, selectedMinutes),
+ Toast.LENGTH_SHORT,
+ ).show()
+ setTimerDialogFalse.invoke()
+ },
+ ) {
+ Text(stringResource(R.string.timer_start))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { setTimerDialogFalse() }) {
+ Text(stringResource(R.string.dialog_cancel))
+ }
+ },
+ )
+ }
+}
+
+@Composable
+fun SleepTimerActiveCard(
+ sleepTimerRemainingMillis: Long,
+ setShowCancelTimerDialogTrue: () -> Unit,
+) {
+ CustomCard {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text =
+ stringResource(
+ R.string.timer_countdown_label,
+ formatTimer(sleepTimerRemainingMillis),
+ ),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ IconButton(onClick = { setShowCancelTimerDialogTrue.invoke() }) {
+ Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = stringResource(R.string.timer_cancel),
+ )
+ }
+ }
+ }
+}
+
+private fun formatRemainingTimerLabel(remainingMillis: Long): String {
+ val totalSeconds = (remainingMillis / 1000L).coerceAtLeast(0L)
+ val hours = totalSeconds / 3600L
+ val minutes = (totalSeconds % 3600L) / 60L
+ val seconds = totalSeconds % 60L
+ return if (hours > 0L) {
+ "%d:%02d:%02d".format(hours, minutes, seconds)
+ } else {
+ "%02d:%02d".format(minutes, seconds)
+ }
+}
diff --git a/app/src/main/java/com/pronaycoding/blankee/feature/home/HomeViewmodel.kt b/app/src/main/java/com/pronaycoding/blankee/feature/home/HomeViewmodel.kt
new file mode 100644
index 0000000..c7801b2
--- /dev/null
+++ b/app/src/main/java/com/pronaycoding/blankee/feature/home/HomeViewmodel.kt
@@ -0,0 +1,513 @@
+package com.pronaycoding.blankee.feature.home
+
+/**
+ * ViewModel for the Home screen managing all audio playback and preset logic.
+ *
+ * Responsibilities:
+ * - Load and manage built-in sounds
+ * - Load and manage custom user-uploaded sounds
+ * - Control sound volume levels (both built-in and custom)
+ * - Manage global playback state (play/pause/reset)
+ * - Save and load sound presets
+ * - Manage sleep timer functionality
+ * - Handle premium feature availability
+ * - Coordinate with SoundManager, GlobalPlaybackState, and repositories
+ *
+ * State flows exposed:
+ * - `canPlay`: Global playback state
+ * - `customSounds`: List of user-uploaded custom sounds
+ * - `builtinVolumes`: Current volume levels for built-in sounds
+ * - `customVolumes`: Current volume levels for custom sounds
+ * - `presets`: Saved sound presets
+ * - `customSoundsUnlocked`: Premium status for custom sounds
+ * - `sleepTimerRemainingMillis`: Sleep timer countdown
+ *
+ * @see SoundManager for audio playback control
+ * @see GlobalPlaybackState for global playback coordination
+ * @see PresetRepositoryImpl for preset persistence
+ * @see CustomSoundRepositoryImpl for custom sound management
+ */
+
+import android.app.Activity
+import android.content.Context
+import android.util.Log
+import android.widget.Toast
+import androidx.core.net.toUri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.pronaycoding.blankee.BuildConfig
+import com.pronaycoding.blankee.R
+import com.pronaycoding.blankee.core.common.PresetJson
+import com.pronaycoding.blankee.core.data.repositoryImpl.CustomSoundRepositoryImpl
+import com.pronaycoding.blankee.core.data.repositoryImpl.PresetRepositoryImpl
+import com.pronaycoding.blankee.core.database.entities.CustomSoundEntity
+import com.pronaycoding.blankee.core.database.entities.PresetEntity
+import com.pronaycoding.blankee.core.datastore.PreferenceManagerRepository
+import com.pronaycoding.blankee.core.service.billing.PlayBillingManager
+import com.pronaycoding.blankee.core.service.playback.GlobalPlaybackState
+import com.pronaycoding.blankee.core.service.playback.MediaPlaybackNotifications
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import java.io.File
+
+class HomeViewmodel(
+ private val soundManager: SoundManager,
+ private val customSoundRepository: CustomSoundRepositoryImpl,
+ private val presetRepository: PresetRepositoryImpl,
+ private val globalPlaybackState: GlobalPlaybackState,
+ private val mediaPlaybackNotifications: MediaPlaybackNotifications,
+ private val context: Context,
+ private val preferenceManager: PreferenceManagerRepository,
+ private val playBillingManager: PlayBillingManager,
+) : ViewModel() {
+ val canPlay: StateFlow = globalPlaybackState.canPlay
+
+ private val _customSounds: MutableStateFlow> = MutableStateFlow(emptyList())
+ val customSounds: StateFlow> = _customSounds.asStateFlow()
+
+ private val _builtinVolumes: MutableStateFlow