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 @@ Kotlin Jetpack Compose Open Source - Checks Passing + Checks Passing

-

# 🎵 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!** -[![Get it on Google Play](https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png)](https://play.google.com/store/apps/details?id=com.pronaycoding.blanket_mobile) +[![Get it on Google Play](https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png)](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> = MutableStateFlow(emptyMap()) + val builtinVolumes: StateFlow> = _builtinVolumes.asStateFlow() + + private val _customVolumes: MutableStateFlow> = MutableStateFlow(emptyMap()) + val customVolumes: StateFlow> = _customVolumes.asStateFlow() + + val presets: StateFlow> = + presetRepository + .observePresets() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + private val loadedCustomSoundIds: MutableSet = mutableSetOf() + private var customSoundsCollectionJob: Job? = null + private var sleepTimerJob: Job? = null + + /** + * Custom sounds are free in debug. In release they require an active Premium purchase + * (synced from Play Billing into preferences). + */ + val customSoundsUnlocked: StateFlow = + preferenceManager + .premiumUnlockedFlow() + .map { storedPremium -> !BuildConfig.CUSTOM_SOUNDS_PREMIUM_LOCKED || storedPremium } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + !BuildConfig.CUSTOM_SOUNDS_PREMIUM_LOCKED, + ) + private val _sleepTimerRemainingMillis: MutableStateFlow = MutableStateFlow(null) + val sleepTimerRemainingMillis: StateFlow = _sleepTimerRemainingMillis.asStateFlow() + + init { + soundManager.loadSounds() + viewModelScope.launch { + customSoundsUnlocked.collect { unlocked -> + if (unlocked) { + startCustomSoundsCollection() + } else { + stopCustomSoundsCollection() + } + } + } + // Always start from a clean playback state on app launch/re-entry. + resetAllSounds() + } + + private fun builtInPresetIndexEndExclusive(): Int = getCardList().size - 1 + + private fun startCustomSoundsCollection() { + if (customSoundsCollectionJob?.isActive == true) return + customSoundsCollectionJob = + viewModelScope.launch { + customSoundRepository.getAllCustomSounds().collect { sounds -> + _customSounds.value = sounds + + sounds.forEach { sound -> + if (!loadedCustomSoundIds.contains(sound.id)) { + val persistentPath = getPersistentFilePath(sound.id, sound.filePath) + + Log.d("HomeViewmodel", "Loading custom sound from: $persistentPath") + soundManager.loadCustomSound(sound.id, persistentPath) + loadedCustomSoundIds.add(sound.id) + Log.d("HomeViewmodel", "Loaded new custom sound: ${sound.displayName}") + } + } + + val soundIds = sounds.map { it.id }.toSet() + loadedCustomSoundIds.forEach { loadedId -> + if (!soundIds.contains(loadedId)) { + soundManager.unloadCustomSound(loadedId) + Log.d("HomeViewmodel", "Unloaded deleted custom sound: $loadedId") + } + } + loadedCustomSoundIds.retainAll(soundIds) + _customVolumes.update { current -> + current.filterKeys { it in soundIds } + } + syncPlaybackNotification() + } + } + } + + private fun stopCustomSoundsCollection() { + customSoundsCollectionJob?.cancel() + customSoundsCollectionJob = null + loadedCustomSoundIds.toList().forEach { id -> + soundManager.unloadCustomSound(id) + } + loadedCustomSoundIds.clear() + _customSounds.value = emptyList() + _customVolumes.value = emptyMap() + syncPlaybackNotification() + } + + fun launchPremiumPurchase(activity: Activity) { + playBillingManager.launchPremiumPurchase(activity) + } + + private fun hasAudibleMix(): Boolean { + val end = builtInPresetIndexEndExclusive() + val builtIn = _builtinVolumes.value.any { (i, v) -> i in 0 until end && v > 0f } + val custom = _customVolumes.value.any { (_, v) -> v > 0f } + return builtIn || custom + } + + private fun syncPlaybackNotification() { + globalPlaybackState.lastHasAudibleMix = hasAudibleMix() + mediaPlaybackNotifications.requestSync( + globalPlaybackState.canPlay.value, + globalPlaybackState.lastHasAudibleMix, + ) + } + + private fun getPersistentFilePath( + soundId: Int, + originalPath: String, + ): String { + val customSoundsDir = File(context.filesDir, "custom_sounds") + if (!customSoundsDir.exists()) { + customSoundsDir.mkdirs() + } + + val persistentFile = File(customSoundsDir, "sound_$soundId.mp3") + + if (!persistentFile.exists()) { + try { + Log.d("HomeViewmodel", "Copying sound from $originalPath to ${persistentFile.absolutePath}") + val inputStream = + if (originalPath.startsWith("content://")) { + context.contentResolver.openInputStream(originalPath.toUri()) + } else { + File(originalPath).inputStream() + } + + inputStream?.use { input -> + persistentFile.outputStream().use { output -> + input.copyTo(output) + } + } + Log.d("HomeViewmodel", "Successfully copied sound to persistent storage") + } catch (e: Exception) { + Log.e("HomeViewmodel", "Error copying sound file: ${e.message}", e) + return originalPath + } + } + + return persistentFile.absolutePath + } + + fun handlePlayPause(canPlay: Boolean) { + globalPlaybackState.setCanPlay(canPlay) + syncPlaybackNotification() + } + + fun resetAllSounds() { + soundManager.stopAllSounds() + _builtinVolumes.value = emptyMap() + _customVolumes.value = emptyMap() + globalPlaybackState.setCanPlay(true) + syncPlaybackNotification() + } + + fun setBuiltinVolume( + index: Int, + volume: Float, + ) { + _builtinVolumes.update { current -> + current.toMutableMap().apply { put(index, volume) } + } + if (!globalPlaybackState.canPlay.value) return + if (volume <= 0f) { + soundManager.stopSound(index) + } else { + if (soundManager.isBuiltInPlaying(index)) { + soundManager.controlSound(index, volume) + } else { + soundManager.playSound(index, volume) + } + } + syncPlaybackNotification() + } + + /** + * Updates built-in sound audio only while dragging a slider. Avoids updating [builtinVolumes] + * and notification sync on every frame (those caused heavy recomposition and service churn). + */ + fun previewBuiltinVolume( + index: Int, + volume: Float, + ) { + if (!globalPlaybackState.canPlay.value) return + if (volume <= 0f) { + soundManager.stopSound(index) + } else { + if (soundManager.isBuiltInPlaying(index)) { + soundManager.controlSound(index, volume) + } else { + soundManager.playSound(index, volume) + } + } + } + + fun onBuiltInCardClick(index: Int) { + val isCurrentlyActive = (_builtinVolumes.value[index] ?: 0f) > 0f + if (!globalPlaybackState.canPlay.value) { + globalPlaybackState.setCanPlay(true) + if (!isCurrentlyActive) { + setBuiltinVolume(index, 1f) + } + return + } + val targetVolume = if (isCurrentlyActive) 0f else 1f + setBuiltinVolume(index, targetVolume) + } + + fun setCustomSoundVolume( + soundId: Int, + volume: Float, + ) { + _customVolumes.update { current -> + current.toMutableMap().apply { put(soundId, volume) } + } + if (!globalPlaybackState.canPlay.value) return + if (volume <= 0f) { + soundManager.stopCustomSound(soundId) + } else { + if (soundManager.isCustomSoundPlaying(soundId)) { + soundManager.controlCustomSound(soundId, volume) + } else { + soundManager.playCustomSound(soundId, volume) + } + } + syncPlaybackNotification() + } + + /** Same as [previewBuiltinVolume] for custom sounds. */ + fun previewCustomSoundVolume( + soundId: Int, + volume: Float, + ) { + if (!globalPlaybackState.canPlay.value) return + if (volume <= 0f) { + soundManager.stopCustomSound(soundId) + } else { + if (soundManager.isCustomSoundPlaying(soundId)) { + soundManager.controlCustomSound(soundId, volume) + } else { + soundManager.playCustomSound(soundId, volume) + } + } + } + + fun onCustomCardClick(soundId: Int) { + val isCurrentlyActive = (_customVolumes.value[soundId] ?: 0f) > 0f + if (!globalPlaybackState.canPlay.value) { + globalPlaybackState.setCanPlay(true) + if (!isCurrentlyActive) { + setCustomSoundVolume(soundId, 1f) + } + return + } + val targetVolume = if (isCurrentlyActive) 0f else 1f + setCustomSoundVolume(soundId, targetVolume) + } + + fun stopSound(int: Int) { + soundManager.stopSound(int) + } + + fun stopCustomSound(soundId: Int) { + soundManager.stopCustomSound(soundId) + } + + fun playSound( + int: Int, + volume: Float, + ) { + if (globalPlaybackState.canPlay.value) { + soundManager.playSound(int, volume) + } + } + + fun playCustomSound( + soundId: Int, + volume: Float, + ) { + if (globalPlaybackState.canPlay.value) { + soundManager.playCustomSound(soundId, volume) + } + } + + fun addCustomSound( + displayName: String, + filePath: String, + ) { + if (!customSoundsUnlocked.value) { + Toast + .makeText( + context, + context.getString(R.string.custom_sounds_premium_message), + Toast.LENGTH_LONG, + ).show() + return + } + viewModelScope.launch { + customSoundRepository.addCustomSound(displayName, filePath) + } + } + + fun removeCustomSound(soundId: Int) { + viewModelScope.launch { + soundManager.unloadCustomSound(soundId) + customSoundRepository.removeCustomSound(soundId) + _customVolumes.update { it - soundId } + syncPlaybackNotification() + } + } + + fun renameCustomSound( + soundId: Int, + newDisplayName: String, + ) { + val sanitizedName = newDisplayName.trim() + if (sanitizedName.isEmpty()) return + viewModelScope.launch { + customSoundRepository.updateCustomSoundDisplayName(soundId, sanitizedName) + } + } + + fun snapshotForNewPreset(): Pair, Map> { + val end = builtInPresetIndexEndExclusive() + val builtIn = + _builtinVolumes.value.filter { (idx, vol) -> + idx in 0 until end && vol > 0f + } + val custom = + if (!customSoundsUnlocked.value) { + emptyMap() + } else { + _customVolumes.value.filter { (_, vol) -> vol > 0f } + } + return builtIn to custom + } + + fun savePreset(name: String) { + val (builtIn, custom) = snapshotForNewPreset() + if (builtIn.isEmpty() && custom.isEmpty()) return + viewModelScope.launch { + presetRepository.savePreset( + PresetEntity( + name = name.trim().ifEmpty { context.getString(R.string.preset_untitled) }, + builtInVolumesJson = PresetJson.mapToJson(builtIn), + customVolumesJson = PresetJson.mapToJson(custom), + ), + ) + Toast + .makeText( + context, + context.getString(R.string.preset_saved), + Toast.LENGTH_SHORT, + ).show() + } + } + + fun applyPreset(preset: PresetEntity) { + val endExclusive = builtInPresetIndexEndExclusive() + val builtIn = + PresetJson + .jsonToMap(preset.builtInVolumesJson) + .filterKeys { it in 0 until endExclusive } + val custom = + if (!customSoundsUnlocked.value) { + emptyMap() + } else { + PresetJson.jsonToMap(preset.customVolumesJson) + } + + soundManager.stopAllSounds() + + _builtinVolumes.value = builtIn + _customVolumes.value = custom + globalPlaybackState.setCanPlay(true) + + builtIn.forEach { (index, volume) -> + if (volume > 0f) { + soundManager.playSound(index, volume) + } + } + custom.forEach { (soundId, volume) -> + if (volume > 0f && loadedCustomSoundIds.contains(soundId)) { + soundManager.playCustomSound(soundId, volume) + } + } + syncPlaybackNotification() + } + + fun deletePreset(id: Long) { + viewModelScope.launch { + presetRepository.deletePreset(id) + } + } + + fun startSleepTimer(durationMillis: Long) { + if (durationMillis <= 0L) return + sleepTimerJob?.cancel() + _sleepTimerRemainingMillis.value = durationMillis + sleepTimerJob = + viewModelScope.launch { + val endTimeMillis = System.currentTimeMillis() + durationMillis + while (true) { + val remaining = (endTimeMillis - System.currentTimeMillis()).coerceAtLeast(0L) + _sleepTimerRemainingMillis.value = remaining + if (remaining <= 0L) break + delay(1000L) + } + _sleepTimerRemainingMillis.value = null + resetAllSounds() + Toast + .makeText( + context, + context.getString(R.string.sleep_timer_finished), + Toast.LENGTH_SHORT, + ).show() + } + } + + fun cancelSleepTimer() { + sleepTimerJob?.cancel() + sleepTimerJob = null + _sleepTimerRemainingMillis.value = null + } + + override fun onCleared() { + sleepTimerJob?.cancel() + customSoundsCollectionJob?.cancel() + super.onCleared() + } +} diff --git a/app/src/main/java/com/pronaycoding/blankee/feature/home/SoundManager.kt b/app/src/main/java/com/pronaycoding/blankee/feature/home/SoundManager.kt new file mode 100644 index 0000000..7b0ea70 --- /dev/null +++ b/app/src/main/java/com/pronaycoding/blankee/feature/home/SoundManager.kt @@ -0,0 +1,296 @@ +package com.pronaycoding.blankee.feature.home + +import android.content.Context +import android.media.MediaPlayer +import android.net.Uri +import android.util.Log +import com.pronaycoding.blankee.core.model.CardItems +import java.io.File + +/** + * Manages audio playback for both built-in and custom sounds in the Blankee application. + * + * This class handles the lifecycle and control of MediaPlayer instances for: + * - Built-in sounds (e.g., rain, birds, waves) loaded from app resources + * - Custom sounds uploaded by users from device storage + * + * Key responsibilities: + * - Loading and unloading audio files + * - Playing, pausing, stopping, and controlling volume of sounds + * - Managing looping playback for ambient/sleep sounds + * - Tracking currently playing sounds for pause/resume functionality + * - Proper resource cleanup and error handling + * + * Thread-safety: This class is designed to be used primarily from the main/UI thread. + * Multiple instances may exist but typically one central instance is used through Koin DI. + * + * @param context The application context used for resource access and MediaPlayer creation + * + * @see MediaPlayer for the underlying audio playback mechanism + * @see GlobalPlaybackState for overall app playback state management + */ +class SoundManager( + private val context: Context, +) { + private val mediaPlayers = mutableMapOf() + private val customMediaPlayers = mutableMapOf() + private var currentlyPlayingSounds: MutableList = mutableListOf() + + /** + * Loads all built-in sounds from app resources into MediaPlayer instances. + * + * This method should be called once during initialization. It iterates through all available + * built-in sounds from [getCardList], creates a MediaPlayer for each, and stores them in + * the [mediaPlayers] map indexed by their position. + * + * Each sound is prepared for playback with looping enabled during [playSound] call. + * Errors during loading are logged but do not halt the process for other sounds. + * + * @see getCardList for the list of available built-in sounds + * @see playSound for playing a loaded sound + */ + fun loadSounds() { + getCardList().forEachIndexed { index: Int, item: CardItems -> + Log.d("SoundManager", "Loading sound: $index") + val mediaPlayer = MediaPlayer.create(context, item.audioSource) + mediaPlayers[index] = mediaPlayer + } + } + + /** + * Loads a custom sound from file path or content URI + */ + fun loadCustomSound( + soundId: Int, + filePath: String, + ): Boolean { + return try { + Log.d("SoundManager", "Loading custom sound: $soundId from path: $filePath") + val mediaPlayer = MediaPlayer() + + // Try to handle both file paths and content URIs + if (filePath.startsWith("content://")) { + // Handle content URI + val uri = Uri.parse(filePath) + mediaPlayer.setDataSource(context, uri) + } else if (filePath.startsWith("file://")) { + // Handle file URI + val uri = Uri.parse(filePath) + mediaPlayer.setDataSource(context, uri) + } else { + // Handle file path + val file = File(filePath) + if (!file.exists()) { + Log.e("SoundManager", "Custom sound file does not exist: $filePath") + return false + } + mediaPlayer.setDataSource(context, Uri.fromFile(file)) + } + + mediaPlayer.prepare() + customMediaPlayers[soundId] = mediaPlayer + Log.d("SoundManager", "Successfully loaded custom sound: $soundId") + true + } catch (e: Exception) { + Log.e("SoundManager", "Error loading custom sound: $soundId - ${e.message}", e) + false + } + } + + /** + * Unloads a custom sound and releases resources + */ + fun unloadCustomSound(soundId: Int) { + customMediaPlayers[soundId]?.let { + try { + if (it.isPlaying) { + it.stop() + } + it.release() + customMediaPlayers.remove(soundId) + Log.d("SoundManager", "Unloaded custom sound: $soundId") + } catch (e: Exception) { + Log.e("SoundManager", "Error unloading custom sound: $soundId", e) + } + } + } + + /** + * Checks if any sound is playing. + */ + fun isAnySoundPlaying(): Boolean = mediaPlayers.values.any { it.isPlaying } || customMediaPlayers.values.any { it.isPlaying } + + /** + * Plays a sound if it's not already playing. + */ + fun playSound( + soundIndex: Int, + volume: Float, + ) { + Log.d("SoundManager", "Play sound called for index: $soundIndex") + mediaPlayers[soundIndex]?.let { mediaPlayer -> + if (!mediaPlayer.isPlaying) { + mediaPlayer.setVolume(volume, volume) + mediaPlayer.start() + mediaPlayer.isLooping = true + Log.d("SoundManager", "Playing sound at index: $soundIndex") + } else { + mediaPlayer.setVolume(volume, volume) + Log.d("SoundManager", "Sound at index: $soundIndex already playing; volume updated.") + } + } ?: Log.e("SoundManager", "MediaPlayer not found for index: $soundIndex") + } + + fun isBuiltInPlaying(soundIndex: Int): Boolean = mediaPlayers[soundIndex]?.isPlaying == true + + /** + * Plays a custom sound from file + */ + fun playCustomSound( + soundId: Int, + volume: Float, + ) { + Log.d("SoundManager", "Play custom sound called for id: $soundId") + customMediaPlayers[soundId]?.let { mediaPlayer -> + if (!mediaPlayer.isPlaying) { + mediaPlayer.setVolume(volume, volume) + mediaPlayer.start() + mediaPlayer.isLooping = true + Log.d("SoundManager", "Playing custom sound: $soundId") + } else { + mediaPlayer.setVolume(volume, volume) + Log.d("SoundManager", "Custom sound: $soundId already playing; volume updated.") + } + } ?: Log.e("SoundManager", "Custom MediaPlayer not found for id: $soundId") + } + + fun isCustomSoundPlaying(soundId: Int): Boolean = customMediaPlayers[soundId]?.isPlaying == true + + /** + * Adjusts the volume of a currently playing sound. + */ + fun controlSound( + soundIndex: Int, + volume: Float, + ) { + mediaPlayers[soundIndex]?.let { + it.setVolume(volume, volume) + } ?: Log.e("SoundManager", "Cannot control sound at index: $soundIndex, not found.") + } + + /** + * Adjusts the volume of a custom sound. + */ + fun controlCustomSound( + soundId: Int, + volume: Float, + ) { + customMediaPlayers[soundId]?.let { + it.setVolume(volume, volume) + } ?: Log.e("SoundManager", "Cannot control custom sound: $soundId, not found.") + } + + /** + * Stops a currently playing sound. + */ + fun stopSound(soundIndex: Int) { + mediaPlayers[soundIndex]?.let { + if (it.isPlaying) { + it.stop() + it.prepare() // Reset to initial state + Log.d("SoundManager", "Stopped sound at index: $soundIndex") + } + } ?: Log.e("SoundManager", "Cannot stop sound at index: $soundIndex, not found.") + } + + /** + * Stops a custom sound. + */ + fun stopCustomSound(soundId: Int) { + customMediaPlayers[soundId]?.let { + if (it.isPlaying) { + it.stop() + try { + it.prepare() + } catch (e: Exception) { + Log.e("SoundManager", "Error preparing custom sound after stop: $soundId", e) + } + Log.d("SoundManager", "Stopped custom sound: $soundId") + } + } ?: Log.e("SoundManager", "Cannot stop custom sound: $soundId, not found.") + } + + fun pauseAllSounds() { + mediaPlayers.entries.forEach { (index, mediaPlayer) -> + if (mediaPlayer.isPlaying) { + mediaPlayer.pause() + currentlyPlayingSounds.add(index) + Log.d("SoundManager", "Paused sound at index: $index") + } + } + customMediaPlayers.entries.forEach { (id, mediaPlayer) -> + if (mediaPlayer.isPlaying) { + mediaPlayer.pause() + currentlyPlayingSounds.add(id) + Log.d("SoundManager", "Paused custom sound: $id") + } + } + Log.d("SoundManager", "All sounds paused.") + } + + /** + * Resumes all paused sounds. + */ + fun resumeAllSounds() { + mediaPlayers.entries.forEach { (index, mediaPlayer) -> + if (currentlyPlayingSounds.contains(index)) { + mediaPlayer.start() + } + } + customMediaPlayers.entries.forEach { (id, mediaPlayer) -> + if (currentlyPlayingSounds.contains(id)) { + mediaPlayer.start() + } + } + currentlyPlayingSounds = mutableListOf() + } + + /** + * Stops all currently playing sounds and clears active sound records. + */ + fun stopAllSounds() { + mediaPlayers.values.forEach { mediaPlayer -> + if (mediaPlayer.isPlaying) { + mediaPlayer.stop() + mediaPlayer.prepare() + } + } + customMediaPlayers.values.forEach { mediaPlayer -> + if (mediaPlayer.isPlaying) { + mediaPlayer.stop() + try { + mediaPlayer.prepare() + } catch (e: Exception) { + Log.e("SoundManager", "Error preparing custom sound after stop", e) + } + } + } + currentlyPlayingSounds.clear() + Log.d("SoundManager", "All sounds stopped.") + } + + /** + * Releases resources used by MediaPlayer. + */ + fun release() { + mediaPlayers.values.forEach { mediaPlayer -> + mediaPlayer.release() + } + customMediaPlayers.values.forEach { mediaPlayer -> + mediaPlayer.release() + } + mediaPlayers.clear() + customMediaPlayers.clear() + Log.d("SoundManager", "SoundManager resources released.") + } +} diff --git a/app/src/main/java/com/pronaycoding/blankee/feature/settings/AboutUsDialogScreen.kt b/app/src/main/java/com/pronaycoding/blankee/feature/settings/AboutUsDialogScreen.kt new file mode 100644 index 0000000..42fe67b --- /dev/null +++ b/app/src/main/java/com/pronaycoding/blankee/feature/settings/AboutUsDialogScreen.kt @@ -0,0 +1,13 @@ +package com.pronaycoding.blankee.feature.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun AboutUsDialogScreen(modifier: Modifier = Modifier) { +} + +@Composable +private fun PreviewAboutUsDialogScreen(modifier: Modifier = Modifier) { + AboutUsDialogScreen() +} diff --git a/app/src/main/java/com/pronaycoding/blankee/feature/settings/SettingsScreen.kt b/app/src/main/java/com/pronaycoding/blankee/feature/settings/SettingsScreen.kt new file mode 100644 index 0000000..7195294 --- /dev/null +++ b/app/src/main/java/com/pronaycoding/blankee/feature/settings/SettingsScreen.kt @@ -0,0 +1,522 @@ +package com.pronaycoding.blankee.feature.settings + +/** + * Settings screen composable for the Blankee application. + * + * Displays app preferences and configuration options: + * - Theme selection (Light/Dark/System) + * - Language selection (English, Hindi, Bengali, Spanish, System) + * - Premium/Billing section (upgrade, restore purchases, management) + * - About section (app info, attribution, links) + * - Privacy & Legal (privacy policy, open source attribution) + * + * Features: + * - Segmented buttons for theme selection + * - Single choice button row for language selection + * - Premium purchase integration + * - External links to privacy policy and GitHub + * - Scrollable layout for all options + * + * State management via [SettingsViewModel]: + * - Theme preference + * - Language preference + * - Premium unlock status + * - Billing operations + * + * @see SettingsViewModel for screen state and business logic + * @see PreferenceManagerRepository for preference persistence + * @see PlayBillingManager for in-app purchases + */ + +import android.app.Activity +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +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.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.BrightnessAuto +import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pronaycoding.blankee.BuildConfig +import com.pronaycoding.blankee.R +import com.pronaycoding.blankee.core.common.Constants +import com.pronaycoding.blankee.core.common.util.findActivity +import com.pronaycoding.blankee.core.common.util.openExternalUrl +import org.koin.androidx.compose.koinViewModel + +@Composable +fun SettingsScreenRoute( + onBackPressed: () -> Unit, + viewModel: SettingsViewModel = koinViewModel(), +) { + val selectedTheme by viewModel.selectedTheme.collectAsStateWithLifecycle() + val selectedLanguage by viewModel.selectedLanguage.collectAsStateWithLifecycle() + val customSoundsUnlocked by viewModel.customSoundsUnlocked.collectAsStateWithLifecycle() + + SettingsScreen( + selectedTheme = selectedTheme, + selectedLanguage = selectedLanguage, + customSoundsUnlocked = customSoundsUnlocked, + themeChoices = viewModel.themeChoices, + languageChoices = viewModel.languageChoices, + onBackPressed = onBackPressed, + updateTheme = viewModel::updateTheme, + updateLanguage = viewModel::updateLanguage, + restorePurchases = viewModel::restorePurchases, + launchPremiumPurchase = viewModel::launchPremiumPurchase, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + selectedTheme: String, + selectedLanguage: String, + customSoundsUnlocked: Boolean, + themeChoices: List, + languageChoices: List, + updateTheme: (String) -> Unit, + updateLanguage: (String) -> Unit, + restorePurchases: (Context) -> Unit, + onBackPressed: () -> Unit, + launchPremiumPurchase: (Activity) -> Unit, +) { + val context = LocalContext.current + val scheme = MaterialTheme.colorScheme + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = scheme.background, + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(R.string.settings_title), + style = MaterialTheme.typography.titleLarge, + ) + }, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_desc_back), + ) + } + }, + colors = + TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = scheme.surface, + scrolledContainerColor = scheme.surface, + ), + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 20.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.settings_section_display), + style = MaterialTheme.typography.labelLarge, + color = scheme.primary, + modifier = Modifier.padding(start = 4.dp, bottom = 2.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = + CardDefaults.cardColors( + containerColor = scheme.surfaceContainerLow, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(Modifier.padding(20.dp)) { + Text( + text = stringResource(R.string.settings_theme), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.settings_theme_hint), + style = MaterialTheme.typography.bodySmall, + color = scheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp, bottom = 16.dp), + ) + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + themeChoices.forEachIndexed { index, choice -> + val selected = selectedTheme == choice.mode + SegmentedButton( + selected = selected, + onClick = { + if (selectedTheme == choice.mode) return@SegmentedButton + updateTheme(choice.mode) + context.findActivity()?.recreate() + }, + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = themeChoices.size, + ), + colors = + SegmentedButtonDefaults.colors( + activeContainerColor = scheme.primaryContainer, + activeContentColor = scheme.onPrimaryContainer, + inactiveContainerColor = scheme.surfaceContainerHighest, + inactiveContentColor = scheme.onSurface, + ), + ) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp), + ) { + Icon( + imageVector = + when (choice.mode) { + Constants.MODE_LIGHT -> Icons.Outlined.LightMode + Constants.MODE_DARK -> Icons.Outlined.DarkMode + else -> Icons.Outlined.BrightnessAuto + }, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.size(6.dp)) + Text( + text = stringResource(choice.labelRes), + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.settings_section_language), + style = MaterialTheme.typography.labelLarge, + color = scheme.primary, + modifier = Modifier.padding(start = 4.dp, bottom = 2.dp, top = 4.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = + CardDefaults.cardColors( + containerColor = scheme.surfaceContainerLow, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(Modifier.padding(vertical = 8.dp)) { + Text( + text = stringResource(R.string.settings_language), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + ) + Text( + text = stringResource(R.string.settings_language_hint), + style = MaterialTheme.typography.bodySmall, + color = scheme.onSurfaceVariant, + modifier = + Modifier + .padding(horizontal = 20.dp) + .padding(bottom = 8.dp), + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + languageChoices.forEach { choice -> + val label = stringResource(choice.labelRes) + val selected = selectedLanguage == choice.tag + Surface( + onClick = { + if (selectedLanguage == choice.tag) return@Surface + updateLanguage(choice.tag) + context.findActivity()?.recreate() + }, + shape = MaterialTheme.shapes.large, + color = + if (selected) { + scheme.primary.copy(alpha = 0.14f) + } else { + scheme.surfaceContainerHighest.copy(alpha = 0.45f) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = scheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = scheme.primary, + modifier = Modifier.size(22.dp), + ) + } + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + if (BuildConfig.CUSTOM_SOUNDS_PREMIUM_LOCKED) { + Text( + text = stringResource(R.string.settings_section_premium), + style = MaterialTheme.typography.labelLarge, + color = scheme.primary, + modifier = Modifier.padding(start = 4.dp, bottom = 2.dp, top = 4.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = + CardDefaults.cardColors( + containerColor = scheme.surfaceContainerLow, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(Modifier.padding(20.dp)) { + if (customSoundsUnlocked) { + Text( + text = stringResource(R.string.premium_active), + style = MaterialTheme.typography.bodyMedium, + color = scheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + TextButton( + onClick = { restorePurchases(context) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.restore_purchases)) + } + } else { + Text( + text = stringResource(R.string.custom_sounds_premium_message), + style = MaterialTheme.typography.bodyMedium, + color = scheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + context + .findActivity() + ?.let { launchPremiumPurchase(it) } + }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + ) { + Text(stringResource(R.string.buy_premium)) + } + Spacer(modifier = Modifier.height(8.dp)) + TextButton( + onClick = { restorePurchases(context) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.restore_purchases)) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + Text( + text = stringResource(R.string.settings_section_others), + style = MaterialTheme.typography.labelLarge, + color = scheme.primary, + modifier = Modifier.padding(start = 4.dp, bottom = 2.dp, top = 4.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + colors = + CardDefaults.cardColors( + containerColor = scheme.surfaceContainerLow, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Surface( + onClick = { openExternalUrl(context, Constants.GITHUB_REPO) }, + color = Color.Transparent, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Text( + text = stringResource(R.string.settings_report_bug_feature), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.settings_report_bug_feature_hint), + style = MaterialTheme.typography.bodySmall, + color = scheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + AttributionFooter( + onOpenRafael = { openExternalUrl(context, Constants.RAFAEL_MARDOJAI_GITHUB) }, + onOpenPronay = { openExternalUrl(context, Constants.PRONAY_GITHUB) }, + ) + } + } +} + +@Composable +private fun AttributionFooter( + onOpenRafael: () -> Unit, + onOpenPronay: () -> Unit, +) { + val scheme = MaterialTheme.colorScheme + + Spacer(modifier = Modifier.height(12.dp)) + + val linkStyle = + SpanStyle( + color = scheme.primary, + fontWeight = FontWeight.SemiBold, + ) + + val inspired = + AnnotatedString + .Builder() + .apply { + append("Inspired by ") + pushStringAnnotation(tag = "rafael", annotation = "rafael") + withStyle(linkStyle) { append("Rafael Mardojai") } + pop() + append("'s Blanket") + }.toAnnotatedString() + + ClickableText( + text = inspired, + style = + MaterialTheme.typography.bodySmall.copy( + color = scheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ), + modifier = Modifier.fillMaxWidth(), + onClick = { offset -> + inspired + .getStringAnnotations(tag = "rafael", start = offset, end = offset) + .firstOrNull() + ?.let { onOpenRafael() } + }, + ) + + val madeBy = + AnnotatedString + .Builder() + .apply { + append("Made with ❤️ by ") + pushStringAnnotation(tag = "pronay", annotation = "pronay") + withStyle(linkStyle) { append("Pronay") } + pop() + }.toAnnotatedString() + + ClickableText( + text = madeBy, + style = + MaterialTheme.typography.bodySmall.copy( + color = scheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 8.dp), + onClick = { offset -> + madeBy + .getStringAnnotations(tag = "pronay", start = offset, end = offset) + .firstOrNull() + ?.let { onOpenPronay() } + }, + ) +} diff --git a/app/src/main/java/com/pronaycoding/blankee/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/pronaycoding/blankee/feature/settings/SettingsViewModel.kt new file mode 100644 index 0000000..ac37858 --- /dev/null +++ b/app/src/main/java/com/pronaycoding/blankee/feature/settings/SettingsViewModel.kt @@ -0,0 +1,127 @@ +package com.pronaycoding.blankee.feature.settings + +/** + * ViewModel for the Settings screen managing user preferences. + * + * Responsibilities: + * - Theme preference management (light/dark/system) + * - Language/locale preference management + * - Premium unlock status monitoring + * - Billing operations coordination + * + * State exposed: + * - `selectedTheme`: Current theme mode + * - `selectedLanguage`: Current language tag (BCP 47) + * - `customSoundsUnlocked`: Premium status + * - `themeChoices`: Available theme options + * - `languageChoices`: Available language options + * + * Theme changes require Activity recreation to apply new theme/language. + * + * @see PreferenceManagerRepository for preference persistence + * @see PlayBillingManager for in-app purchase operations + */ + +import android.app.Activity +import android.content.Context +import android.widget.Toast +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pronaycoding.blankee.BuildConfig +import com.pronaycoding.blankee.R +import com.pronaycoding.blankee.core.common.Constants +import com.pronaycoding.blankee.core.datastore.PreferenceManagerRepository +import com.pronaycoding.blankee.core.service.billing.PlayBillingManager +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.launch +import kotlinx.coroutines.runBlocking + +class SettingsViewModel( + private val prefManager: PreferenceManagerRepository, + private val billing: PlayBillingManager, +) : ViewModel() { + val themeChoices = + listOf( + ThemeChoice(Constants.MODE_LIGHT, R.string.theme_light), + ThemeChoice(Constants.MODE_DARK, R.string.theme_dark), + ThemeChoice(Constants.MODE_SYSTEM, R.string.theme_system), + ) + + val languageChoices = + listOf( + LanguageChoice(Constants.LANGUAGE_TAG_SYSTEM, R.string.language_system), + LanguageChoice(Constants.LANGUAGE_TAG_ENGLISH, R.string.language_english), + LanguageChoice(Constants.LANGUAGE_TAG_HINDI, R.string.language_hindi), + LanguageChoice(Constants.LANGUAGE_TAG_BENGALI, R.string.language_bengali), + LanguageChoice(Constants.LANGUAGE_TAG_SPANISH, R.string.language_spanish), + ) + + private val _selectedTheme = + MutableStateFlow(prefManager.getThemeModeBlocking(Constants.MODE_DARK)) + val selectedTheme: StateFlow = _selectedTheme.asStateFlow() + + private val _selectedLanguage = + MutableStateFlow( + prefManager.getLanguageTagBlocking(Constants.LANGUAGE_TAG_SYSTEM), + ) + val selectedLanguage: StateFlow = _selectedLanguage.asStateFlow() + + val customSoundsUnlocked: StateFlow = + prefManager + .premiumUnlockedFlow() + .map { storedPremium -> !BuildConfig.CUSTOM_SOUNDS_PREMIUM_LOCKED || storedPremium } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + !BuildConfig.CUSTOM_SOUNDS_PREMIUM_LOCKED, + ) + + fun updateTheme(mode: String) { + if (_selectedTheme.value == mode) return + _selectedTheme.value = mode + runBlocking { + prefManager.setThemeMode(mode) + } + } + + fun updateLanguage(tag: String) { + if (_selectedLanguage.value == tag) return + _selectedLanguage.value = tag + runBlocking { + prefManager.setLanguageTag(tag) + } + } + + fun launchPremiumPurchase(activity: Activity) { + billing.launchPremiumPurchase(activity) + } + + fun restorePurchases(context: Context) { + viewModelScope.launch { + val has = billing.syncPremiumFromPlay() + Toast + .makeText( + context, + context.getString( + if (has) R.string.billing_restored else R.string.billing_restore_none, + ), + Toast.LENGTH_SHORT, + ).show() + } + } +} + +data class ThemeChoice( + val mode: String, + val labelRes: Int, +) + +data class LanguageChoice( + val tag: String, + val labelRes: Int, +) diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/App.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/App.kt deleted file mode 100644 index 1fbe41a..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/App.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.pronaycoding.blanket_mobile - -import android.app.Application -import dagger.hilt.android.HiltAndroidApp - -/** - * Created by Pronay Sarker on 12/01/2025 (1:00 AM) - */ -@HiltAndroidApp -class App : Application() \ No newline at end of file diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/AppModule.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/AppModule.kt deleted file mode 100644 index 80377eb..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/AppModule.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.pronaycoding.blanket_mobile - -import android.content.Context -import com.pronaycoding.blanket_mobile.ui.screens.homeScreen.SoundManager -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -/** - * Created by Pronay Sarker on 12/01/2025 (11:01 AM) - */ -@Module -@InstallIn(SingletonComponent::class) // Specify the Hilt component where this module will be installed -object AppModule { - - @Provides - @Singleton - fun provideSoundManager2(@ApplicationContext context: Context): SoundManager { - return SoundManager(context) // Provide context here if SoundManager needs it - } - -} - diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/MainActivity.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/MainActivity.kt deleted file mode 100644 index 26267ca..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/MainActivity.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.pronaycoding.blanket_mobile - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Surface -import androidx.compose.material3.TopAppBarDefaults -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewmodel.compose.viewModel -import com.pronaycoding.blanket_mobile.ui.nav.Navigation -import com.pronaycoding.blanket_mobile.ui.screens.about.AboutScreen -import com.pronaycoding.blanket_mobile.ui.screens.homeScreen.HomeScreenRoute -import com.pronaycoding.blanket_mobile.ui.screens.homeScreen.HomeViewmodel -import com.pronaycoding.blanket_mobile.ui.theme.NapifyAppTheme -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch - -@AndroidEntryPoint -class MainActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - lifecycleScope.launch { - - } - setContent { - NapifyAppTheme { - Surface { - Navigation() - } - } - } - } -} - - diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/common/components/BlanketTabRow.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/common/components/BlanketTabRow.kt deleted file mode 100644 index 3b6374b..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/common/components/BlanketTabRow.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.pronaycoding.blanket_mobile.common.components - -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.ExperimentalMaterial3Api -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.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -@Composable -fun BlanketTabRow(modifier: Modifier = Modifier) { - val titles = listOf("Home", "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/blanket_mobile/common/components/NapifyTopAppBar.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/common/components/NapifyTopAppBar.kt deleted file mode 100644 index 1429cdc..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/common/components/NapifyTopAppBar.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.pronaycoding.blanket_mobile.common.components - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -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.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NapifyTopAppBar( - scrollBehavior: TopAppBarScrollBehavior, - navigateToAboutScreen: () -> Unit, - buttonClicked: (canPlay: Boolean) -> Unit -) { - var showDropdown by rememberSaveable { mutableStateOf(false) } - var canPlay by rememberSaveable { mutableStateOf(true) } - - TopAppBar( - scrollBehavior = scrollBehavior, - title = { - Column( - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "Napify", style = MaterialTheme.typography.labelLarge - ) - Text( - text = "Listen to different sounds", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = .7f) - ) - } - }, - - actions = { - Row { - IconButton( - onClick = { - canPlay = !canPlay - buttonClicked.invoke(canPlay) - } - ) { - Icon( - imageVector = when (canPlay) { - true -> Icons.Default.Pause - false -> Icons.Default.PlayArrow - }, - contentDescription = null - ) - } - IconButton( - onClick = { - showDropdown = true - }, - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = "" - ) - if (showDropdown) { - DropdownMenu( - expanded = showDropdown, - onDismissRequest = { showDropdown = false }) { - - DropdownMenuItem( - text = { Text("About") }, - onClick = { - showDropdown = false - navigateToAboutScreen() - } - ) - } - } - } - } - } - ) -} - - - - - - diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/common/components/PrettyCardView.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/common/components/PrettyCardView.kt deleted file mode 100644 index cebee54..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/common/components/PrettyCardView.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.pronaycoding.blanket_mobile.common.components - -import android.widget.Toast -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -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.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import com.pronaycoding.blanket_mobile.common.model.CardItems -import com.pronaycoding.blanket_mobile.ui.screens.homeScreen.HomeViewmodel - -@Composable -fun PrettyCardView( - index: Int, - cardItem: CardItems, - playOrPause: Boolean, - viewModel: HomeViewmodel = hiltViewModel(), -) { - val context = LocalContext.current - var shouldPlaySound by rememberSaveable { mutableStateOf(false) } - var soundVolumeSlider by rememberSaveable { mutableFloatStateOf(0F) } - - - LaunchedEffect(key1 = shouldPlaySound) { - if (shouldPlaySound) { - viewModel.playSound(index, soundVolumeSlider) - } else viewModel.stopSound(index) - } - - Column(modifier = Modifier.padding(16.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - painter = painterResource(id = cardItem.icon), - contentDescription = "Icon", - modifier = Modifier.size(32.dp), - tint = if (shouldPlaySound) Color(0xFF27a157) else MaterialTheme.colorScheme.onBackground - ) - - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = cardItem.title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onBackground - ) - } - Spacer(modifier = Modifier.height(4.dp)) - - Slider( - colors = SliderDefaults.colors( - thumbColor = when (playOrPause) { - true -> Color(0xFF27a157) - false -> Color.Gray - }, - activeTrackColor = when (playOrPause) { - true -> Color(0xFF27a157) - false -> Color.Gray - }, - inactiveTrackColor = Color(0xFF27a157).copy(alpha = 0.2f), - disabledThumbColor = Color.Gray, - disabledActiveTrackColor = Color.Gray, - disabledInactiveTrackColor = Color.Gray - ), - value = soundVolumeSlider, - onValueChange = { newValue -> - if (playOrPause) { - soundVolumeSlider = newValue - shouldPlaySound = newValue > 0 - viewModel.setVolume(index, soundVolumeSlider) - } - }, - onValueChangeFinished = { - if (!playOrPause) { - Toast.makeText(context, "Can't play sound, Sound on pause", Toast.LENGTH_SHORT) - .show() - - } - }, - valueRange = 0f..1f, - modifier = Modifier - .fillMaxWidth() - .height(8.dp) // Adjusted track height - ) - - } -} diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/common/components/TitleCardView.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/common/components/TitleCardView.kt deleted file mode 100644 index 1cba897..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/common/components/TitleCardView.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.pronaycoding.blanket_mobile.common.components - -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 -) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp, start = 8.dp) - .padding(8.dp), - - colors = CardDefaults.cardColors( - containerColor = Color.Transparent - ) - ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = text, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = Color(0xFF27a157), - style = MaterialTheme.typography.titleLarge - ) - } - } -} - - - -@Composable -//@Preview (showBackground = true) -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") -} \ No newline at end of file diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/common/model/CardItems.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/common/model/CardItems.kt deleted file mode 100644 index dbba386..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/common/model/CardItems.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.pronaycoding.blanket_mobile.common.model - -import com.pronaycoding.blanket_mobile.R - -sealed class CardItems( - var title: String, - var icon: Int, - val audioSource: Int, - val type: String = "", - val firstInType: Boolean = false -) { - /* - * Nature - */ - data object Rain : CardItems( - title = "Rain", - icon = R.drawable.rain, - audioSource = R.raw.nature_rain, - type = "Nature", - firstInType = true - ) - - data object SummerNight : CardItems( - title = "Summer night", - icon = R.drawable.moon, - audioSource = R.raw.nature_summernight, - type = "Nature" - ) - - data object Wind : CardItems( - title = "Wind", - icon = R.drawable.wind, - audioSource = R.raw.nature_wind, - type = "Nature" - ) - - data object Wave : CardItems( - title = "Waves", - icon = R.drawable.wave, - audioSource = R.raw.nature_waves, - type = "Nature" - ) - - data object Stream : CardItems( - title = "Stream", - icon = R.drawable.stream, - audioSource = R.raw.nature_stream, - type = "Nature" - ) - - data object Storm : CardItems( - title = "Storm", - icon = R.drawable.storm, - audioSource = R.raw.nature_storm, - type = "Nature" - ) - - data object Birds : CardItems( - title = "Birds", - icon = R.drawable.birds, - audioSource = R.raw.nature_birds, - type = "Nature" - ) - - /* - Travel - */ - - data object Train : CardItems( - title = "Train", - icon = R.drawable.train, - audioSource = R.raw.travel_train, - type = "Travel", - firstInType = true - ) - - data object Boat : CardItems( - title = "Boat", - icon = R.drawable.sailboat, - audioSource = R.raw.travel_boat, - type = "Travel" - ) - - data object City : CardItems( - title = "City", - icon = R.drawable.city, - audioSource = R.raw.travel_city, - type = "Travel" - ) - - - /* - Interiors - */ - data object CoffeeShop : CardItems( - title = "Coffee Shop", - icon = R.drawable.coffee, - audioSource = R.raw.indoor_interior_coffeeshop, - type = "Interiors", - firstInType = true - ) - - data object FirePlace : CardItems( - title = "Fireplace", - icon = R.drawable.fireplace, - audioSource = R.raw.indoor_interior_fireplace, - type = "Interiors" - ) - - data object BusyRestaurant : CardItems( - title = "Busy Restaurant", - icon = R.drawable.food_delivery, - audioSource = R.raw.indoor_busy_restaurant, - type = "Interiors" - ) - - - /* - Noise - */ - data object PinkNoise : CardItems( - title = "Pink Noise", - icon = R.drawable.pink_noise, - audioSource = R.raw.noise_pink_noise, - type = "Noise", - firstInType = true - ) - - data object WhiteNoise : CardItems( - title = "White Noise", - icon = R.drawable.white_noise, - audioSource = R.raw.noise_white_noise, - type = "Noise" - ) - - /* - custom - */ - data object Custom : CardItems( - title = "", - icon = R.drawable.white_noise, - audioSource = R.raw.noise_white_noise, - type = "Custom" - ) -} diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/nav/Navigation.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/ui/nav/Navigation.kt deleted file mode 100644 index 88975f0..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/nav/Navigation.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.pronaycoding.blanket_mobile.ui.nav - -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.blanket_mobile.ui.screens.about.AboutScreen -import com.pronaycoding.blanket_mobile.ui.screens.homeScreen.HomeScreenRoute - -@Composable -fun Navigation() { - val navController: NavHostController = rememberNavController() - - NavHost(navController = navController, startDestination = Routes.Home.name) { - composable(Routes.Home.name) { - HomeScreenRoute( - navigateToAboutScreen = { navController.navigate(Routes.AboutUs.name) } - ) - } - - composable(Routes.AboutUs.name) { - AboutScreen( - onBackPressed = { - navController.navigateUp() - } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/nav/Routes.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/ui/nav/Routes.kt deleted file mode 100644 index 9f8ec7a..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/nav/Routes.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.pronaycoding.blanket_mobile.ui.nav - -enum class Routes { - Home, - AboutUs, -} \ No newline at end of file diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/about/AboutScreen.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/about/AboutScreen.kt deleted file mode 100644 index 1ce2f23..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/about/AboutScreen.kt +++ /dev/null @@ -1,160 +0,0 @@ -package com.pronaycoding.blanket_mobile.ui.screens.about - -import android.content.ActivityNotFoundException -import android.content.Intent -import android.net.Uri -import android.widget.Toast -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -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.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat.startActivity -import com.pronaycoding.blanket_mobile.R - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AboutScreen( - onBackPressed: () -> Unit -) { - val context = LocalContext.current - Scaffold(topBar = { - TopAppBar(title = { - Text(text = "About us") - }, navigationIcon = { - IconButton(onClick = { onBackPressed.invoke() }) { - Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "") - } - }) - }) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(it) - .padding(horizontal = 16.dp) - ) { - Image( - modifier = Modifier - .size(100.dp) - .align(Alignment.CenterHorizontally).clip( - RoundedCornerShape(30.dp) - ), - painter = painterResource( - id = R.drawable.logo - ), - contentDescription = "", - ) - - Text( - modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 16.dp), - text = "Napify", - style = MaterialTheme.typography.titleLarge - ) - Text( - text = "Listen to different sounds. Improve focus and increase your productivity.", - color = MaterialTheme.colorScheme.inverseSurface.copy(0.5f), - textAlign = TextAlign.Center - ) - - - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedCard( - elevation = CardDefaults.cardElevation(10.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Text( - text = "App Version 1.0", style = MaterialTheme.typography.bodyLarge - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - - OutlinedCard( - elevation = CardDefaults.cardElevation(10.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Text( - text = "Inspired by Rafael Mardojai's Blanket, which is originally a GNOME application.", - ) - } - } - - Spacer(modifier = Modifier.height(2.dp)) - - OutlinedCard( - onClick = { - val url = "https://github.com/itsPronay/napify" - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - context.startActivity(intent) - } catch (e: ActivityNotFoundException) { - Toast.makeText( - context, - "No application can handle this request. Please install a web browser.", - Toast.LENGTH_LONG - ).show() - } catch (e: Exception) { - Toast.makeText(context, "An unexpected error occurred.", Toast.LENGTH_LONG) - .show() - } - }, elevation = CardDefaults.cardElevation(10.dp), modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Text( - text = "Source code", style = MaterialTheme.typography.bodyLarge - ) - Text( - text = "View source code of this app in github", - color = MaterialTheme.colorScheme.inverseSurface.copy(0.5f), - ) - } - } - } - } -} - - - -@Composable -@Preview(showSystemUi = true) -fun AboutScreenPreview(modifier: Modifier = Modifier) { - AboutScreen(onBackPressed = {}) -} \ No newline at end of file diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/homeScreen/HomeViewmodel.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/homeScreen/HomeViewmodel.kt deleted file mode 100644 index 9d6b177..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/homeScreen/HomeViewmodel.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.pronaycoding.blanket_mobile.ui.screens.homeScreen - -import android.util.Log -import androidx.compose.runtime.MutableState -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import com.pronaycoding.blanket_mobile.common.model.CardItems -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject - - -@HiltViewModel -class HomeViewmodel @Inject constructor( - private val soundManager: SoundManager, - savedStateHandle: SavedStateHandle -) : ViewModel() { - - private val _canPlay: MutableStateFlow = MutableStateFlow(true) - val canPlay: StateFlow = _canPlay.asStateFlow() - - init { - soundManager.loadSounds() - } - - fun handlePlayPause(canPlay: Boolean) { - _canPlay.value = canPlay - - if (canPlay) { - soundManager.resumeAllSounds() - } else { - soundManager.pauseAllSounds() - } - } - - fun setVolume(id: Int, volume: Float) { - if (canPlay.value) { - soundManager.controlSound(id, volume) - } - } - - fun stopSound(int: Int) { - soundManager.stopSound(int) - } - - fun playSound(int: Int, volume: Float) { - if (canPlay.value) { - soundManager.playSound(int, volume) - } - } - -} - - -fun getCardList(): List { - return 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, - - CardItems.Custom - ) -} - - - - - - - diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/homeScreen/MainScreen.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/homeScreen/MainScreen.kt deleted file mode 100644 index 9d01b71..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/homeScreen/MainScreen.kt +++ /dev/null @@ -1,198 +0,0 @@ -package com.pronaycoding.blanket_mobile.ui.screens.homeScreen - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -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.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.pronaycoding.blanket_mobile.common.components.NapifyTopAppBar -import com.pronaycoding.blanket_mobile.common.components.PrettyCardView -import com.pronaycoding.blanket_mobile.common.components.TitleCardView -import com.pronaycoding.blanket_mobile.common.model.CardItems - -@Composable -fun HomeScreenRoute( - navigateToAboutScreen: () -> Unit, - viewmodel: HomeViewmodel = hiltViewModel() -) { - val canPlaySound by viewmodel.canPlay.collectAsStateWithLifecycle() - - HomeScreen( - navigateToAboutScreen = navigateToAboutScreen, - handlePlayPause = { viewmodel.handlePlayPause(it) }, - canPlaySound = canPlaySound - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun HomeScreen( - navigateToAboutScreen: () -> Unit, - handlePlayPause: (Boolean) -> Unit, - canPlaySound : Boolean -) { - var scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - NapifyTopAppBar( - scrollBehavior = scrollBehavior, - navigateToAboutScreen = navigateToAboutScreen, - buttonClicked = handlePlayPause - ) - } - ) { - Column( - modifier = Modifier - .padding(it) - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .background( - MaterialTheme.colorScheme.surface.copy(alpha = 0.9f) - ) - ) { - - item { - TitleCardView("Nature") - - CustomCard { - getNatureCards().forEachIndexed { _, cardItems -> - PrettyCardView( - index = getCardList().indexOf(cardItems), - cardItem = cardItems, - canPlaySound - ) - } - } - } - - item { - TitleCardView("Travel") - - CustomCard { - getTravelCards().forEachIndexed { _, cardItems -> - PrettyCardView( - index = getCardList().indexOf(cardItems), - cardItem = cardItems, - canPlaySound - ) - } - } - } - - item { - TitleCardView("Indoor") - - CustomCard { - getIndoorCards().forEachIndexed { _, cardItems -> - PrettyCardView( - index = getCardList().indexOf(cardItems), - cardItem = cardItems, - canPlaySound - ) - } - } - } - - item { - TitleCardView("Noise") - - CustomCard { - getNoiseCards().forEachIndexed { _, cardItems -> - PrettyCardView( - index = getCardList().indexOf(cardItems), - cardItem = cardItems, - canPlaySound - ) - } - } - } - - item { - Spacer(modifier = Modifier.height(20.dp)) - } - } - } - } -} - - -fun getNatureCards(): List { - return listOf( - CardItems.Rain, - CardItems.Wind, - CardItems.Storm, - CardItems.Wave, - CardItems.Stream, - CardItems.Birds, - CardItems.SummerNight - ) -} - -fun getTravelCards(): List { - return listOf( - CardItems.Train, - CardItems.Boat, - CardItems.City - ) -} - -fun getIndoorCards(): List { - return listOf( - CardItems.CoffeeShop, - CardItems.FirePlace, - CardItems.BusyRestaurant - ) -} - -fun getNoiseCards(): List { - return listOf( - CardItems.PinkNoise, - CardItems.WhiteNoise - ) -} - -@Composable -fun CustomCard( - modifier: Modifier = Modifier, - content: @Composable () -> Unit -) { - Card( - modifier = Modifier - .fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(4.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - Column( - modifier = Modifier - .padding(16.dp) - ) { - content() - } - } -} diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/homeScreen/SoundManager.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/homeScreen/SoundManager.kt deleted file mode 100644 index 27c474e..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/screens/homeScreen/SoundManager.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.pronaycoding.blanket_mobile.ui.screens.homeScreen - -import android.content.Context -import android.media.MediaPlayer -import android.util.Log - -class SoundManager(private val context: Context) { - - private val mediaPlayers = mutableMapOf() - private var currentlyPlayingSounds: MutableList = mutableListOf() - - /** - * Loads a list of sounds into the media players. - */ - fun loadSounds() { - getCardList().forEachIndexed { index, item -> - Log.d("SoundManager", "Loading sound: $index") - val mediaPlayer = MediaPlayer.create(context, item.audioSource) - mediaPlayers[index] = mediaPlayer - } - } - - /** - * Checks if a sound is currently playing. - */ - private fun isSoundPlaying(soundIndex: Int): Boolean { - return mediaPlayers[soundIndex]?.isPlaying == true - } - - /** - * Checks if any sound is playing. - */ - fun isAnySoundPlaying(): Boolean { - return mediaPlayers.values.any { it.isPlaying } - } - - /** - * Plays a sound if it's not already playing. - */ - fun playSound(soundIndex: Int, volume: Float) { - Log.d("SoundManager", "Play sound called for index: $soundIndex") - mediaPlayers[soundIndex]?.let { mediaPlayer -> - if (!mediaPlayer.isPlaying) { - mediaPlayer.setVolume(volume, volume) - mediaPlayer.start() - mediaPlayer.isLooping = true - Log.d("SoundManager", "Playing sound at index: $soundIndex") - } else { - Log.d("SoundManager", "Sound at index: $soundIndex is already playing.") - } - } ?: Log.e("SoundManager", "MediaPlayer not found for index: $soundIndex") - } - - /** - * Adjusts the volume of a currently playing sound. - */ - fun controlSound(soundIndex: Int, volume: Float) { - mediaPlayers[soundIndex]?.let { - it.setVolume(volume, volume) - } ?: Log.e("SoundManager", "Cannot control sound at index: $soundIndex, not found.") - } - - /** - * Stops a currently playing sound. - */ - fun stopSound(soundIndex: Int) { - mediaPlayers[soundIndex]?.let { - if (it.isPlaying) { - it.stop() - it.prepare() // Reset to initial state - Log.d("SoundManager", "Stopped sound at index: $soundIndex") - } - } ?: Log.e("SoundManager", "Cannot stop sound at index: $soundIndex, not found.") - } - - fun pauseAllSounds() { - mediaPlayers.entries.forEach { (index, mediaPlayer) -> - if (mediaPlayer.isPlaying) { - mediaPlayer.pause() - currentlyPlayingSounds.add(index) - Log.d("SoundManager", "Paused sound at index: $index") - } - } - Log.d("SoundManager", "All sounds paused.") - } - - - /** - * Resumes all paused sounds. - */ - fun resumeAllSounds() { - mediaPlayers.entries.forEach { (index, mediaPlayer) -> - if (currentlyPlayingSounds.contains(index)) { - mediaPlayer.start() - } - } - currentlyPlayingSounds = mutableListOf() - } - - /** - * Stops all currently playing sounds and clears active sound records. - */ - fun stopAllSounds() { - mediaPlayers.values.forEach { mediaPlayer -> - if (mediaPlayer.isPlaying) { - mediaPlayer.stop() - mediaPlayer.prepare() - } - } - Log.d("SoundManager", "All sounds stopped.") - } - - /** - * Releases resources used by MediaPlayer. - */ - fun release() { - mediaPlayers.values.forEach { mediaPlayer -> - mediaPlayer.release() - } - mediaPlayers.clear() - Log.d("SoundManager", "SoundManager resources released.") - } -} diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/theme/Color.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/ui/theme/Color.kt deleted file mode 100644 index 5e34cf4..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/theme/Color.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.pronaycoding.blanket_mobile.ui.theme - -import androidx.compose.ui.graphics.Color - -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/theme/Theme.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/ui/theme/Theme.kt deleted file mode 100644 index 69d7da2..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/theme/Theme.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.pronaycoding.blanket_mobile.ui.theme - -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 -private val DarkColorScheme = darkColorScheme( - primary = Color(0xFF4A148C), // Muted Purple - secondary = Color(0xFF00796B), // Muted Teal - tertiary = Color(0xFF80CBC4), // Soft Mint Green - background = Color(0xFF121212), // Very Dark Grey for background - surface = Color(0xFF1C1B1F), // Dark surface color for cards and other surfaces - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color(0xFF1C1B1F), // Dark for tertiary elements - onBackground = Color.White, - onSurface = Color.White -) - -private val LightColorScheme = lightColorScheme( - primary = Color(0xFF673AB7), // Soft Purple - secondary = Color(0xFF26A69A), // Calm Teal - tertiary = Color(0xFF80CBC4), // Soft Mint Green - background = Color(0xFFF0F0F0), // Light Grey for background - surface = Color(0xFFFFFFFF), // White surface for cards - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color(0xFF1C1B1F), // Dark text on tertiary elements - onBackground = Color(0xFF1C1B1F), // Dark text on light background - onSurface = Color(0xFF1C1B1F) // Dark text on white surfaces -) - - -@Composable -fun NapifyAppTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit -) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme - } - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/theme/Type.kt b/app/src/main/java/com/pronaycoding/blanket_mobile/ui/theme/Type.kt deleted file mode 100644 index 7f4a490..0000000 --- a/app/src/main/java/com/pronaycoding/blanket_mobile/ui/theme/Type.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.pronaycoding.blanket_mobile.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 - -// Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..e009ebe --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_stat_blankee.xml b/app/src/main/res/drawable/ic_stat_blankee.xml new file mode 100644 index 0000000..1a8e985 --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_blankee.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 036d09b..65291b9 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 036d09b..65291b9 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index eee277e..126ca0e 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index 7078b2a..7f4e591 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 9dd7297..78bc168 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 8f2cec3..1da4cdd 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index 290b355..2e3792e 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 689cafc..b83e579 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 54d6121..a6562d3 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index 56f015a..6083c20 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 0645ac9..e18914a 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 89e24eb..891e038 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index ed13224..8750dd7 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 9e357f2..9e1daeb 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index d6fde69..8fe182d 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index 6001e96..f2f5c68 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 6f308e3..cc83bc3 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..6143a70 --- /dev/null +++ b/app/src/main/res/values-bn/strings.xml @@ -0,0 +1,170 @@ + + + Blankee + Blankee + আরাম করুন এবং মনোযোগ দিন + + সেটিংস + সম্পর্কে + শব্দ বন্ধ করুন + শব্দ চালান + ঘুমের টাইমার + শব্দ রিসেট করুন + আরও বিকল্প + + হোম + সেটিংস + + প্রকৃতি + ভ্রমণ + ইনডোর + ইন্টেরিয়র + শোর + কাস্টম শব্দ + + বৃষ্টি + বাতাস + ঝড় + ঢেউ + স্রোত + পাখির ডাক + গ্রীষ্মের রাত + + ট্রেন + নৌকা + শহর + + কফি শপ + আগুনকুণ্ড + ব্যস্ত রেস্তোরাঁ + + পিঙ্ক নয়েজ + হোয়াইট নয়েজ + + সেটিংস + ডিসপ্লে + ভাষা + থিম + ডিফল্ট গাঢ়। সিস্টেম আপনার ডিভাইসের লাইট/ডার্ক মোড অনুসরণ করে। + ভাষা + সঙ্গে সঙ্গে প্রযোজ্য। নতুন ভাষা লোড করতে অ্যাপ পুনরায় শুরু হয়। + সিস্টেম ভাষা ব্যবহার করুন + হালকা + গাঢ় + সিস্টেম অনুযায়ী + ইংরেজি + বাংলা + হিন্দি + স্প্যানিশ + + কাস্টম শব্দ যোগ করুন + কাস্টম শব্দ প্রিমিয়ামের অংশ। একবার কিনে সেগুলি আনলক করুন। + প্রিমিয়াম কিনুন + কাস্টম শব্দ আনলক করতে প্রিমিয়াম কিনুন + ক্রয় পুনরুদ্ধার করুন + প্রিমিয়াম সক্রিয়। আপনার সমর্থনের জন্য ধন্যবাদ। + প্রিমিয়াম + বিলিং এখনও প্রস্তুত নয়। কয়েক মুহূর্তে আবার চেষ্টা করুন। + স্টোর তালিকা লোড হয়নি। আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন। + ক্রয় পর্দা খুলতে পারেনি। + ক্রয় পুনরুদ্ধার করা হয়েছে। + এই অ্যাকাউন্টের জন্য কোনো সক্রিয় ক্রয় পাওয়া যায়নি। + Custom %1$d + শব্দ যোগ হয়েছে: %1$s + শব্দ যোগ করা যায়নি + সব শব্দ রিসেট হয়েছে + বিরত অবস্থায় শব্দ চালানো যাবে না + কাস্টম শব্দ মুছুন + শব্দ মুছবেন? + কাস্টম শব্দ থেকে \"%1$s\" সরান? + মুছে দিন + শব্দ পুনঃনাম করুন + শব্দের নাম + পুনঃনাম করুন + + এই লিঙ্ক খুলতে কোনো অ্যাপ নেই। দয়া করে একটি ওয়েব ব্রাউজার ইনস্টল করুন। + একটি অপ্রত্যাশিত ত্রুটি ঘটেছে। + + রাফায়েল মার্দোজাই-এর Blanket থেকে অনুপ্রাণিত, যা মূলত একটি GNOME অ্যাপ্লিকেশন। + বিভিন্ন শব্দ শুনুন। মনোযোগ বাড়ান এবং উৎপাদনশীলতা বৃদ্ধি করুন। + + আমাদের সম্পর্কে + সংস্করণ %1$s + সোর্স কোড + GitHub-এ এই অ্যাপের সোর্স কোড দেখুন + পিছনে + অ্যাপ লোগো + শব্দের আইকন + কাস্টম শব্দ যোগ করুন + + প্রিসেট + আপনার মিক্স সংরক্ষণ করুন, পরে যেকোনো সময় লোড করুন। + এখনও কোনো প্রিসেট নেই। + বর্তমান মিক্স সংরক্ষণ করুন + লোড + প্রিসেট মুছুন + প্রিসেট মুছবেন? + \"%1$s\" স্থায়ীভাবে মুছবেন? + শিরোনামহীন মিক্স + প্রিসেট সংরক্ষিত হয়েছে + সংরক্ষণের আগে অন্তত একটি শব্দের ভলিউম বাড়ান। + মিক্স সংরক্ষণ + প্রিসেটের নাম + খালি রেখে গেলে \"শিরোনামহীন মিক্স\" হিসাবে সংরক্ষিত হবে + সংরক্ষণ + বাতিল + ঘুমের টাইমার সেট করুন + কাস্টম + মিনিট + %1$d মি + শুরু করুন + টাইমার সেট করা হয়েছে %1$d মিনিটের জন্য + টাইমারের জন্য একটি বৈধ সময়কাল প্রবেশ করুন + টাইমার শেষ। শব্দ বন্ধ করা হয়েছে। + ঘুমের টাইমার: %1$s + টাইমার বাতিল করুন + ঘুমের টাইমার বাতিল করবেন? + আপনি বর্তমান টাইমার থামাতে চান? + + প্লেব্যাক + আপনার Blankee মিক্স সক্রিয় থাকলে দেখান + মিক্স বাজছে + মিক্স বন্ধ + চালু + বিরতি + বিজ্ঞপ্তি অনুমতি দেবেন? + বিজ্ঞপ্তিগুলি শব্দ চালাকালীন আপনার প্লেব্যাক নিয়ন্ত্রণগুলি উপলব্ধ রাখে। + অনুমতি দিন + তবুও চালিয়ে যান + + Blankee কেমন লাগছে? + আপনার পছন্দ হলে, GitHub-এ প্রোজেক্টটা স্টার করবেন? + হ্যাঁ + না + GitHub-এ স্টার দিন + এটা আমাদের অনেক সাহায্য করে এবং প্রোজেক্ট এগোতে সহায়তা করে। + এখনই স্টার দিন + পরে + + Blankee-তে স্বাগতম + শুরু করতে দ্রুত ট্যুর। + শান্তিপূর্ণ শব্দ মিশ্রিত করুন + একাধিক শব্দ একত্রিত করুন এবং ভলিউম নিয়ন্ত্রণ করে আপনার নিখুঁত পরিবেশ তৈরি করুন। + ঘুমের টাইমার + একটি টাইমার সেট করুন যাতে আপনার মিক্স স্বয়ংক্রিয়ভাবে বন্ধ হয়। + প্রিসেট + আপনার প্রিয় মিক্সগুলি সংরক্ষণ করুন এবং যেকোনো সময় লোড করুন। + কাস্টম শব্দ + আপনার নিজের অডিও ফাইলগুলি আমদানি করুন এবং আপনার মিক্সে যোগ করুন। + ভাষা নির্বাচন করুন + আপনি এটি পরে সেটিংস-এ পরিবর্তন করতে পারেন। + পিছনে + পরবর্তী + শুরু করুন + অন্যান্য + একটি বাগ রিপোর্ট করুন বা বৈশিষ্ট্য অনুরোধ করুন + একটি সমস্যা তৈরি করতে GitHub রিপোজিটরি খুলুন। + Pronay দ্বারা ভালোবাসা দিয়ে তৈরি + + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..e62513d --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,170 @@ + + + Blankee + Blankee + Relájate y concéntrate + + Ajustes + Acerca de + Pausar sonidos + Reproducir sonidos + Temporizador de sueño + Restablecer sonidos + Más opciones + + Inicio + Ajustes + + Naturaleza + Viajes + Interior + Interiores + Ruido + Sonidos personalizados + + Lluvia + Viento + Tormenta + Olas + Arroyo + Pájaros + Noche de verano + + Tren + Barco + Ciudad + + Cafetería + Chimenea + Restaurante concurrido + + Ruido rosa + Ruido blanco + + Ajustes + Pantalla + Idioma + Tema + Oscuro por defecto. Sistema sigue el modo claro/oscuro del dispositivo. + Idioma + Se aplica al instante. La app se reinicia para cargar el nuevo idioma. + Usar idioma del sistema + Claro + Oscuro + Sistema + Inglés + Bengalí + Hindi + Español + + Añadir sonido personalizado + Los sonidos personalizados son parte de Premium. Desbloquéalos con una compra única. + Comprar Premium + Compra Premium para desbloquear sonidos personalizados + Restaurar compras + Premium está activo. Gracias por tu apoyo. + Premium + Facturación no está lista aún. Intenta de nuevo en un momento. + Listado de tienda no cargado. Comprueba tu conexión e intenta de nuevo. + No se pudo abrir la pantalla de compra. + Compras restauradas. + No se encontraron compras activas para esta cuenta. + Custom %1$d + Sonido añadido: %1$s + No se pudo añadir el sonido + Todos los sonidos se restablecieron + No se puede reproducir mientras está en pausa + Eliminar sonido personalizado + ¿Eliminar sonido? + ¿Eliminar \"%1$s\" de sonidos personalizados? + Eliminar + Renombrar sonido + Nombre del sonido + Renombrar + + Ninguna aplicación puede abrir este enlace. Instala un navegador web. + Ocurrió un error inesperado. + + Inspirado en Blanket de Rafael Mardojai, originalmente una aplicación de GNOME. + Escucha distintos sonidos. Mejora la concentración y aumenta tu productividad. + + Acerca de nosotros + Versión %1$s + Código fuente + Ver el código fuente de esta app en GitHub + Volver + Logotipo de la app + Icono de sonido + Añadir sonido personalizado + + Preajustes + Guarda tu mezcla actual y cárgala cuando quieras. + Aún no hay preajustes. + Guardar mezcla actual + Cargar + Eliminar preajuste + ¿Eliminar preajuste? + ¿Eliminar \"%1$s\" permanentemente? + Mezcla sin título + Preajuste guardado + Sube al menos un sonido antes de guardar. + Guardar mezcla + Nombre del preajuste + Si se deja en blanco, se guardará como \"Mezcla sin título\" + Guardar + Cancelar + Configurar temporizador de sueño + Personalizado + Minutos + %1$d m + Iniciar + Temporizador configurado para %1$d minutos + Ingresa una duración válida para el temporizador + Temporizador finalizado. Sonidos detenidos. + Temporizador de sueño: %1$s + Cancelar temporizador + ¿Cancelar temporizador? + ¿Quieres detener el temporizador actual? + + Reproducción + Se muestra cuando tu mezcla de Blankee está activa + Mezcla en reproducción + Mezcla en pausa + Reproducir + Pausar + ¿Permitir notificaciones? + Las notificaciones mantienen tus controles de reproducción disponibles mientras se reproducen los sonidos. + Permitir + Continuar de todas formas + + ¿Te está gustando Blankee? + Si te gusta, ¿quieres darle una estrella al proyecto en GitHub? + + No + Dale una estrella en GitHub + Ayuda mucho y hace que el proyecto siga creciendo. + Dar estrella + Quizá después + + Bienvenido a Blankee + Un recorrido rápido para comenzar. + Mezcla sonidos relajantes + Combina múltiples sonidos y controla el volumen para crear tu ambiente perfecto. + Temporizador de sueño + Configura un temporizador para que tu mezcla se detenga automáticamente. + Preajustes + Guarda tus mezclas favoritas y cárgalas en cualquier momento. + Sonidos personalizados + Importa tus propios archivos de audio y agrégalos a tu mezcla. + Elige idioma + Puedes cambiar esto más tarde en Ajustes. + Atrás + Siguiente + Comienza + Otros + Reportar un error o solicitar una función + Abre el repositorio de GitHub para crear un problema. + Hecho con amor por Pronay + + + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..7db4963 --- /dev/null +++ b/app/src/main/res/values-hi/strings.xml @@ -0,0 +1,170 @@ + + + Blankee + Blankee + आराम करें और एकाग्र रहें + + सेटिंग्स + परिचय + ध्वनि रोकें + ध्वनि चलाएँ + स्लीप टाइमर + ध्वनियाँ रीसेट करें + और विकल्प + + होम + सेटिंग्स + + प्रकृति + यात्रा + इनडोर + इंटीरियर + शोर + कस्टम ध्वनियाँ + + बारिश + हवा + तूफ़ान + लहरें + नदी/धारा + पक्षियों की चहचहाहट + गर्मियों की रात + + ट्रेन + नाव + शहर + + कॉफ़ी शॉप + अंगीठी + भीड़भाड़ वाला रेस्तरां + + पिंक नॉइज़ + व्हाइट नॉइज़ + + सेटिंग्स + प्रदर्शन + भाषा + थीम + डिफ़ॉल्ट गहरा है। सिस्टम आपके डिवाइस की लाइट/डार्क सेटिंग का पालन करता है। + भाषा + तुरंत लागू होता है। नई भाषा लोड करने के लिए ऐप फिर से शुरू होता है। + सिस्टम भाषा का उपयोग करें + हल्का + गहरा + सिस्टम के अनुसार + अंग्रेज़ी + बंगाली + हिंदी + स्पेनिश + + कस्टम ध्वनि जोड़ें + कस्टम ध्वनियाँ प्रीमियम का हिस्सा हैं। उन्हें एकबारी खरीद से अनलॉक करें। + प्रीमियम खरीदें + कस्टम ध्वनियों को अनलॉक करने के लिए प्रीमियम खरीदें + खरीद पुनः स्थापित करें + प्रीमियम सक्रिय है। आपके समर्थन के लिए धन्यवाद। + प्रीमियम + बिलिंग अभी तैयार नहीं है। कुछ समय में फिर से कोशिश करें। + स्टोर लिस्टिंग लोड नहीं हुई। अपना कनेक्शन जांचें और फिर से कोशिश करें। + क्रय स्क्रीन नहीं खोल सके। + खरीद पुनः स्थापित की गई। + इस खाते के लिए कोई सक्रिय खरीद नहीं मिली। + Custom %1$d + ध्वनि जोड़ी गई: %1$s + ध्वनि नहीं जोड़ी जा सकी + सभी ध्वनियाँ रीसेट हो गईं + रोके जाने पर ध्वनि नहीं चल सकती + कस्टम ध्वनि हटाएँ + ध्वनि हटाएँ? + कस्टम ध्वनियों से \"%1$s\" हटाएँ? + हटाएँ + ध्वनि का नाम बदलें + ध्वनि का नाम + नाम बदलें + + यह लिंक खोलने वाला कोई ऐप नहीं है। कृपया वेब ब्राउज़र इंस्टॉल करें। + अनपेक्षित त्रुटि हुई। + + राफ़ेल मार्डोजाई के Blanket से प्रेरित, जो मूल रूप से एक GNOME ऐप्लिकेशन है। + अलग-अलग ध्वनियाँ सुनें। फोकस बेहतर करें और उत्पादकता बढ़ाएँ। + + हमारे बारे में + संस्करण %1$s + सोर्स कोड + GitHub पर इस ऐप का सोर्स कोड देखें + पीछे जाएँ + ऐप लोगो + ध्वनि आइकन + कस्टम ध्वनि जोड़ें + + प्रीसेट + अपना मिश्रण सहेजें, फिर कभी भी लोड करें। + अभी कोई प्रीसेट नहीं। + वर्तमान मिश्रण सहेजें + लोड करें + प्रीसेट हटाएँ + प्रीसेट हटाएँ? + \"%1$s\" स्थायी रूप से हटाएँ? + अनाम मिश्रण + प्रीसेट सहेजा गया + सहेजने से पहले कम से कम एक ध्वनि बढ़ाएँ। + मिश्रण सहेजें + प्रीसेट नाम + यदि खाली छोड़ा जाता है, तो \"अनाम मिश्रण\" के रूप में सहेजा जाएगा + सहेजें + रद्द करें + स्लीप टाइमर सेट करें + कस्टम + मिनट + %1$d मि + शुरू करें + टाइमर %1$d मिनट के लिए सेट किया गया + टाइमर के लिए वैध अवधि दर्ज करें + टाइमर खत्म। ध्वनियाँ रुक गईं। + स्लीप टाइमर: %1$s + टाइमर रद्द करें + स्लीप टाइमर रद्द करें? + क्या आप वर्तमान टाइमर को रोकना चाहते हैं? + + प्लेबैक + जब आपका Blankee मिश्रण सक्रिय हो तब दिखाएँ + मिश्रण चल रहा है + मिश्रण रोका गया + चलाएँ + रोकें + सूचनाओं की अनुमति दें? + सूचनाएँ ध्वनियाँ चलते समय आपके प्लेबैक नियंत्रण उपलब्ध रखती हैं। + अनुमति दें + फिर भी जारी रखें + + क्या आपको Blankee पसंद आ रहा है? + अगर पसंद आया, तो क्या आप GitHub पर प्रोजेक्ट को स्टार करना चाहेंगे? + हाँ + नहीं + GitHub पर स्टार करें + इससे हमें बहुत मदद मिलती है और प्रोजेक्ट आगे बढ़ता है। + अभी स्टार करें + बाद में + + Blankee में स्वागत है + शुरुआत के लिए एक दृश्य। + आरामदायक ध्वनियाँ मिलाएँ + एकाधिक ध्वनियों को संयोजित करें और वॉल्यूम नियंत्रित करके अपना सही माहौल बनाएँ। + स्लीप टाइमर + एक टाइमर सेट करें ताकि आपका मिश्रण स्वतः बंद हो जाए। + प्रीसेट + अपने पसंदीदा मिश्रण सहेजें और कभी भी लोड करें। + कस्टम ध्वनियाँ + अपनी ऑडियो फाइलें आयात करें और अपने मिश्रण में जोड़ें। + भाषा चुनें + आप यह बाद में सेटिंग्स में बदल सकते हैं। + वापस जाएँ + अगला + शुरू करें + अन्य + बग रिपोर्ट करें या फीचर का अनुरोध करें + समस्या बनाने के लिए GitHub रिपॉजिटरी खोलें। + Pronay द्वारा प्रेम से बनाया गया + + + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index beab31f..c5d5899 100644 --- a/app/src/main/res/values/ic_launcher_background.xml +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #000000 + #FFFFFF \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4818cc9..9aeae14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,170 @@ + - Napify - \ No newline at end of file + Blankee + Blankee + Relax and focus + + Settings + About + Pause sounds + Play sounds + Sleep timer + Reset sounds + More options + + Home + Settings + + Nature + Travel + Indoor + Interiors + Noise + Custom sounds + + Rain + Wind + Storm + Waves + Stream + Birds + Summer night + + Train + Boat + City + + Coffee shop + Fireplace + Busy restaurant + + Pink noise + White noise + + Settings + Display + Language + Theme + Dark is the default. System follows your device light/dark mode. + Language + Applies immediately. The app restarts to load the new language. + Use system language + Light + Dark + System + English + Bengali + Hindi + Spanish + + Add custom sound + Custom sounds are part of Premium. Unlock them with a one-time purchase. + Buy Premium + Buy Premium to unlock custom sounds + Restore purchases + Premium is active. Thank you for your support. + Premium + Billing is not ready yet. Try again in a moment. + Store listing not loaded. Check your connection and try again. + Could not open the purchase screen. + Purchases restored. + No active purchases found for this account. + Custom %1$d + Sound added: %1$s + Could not add sound + All sounds reset + Can\'t play sound while paused + Delete custom sound + Delete sound? + Remove \"%1$s\" from custom sounds? + Delete + Rename sound + Sound name + Rename + + No app can open this link. Please install a web browser. + An unexpected error occurred. + + Inspired by Rafael Mardojai\'s Blanket, originally a GNOME application. + Listen to different sounds. Improve focus and increase your productivity. + + About us + Version %1$s + Source code + View this app\'s source code on GitHub + Back + App logo + Sound icon + Add custom sound + + Presets + Save your current mix, then load it anytime. + No presets yet. + Save current mix + Load + Delete preset + Delete preset? + Delete \"%1$s\" permanently? + Untitled mix + Preset saved + Turn up at least one sound before saving. + Save mix + Preset name + If left empty, it will be saved as \"Untitled mix\" + Save + Cancel + Set sleep timer + Custom + Minutes + %1$d m + Start + Timer set for %1$d minutes + Enter a valid timer duration + Timer finished. Sounds stopped. + Sleep timer: %1$s + Cancel timer + Cancel sleep timer? + Do you want to stop the current timer? + + Playback + Shows when your Blankee mix is active + Mix playing + Mix paused + Play + Pause + Allow notifications? + Notifications keep your playback controls available while sounds are running. + Grant + Continue anyway + + Enjoying Blankee? + If you like it, would you like to star the project on GitHub? + Yes + No + Star on GitHub + It helps a lot and keeps the project growing. + Star now + Maybe later + + Welcome to Blankee + A quick tour to get you started. + Mix relaxing sounds + Combine multiple sounds and control volume to create your perfect ambience. + Sleep timer + Set a timer so your mix stops automatically. + Presets + Save your favorite mixes and load them anytime. + Custom sounds + Import your own audio files and add them to your mix. + Choose language + You can change this later in Settings. + Back + Next + Start + Others + Report a bug or request a feature + Open the GitHub repository to create an issue. + Made with love by Pronay + + + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..15476c9 --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/test/java/com/pronaycoding/blanket_mobile/ExampleUnitTest.kt b/app/src/test/java/com/pronaycoding/blankee/ExampleUnitTest.kt similarity index 78% rename from app/src/test/java/com/pronaycoding/blanket_mobile/ExampleUnitTest.kt rename to app/src/test/java/com/pronaycoding/blankee/ExampleUnitTest.kt index 009f4fd..2e229f7 100644 --- a/app/src/test/java/com/pronaycoding/blanket_mobile/ExampleUnitTest.kt +++ b/app/src/test/java/com/pronaycoding/blankee/ExampleUnitTest.kt @@ -1,9 +1,8 @@ -package com.pronaycoding.blanket_mobile +package com.pronaycoding.blankee +import junit.framework.TestCase.assertEquals import org.junit.Test -import org.junit.Assert.* - /** * Example local unit test, which will execute on the development machine (host). * @@ -14,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/appScreenshots/mobile_view.png b/appScreenshots/mobile_view.png new file mode 100644 index 0000000..9782a65 Binary files /dev/null and b/appScreenshots/mobile_view.png differ diff --git a/appScreenshots/tab_image.png b/appScreenshots/tab_image.png new file mode 100644 index 0000000..96d95e3 Binary files /dev/null and b/appScreenshots/tab_image.png differ diff --git a/build.gradle.kts b/build.gradle.kts index ff934bb..97307c6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,9 +2,29 @@ plugins { alias(libs.plugins.androidApplication) apply false alias(libs.plugins.jetbrainsKotlinAndroid) apply false - - //hilt - id("com.google.dagger.hilt.android") version "2.54" apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.google.ksp) apply false + alias(libs.plugins.google.gms.google.services) apply false + alias(libs.plugins.google.firebase.crashlytics) apply false + alias(libs.plugins.spotless) +} + +spotless { + kotlin { + target("**/src/**/*.kt") + targetExclude("**/build/**") + ktlint(libs.versions.ktlint.get()) + .setEditorConfigPath("$rootDir/.editorconfig") + trimTrailingWhitespace() + endWithNewline() + } -} \ No newline at end of file + kotlinGradle { + target("*.gradle.kts", "**/*.gradle.kts") + targetExclude("**/build/**") + ktlint(libs.versions.ktlint.get()) + .setEditorConfigPath("$rootDir/.editorconfig") + trimTrailingWhitespace() + endWithNewline() + } +} diff --git a/build/tmp/spotless-register-dependencies b/build/tmp/spotless-register-dependencies new file mode 100644 index 0000000..56a6051 --- /dev/null +++ b/build/tmp/spotless-register-dependencies @@ -0,0 +1 @@ +1 \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 20e2a01..889a959 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,5 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.suppressUnsupportedCompileSdk=35 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f4b70fe..6ed37ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,12 @@ [versions] agp = "8.5.2" -hiltNavigationCompose = "1.2.0" +billing = "8.3.0" +koinAndroid = "3.5.6" +koinAndroidxCompose = "3.5.6" kotlin = "2.1.0" +kotlinxCoroutinesAndroid = "1.5.2" +kotlinxCoroutinesCore = "1.5.2" +ksp = "2.1.0-1.0.28" coreKtx = "1.15.0" junit = "4.13.2" junitVersion = "1.2.1" @@ -10,12 +15,34 @@ lifecycleRuntimeKtx = "2.8.7" activityCompose = "1.9.3" composeBom = "2024.12.01" lifecycleViewmodelCompose = "2.8.7" +lifecycleViewmodelKtx = "2.4.0" material3Android = "1.3.1" lifecycleRuntimeComposeAndroid = "2.8.7" +firebaseCrashlytics = "20.0.5" +googleGmsGoogleServices = "4.4.4" +googleFirebaseCrashlytics = "3.0.7" +materialIconsExtended = "1.5.1" +media3Exoplayer = "1.3.1" +media3Ui = "1.3.1" +media3ExoplayerDash = "1.3.1" +navigationCompose = "2.7.7" +roomRuntime = "2.8.4" +spotless = "6.25.0" +ktlint = "1.4.1" [libraries] +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } +androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "media3ExoplayerDash" } +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRuntime" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } +billing = { module = "com.android.billingclient:billing", version.ref = "billing" } +billing-ktx = { module = "com.android.billingclient:billing-ktx", version.ref = "billing" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -32,9 +59,18 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } androidx-lifecycle-runtime-compose-android = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose-android", version.ref = "lifecycleRuntimeComposeAndroid" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" } +koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koinAndroidxCompose" } +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koinAndroid" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +google-gms-google-services = { id = "com.google.gms.google-services", version.ref = "googleGmsGoogleServices" } +google-firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "googleFirebaseCrashlytics" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/local.properties b/local.properties index d13015d..11a235f 100644 --- a/local.properties +++ b/local.properties @@ -1,10 +1,8 @@ -## This file is automatically generated by Android Studio. -# Do not modify this file -- YOUR CHANGES WILL BE ERASED! -# -# This file should *NOT* be checked into Version Control Systems, +## This file must *NOT* be checked into Version Control Systems, # as it contains information specific to your local configuration. # # Location of the SDK. This is only used by Gradle. # For customization when using a Version Control System, please read the # header note. -sdk.dir=/home/pronay/Android/Sdk \ No newline at end of file +#Thu Apr 02 17:21:24 BDT 2026 +sdk.dir=/Users/pronaysarker/Library/Android/sdk diff --git a/settings.gradle.kts b/settings.gradle.kts index 8e4c8c2..c88f202 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,5 +19,5 @@ dependencyResolutionManagement { } } -rootProject.name = "Napify - Listen to different sounds" +rootProject.name = "Blankee - Listen to different sounds" include(":app")