diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100755 index 4ffde15f2..000000000 --- a/app/build.gradle +++ /dev/null @@ -1,117 +0,0 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-parcelize' -apply plugin: 'kotlin-kapt' -//apply plugin: 'com.google.firebase.crashlytics' -//apply plugin: 'com.google.gms.google-services' - -android { - namespace 'com.dimowner.audiorecorder' - compileSdkVersion 34 - defaultConfig { - applicationId "com.dimowner.audiorecorder" - minSdkVersion 23 - targetSdkVersion 34 - versionCode 935 - versionName "0.9.99" - } - - buildFeatures { - viewBinding true - buildConfig true - } - - def keystorePropertiesFile = rootProject.file("keystore.properties") - def keystoreProperties = new Properties() - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) - - signingConfigs { - dev { - storeFile file('key/debug/debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - release { - storeFile file(keystoreProperties['prodStoreFile']) - storePassword keystoreProperties['prodStorePassword'] - keyAlias keystoreProperties['prodKeyAlias'] - keyPassword keystoreProperties['prodKeyPassword'] - } - } - - buildTypes { - release { - minifyEnabled true - shrinkResources true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' -// firebaseCrashlytics { -// mappingFileUploadEnabled true -// } - } - debug { - minifyEnabled false - } - } - - flavorDimensions "default" - - productFlavors { - debugConfig { - dimension "default" - applicationId "com.dimowner.audiorecorder.debug" - signingConfig = signingConfigs.dev - } - releaseConfig { - dimension "default" - signingConfig = signingConfigs.dev - applicationId "com.dimowner.audiorecorder" - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - lintOptions { - abortOnError false - } -} - -// Remove not needed buildVariants. -android.variantFilter { variant -> - if (variant.buildType.name == 'release' - && variant.getFlavors().get(0).name == 'debugConfig') { - variant.setIgnore(true) - } - if (variant.buildType.name == 'debug' - && variant.getFlavors().get(0).name == 'releaseConfig') { - variant.setIgnore(true) - } -} - -dependencies { - def androidX = "1.3.2" - def coroutines = "1.8.0" - def timber = "5.0.1" - - //Kotlin - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines" - - //Timber - implementation "com.jakewharton.timber:timber:$timber" - implementation "androidx.recyclerview:recyclerview:$androidX" - - testImplementation("junit:junit:4.13.2") - testImplementation("io.mockk:mockk:1.13.10") - -// // Import the BoM for the Firebase platform -// implementation platform('com.google.firebase:firebase-bom:26.1.0') -// // Declare the dependencies for the Crashlytics and Analytics libraries -// // When using the BoM, you don't specify versions in Firebase library dependencies -// implementation 'com.google.firebase:firebase-crashlytics' -// implementation 'com.google.firebase:firebase-analytics' -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 000000000..377d55b88 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,135 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) + alias(libs.plugins.compose.compiler) + id("kotlin-parcelize") +} + +android { + namespace = "com.dimowner.audiorecorder" + compileSdk = 36 + + defaultConfig { + applicationId = "com.dimowner.audiorecorder" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + } + + buildFeatures { + viewBinding = true + } + + signingConfigs { + create("dev") { + storeFile = file("key/debug/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-rules.pro" + ) +// firebaseCrashlytics { +// mappingFileUploadEnabled true +// } + } + getByName("debug") { + isMinifyEnabled = false + } + } + + flavorDimensions += listOf("default") + productFlavors { + create("debugConfig") { + dimension = "default" + applicationId = "com.dimowner.audiorecorder.debug" + signingConfig = signingConfigs.getByName("dev") + } + create("releaseConfig") { + dimension = "default" + signingConfig = signingConfigs.getByName("dev") + applicationId = "com.dimowner.audiorecorder" + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + viewBinding = true + buildConfig = true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + packaging { + resources.excludes.addAll( + listOf("META-INF/LICENSE.md", "META-INF/LICENSE-notice.md") + ) + } +} + +composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") + stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf") +} + +dependencies { + ksp(libs.androidx.room.compiler) + ksp(libs.hilt.android.compiler) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.timber) + implementation(libs.androidx.viewpager2) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.documentfile) + implementation(libs.hilt.android) + implementation(libs.exoplayer.core) + implementation(libs.exoplayer.ui) + implementation(libs.androidx.core.splashscreen) + implementation(libs.gson) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.constraintlayout.compose) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.viewbinding) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.navigation.compose) + implementation(libs.hilt.navigation.compose) + + testImplementation(libs.junit) + testImplementation(libs.androidx.junit.ktx) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + androidTestImplementation(libs.mockk) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/AppExtensionsTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/AppExtensionsTest.kt new file mode 100644 index 000000000..e693de939 --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/AppExtensionsTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app + +import android.content.Context +import android.content.res.Resources +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AppExtensionsTest { + + @Test + fun formatDuration_correctly_formats_duration() { + val resources: Resources = ApplicationProvider.getApplicationContext().resources + + val durationMillis = (365L * 24 * 60 * 60 + 24L * 60 * 60 + 60L * 60 + 60 + 1) * 1000 + Assert.assertEquals("1year 1day 01h:01m:01s", formatDuration(resources, durationMillis)) + + val durationMillis2 = (365L * 24 * 60 * 60) * 1000 + Assert.assertEquals("1year 00m:00s", formatDuration(resources, durationMillis2)) + + val durationMillis3 = (24L * 60 * 60 + 60L * 60 + 60 + 1) * 1000 + Assert.assertEquals("1day 01h:01m:01s", formatDuration(resources, durationMillis3)) + + val durationMillis4 = (23L * 60 * 60 + 59 * 60 + 59) * 1000 + Assert.assertEquals("23h:59m:59s", formatDuration(resources, durationMillis4)) + + val durationMillis5 = (10 * 365L * 24 * 60 * 60 + 125 * 24 * 60 * 60 + 23L * 60 * 60 + 59 * 60 + 59) * 1000 + Assert.assertEquals("10years 125days 23h:59m:59s", formatDuration(resources, durationMillis5)) + } + + @Test + fun formatDuration_handles_zero_duration() { + // Example input: 0 milliseconds + val durationMillis = 0L + + val resources: Resources = ApplicationProvider.getApplicationContext().resources + + val formattedDuration = formatDuration(resources, durationMillis) + + Assert.assertEquals("00m:00s", formattedDuration) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensionsTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensionsTest.kt new file mode 100644 index 000000000..4887f2f87 --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensionsTest.kt @@ -0,0 +1,522 @@ +package com.dimowner.audiorecorder.v2.app.records + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.dimowner.audiorecorder.util.TimeUtils.formatDateSmartLocale +import com.dimowner.audiorecorder.v2.data.model.SortOrder +import io.mockk.impl.annotations.MockK +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import io.mockk.MockKAnnotations +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.fail +import java.util.Calendar +import kotlin.collections.find + +@RunWith(AndroidJUnit4::class) +class RecordsExtensionsTest { + + @MockK + lateinit var mockContext: Context + + private lateinit var records: List + private lateinit var initialState: RecordsScreenState + + @Before + fun setup() { + MockKAnnotations.init(this) + + val time = Calendar.getInstance() + time.set(2025, 5, 24) + val time1 = time.timeInMillis + time.set(2025, 5, 22) + val time2 = time.timeInMillis + 1000 + val time3 = time.timeInMillis + time.set(2024, 7, 20) + val time4 = time.timeInMillis + + records = listOf( + RecordListItem( + recordId = 101, + name = "Name1", + details = "Details1", + duration = "1:01", + added = time1, + isBookmarked = false + ), + RecordListItem( + recordId = 202, + name = "Name2", + details = "Details2", + duration = "2:02", + added = time2, + isBookmarked = false + ), + RecordListItem( + recordId = 303, + name = "Name3", + details = "Details3", + duration = "3:03", + added = time3, + isBookmarked = false + ), + RecordListItem( + recordId = 404, + name = "Name4", + details = "Details4", + duration = "4:04", + added = time4, + isBookmarked = false + ), + ) + + initialState = RecordsScreenState( + sortOrder = SortOrder.DateDesc, + recordsMap = records.groupRecordsByDate(mockContext, SortOrder.DateDesc), + ) + } + + @Test + fun sort_by_DateAsc_returns_oldest_first() { + // time4 (2024) is the oldest + val result = records.sort(SortOrder.DateAsc) + + assertEquals(404L, result.first().recordId) + assertEquals(303L, result[1].recordId) + assertEquals(101L, result.last().recordId) + } + + @Test + fun sort_by_DateDesc_returns_newest_first() { + // time1 and time2 are the newest + val result = records.sort(SortOrder.DateDesc) + + assertEquals(101L, result.first().recordId) + assertEquals(202L, result[1].recordId) + assertEquals(404L, result.last().recordId) + } + + @Test + fun sort_by_NameDesc_returns_Name4_first() { + val result = records.sort(SortOrder.NameDesc) + + assertEquals("Name4", result.first().name) + assertEquals("Name3", result[1].name) + assertEquals("Name1", result.last().name) + } + + @Test + fun sort_by_NameAsc_returns_Name1_first() { + val result = records.sort(SortOrder.NameAsc) + + assertEquals("Name1", result.first().name) + assertEquals("Name2", result[1].name) + assertEquals("Name4", result.last().name) + } + + @Test + fun sort_by_DurationShortest_returns_1_01_first() { + val result = records.sort(SortOrder.DurationShortest) + + assertEquals("1:01", result.first().duration) + assertEquals("2:02", result[1].duration) + assertEquals("4:04", result.last().duration) + } + + @Test + fun sort_by_DurationLongest_returns_1_01_first() { + val result = records.sort(SortOrder.DurationLongest) + + assertEquals("4:04", result.first().duration) + assertEquals("3:03", result[1].duration) + assertEquals("1:01", result.last().duration) + } + + @Test + fun addRecordToMap_adds_record_to_existing_group_and_maintains_sort() { + // 1. Setup: A new record for an existing date (June 20, 2025 - same as Name1) + // We'll use NameDesc sort to see it move to the top of its group + val sortOrder = SortOrder.NameDesc + val existingMap = records.groupRecordsByDate(mockContext, sortOrder) + + val newRecord = RecordListItem( + recordId = 999, + name = "Name33", // Should come second in NameDesc + details = "Details5", + added = records.first().added, // June 20, 2025 + duration = "0:30", + isBookmarked = false, + ) + + // 2. Execution + val resultMap = existingMap.addRecordToMap(mockContext, newRecord, sortOrder) + + // 3. Verification + //For sortOrder not by date group key is always empty string. + val groupList = resultMap[""] + if (groupList == null) { + fail("Group key not found") + } else { + assertEquals(404L, groupList.first().recordId) + assertEquals(999, groupList[1].recordId) + assertEquals(5, groupList.size) + } + } + + @Test + fun addRecordToMap_creates_new_group_when_date_does_not_exist() { + // 1. Setup: A record from a completely different year (1999) + val oldTime = Calendar.getInstance().apply { set(1999, 0, 1) }.timeInMillis + val oldRecord = RecordListItem( + recordId = 999, + name = "Vintage", + details = "Details", + added = oldTime, + duration = "9:99", + isBookmarked = false + ) + + // 2. Execution + val resultMap = initialState.recordsMap.addRecordToMap( + mockContext, + oldRecord, + SortOrder.DateDesc + ) + + // 3. Verification + val newKey = formatDateSmartLocale(oldTime, mockContext) + assertTrue(resultMap.containsKey(newKey)) + assertEquals(1, resultMap[newKey]?.size) + assertEquals(999L, resultMap[newKey]?.first()?.recordId) + } + + @Test + fun addRecordToMap_preserves_other_groups_during_insertion() { + // Setup + val newRecord = RecordListItem( + recordId = 777, + name = "UniqueDate", + added = 0L, // Different date + details = "Details", + duration = "1:00", + isBookmarked = false + ) + + // Execution + val resultMap = initialState.recordsMap.addRecordToMap( + mockContext, + newRecord, + SortOrder.DateDesc + ) + + // Verification + assertEquals(4, resultMap.size) + // Check that Name1 (the May 24th 2025 record) is still there + val key2025May24th = formatDateSmartLocale(records.first().added, mockContext) + assertNotNull(resultMap[key2025May24th]) + assertEquals(1, resultMap[key2025May24th]?.size) + + // Check that Name1 (the May 22th 2025 record) is still there + val key2025May22th = formatDateSmartLocale(records[1].added, mockContext) + assertNotNull(resultMap[key2025May22th]) + assertEquals(2, resultMap[key2025May22th]?.size) + + // Check that Name4 (the 2024 record) is still there + val key2024 = formatDateSmartLocale(records.last().added, mockContext) + assertNotNull(resultMap[key2024]) + assertEquals(1, resultMap[key2024]?.size) + } + + //================ Test mapRecordInMap ==================== + + @Test + fun mapRecordInMap_shouldSuccessfullyUpdateTheTargetRecord() { + val newName = "New Target Name" + val recordId = 303L + val originalMap = records.groupRecordsByDate( + mockContext, SortOrder.DateDesc + ) + + // The update operation: changing name and bookmark status + val updateOperation: (RecordListItem) -> RecordListItem = { oldRecord -> + oldRecord.copy(name = newName, isBookmarked = true) + } + + // Act + val newMap = originalMap.mapRecordInMap(recordId, updateOperation) + + // Retrieve the updated record from the new map + val updatedRecord = newMap["Jun 22"]?.find { it.recordId == recordId } + + // Assert + assertNotEquals(originalMap, newMap) + assertEquals(newName, updatedRecord?.name) + assertTrue(updatedRecord?.isBookmarked == true) + assertEquals(recordId, updatedRecord?.recordId) + } + + @Test + fun mapRecordInMap_shouldReturnLogicallyIdenticalMap_whenIdIsNotFound() { + val newName = "New Target Name" + val recordId = 999L + val originalMap = records.groupRecordsByDate( + mockContext, SortOrder.DateDesc + ) + + // The update operation: changing name and bookmark status + val updateOperation: (RecordListItem) -> RecordListItem = { oldRecord -> + oldRecord.copy(name = newName, isBookmarked = true) + } + + // Act + val newMap = originalMap.mapRecordInMap(recordId, updateOperation) + + // Assert + newMap.values.forEach { list -> + assertNull(list.find { it.recordId == recordId }) + } + + assertEquals(originalMap, newMap) + assertNotSame(originalMap, newMap) + } + + @Test + fun mapRecordInMap_shouldHandleEmptyMap() { + // Arrange + val emptyMap = emptyMap>() + val updateOperation: (RecordListItem) -> RecordListItem = { it.copy(isBookmarked = true) } + + // Act + val newMap = emptyMap.mapRecordInMap(101, updateOperation) + + // Assert + assert(newMap.isEmpty()) { "Processing an empty map should result in an empty map." } + } + + //=============== Test groupRecordsByDate ===================== + + @Test + fun updateRecordInMap_shouldCreateNewStateAndUpdateRecord() { + // Arrange + val newName = "State Update Target" + val recordId = 303L + val updateOperation: (RecordListItem) -> RecordListItem = { it.copy(name = newName, isBookmarked = true) } + + // Act + val newState = initialState.updateRecordInMap(recordId, updateOperation) + + // Retrieve the updated record from the new map + val updatedRecord = newState.recordsMap["Jun 22"]?.find { it.recordId == recordId } + + // Assert + assertNotEquals(initialState, newState) + assertEquals(newName, updatedRecord?.name) + assertTrue(updatedRecord?.isBookmarked == true) + assertEquals(recordId, updatedRecord?.recordId) + } + + @Test + fun updateRecordInMap_shouldPreserveUnrelatedStateFields() { + // Arrange + val newName = "State Update Target" + val recordId = 303L + val updateOperation: (RecordListItem) -> RecordListItem = { it.copy(name = newName, isBookmarked = true) } + + // Act + val newState = initialState.updateRecordInMap(recordId, updateOperation) + + // Assert: Check unrelated state fields are preserved + assertNotEquals(initialState.recordsMap, newState.recordsMap) + assertEquals(initialState.selectedRecords, newState.selectedRecords) + assertEquals(initialState.sortOrder, newState.sortOrder) + assertEquals(initialState.bookmarksSelected, newState.bookmarksSelected) + assertEquals(initialState.showDeletedRecordsButton, newState.showDeletedRecordsButton) + assertEquals(initialState.showRecordPlaybackPanel, newState.showRecordPlaybackPanel) + assertEquals(initialState.deletedRecordsCount, newState.deletedRecordsCount) + assertEquals(initialState.isShowLoadingProgress, newState.isShowLoadingProgress) + assertEquals(initialState.showRenameDialog, newState.showRenameDialog) + assertEquals(initialState.showMoveToRecycleDialog, newState.showMoveToRecycleDialog) + assertEquals(initialState.showMoveToRecycleMultipleDialog, newState.showMoveToRecycleMultipleDialog) + assertEquals(initialState.showSaveAsDialog, newState.showSaveAsDialog) + assertEquals(initialState.showSaveAsMultipleDialog, newState.showSaveAsMultipleDialog) + assertEquals(initialState.operationSelectedRecord, newState.operationSelectedRecord) + assertEquals(initialState.activeRecord, newState.activeRecord) + } + + @Test + fun updateRecordInMap_shouldReturnNewStateButIdenticalRecordContent_whenIdIsNotFound() { + // Arrange + val nonExistentId = 999L + val updateOperation: (RecordListItem) -> RecordListItem = { it.copy(name = "Should Not Be Seen") } + + // Act + val newState = initialState.updateRecordInMap(nonExistentId, updateOperation) + + // Assert 1: The state object must still be new (due to the outer copy) + assertNotSame(initialState, newState) + assertEquals(initialState, newState) + } + + //=============== Test groupRecordsByDate ====================== + + @Test + fun removeRecordFromMap_shouldRemoveRecordButPreserveGroup() { + val initialMap = records.groupRecordsByDate(mockContext, SortOrder.DateAsc) + val recordId = 202L + + // Act + val newMap = initialMap.removeRecordFromMap(recordId) + + // Assert 1: The key for Group A still exists + assert(newMap.containsKey("Jun 22")) { "Group key should still exist." } + + // Assert 2: Group A now has only 1 item + assertEquals(1, newMap["Jun 22"]?.size) + + // Assert 3: The removed record is gone + val removedRecord = newMap["Jun 22"]?.find { it.recordId == recordId } + assertNull(removedRecord) + } + + @Test + fun removeRecordFromMap_shouldRemoveRecordAndDeleteGroup_whenListBecomesEmpty() { + val initialMap = records.groupRecordsByDate(mockContext, SortOrder.DateAsc) + val recordId = 101L + + // Act + val newMap = initialMap.removeRecordFromMap(recordId) + + // Assert 1: The key for Group (Jun 20) should be gone + assert(!newMap.containsKey("Jun 20")) { "Group Jun 20 key should be deleted because its list is now empty." } + + // Assert 2: The map size should be reduced from 3 to 2 + assertEquals(2, newMap.size) + } + + @Test + fun removeRecordFromMap_shouldReturnLogicallyIdenticalMap_whenIdIsNotFound() { + // Arrange + val initialMap = records.groupRecordsByDate(mockContext, SortOrder.DateAsc) + val nonExistentId = 999L + + // Act + val newMap = initialMap.removeRecordFromMap(nonExistentId) + + // Assert: The maps are equal but not the same + assertEquals(3, newMap.size) + assertEquals(initialMap, newMap) + assertNotSame(initialMap, newMap) + } + + @Test + fun removeRecordFromMap_shouldHandleEmptyMap() { + // Arrange + val recordId = 202L + val emptyMap = emptyMap>() + + // Act + val newMap = emptyMap.removeRecordFromMap(recordId) + + // Assert + assert(newMap.isEmpty()) { "Processing an empty map should result in an empty map." } + } + + //================== Test groupRecordsByDate ========================= + + @Test + fun groupRecordsByDate_shouldGroupItemsByDate_whenSortOrderIsDateAsc() { + val result = records.groupRecordsByDate(mockContext, SortOrder.DateAsc) + assertEquals(3, result.size) + assertEquals(1, result["Jun 24"]?.size) + assertEquals(2, result["Jun 22"]?.size) + assertEquals(1, result["Aug 20, 2024"]?.size) + } + + @Test + fun groupRecordsByDate_shouldGroupItemsByDate_whenSortOrderIsDateDesc() { + val result = records.groupRecordsByDate(mockContext, SortOrder.DateDesc) + + assertEquals(3, result.size) + assertEquals(1, result["Jun 24"]?.size) + assertEquals(2, result["Jun 22"]?.size) + assertEquals(1, result["Aug 20, 2024"]?.size) + } + + @Test + fun groupRecordsByDate_shouldGroupAllItemsByEmptyString_whenSortOrderIs_NameAsc() { + val result = records.groupRecordsByDate(mockContext, SortOrder.NameAsc) + + // Assert: Expecting exactly one group with the key "" + assertEquals(1, result.size) + + // Assert: The single group key should be the empty string + val singleGroupKey = "" + assert(result.containsKey(singleGroupKey)) { "The single group key must be an empty string." } + assertEquals(records.size, result[singleGroupKey]?.size) + } + + @Test + fun groupRecordsByDate_shouldGroupAllItemsByEmptyString_whenSortOrderIs_NameDesc() { + val result = records.groupRecordsByDate(mockContext, SortOrder.NameDesc) + + // Assert: Expecting exactly one group with the key "" + assertEquals(1, result.size) + + // Assert: The single group key should be the empty string + val singleGroupKey = "" + assert(result.containsKey(singleGroupKey)) { "The single group key must be an empty string." } + assertEquals(records.size, result[singleGroupKey]?.size) + } + + @Test + fun groupRecordsByDate_shouldGroupAllItemsByEmptyString_whenSortOrderIs_DurationLongest() { + val result = records.groupRecordsByDate(mockContext, SortOrder.DurationLongest) + + // Assert: Expecting exactly one group with the key "" + assertEquals(1, result.size) + + // Assert: The single group key should be the empty string + val singleGroupKey = "" + assert(result.containsKey(singleGroupKey)) { "The single group key must be an empty string." } + assertEquals(records.size, result[singleGroupKey]?.size) + } + + @Test + fun groupRecordsByDate_shouldGroupAllItemsByEmptyString_whenSortOrderIs_DurationShortest() { + val result = records.groupRecordsByDate(mockContext, SortOrder.DurationShortest) + + // Assert: Expecting exactly one group with the key "" + assertEquals(1, result.size) + + // Assert: The single group key should be the empty string + val singleGroupKey = "" + assert(result.containsKey(singleGroupKey)) { "The single group key must be an empty string." } + assertEquals(records.size, result[singleGroupKey]?.size) + } + + @Test + fun groupRecordsByDate_shouldHandleEmptyList() { + val emptyRecords = emptyList() + + val result = emptyRecords.groupRecordsByDate(mockContext, SortOrder.DateAsc) + + assert(result.isEmpty()) { "Grouping an empty list should result in an empty map." } + } + + @Test + fun isSortOrderByDate_shouldReturnTrue_forDateAsc() { + assertTrue(SortOrder.DateAsc.isSortOrderByDate()) + assertTrue(SortOrder.DateDesc.isSortOrderByDate()) + assertFalse(SortOrder.NameAsc.isSortOrderByDate()) + assertFalse(SortOrder.NameDesc.isSortOrderByDate()) + assertFalse(SortOrder.DurationLongest.isSortOrderByDate()) + assertFalse(SortOrder.DurationShortest.isSortOrderByDate()) + } +} diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/PrefsImplTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/PrefsImplTest.kt new file mode 100644 index 000000000..8a729511e --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/PrefsImplTest.kt @@ -0,0 +1,211 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import com.dimowner.audiorecorder.v2.DefaultValues +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.data.model.SortOrder +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@LargeTest +class PrefsImplTest { + + private lateinit var prefs: PrefsV2Impl + + @Before + fun createPrefs() { + val context: Context = ApplicationProvider.getApplicationContext() + prefs = PrefsV2Impl(context) + } + + @After + fun resetPrefs() { + prefs.fullPreferenceReset() + } + + @Test + fun test_fullPreferenceReset() { + val id = 101L + + prefs.confirmFirstRunExecuted() + prefs.activeRecordId = id + prefs.recordedRecordId = id + prefs.isDarkTheme = true + prefs.isAppV2 = true + prefs.settingSampleRate = SampleRate.SR16000 + prefs.settingNamingFormat = NameFormat.DateUs + + prefs.fullPreferenceReset() + + assertEquals(-1, prefs.activeRecordId) + assertEquals(-1, prefs.recordedRecordId) + assertEquals(DefaultValues.isDarkTheme, prefs.isDarkTheme) + assertEquals(DefaultValues.isAppV2, prefs.isAppV2) + assertEquals(DefaultValues.DefaultSampleRate, prefs.settingSampleRate) + assertEquals(DefaultValues.DefaultNameFormat, prefs.settingNamingFormat) + } + + @Test + fun test_firstRun() { + assertTrue(prefs.isFirstRun) + + prefs.confirmFirstRunExecuted() + assertFalse(prefs.isFirstRun) + } + + @Test + fun test_askToRenameAfterRecordingStopped() { + assertEquals(DefaultValues.isAskToRename, prefs.askToRenameAfterRecordingStopped) + + prefs.askToRenameAfterRecordingStopped = !DefaultValues.isAskToRename + assertEquals(!DefaultValues.isAskToRename, prefs.askToRenameAfterRecordingStopped) + } + + @Test + fun test_activeRecordId() { + assertEquals(-1, prefs.activeRecordId) + + prefs.activeRecordId = 303 + assertEquals(303L, prefs.activeRecordId) + } + + @Test + fun test_recordedRecordId() { + assertEquals(-1, prefs.recordedRecordId) + + prefs.recordedRecordId = 404 + assertEquals(404L, prefs.recordedRecordId) + } + + @Test + fun test_recordCounter() { + assertEquals(1, prefs.recordCounter) + + prefs.incrementRecordCounter() + assertEquals(2, prefs.recordCounter) + } + + @Test + fun test_isKeepScreenOn() { + assertEquals(DefaultValues.isKeepScreenOn, prefs.isKeepScreenOn) + + prefs.isKeepScreenOn = !DefaultValues.isKeepScreenOn + assertEquals(!DefaultValues.isKeepScreenOn, prefs.isKeepScreenOn) + } + + @Test + fun test_recordsSortOrder() { + assertEquals(DefaultValues.DefaultSortOrder, prefs.recordsSortOrder) + + prefs.recordsSortOrder = SortOrder.NameDesc + assertEquals(SortOrder.NameDesc, prefs.recordsSortOrder) + } + + @Test + fun test_isDynamicTheme() { + assertEquals(DefaultValues.isDynamicTheme, prefs.isDynamicTheme) + + prefs.isDynamicTheme = !DefaultValues.isDynamicTheme + assertEquals(!DefaultValues.isDynamicTheme, prefs.isDynamicTheme) + } + + @Test + fun test_isDarkTheme() { + assertEquals(DefaultValues.isDarkTheme, prefs.isDarkTheme) + + prefs.isDarkTheme = !DefaultValues.isDarkTheme + assertEquals(!DefaultValues.isDarkTheme, prefs.isDarkTheme) + } + + @Test + fun test_isAppV2() { + assertEquals(DefaultValues.isAppV2, prefs.isAppV2) + + prefs.isAppV2 = !DefaultValues.isAppV2 + assertEquals(!DefaultValues.isAppV2, prefs.isAppV2) + } + + @Test + fun test_settingNamingFormat() { + assertEquals(DefaultValues.DefaultNameFormat, prefs.settingNamingFormat) + + prefs.settingNamingFormat = NameFormat.DateUs + assertEquals(NameFormat.DateUs, prefs.settingNamingFormat) + } + + @Test + fun test_settingRecordingFormat() { + assertEquals(DefaultValues.DefaultRecordingFormat, prefs.settingRecordingFormat) + + prefs.settingRecordingFormat = RecordingFormat.ThreeGp + assertEquals(RecordingFormat.ThreeGp, prefs.settingRecordingFormat) + } + + @Test + fun test_settingSampleRate() { + assertEquals(DefaultValues.DefaultSampleRate, prefs.settingSampleRate) + + prefs.settingSampleRate = SampleRate.SR32000 + assertEquals(SampleRate.SR32000, prefs.settingSampleRate) + } + + @Test + fun test_settingBitrate() { + assertEquals(DefaultValues.DefaultBitRate, prefs.settingBitrate) + + prefs.settingBitrate = BitRate.BR256 + assertEquals(BitRate.BR256, prefs.settingBitrate) + } + + @Test + fun test_settingChannelCount() { + assertEquals(DefaultValues.DefaultChannelCount, prefs.settingChannelCount) + + prefs.settingChannelCount = ChannelCount.Mono + assertEquals(ChannelCount.Mono, prefs.settingChannelCount) + } + + @Test + fun test_resetRecordingSettings() { + prefs.settingRecordingFormat = RecordingFormat.ThreeGp + prefs.settingSampleRate = SampleRate.SR32000 + prefs.settingBitrate = BitRate.BR256 + prefs.settingChannelCount = ChannelCount.Mono + + prefs.resetRecordingSettings() + + assertEquals(DefaultValues.DefaultRecordingFormat, prefs.settingRecordingFormat) + assertEquals(DefaultValues.DefaultSampleRate, prefs.settingSampleRate) + assertEquals(DefaultValues.DefaultBitRate, prefs.settingBitrate) + assertEquals(DefaultValues.DefaultChannelCount, prefs.settingChannelCount) + } +} diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensionsTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensionsTest.kt new file mode 100644 index 000000000..a17fa279e --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensionsTest.kt @@ -0,0 +1,288 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.extensions + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import io.mockk.every +import io.mockk.mockk +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +@LargeTest +class FileExtensionsTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun test_createFile_Existing_Directory() { + // Create a temporary directory for testing + val directory = tempFolder.newFolder("testDir") + + // Create a file within the directory + val fileName = "testFile.txt" + val createdFile = createFile(directory, fileName) + + // Verify that the file was created + assertTrue(createdFile.exists()) + assertEquals(fileName, createdFile.name) + } + + @Test + fun test_createFile_Existing_Directory_and_Existing_File() { + // Create a temporary directory for testing + val directory = tempFolder.newFolder("testDir") + + // Create a file within the directory + val fileName = "testFile.txt" + val createdFile = createFile(directory, fileName) + + // Verify that the file 1 was created and has correct name + assertTrue(createdFile.exists()) + assertEquals(fileName, createdFile.name) + + val expectedFileName2 = "testFile-1.txt" + val createdFile2 = createFile(directory, fileName) + // Verify that the file 2 was created and has correct name + assertTrue(createdFile2.exists()) + assertEquals(expectedFileName2, createdFile2.name) + + val expectedFileName3 = "testFile-2.txt" + val createdFile3 = createFile(directory, fileName) + // Verify that the file 3 was created and has correct name + assertTrue(createdFile3.exists()) + assertEquals(expectedFileName3, createdFile3.name) + } + + @Test + fun test_createFile_Non_Existent_Directory() { + // Create a non-existent directory + val nonExistentDirectory = File("/path/to/non_existent_directory") + val fileName = "testFile.txt" + + // Attempt to create a file in the non-existent directory + assertThrows(IOException::class.java) { + createFile(nonExistentDirectory, fileName) + } + } + + @Throws(IOException::class) + fun File.verifyCanReadWrite() { + if (!this.canRead()) { + throw IOException("Can't read file") + } else if (!this.canWrite()) { + throw IOException("Can't write file") + } + } + + @SuppressWarnings("SwallowedException") + @Test + fun test_verifyCanReadWrite() { + val file = mockk() + every { file.canRead() } returns false + every { file.canWrite() } returns true + + assertThrows(IOException::class.java) { + file.verifyCanReadWrite() + } + + every { file.canRead() } returns true + every { file.canWrite() } returns false + + assertThrows(IOException::class.java) { + file.verifyCanReadWrite() + } + + every { file.canRead() } returns false + every { file.canWrite() } returns false + + assertThrows(IOException::class.java) { + file.verifyCanReadWrite() + } + + every { file.canRead() } returns true + every { file.canWrite() } returns true + + try { + file.verifyCanReadWrite() + } catch (e: IOException) { + fail("Should not have thrown any exception") + } + } + + @Test + fun test_renameExistingFile() { + val tempDir = createTempDir() + val existingFile = File(tempDir, "existing_file.txt") + existingFile.createNewFile() + + val renamed = "renamed_file" + val expectedFile = "$renamed.txt" + val renamedFile = File(tempDir, expectedFile) + + assertTrue(existingFile.exists()) + val result = renameFileWithExtension(existingFile, renamed) + assertEquals(expectedFile, result?.name) + assertFalse(existingFile.exists()) + assertTrue(renamedFile.exists()) + } + + @Test + fun test_renameNonExistentFile() { + val tempDir = createTempDir() + val nonexistentFile = File(tempDir, "nonexistent_file.txt") + + val renamedFile = "renamed_file" + + assertFalse(nonexistentFile.exists()) + assertNull(renameFileWithExtension(nonexistentFile, renamedFile)) + } + + @Test + fun test_renameWithSameName() { + val tempDir = createTempDir() + val existingFile = File(tempDir, "existing_file.txt") + existingFile.createNewFile() + + val renamedFile = "existing_file" + + assertTrue(existingFile.exists()) + assertNull(renameFileWithExtension(existingFile, renamedFile)) + assertTrue(existingFile.exists()) + } + + @Test + fun test_deleteExistingFile() { + val tempDir = createTempDir() + val existingFile = File(tempDir, "existing_file.txt") + existingFile.createNewFile() + + assertTrue(existingFile.exists()) + assertTrue(deleteFileAndChildren(existingFile)) + assertFalse(existingFile.exists()) + } + + @Test + fun test_deleteExistingDirectory() { + val tempDir = createTempDir() + val existingDir = File(tempDir, "existing_directory") + val existingFile = File(existingDir, "existing_file.txt") + existingFile.mkdirs() + + assertTrue(existingDir.isDirectory) + assertTrue(existingFile.exists()) + assertTrue(deleteFileAndChildren(existingFile)) + assertFalse(existingFile.exists()) + } + + @Test + fun test_deleteNonexistentFile() { + val tempDir = createTempDir() + val nonexistentFile = File(tempDir, "nonexistent_file.txt") + + assertFalse(nonexistentFile.exists()) + assertFalse(deleteFileAndChildren(nonexistentFile)) + } + + @Test + fun test_markFileAsDeleted() { + // Create a temporary file for testing + val tempDir = createTempDir() + val tempFile = File(tempDir, "Record.m4a") + tempFile.createNewFile() + + val name = tempFile.name + + assertTrue(tempFile.exists()) + // Mark the file as deleted + val trashFile = markFileAsDeleted(tempFile) + + // Verify that the file was renamed + assertEquals("Record.m4a.deleted", trashFile?.name) + } + + @Test + fun test_markFileAsDeleted_with_non_existent_file() { + // Create a non-existent file + val nonExistentFile = File("/path/to/non_existent_file.m4a") + + assertFalse(nonExistentFile.exists()) + // Mark the file as deleted + val restoredFile = markFileAsDeleted(nonExistentFile) + + // Verify that the result is null (file doesn't exist) + assertNull(restoredFile) + } + + @Test + fun test_unmarkFileAsDeleted() { + // Create a temporary trash file for testing + val tempDir = createTempDir() + val tempTrashFile = File(tempDir, "Record.m4a.deleted") + tempTrashFile.createNewFile() + + val name = tempTrashFile.nameWithoutExtension + + assertTrue(tempTrashFile.exists()) + // Unmark the file (restore it) + val restoredFile = unmarkFileAsDeleted(tempTrashFile) + + // Verify that the file was renamed back to its original name + assertEquals(name, restoredFile?.name) + assertEquals("Record.m4a", restoredFile?.name) + } + + @Test + fun test_unmarkFileAsDeleted_with_non_existent_file() { + // Create a non-existent trash file + val nonExistentTrashFile = File("/path/to/non_existent_file.deleted") + + assertFalse(nonExistentTrashFile.exists()) + // Attempt to unmark the file + val restoredFile = unmarkFileAsDeleted(nonExistentTrashFile) + + // Verify that the result is null (file doesn't exist) + assertNull(restoredFile) + } + + @Test + fun test_getPrivateMusicStorageDir_ExternalStorageAvailable() { + val context: Context = ApplicationProvider.getApplicationContext() + val directoryName = "MyMusic" + + val result = getPrivateMusicStorageDir(context, directoryName) + + assertNotNull(result) + assertTrue(result!!.exists()) + assertEquals(directoryName, result.name) + } +} diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordDaoTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordDaoTest.kt new file mode 100644 index 000000000..e224f7a93 --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordDaoTest.kt @@ -0,0 +1,401 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data.room + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class RecordDaoTest { + + private lateinit var recordDao: RecordDao + private lateinit var db: AppDatabase + + @Before + fun createDb() { + val context: Context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries() + .build() + recordDao = db.recordDao() + //Set demo data + val records = Array(100) { + RecordEntity( + it + 1L, + "Record $it", + 1000L + it, + 123456789L + it, + 123456789L + it, + 0L, + "path/to/record$it", + "mp3", + 1024, + 44100, + 2, + 128, + false, + false, + false, + IntArray(10), + ) + } + + records.forEach { + recordDao.insertRecord(it) + } + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun test_auto_generated_primary_key() { + // Create a sample RecordEntity + val record = RecordEntity( + name = "Sample Record", + duration = 120000L, + created = System.currentTimeMillis(), + added = System.currentTimeMillis(), + removed = System.currentTimeMillis(), + path = "/path/to/record", + format = "mp3", + size = 1024L, + sampleRate = 44100, + channelCount = 2, + bitrate = 128, + isBookmarked = false, + isWaveformProcessed = true, + isMovedToRecycle = false, + amps = intArrayOf(10, 20, 30) + ) + + // Verify that the initial ID is 0 + assertEquals(0, record.id) + + // Insert the record into your database + val insertedId = recordDao.insertRecord(record) + val insertedId2 = recordDao.insertRecord(record) + val insertedId3 = recordDao.insertRecord(record) + + // Verify that the inserted ID is non-zero + assertNotEquals(0, insertedId) + assertNotEquals(0, insertedId2) + assertNotEquals(0, insertedId3) + + // Fetch the record by ID (use your actual DAO here) + val fetchedRecord1 = recordDao.getRecordById(insertedId) + val fetchedRecord2 = recordDao.getRecordById(insertedId2) + val fetchedRecord3 = recordDao.getRecordById(insertedId3) + + assertNotEquals(fetchedRecord1, fetchedRecord2) + assertNotEquals(fetchedRecord1, fetchedRecord3) + assertNotEquals(fetchedRecord2, fetchedRecord3) + + // Verify that the fetched record matches the original record + assertEquals(record.copy(id = fetchedRecord1?.id ?: 0), fetchedRecord1) + assertEquals(record.copy(id = fetchedRecord2?.id ?: 0), fetchedRecord2) + assertEquals(record.copy(id = fetchedRecord3?.id ?: 0), fetchedRecord3) + } + + @Test + fun testInsertAndGetRecordById() { + val record = RecordEntity( + 1001L, + "Test Record", + 1000, + 123456789L, + 123456789L, + 0L, + "path/to/record", + "mp3", + 1024, + 44100, + 2, + 128, + false, + false, + false, + IntArray(10), + ) + recordDao.insertRecord(record) + + val loaded = recordDao.getRecordById(1001L) + assertEquals(record, loaded) + } + + @Test + fun testGetRecordsByIds() { + //Test valid records request + val records = recordDao.getRecordsByIds(listOf(2, 45, 91, 28)) + + assertEquals(4, records.size) + assertEquals("Record 1", records[0].name) + assertEquals("Record 27", records[1].name) + assertEquals("Record 44", records[2].name) + assertEquals("Record 90", records[3].name) + assertEquals(2, records[0].id) + assertEquals(28, records[1].id) + assertEquals(45, records[2].id) + assertEquals(91, records[3].id) + + //Test invalid records request (all invalid ids) + val invalidRecords = recordDao.getRecordsByIds(listOf(-1, -1000, 10101, 200)) + assertEquals(0, invalidRecords.size) + + //Test mixed records request (3 valid ids and 2 invalid) + val mixedRecords = recordDao.getRecordsByIds(listOf(2, -1, 28, 200, 45)) + assertEquals(3, mixedRecords.size) + assertEquals("Record 1", records[0].name) + assertEquals("Record 27", records[1].name) + assertEquals("Record 44", records[2].name) + assertEquals(2, records[0].id) + assertEquals(28, records[1].id) + assertEquals(45, records[2].id) + } + + @Test + @Throws(Exception::class) + fun testUpdateRecord() = runBlocking { + val record = recordDao.getRecordById(1) + + record?.copy(name = "Updated Record")?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + + val updated = recordDao.getRecordById(1) + assertEquals("Updated Record", updated?.name) + } + + @Test + @Throws(Exception::class) + fun testUpdateRecords() = runBlocking { + val records = recordDao.getRecordsByIds(listOf(1, 2)) + + val toUpdate = records.mapIndexed { index, record -> record.copy(name = "Updated record $index") } + val updatedCount = recordDao.updateRecords(toUpdate) + assertEquals(2, updatedCount) + + val updated1 = recordDao.getRecordById(1) + assertEquals("Updated record 0", updated1?.name) + val updated2 = recordDao.getRecordById(2) + assertEquals("Updated record 1", updated2?.name) + } + + @Test + @Throws(Exception::class) + fun testDeleteRecord() = runBlocking { + + recordDao.getRecordById(1)?.let { + recordDao.deleteRecord(it) + } + + val loaded = recordDao.getRecordById(1) + assertNull(loaded) + + val record = RecordEntity( + 1001L, + "Test Record", + 1000, + 123456789L, + 123456789L, + 0L, + "path/to/record", + "mp3", + 1024, + 44100, + 2, + 128, + false, + false, + false, + IntArray(10), + ) + //Delete not existing record silently skipped + recordDao.deleteRecord(record) + } + + @Test + fun testDeleteRecordById() { + val recordBefore = recordDao.getRecordById(1) + assertNotNull(recordBefore) + + recordDao.deleteRecordById(1) + val recordAfter = recordDao.getRecordById(1) + assertNull(recordAfter) + } + + @Test + fun testGetRecordsCount() { + val count = recordDao.getRecordsCount() + assertEquals(100, count) + } + + @Test + fun testGetRecordTotalDuration() { + val duration = recordDao.getRecordTotalDuration() + assertEquals(1000L*100+4950, duration) + //4950 is sum of number sequence from 1 to 100 (1, 2, 3, 4, 5, 6...) + } + + @Test + fun testDeleteAllRecords() { + val countBefore = recordDao.getRecordsCount() + assertEquals(100, countBefore) + + recordDao.deleteAllRecords() + val countAfter = recordDao.getRecordsCount() + assertEquals(0, countAfter) + } + + @Test + fun testGetRecordsByPage() { + val pageSize = 20 + val offset = 40 + val records = recordDao.getAllRecords() + val recordsByPage = recordDao.getRecordsByPage(pageSize, offset) + assertEquals(pageSize, recordsByPage.size) + val expected = records.slice(offset until (offset + pageSize)) + assertEquals(expected, recordsByPage) + } + + @Test + fun test_getAllRecords() { + val recordsAsc = recordDao.getAllRecords() + + assertEquals(100, recordsAsc.size) + assertEquals("Record 99", recordsAsc[0].name) + assertEquals("Record 93", recordsAsc[6].name) + assertEquals("Record 50", recordsAsc[49].name) + assertEquals("Record 6", recordsAsc[93].name) + assertEquals("Record 0", recordsAsc[99].name) + } + + @Test + fun test_getMovedToRecycleRecords() { + val records = recordDao.getMovedToRecycleRecords() + assertEquals(0, records.size) + + val record1 = recordDao.getRecordById(1) + val record50 = recordDao.getRecordById(50) + val record93 = recordDao.getRecordById(93) + + record1?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + record50?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + record93?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + val records2 = recordDao.getMovedToRecycleRecords() + assertEquals(3, records2.size) + assertEquals("Record 0", records2[0].name) + assertEquals("Record 49", records2[1].name) + assertEquals("Record 92", records2[2].name) + assertEquals(1, records2[0].id) + assertEquals(50, records2[1].id) + assertEquals(93, records2[2].id) + } + + @Test + fun test_getMovedToRecycleRecordsCount() { + val count = recordDao.getMovedToRecycleRecordsCount() + assertEquals(0, count) + + val record1 = recordDao.getRecordById(1) + val record50 = recordDao.getRecordById(50) + val record93 = recordDao.getRecordById(93) + + record1?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + val count2 = recordDao.getMovedToRecycleRecordsCount() + assertEquals(1, count2) + record50?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + val count3 = recordDao.getMovedToRecycleRecordsCount() + assertEquals(2, count3) + record93?.copy(isMovedToRecycle = true)?.let { + val updated = recordDao.updateRecord(it) + assertEquals(1, updated) + } + val count4 = recordDao.getMovedToRecycleRecordsCount() + assertEquals(3, count4) + } + + @Test + fun test_getRecordsRewQuery() { + val query1 = "SELECT * FROM records" + + val records1 = recordDao.getRecordsRewQuery(SimpleSQLiteQuery(query1)) + assertEquals(100, records1.size) + assertEquals(1, records1[0].id) + assertEquals(100, records1[99].id) + + val sortField = "added" + val page = 2 + val pageSize = 5 + + val query2 = "SELECT * FROM records" + + " WHERE isBookmarked = 0" + + " ORDER BY $sortField DESC" + + " LIMIT $pageSize OFFSET ${(page - 1) * pageSize}" + + val records2 = recordDao.getRecordsRewQuery(SimpleSQLiteQuery(query2)) + + assertEquals(5, records2.size) + assertEquals("Record 94", records2[0].name) + assertEquals("Record 93", records2[1].name) + assertEquals("Record 92", records2[2].name) + assertEquals("Record 91", records2[3].name) + assertEquals("Record 90", records2[4].name) + + val bookmarkedRecord = recordDao.getRecordById(10)?.copy(id = 0, isBookmarked = true) + if (bookmarkedRecord != null) { + recordDao.insertRecord(bookmarkedRecord) + } + val query3 = "SELECT * FROM records" + + " WHERE isBookmarked = 1" + val records3 = recordDao.getRecordsRewQuery(SimpleSQLiteQuery(query3)) + + assertEquals(1, records3.size) + assertEquals(bookmarkedRecord?.copy(id = 101L), records3[0]) + } +} diff --git a/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDaoTest.kt b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDaoTest.kt new file mode 100644 index 000000000..8efb67c24 --- /dev/null +++ b/app/src/androidTest/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDaoTest.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data.room + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.dimowner.audiorecorder.v2.data.model.RecordEditOperation +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class RecordEditDaoTest { + + private lateinit var recordEditDao: RecordEditDao + private lateinit var db: AppDatabase + + @Before + fun createDb() { + val context: Context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries() + .build() + recordEditDao = db.recordEditDao() + //Set demo data + val recordOperation1 = RecordEditEntity( + 1L, + 101L, + RecordEditOperation.Rename, + "renameName1", + 123456789L, + 0, + ) + val recordOperation2 = RecordEditEntity( + 2L, + 102L, + RecordEditOperation.MoveToRecycle, + null, + 223456789L, + 0, + ) + val recordOperation3 = RecordEditEntity( + 3L, + 103L, + RecordEditOperation.RestoreFromRecycle, + null, + 323456789L, + 0, + ) + val recordOperation4 = RecordEditEntity( + 4L, + 104L, + RecordEditOperation.DeleteForever, + null, + 423456789L, + 0, + ) + recordEditDao.insertRecordsEditOperation(recordOperation1) + recordEditDao.insertRecordsEditOperation(recordOperation2) + recordEditDao.insertRecordsEditOperation(recordOperation3) + recordEditDao.insertRecordsEditOperation(recordOperation4) + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun test_auto_generated_primary_key() { + // Create a sample RecordEditEntity + val recordOperation = RecordEditEntity( + recordId = 110L, + editOperation = RecordEditOperation.Rename, + renameName = "renameName1", + created = 123456789L, + retryCount = 0, + ) + + // Verify that the initial ID is 0 + assertEquals(0, recordOperation.id) + + // Insert the record into your database (use your actual DAO here) + // For demonstration purposes, assume the DAO method is called insertRecord + val insertedId = recordEditDao.insertRecordsEditOperation(recordOperation) + val insertedId2 = recordEditDao.insertRecordsEditOperation(recordOperation) + val insertedId3 = recordEditDao.insertRecordsEditOperation(recordOperation) + + // Verify that the inserted ID is non-zero + assertNotEquals(0, insertedId) + assertNotEquals(0, insertedId2) + assertNotEquals(0, insertedId3) + + // Fetch the record by ID (use your actual DAO here) + val fetchedRecord1 = recordEditDao.getRecordsEditOperationById(insertedId) + val fetchedRecord2 = recordEditDao.getRecordsEditOperationById(insertedId2) + val fetchedRecord3 = recordEditDao.getRecordsEditOperationById(insertedId3) + + assertNotEquals(fetchedRecord1, fetchedRecord2) + assertNotEquals(fetchedRecord1, fetchedRecord3) + assertNotEquals(fetchedRecord2, fetchedRecord3) + + // Verify that the fetched record matches the original record + assertEquals(recordOperation.copy(id = fetchedRecord1?.id ?: 0), fetchedRecord1) + assertEquals(recordOperation.copy(id = fetchedRecord2?.id ?: 0), fetchedRecord2) + assertEquals(recordOperation.copy(id = fetchedRecord3?.id ?: 0), fetchedRecord3) + } + + @Test + fun testInsertAndGetRecordEditOperationById() { + val recordOperation = RecordEditEntity( + 10L, + 110L, + RecordEditOperation.Rename, + "renameName1", + 1023456789L, + 0, + ) + recordEditDao.insertRecordsEditOperation(recordOperation) + + val loaded = recordEditDao.getRecordsEditOperationById(10L) + assertEquals(recordOperation, loaded) + } + + @Test + @Throws(Exception::class) + fun testUpdateRecordEditOperation() = runBlocking { + val record = recordEditDao.getRecordsEditOperationById(1) + + record?.copy( + editOperation = RecordEditOperation.MoveToRecycle)?.let { + recordEditDao.updateRecordsEditOperation(it) + } + + val updated = recordEditDao.getRecordsEditOperationById(1) + assertEquals(RecordEditOperation.MoveToRecycle, updated?.editOperation) + } + + @Test + @Throws(Exception::class) + fun testDeleteRecordEditOperation() = runBlocking { + + recordEditDao.getRecordsEditOperationById(1)?.let { + recordEditDao.deleteRecordsEditOperation(it) + } + + val loaded = recordEditDao.getRecordsEditOperationById(1) + assertNull(loaded) + + val recordOperation = RecordEditEntity( + 10L, + 110L, + RecordEditOperation.Rename, + "renameName1", + 1234567890L, + 0, + ) + //Delete not existing record silently skipped + recordEditDao.deleteRecordsEditOperation(recordOperation) + } + + @Test + fun testDeleteRecordEditOperationById() { + val recordBefore = recordEditDao.getRecordsEditOperationById(1) + assertNotNull(recordBefore) + + recordEditDao.deleteRecordEditOperationById(1) + val recordAfter = recordEditDao.getRecordsEditOperationById(1) + assertNull(recordAfter) + } + + @Test + fun testDeleteAllRecords() { + val countBefore = recordEditDao.getAllRecordsEditOperations().size + assertEquals(4, countBefore) + + recordEditDao.deleteAllRecordsEditOperations() + val countAfter = recordEditDao.getAllRecordsEditOperations().size + assertEquals(0, countAfter) + } + + @Test + fun test_getAllRecords() { + val recordsAsc = recordEditDao.getAllRecordsEditOperations() + + assertEquals(4, recordsAsc.size) + assertEquals(RecordEditOperation.Rename, recordsAsc[3].editOperation) + assertEquals(RecordEditOperation.MoveToRecycle, recordsAsc[2].editOperation) + assertEquals(RecordEditOperation.RestoreFromRecycle, recordsAsc[1].editOperation) + assertEquals(RecordEditOperation.DeleteForever, recordsAsc[0].editOperation) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7de796d5f..1b3338252 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,6 +63,8 @@ + + @@ -130,4 +132,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/com/dimowner/audiorecorder/ARApplication.kt b/app/src/main/java/com/dimowner/audiorecorder/ARApplication.kt index f46696eae..c419c8a35 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/ARApplication.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/ARApplication.kt @@ -33,11 +33,13 @@ import android.telephony.TelephonyManager import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import com.dimowner.audiorecorder.util.AndroidUtils +import dagger.hilt.android.HiltAndroidApp import timber.log.Timber import timber.log.Timber.DebugTree - //import com.google.firebase.FirebaseApp; + +@HiltAndroidApp class ARApplication : Application() { private var audioOutputChangeReceiver: AudioOutputChangeReceiver? = null @@ -201,4 +203,4 @@ class ARApplication : Application() { val longWaveformSampleCount: Int get() = (AppConstants.WAVEFORM_WIDTH * screenWidthDp).toInt() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/AppConstants.java b/app/src/main/java/com/dimowner/audiorecorder/AppConstants.java index 6e90901e8..94d0b0815 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/AppConstants.java +++ b/app/src/main/java/com/dimowner/audiorecorder/AppConstants.java @@ -35,6 +35,18 @@ private AppConstants() {} public static final int PENDING_INTENT_FLAGS; + public static final String PREF_NAME = "com.dimowner.audiorecorder.data.PrefsImpl"; + public static final String PREF_KEY_IS_APP_V2 = "pref_is_app_v2"; + public static final String PREF_KEY_IS_FIRST_RUN = "is_first_run"; + public static final String PREF_KEY_RECORD_COUNTER = "record_counter"; + public static final String PREF_KEY_KEEP_SCREEN_ON = "keep_screen_on"; + //Recording prefs. + public static final String PREF_KEY_SETTING_RECORDING_FORMAT = "setting_recording_format"; + public static final String PREF_KEY_SETTING_BITRATE = "setting_bitrate"; + public static final String PREF_KEY_SETTING_SAMPLE_RATE = "setting_sample_rate"; + public static final String PREF_KEY_SETTING_NAMING_FORMAT = "setting_naming_format"; + public static final String PREF_KEY_SETTING_CHANNEL_COUNT = "setting_channel_count"; + public static final String REQUESTS_RECEIVER = "dmitriy.ponomarenko.ua@gmail.com"; public static final String APPLICATION_NAME = "AudioRecorder"; @@ -55,6 +67,7 @@ private AppConstants() {} public static final String THEME_BLUE_GREY = "blue_gray"; public static final String[] SUPPORTED_EXT = new String[]{"mp3", "wav", "3gpp", "3gp", "amr", "aac", "m4a", "mp4", "ogg", "flac"}; + //.mkv, .aiff .aif .aifc ?? public static final String FORMAT_M4A = "m4a"; public static final String FORMAT_WAV = "wav"; diff --git a/app/src/main/java/com/dimowner/audiorecorder/AppConstantsV2.kt b/app/src/main/java/com/dimowner/audiorecorder/AppConstantsV2.kt new file mode 100644 index 000000000..af01eb80a --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/AppConstantsV2.kt @@ -0,0 +1,7 @@ +package com.dimowner.audiorecorder + +object AppConstantsV2 { + const val WAVEFORM_AMPLITUDE_MAX_VALUE = 32767f + const val SHORT_RECORD = 18000L //Milliseconds + const val DEFAULT_WIDTH_SCALE = 1.5F //Const val describes how many screens a record will take. +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/Injector.java b/app/src/main/java/com/dimowner/audiorecorder/Injector.java index 17e4c84e3..8cdf94cb8 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/Injector.java +++ b/app/src/main/java/com/dimowner/audiorecorder/Injector.java @@ -162,11 +162,11 @@ public RecorderContract.Recorder provideAudioRecorder(Context context) { switch (providePrefs(context).getSettingRecordingFormat()) { default: case AppConstants.FORMAT_M4A: - return AudioRecorder.getInstance(); + return AudioRecorder.getInstance(context); case AppConstants.FORMAT_WAV: return WavRecorder.getInstance(); case AppConstants.FORMAT_3GP: - return ThreeGpRecorder.getInstance(); + return ThreeGpRecorder.getInstance(context); } } @@ -203,7 +203,7 @@ public SettingsContract.UserActionsListener provideSettingsPresenter(Context con if (settingsPresenter == null) { settingsPresenter = new SettingsPresenter(provideLocalRepository(context), provideFileRepository(context), provideRecordingTasksQueue(), provideLoadingTasksQueue(), providePrefs(context), - provideSettingsMapper(context), provideAppRecorder(context)); + provideSettingsMapper(context), provideAppRecorder(context), provideAudioPlayer()); } return settingsPresenter; } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderCallback.java b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderCallback.java index 32ec16d77..9c038a2fa 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderCallback.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderCallback.java @@ -16,16 +16,16 @@ package com.dimowner.audiorecorder.app; +import androidx.annotation.NonNull; import com.dimowner.audiorecorder.data.database.Record; import com.dimowner.audiorecorder.exception.AppException; - import java.io.File; public interface AppRecorderCallback { - void onRecordingStarted(File file); + void onRecordingStarted(@NonNull File file); void onRecordingPaused(); void onRecordingResumed(); - void onRecordingStopped(File file, Record record); + void onRecordingStopped(@NonNull File file, @NonNull Record record); void onRecordingProgress(long mills, int amp); void onError(AppException throwable); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java index 3028898cd..d81be92a4 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/AppRecorderImpl.java @@ -35,11 +35,9 @@ import java.util.List; import java.util.Timer; import java.util.TimerTask; - import timber.log.Timber; - import static com.dimowner.audiorecorder.AppConstants.PLAYBACK_VISUALIZATION_INTERVAL; - +import androidx.annotation.NonNull; public class AppRecorderImpl implements AppRecorder { private RecorderContract.Recorder audioRecorder; @@ -89,7 +87,7 @@ private AppRecorderImpl( recorderCallback = new RecorderContract.RecorderCallback() { @Override - public void onStartRecord(File output) { + public void onStartRecord(@NonNull File output) { durationMills = 0; scheduleRecordingTimeUpdate(); onRecordingStarted(output); @@ -113,7 +111,7 @@ public void onRecordProgress(final long mills, final int amplitude) { } @Override - public void onStopRecord(final File output) { + public void onStopRecord(@NonNull final File output) { stopRecordingTimer(); recordingsTasks.postRunnable(() -> { RecordInfo info = AudioDecoder.readRecordInfo(output); @@ -164,7 +162,7 @@ public void onStopRecord(final File output) { } @Override - public void onError(AppException e) { + public void onError(@NonNull AppException e) { Timber.e(e); onRecordingError(e); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt b/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt index 125df6d11..6228701a3 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/app/DecodeService.kt @@ -64,6 +64,8 @@ class DecodeService : Service() { const val ACTION_STOP_DECODING_SERVICE = "ACTION_STOP_DECODING_SERVICE" const val ACTION_CANCEL_DECODE = "ACTION_CANCEL_DECODE" const val EXTRAS_KEY_DECODE_INFO = "key_decode_info" + const val EXTRAS_KEY_DECODE_RECORD_DURATION = "key_decode_decode_record_duration" + const val EXTRAS_KEY_DECODE_RECORD_PATH = "key_decode_decode_record_path" private const val NOTIF_ID = 104 fun startNotification(context: Context, recId: Int) { @@ -72,6 +74,14 @@ class DecodeService : Service() { intent.putExtra(EXTRAS_KEY_DECODE_INFO, recId) context.startService(intent) } + + fun startNotificationV2(context: Context, path: String, durationMills: Long) { + val intent = Intent(context, DecodeService::class.java) + intent.action = ACTION_START_DECODING_SERVICE + intent.putExtra(EXTRAS_KEY_DECODE_RECORD_PATH, path) + intent.putExtra(EXTRAS_KEY_DECODE_RECORD_DURATION, durationMills) + context.startService(intent) + } } private var decodeListener: DecodeServiceListener? = null @@ -106,10 +116,18 @@ class DecodeService : Service() { val action = intent.action if (action != null && action.isNotEmpty()) { when (action) { - ACTION_START_DECODING_SERVICE -> if (intent.hasExtra(EXTRAS_KEY_DECODE_INFO)) { - val id = intent.getIntExtra(EXTRAS_KEY_DECODE_INFO, -1) - if (id >= 0) { - startDecode(id) + ACTION_START_DECODING_SERVICE -> { + if (intent.hasExtra(EXTRAS_KEY_DECODE_RECORD_PATH)) { + val path = intent.getStringExtra(EXTRAS_KEY_DECODE_RECORD_PATH) + val duration = intent.getLongExtra(EXTRAS_KEY_DECODE_RECORD_DURATION, 0) + path?.let { + startDecodeV2(path, duration) + } + } else if (intent.hasExtra(EXTRAS_KEY_DECODE_INFO)) { + val id = intent.getIntExtra(EXTRAS_KEY_DECODE_INFO, -1) + if (id >= 0) { + startDecode(id) + } } } ACTION_STOP_DECODING_SERVICE -> stopService() @@ -149,7 +167,7 @@ class DecodeService : Service() { override fun onProcessingCancel() { Toast.makeText(applicationContext, R.string.processing_canceled, Toast.LENGTH_LONG).show() - decodeListener?.onFinishProcessing() + decodeListener?.onFinishProcessing(intArrayOf()) stopService() } @@ -175,14 +193,62 @@ class DecodeService : Service() { data) localRepository.updateRecord(decodedRecord) } - decodeListener?.onFinishProcessing() + decodeListener?.onFinishProcessing(data) + stopService() + } + } + + override fun onError(exception: Exception) { + Timber.e(exception) + decodeListener?.onFinishProcessing(intArrayOf()) + stopService() + } + }) + } else { + stopService() + } + } + } + + private fun startDecodeV2(path: String, durationMills: Long) { + isCancel = false + startNotification() + processingTasks.postRunnable { + var prevTime: Long = 0 + if (durationMills < DECODE_DURATION) { + waveformVisualization.decodeRecordWaveform(path, object : AudioDecodingListener { + override fun isCanceled(): Boolean { + return isCancel + } + + override fun onStartProcessing(duration: Long, channelsCount: Int, sampleRate: Int) { + decodeListener?.onStartProcessing() + } + + override fun onProcessingProgress(percent: Int) { + val curTime = System.currentTimeMillis() + if (percent == 100 || curTime > prevTime + 200) { + updateNotification(percent) + prevTime = curTime + } + } + + override fun onProcessingCancel() { + Toast.makeText(applicationContext, R.string.processing_canceled, Toast.LENGTH_LONG).show() + decodeListener?.onFinishProcessing(intArrayOf()) + stopService() + } + + override fun onFinishProcessing(data: IntArray, duration: Long) { + recordingsTasks.postRunnable { + decodeListener?.onFinishProcessing(data) stopService() } } override fun onError(exception: Exception) { Timber.e(exception) - decodeListener?.onFinishProcessing() + decodeListener?.onFinishProcessing(intArrayOf()) stopService() } }) @@ -347,5 +413,5 @@ class DecodeService : Service() { interface DecodeServiceListener { fun onStartProcessing() - fun onFinishProcessing() + fun onFinishProcessing(decodedData: IntArray) } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java b/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java index da989bb13..e9095721e 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/RecordingService.java @@ -31,6 +31,7 @@ import android.os.Build; import android.os.IBinder; +import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; @@ -122,7 +123,7 @@ public void onCreate() { appRecorderCallback = new AppRecorderCallback() { boolean checkHasSpace = true; - @Override public void onRecordingStarted(File file) { + @Override public void onRecordingStarted(@NonNull File file) { updateNotificationResume(); } @Override public void onRecordingPaused() { @@ -131,7 +132,7 @@ public void onCreate() { @Override public void onRecordingResumed() { updateNotificationResume(); } - @Override public void onRecordingStopped(File file, Record rec) { + @Override public void onRecordingStopped(@NonNull File file, @NonNull Record rec) { if (rec != null && rec.getDuration()/1000 < AppConstants.DECODE_DURATION && !rec.isWaveformProcessed()) { DecodeService.Companion.startNotification(getApplicationContext(), rec.getId()); } @@ -414,6 +415,12 @@ private void updateNotification(long mills) { } } + // - Has available space + // - Is already recoding + // - If is playing, stop playback + // - Create empty record in the database + // - Set it as active record + // - Start recording private void startRecording(String path) { appRecorder.setRecorder(recorder); try { diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserActivity.java index ef1caec98..e19c4206f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserActivity.java @@ -36,6 +36,7 @@ import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; +import android.window.OnBackInvokedCallback; import com.dimowner.audiorecorder.ARApplication; import com.dimowner.audiorecorder.R; @@ -67,6 +68,8 @@ public class FileBrowserActivity extends Activity implements FileBrowserContract private FileBrowserAdapter adapter; + private OnBackInvokedCallback backInvokedCallback; + public static Intent getStartIntent(Context context) { return new Intent(context, FileBrowserActivity.class); } @@ -77,6 +80,15 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_file_browser); + AndroidUtils.applyWindowInsets(this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + backInvokedCallback = () -> { + ARApplication.getInjector().releaseFileBrowserPresenter(); + finish(); + }; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback(0, backInvokedCallback); + } ImageButton btnBack = findViewById(R.id.btn_back); btnBack.setOnClickListener(v -> { ARApplication.getInjector().releaseFileBrowserPresenter(); @@ -150,6 +162,14 @@ protected void onStop() { } } + @Override + protected void onDestroy() { + super.onDestroy(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(backInvokedCallback); + } + } + @Override public void onBackPressed() { super.onBackPressed(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserAdapter.java b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserAdapter.java index 6d9ab0a2e..1370310a4 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserAdapter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserAdapter.java @@ -124,7 +124,7 @@ public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { @Override public void onBindViewHolder(@NonNull ItemViewHolder holder, final int position) { - final int pos = holder.getAbsoluteAdapterPosition(); + final int pos = holder.getAdapterPosition(); if (pos != RecyclerView.NO_POSITION) { RecordInfo rec = data.get(pos); holder.name.setText(rec.getName()); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java index a446ae8fe..b50441f34 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/browser/FileBrowserPresenter.java @@ -18,7 +18,7 @@ import android.content.Context; import android.os.Build; - +import androidx.annotation.NonNull; import com.dimowner.audiorecorder.ARApplication; import com.dimowner.audiorecorder.AppConstants; import com.dimowner.audiorecorder.BackgroundQueue; @@ -86,7 +86,7 @@ public void bindView(FileBrowserContract.View v) { appRecorderCallback = new AppRecorderCallback() { @Override - public void onRecordingStarted(final File file) { + public void onRecordingStarted(@NonNull final File file) { } @Override @@ -98,7 +98,7 @@ public void onRecordingResumed() { } @Override - public void onRecordingStopped(final File file, final Record rec) { + public void onRecordingStopped(@NonNull final File file, @NonNull final Record rec) { } @Override diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/info/ActivityInformation.java b/app/src/main/java/com/dimowner/audiorecorder/app/info/ActivityInformation.java index 492a8063b..27de09a96 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/info/ActivityInformation.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/info/ActivityInformation.java @@ -27,6 +27,7 @@ import com.dimowner.audiorecorder.AppConstants; import com.dimowner.audiorecorder.ColorMap; import com.dimowner.audiorecorder.R; +import com.dimowner.audiorecorder.util.AndroidUtils; import com.dimowner.audiorecorder.util.TimeUtils; public class ActivityInformation extends Activity { @@ -46,6 +47,8 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_info); + AndroidUtils.applyWindowInsets(this); + Bundle extras = getIntent().getExtras(); TextView txtName = findViewById(R.id.txt_name); TextView txtFormat = findViewById(R.id.txt_format); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsActivity.java index a89763c94..a025e8922 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsActivity.java @@ -19,6 +19,7 @@ import android.app.Activity; import android.content.Context; import android.content.Intent; +import android.os.Build; import android.os.Bundle; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearLayoutManager; @@ -27,6 +28,7 @@ import android.widget.ImageButton; import android.widget.TextView; import android.widget.Toast; +import android.window.OnBackInvokedCallback; import com.dimowner.audiorecorder.ARApplication; import com.dimowner.audiorecorder.Mapper; @@ -52,6 +54,8 @@ public class LostRecordsActivity extends Activity implements LostRecordsContract private LostRecordsAdapter adapter; + private OnBackInvokedCallback backInvokedCallback; + public static Intent getStartIntent(Context context, ArrayList data) { Intent intent = new Intent(context, LostRecordsActivity.class); intent.putParcelableArrayListExtra(EXTRAS_RECORDS_LIST, data); @@ -64,6 +68,16 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_lost_records); + AndroidUtils.applyWindowInsets(this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + backInvokedCallback = () -> { + ARApplication.getInjector().releaseLostRecordsPresenter(); + finish(); + }; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback(0, backInvokedCallback); + } + ImageButton btnBack = findViewById(R.id.btn_back); btnBack.setOnClickListener(v -> { ARApplication.getInjector().releaseLostRecordsPresenter(); @@ -125,6 +139,14 @@ protected void onStop() { } } + @Override + protected void onDestroy() { + super.onDestroy(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(backInvokedCallback); + } + } + @Override public void onBackPressed() { super.onBackPressed(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsAdapter.java b/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsAdapter.java index f3da3c87c..dc681ca7e 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsAdapter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/lostrecords/LostRecordsAdapter.java @@ -86,7 +86,7 @@ public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { @Override public void onBindViewHolder(@NonNull ItemViewHolder holder, final int position) { - final int pos = holder.getAbsoluteAdapterPosition(); + final int pos = holder.getAdapterPosition(); if (pos != RecyclerView.NO_POSITION) { holder.name.setText(data.get(pos).getName()); holder.location.setText(data.get(pos).getPath()); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java index 7aad701db..619305062 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainActivity.java @@ -30,6 +30,7 @@ import android.os.IBinder; import android.view.MenuInflater; import android.view.View; +import android.view.Window; import android.view.WindowManager; import android.widget.Button; import android.widget.ImageButton; @@ -67,11 +68,14 @@ import com.dimowner.audiorecorder.util.AnimationUtil; import com.dimowner.audiorecorder.util.FileUtil; import com.dimowner.audiorecorder.util.TimeUtils; +import com.dimowner.audiorecorder.v2.app.HomeActivity; import java.io.File; import java.util.List; import androidx.annotation.NonNull; +import androidx.core.view.WindowCompat; + import timber.log.Timber; public class MainActivity extends Activity implements MainContract.View, View.OnClickListener { @@ -128,7 +132,7 @@ public void onStartProcessing() { } @Override - public void onFinishProcessing() { + public void onFinishProcessing(@NonNull int[] decodedData) { runOnUiThread(() -> { hideRecordProcessing(); presenter.loadActiveRecord(); @@ -161,6 +165,11 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); + AndroidUtils.applyWindowInsets(this); + + Window window = getWindow(); + WindowCompat.getInsetsController(window, window.getDecorView()).setAppearanceLightStatusBars(false); + waveformView = findViewById(R.id.record); recordingWaveformView = findViewById(R.id.recording_view); txtProgress = findViewById(R.id.txt_progress); @@ -309,8 +318,11 @@ public void onClick(View view) { if (checkRecordPermission2()) { if (checkStoragePermission2()) { //Start or stop recording - startRecordingService(); - presenter.pauseUnpauseRecording(getApplicationContext()); + if (presenter.isRecording()) { + presenter.pauseUnpauseRecording(getApplicationContext()); + } else { + startRecordingService(); + } } } } else if (id == R.id.btn_record_stop) { @@ -694,6 +706,13 @@ public void showRecordFileNotAvailable(String path) { AndroidUtils.showRecordFileNotAvailable(this, path); } + @Override + public void showAppV2() { + Intent intent = new Intent(this, HomeActivity.class); + startActivity(intent); + finish(); + } + @Override public void onPlayProgress(final long mills, int percent) { playProgress.setProgress(percent); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainContract.java b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainContract.java index 2ad36d012..54b66b7d9 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/main/MainContract.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/main/MainContract.java @@ -84,6 +84,8 @@ interface View extends Contract.View { void showMigratePublicStorageWarning(); void showRecordFileNotAvailable(String path); + + void showAppV2(); } interface UserActionsListener extends Contract.UserActionsListener { @@ -128,8 +130,9 @@ interface UserActionsListener extends Contract.UserActionsListener override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { if (holder is MoveRecordsItemViewHolder) { holder.bind(getItem(position)) - if (holder.absoluteAdapterPosition == activeItem) { + if (holder.adapterPosition == activeItem) { holder.binding.container.setBackgroundResource(R.color.selected_item_color) } else { holder.binding.container.setBackgroundResource(android.R.color.transparent) @@ -145,8 +145,8 @@ class MoveRecordsAdapter : ListAdapter ): RecyclerView.ViewHolder(binding.root) { init { - binding.container.setOnClickListener { itemClickListener?.invoke(bindingAdapterPosition) } - binding.btnMove.setOnClickListener { moveRecordsClickListener?.invoke(bindingAdapterPosition) } + binding.container.setOnClickListener { itemClickListener?.invoke(adapterPosition) } + binding.btnMove.setOnClickListener { moveRecordsClickListener?.invoke(adapterPosition) } binding.btnMove.background = RippleUtils.createRippleShape( ContextCompat.getColor(binding.btnMove.context, R.color.white_transparent_80), ContextCompat.getColor(binding.btnMove.context, R.color.white_transparent_50), diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java index 973ea84d9..91222f86e 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsActivity.java @@ -38,6 +38,7 @@ import android.widget.SeekBar; import android.widget.TextView; import android.widget.Toast; +import android.window.OnBackInvokedCallback; import com.dimowner.audiorecorder.ARApplication; import com.dimowner.audiorecorder.AppConstants; @@ -103,6 +104,8 @@ public class RecordsActivity extends Activity implements RecordsContract.View, V final private List downloadRecords = new ArrayList<>(); + private OnBackInvokedCallback backInvokedCallback; + public static Intent getStartIntent(Context context) { Intent intent = new Intent(context, RecordsActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); @@ -117,6 +120,16 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_records); + AndroidUtils.applyWindowInsets(this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + backInvokedCallback = () -> { + ARApplication.getInjector().releaseRecordsPresenter(); + finish(); + }; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback(0, backInvokedCallback); + } + toolbar = findViewById(R.id.toolbar); // AndroidUtils.setTranslucent(this, true); @@ -626,6 +639,14 @@ protected void onStop() { } } + @Override + protected void onDestroy() { + super.onDestroy(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(backInvokedCallback); + } + } + @Override public void onBackPressed() { super.onBackPressed(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsAdapter.java b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsAdapter.java index 77e960757..be9b38d38 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsAdapter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsAdapter.java @@ -155,7 +155,7 @@ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder viewHolder, final int pos) { if (viewHolder.getItemViewType() == ListItem.ITEM_TYPE_NORMAL) { final ItemViewHolder holder = (ItemViewHolder) viewHolder; - final int p = holder.getAbsoluteAdapterPosition(); + final int p = holder.getAdapterPosition(); final ListItem item = data.get(p); holder.name.setText(item.getName()); holder.description.setText(item.getDurationStr()); @@ -196,7 +196,7 @@ public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder viewHolder, UniversalViewHolder holder = (UniversalViewHolder) viewHolder; ((TextView)holder.view).setText( TimeUtils.formatDateSmartLocale( - data.get(viewHolder.getAbsoluteAdapterPosition()).getAdded(), + data.get(viewHolder.getAdapterPosition()).getAdded(), holder.view.getContext() ) ); @@ -626,13 +626,13 @@ static class ItemViewHolder extends RecyclerView.ViewHolder { super(itemView); view = itemView; view.setOnClickListener(v -> { - int pos = getAbsoluteAdapterPosition(); + int pos = getAdapterPosition(); if (pos != RecyclerView.NO_POSITION && onItemClickListener != null) { onItemClickListener.onItemClick(pos); } }); view.setOnLongClickListener(v -> { - int pos = getAbsoluteAdapterPosition(); + int pos = getAdapterPosition(); if (pos != RecyclerView.NO_POSITION && longClickListener != null) { longClickListener.onItemLongClick(pos); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsPresenter.java b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsPresenter.java index 32da6f067..6c5adb78f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsPresenter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/records/RecordsPresenter.java @@ -76,14 +76,14 @@ public void bindView(final RecordsContract.View v) { if (appRecorderCallback == null) { appRecorderCallback = new AppRecorderCallback() { - @Override public void onRecordingStarted(File file) {} + @Override public void onRecordingStarted(@NonNull File file) {} @Override public void onRecordingPaused() {} @Override public void onRecordingResumed() { } @Override public void onRecordingProgress(long mills, int amp) {} @Override - public void onRecordingStopped(File file, Record rec) { + public void onRecordingStopped(@NonNull File file, @NonNull Record rec) { loadRecords(); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsActivity.java index c77fb1b85..abcbef497 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsActivity.java @@ -36,6 +36,7 @@ import android.widget.Switch; import android.widget.TextView; import android.widget.Toast; +import android.window.OnBackInvokedCallback; import com.dimowner.audiorecorder.ARApplication; import com.dimowner.audiorecorder.AppConstants; @@ -48,6 +49,7 @@ import com.dimowner.audiorecorder.util.AndroidUtils; import com.dimowner.audiorecorder.util.FileUtil; import com.dimowner.audiorecorder.util.RippleUtils; +import com.dimowner.audiorecorder.v2.app.HomeActivity; import java.io.File; import java.util.ArrayList; @@ -83,7 +85,7 @@ public class SettingsActivity extends Activity implements SettingsContract.View, private SettingView channelsSetting; private Button btnReset; - private SettingsContract.UserActionsListener presenter; + private SettingsContract.UserActionsListener presenter; private ColorMap colorMap; private ColorMap.OnThemeColorChangeListener onThemeColorChangeListener; private final CompoundButton.OnCheckedChangeListener publicDirListener = new CompoundButton.OnCheckedChangeListener() { @@ -107,6 +109,8 @@ public void onCheckedChanged(CompoundButton btn, boolean isChecked) { private String[] recChannels; private String[] recChannelsKeys; + private OnBackInvokedCallback backInvokedCallback; + public static Intent getStartIntent(Context context) { Intent intent = new Intent(context, SettingsActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); @@ -120,6 +124,16 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_settings); + AndroidUtils.applyWindowInsets(this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + backInvokedCallback = () -> { + ARApplication.getInjector().releaseSettingsPresenter(); + finish(); + }; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback(0, backInvokedCallback); + } + btnView = findViewById(R.id.btnView); btnView.setBackground(RippleUtils.createRippleShape( @@ -130,6 +144,8 @@ protected void onCreate(Bundle savedInstanceState) { btnView.setOnClickListener(this); btnReset = findViewById(R.id.btnReset); btnReset.setOnClickListener(this); + LinearLayout pnlTry = findViewById(R.id.tryPanel); + pnlTry.setOnClickListener(this); txtSizePerMin = findViewById(R.id.txt_size_per_min); txtInformation = findViewById(R.id.txt_information); txtLocation = findViewById(R.id.txt_records_location); @@ -222,6 +238,12 @@ protected void onCreate(Bundle savedInstanceState) { getResources().getDimension(R.dimen.spacing_normal) ) ); + pnlTry.setBackground( + RippleUtils.createShape( + ContextCompat.getColor(getApplicationContext(),R.color.white_transparent_88), + getResources().getDimension(R.dimen.spacing_normal) + ) + ); btnReset.setBackground( RippleUtils.createShape( @@ -323,6 +345,9 @@ protected void onStop() { protected void onDestroy() { super.onDestroy(); colorMap.removeOnThemeColorChangeListener(onThemeColorChangeListener); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(backInvokedCallback); + } } @SuppressLint("UnsafeOptInUsageWarning") @@ -345,11 +370,19 @@ public void onClick(View v) { } else if (id == R.id.btnReset) { presenter.resetSettings(); presenter.loadSettings(); + } else if (id == R.id.tryPanel) { + presenter.switchAppV2(); } else if (id == R.id.btnRequest) { requestFeature(); } } + public void showAppV2() { + Intent intent = new Intent(this, HomeActivity.class); + startActivity(intent); + finish(); + } + @Override public void onBackPressed() { super.onBackPressed(); @@ -590,6 +623,20 @@ public void disableAudioSettings() { channelsSetting.setEnabled(false); } + @Override + public void showAppV2Confirmation() { + AndroidUtils.showDialogConfirmation( + SettingsActivity.this, + R.drawable.ic_info, + getString(R.string.try_new_audio_recorder), + getString(R.string.audio_recorder_updated_with_improved_features_message), + view -> { + presenter.confirmSwitchAppV2(); + showAppV2(); + } + ); + } + @Override public void showProgress() { // TODO: showProgress diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java b/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java index 2ecd6ca6c..08928225d 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/settings/SettingsContract.java @@ -72,6 +72,8 @@ interface View extends Contract.View { void enableAudioSettings(); void disableAudioSettings(); + + void showAppV2Confirmation(); } public interface UserActionsListener extends Contract.UserActionsListener { @@ -80,6 +82,10 @@ public interface UserActionsListener extends Contract.UserActionsListener= Build.VERSION_CODES.TIRAMISU) { + backInvokedCallback = () -> { + ARApplication.getInjector().releaseSetupPresenter(); + finish(); + }; + getOnBackInvokedDispatcher().registerOnBackInvokedCallback(0, backInvokedCallback); + } + // getWindow().setFlags( // WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, // WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); @@ -238,6 +252,9 @@ protected void onStop() { protected void onDestroy() { super.onDestroy(); colorMap.removeOnThemeColorChangeListener(onThemeColorChangeListener); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(backInvokedCallback); + } } @Override diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashActivity.java index 9a9ff6df9..fb2cf1949 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashActivity.java @@ -63,6 +63,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_trash); + AndroidUtils.applyWindowInsets(this); + ImageButton btnBack = findViewById(R.id.btn_back); btnBack.setOnClickListener(v -> { ARApplication.getInjector().releaseTrashPresenter(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashAdapter.java b/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashAdapter.java index a72c7f939..17ae58b88 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashAdapter.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/trash/TrashAdapter.java @@ -88,7 +88,7 @@ public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { @Override public void onBindViewHolder(@NonNull ItemViewHolder holder, final int pos) { - final int position = holder.getAbsoluteAdapterPosition(); + final int position = holder.getAdapterPosition(); if (position != RecyclerView.NO_POSITION) { holder.name.setText(data.get(position).getName()); holder.duration.setText(TimeUtils.formatTimeIntervalHourMinSec2(data.get(position).getDuration()/1000)); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/welcome/WelcomeActivity.java b/app/src/main/java/com/dimowner/audiorecorder/app/welcome/WelcomeActivity.java index 176ee85d7..6cfbb596c 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/welcome/WelcomeActivity.java +++ b/app/src/main/java/com/dimowner/audiorecorder/app/welcome/WelcomeActivity.java @@ -50,6 +50,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_welcome); + AndroidUtils.applyWindowInsets(this); + actionButton = findViewById(R.id.btn_action); actionButton.setOnClickListener(v -> { startActivity(SetupActivity.getStartIntent(getApplicationContext())); diff --git a/app/src/main/java/com/dimowner/audiorecorder/app/widget/WaveformViewNew.kt b/app/src/main/java/com/dimowner/audiorecorder/app/widget/WaveformViewNew.kt index 880abc715..c812f5374 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/app/widget/WaveformViewNew.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/app/widget/WaveformViewNew.kt @@ -64,7 +64,7 @@ class WaveformViewNew @JvmOverloads constructor( private var viewHeightPx = 0 private var originalData: IntArray = IntArray(0) - private var waveformData: IntArray = IntArray(0) + private var waveformData: FloatArray = FloatArray(0) lateinit var drawLinesArray: FloatArray private var showTimeline: Boolean = true @@ -452,9 +452,9 @@ class WaveformViewNew @JvmOverloads constructor( heights[i] = value * value } val halfHeight = viewHeightPx / 2 - textIndent.toInt() - 1 - waveformData = IntArray(numFrames) + waveformData = FloatArray(numFrames) for (i in 0 until numFrames) { - waveformData[i] = (heights[i] * halfHeight).toInt() + waveformData[i] = (heights[i] * halfHeight) } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java index 436ee6a42..f85c4de6a 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/AudioRecorder.java @@ -16,9 +16,11 @@ package com.dimowner.audiorecorder.audio.recorder; +import android.content.Context; import android.media.MediaRecorder; import android.os.Build; import android.os.Handler; +import android.os.Looper; import com.dimowner.audiorecorder.exception.InvalidOutputFile; import com.dimowner.audiorecorder.exception.RecorderInitException; @@ -26,10 +28,9 @@ import java.io.File; import java.io.IOException; import java.util.concurrent.atomic.AtomicBoolean; - import timber.log.Timber; - import static com.dimowner.audiorecorder.AppConstants.RECORDING_VISUALIZATION_INTERVAL; +import androidx.annotation.NonNull; public class AudioRecorder implements RecorderContract.Recorder { @@ -40,34 +41,42 @@ public class AudioRecorder implements RecorderContract.Recorder { private final AtomicBoolean isRecording = new AtomicBoolean(false); private final AtomicBoolean isPaused = new AtomicBoolean(false); - private final Handler handler = new Handler(); + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Context applicationContext; private RecorderContract.RecorderCallback recorderCallback; - private static class RecorderSingletonHolder { - private static final AudioRecorder singleton = new AudioRecorder(); + private volatile static AudioRecorder instance; - public static AudioRecorder getSingleton() { - return RecorderSingletonHolder.singleton; + public static AudioRecorder getInstance(Context context) { + if (instance == null) { + synchronized (AudioRecorder.class) { + if (instance == null) { + instance = new AudioRecorder(context); + } + } } + return instance; } - public static AudioRecorder getInstance() { - return RecorderSingletonHolder.getSingleton(); + private AudioRecorder(Context context) { + this.applicationContext = context; } - private AudioRecorder() { } - @Override - public void setRecorderCallback(RecorderContract.RecorderCallback callback) { + public void setRecorderCallback(@NonNull RecorderContract.RecorderCallback callback) { this.recorderCallback = callback; } @Override - public void startRecording(String outputFile, int channelCount, int sampleRate, int bitrate) { + public void startRecording(@NonNull String outputFile, int channelCount, int sampleRate, int bitrate) { recordFile = new File(outputFile); if (recordFile.exists() && recordFile.isFile()) { - recorder = new MediaRecorder(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + recorder = new MediaRecorder(applicationContext); // Requires context on S+ + } else { + recorder = new MediaRecorder(); + } recorder.setAudioSource(MediaRecorder.AudioSource.MIC); recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java index 4f1e57638..bb9f5410f 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/RecorderContract.java @@ -16,24 +16,24 @@ package com.dimowner.audiorecorder.audio.recorder; +import androidx.annotation.NonNull; import com.dimowner.audiorecorder.exception.AppException; - import java.io.File; public interface RecorderContract { interface RecorderCallback { - void onStartRecord(File output); + void onStartRecord(@NonNull File output); void onPauseRecord(); void onResumeRecord(); void onRecordProgress(long mills, int amp); - void onStopRecord(File output); - void onError(AppException throwable); + void onStopRecord(@NonNull File output); + void onError(@NonNull AppException throwable); } interface Recorder { - void setRecorderCallback(RecorderCallback callback); - void startRecording(String outputFile, int channelCount, int sampleRate, int bitrate); + void setRecorderCallback(@NonNull RecorderCallback callback); + void startRecording(@NonNull String outputFile, int channelCount, int sampleRate, int bitrate); void resumeRecording(); void pauseRecording(); void stopRecording(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java index b97ae9f8e..66d0a0475 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/ThreeGpRecorder.java @@ -16,9 +16,11 @@ package com.dimowner.audiorecorder.audio.recorder; +import android.content.Context; import android.media.MediaRecorder; import android.os.Build; import android.os.Handler; +import android.os.Looper; import com.dimowner.audiorecorder.exception.InvalidOutputFile; import com.dimowner.audiorecorder.exception.RecorderInitException; @@ -26,10 +28,9 @@ import java.io.File; import java.io.IOException; import java.util.concurrent.atomic.AtomicBoolean; - import timber.log.Timber; - import static com.dimowner.audiorecorder.AppConstants.RECORDING_VISUALIZATION_INTERVAL; +import androidx.annotation.NonNull; public class ThreeGpRecorder implements RecorderContract.Recorder { @@ -40,34 +41,42 @@ public class ThreeGpRecorder implements RecorderContract.Recorder { private final AtomicBoolean isRecording = new AtomicBoolean(false); private final AtomicBoolean isPaused = new AtomicBoolean(false); - private final Handler handler = new Handler(); + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Context applicationContext; private RecorderContract.RecorderCallback recorderCallback; - private static class RecorderSingletonHolder { - private static final ThreeGpRecorder singleton = new ThreeGpRecorder(); + private volatile static ThreeGpRecorder instance; - public static ThreeGpRecorder getSingleton() { - return RecorderSingletonHolder.singleton; + public static ThreeGpRecorder getInstance(Context context) { + if (instance == null) { + synchronized (ThreeGpRecorder.class) { + if (instance == null) { + instance = new ThreeGpRecorder(context); + } + } } + return instance; } - public static ThreeGpRecorder getInstance() { - return RecorderSingletonHolder.getSingleton(); + private ThreeGpRecorder(Context context) { + this.applicationContext = context; } - private ThreeGpRecorder() { } - @Override - public void setRecorderCallback(RecorderContract.RecorderCallback callback) { + public void setRecorderCallback(@NonNull RecorderContract.RecorderCallback callback) { this.recorderCallback = callback; } @Override - public void startRecording(String outputFile, int channelCount, int sampleRate, int bitrate) { + public void startRecording(@NonNull String outputFile, int channelCount, int sampleRate, int bitrate) { recordFile = new File(outputFile); if (recordFile.exists() && recordFile.isFile()) { - recorder = new MediaRecorder(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + recorder = new MediaRecorder(applicationContext); // Requires context on S+ + } else { + recorder = new MediaRecorder(); + } recorder.setAudioSource(MediaRecorder.AudioSource.MIC); recorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP); if (sampleRate > 8000) { diff --git a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java index 574a5cb84..9b3c7f74f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java +++ b/app/src/main/java/com/dimowner/audiorecorder/audio/recorder/WavRecorder.java @@ -35,6 +35,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import timber.log.Timber; import static com.dimowner.audiorecorder.AppConstants.RECORDING_VISUALIZATION_INTERVAL; +import androidx.annotation.NonNull; import androidx.annotation.RequiresPermission; public class WavRecorder implements RecorderContract.Recorder { @@ -78,13 +79,13 @@ public static WavRecorder getInstance() { private WavRecorder() { } @Override - public void setRecorderCallback(RecorderContract.RecorderCallback callback) { + public void setRecorderCallback(@NonNull RecorderContract.RecorderCallback callback) { recorderCallback = callback; } @Override @RequiresPermission(value = "android.permission.RECORD_AUDIO") - public void startRecording(String outputFile, int channelCount, int sampleRate, int bitrate) { + public void startRecording(@NonNull String outputFile, int channelCount, int sampleRate, int bitrate) { this.sampleRate = sampleRate; // this.framesPerVisInterval = (int)((VISUALIZATION_INTERVAL/1000f)/(1f/sampleRate)); this.channelCount = channelCount; diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java b/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java index ee0b259ba..e08560f0a 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java +++ b/app/src/main/java/com/dimowner/audiorecorder/data/FileRepositoryImpl.java @@ -51,6 +51,9 @@ public static FileRepositoryImpl getInstance(Context context, Prefs prefs) { return instance; } + // - Increment record counter + // - Generate new file name with new record counter value + // - Create a new record file with a new name. @Override public File provideRecordFile() throws CantCreateFileException { prefs.incrementRecordCounter(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java b/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java index e3336bb01..bce529232 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java +++ b/app/src/main/java/com/dimowner/audiorecorder/data/Prefs.java @@ -21,7 +21,9 @@ public interface Prefs { boolean isFirstRun(); void firstRunExecuted(); + @Deprecated //Public storage is not used anymore boolean isStoreDirPublic(); + @Deprecated //Public storage is not used anymore void setStoreDirPublic(boolean b); //This is needed for scoped storage support @@ -54,6 +56,9 @@ public interface Prefs { boolean isMigratedDb3(); void migrateDb3Finished(); + boolean isAppV2(); + void setAppV2(boolean value); + void setSettingThemeColor(String colorKey); String getSettingThemeColor(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java b/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java index 652c6f71d..db2dc03bf 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java +++ b/app/src/main/java/com/dimowner/audiorecorder/data/PrefsImpl.java @@ -16,6 +16,16 @@ package com.dimowner.audiorecorder.data; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_IS_APP_V2; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_IS_FIRST_RUN; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_KEEP_SCREEN_ON; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_RECORD_COUNTER; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_BITRATE; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_CHANNEL_COUNT; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_NAMING_FORMAT; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_RECORDING_FORMAT; +import static com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_SAMPLE_RATE; +import static com.dimowner.audiorecorder.AppConstants.PREF_NAME; import android.content.Context; import android.content.SharedPreferences; import android.os.Build; @@ -27,18 +37,13 @@ */ public class PrefsImpl implements Prefs { - private static final String PREF_NAME = "com.dimowner.audiorecorder.data.PrefsImpl"; - - private static final String PREF_KEY_IS_FIRST_RUN = "is_first_run"; private static final String PREF_KEY_IS_MIGRATED = "is_migrated"; private static final String PREF_KEY_IS_MIGRATED_DB3 = "is_migrated_db3"; private static final String PREF_KEY_IS_STORE_DIR_PUBLIC = "is_store_dir_public"; private static final String PREF_KEY_IS_SHOW_DIRECTORY_SETTING = "is_show_directory_setting"; private static final String PREF_KEY_IS_ASK_TO_RENAME_AFTER_STOP_RECORDING = "is_ask_rename_after_stop_recording"; private static final String PREF_KEY_ACTIVE_RECORD = "active_record"; - private static final String PREF_KEY_RECORD_COUNTER = "record_counter"; private static final String PREF_KEY_THEME_COLORMAP_POSITION = "theme_color"; - private static final String PREF_KEY_KEEP_SCREEN_ON = "keep_screen_on"; private static final String PREF_KEY_FORMAT = "pref_format"; private static final String PREF_KEY_BITRATE = "pref_bitrate"; private static final String PREF_KEY_SAMPLE_RATE = "pref_sample_rate"; @@ -49,13 +54,7 @@ public class PrefsImpl implements Prefs { //Recording prefs. private static final String PREF_KEY_RECORD_CHANNEL_COUNT = "record_channel_count"; - private static final String PREF_KEY_SETTING_THEME_COLOR = "setting_theme_color"; - private static final String PREF_KEY_SETTING_RECORDING_FORMAT = "setting_recording_format"; - private static final String PREF_KEY_SETTING_BITRATE = "setting_bitrate"; - private static final String PREF_KEY_SETTING_SAMPLE_RATE = "setting_sample_rate"; - private static final String PREF_KEY_SETTING_NAMING_FORMAT = "setting_naming_format"; - private static final String PREF_KEY_SETTING_CHANNEL_COUNT = "setting_channel_count"; private final SharedPreferences sharedPreferences; @@ -323,6 +322,18 @@ public void migrateDb3Finished() { editor.apply(); } + @Override + public boolean isAppV2() { + return sharedPreferences.getBoolean(PREF_KEY_IS_APP_V2, true); + } + + @Override + public void setAppV2(boolean value) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putBoolean(PREF_KEY_IS_APP_V2, value); + editor.apply(); + } + @Override public void setSettingThemeColor(String colorKey) { SharedPreferences.Editor editor = sharedPreferences.edit(); diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/database/LocalRepositoryImpl.java b/app/src/main/java/com/dimowner/audiorecorder/data/database/LocalRepositoryImpl.java index d29a93756..2661b16cc 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/data/database/LocalRepositoryImpl.java +++ b/app/src/main/java/com/dimowner/audiorecorder/data/database/LocalRepositoryImpl.java @@ -531,8 +531,8 @@ public void removeOutdatedTrashRecords() { List list = trashDataSource.getAll(); for (int i = 0; i < list.size(); i++) { if (list.get(i).getRemoved() + AppConstants.RECORD_IN_TRASH_MAX_DURATION < curTime) { - fileRepository.deleteRecordFile(list.get(i).getPath()); trashDataSource.deleteItem(list.get(i).getId()); + fileRepository.deleteRecordFile(list.get(i).getPath()); } } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/data/database/Record.java b/app/src/main/java/com/dimowner/audiorecorder/data/database/Record.java index 9f3ecc6a4..92d6d8721 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/data/database/Record.java +++ b/app/src/main/java/com/dimowner/audiorecorder/data/database/Record.java @@ -32,6 +32,7 @@ public class Record { private long duration; private final long created; private final long added; + /** Date when record removed. Required to be able to remove the record automatically from Trash after it expired. */ private final long removed; private String path; private final String format; diff --git a/app/src/main/java/com/dimowner/audiorecorder/exception/AlreadyRecordingException.kt b/app/src/main/java/com/dimowner/audiorecorder/exception/AlreadyRecordingException.kt new file mode 100644 index 000000000..d153bfcfc --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/exception/AlreadyRecordingException.kt @@ -0,0 +1,7 @@ +package com.dimowner.audiorecorder.exception + +class AlreadyRecordingException: AppException() { + override fun getType(): Int { + return ALREADY_RECORDING + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java b/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java index 3748a1544..ffa39e1dd 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java +++ b/app/src/main/java/com/dimowner/audiorecorder/exception/AppException.java @@ -28,6 +28,7 @@ public abstract class AppException extends Exception { public static final int NO_SPACE_AVAILABLE = 8; public static final int RECORDING_ERROR = 9; public static final int FAILED_TO_RESTORE = 10; + public static final int ALREADY_RECORDING = 11; public abstract int getType(); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java b/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java index 3d7008562..159bfffe5 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java +++ b/app/src/main/java/com/dimowner/audiorecorder/exception/ErrorParser.java @@ -43,6 +43,8 @@ public static int parseException(AppException e) { return R.string.error_failed_to_restore; } else if (e.getType() == AppException.READ_PERMISSION_DENIED) { return R.string.error_permission_denied; + } else if (e.getType() == AppException.ALREADY_RECORDING) { + return R.string.error_recording_already_started; } return R.string.error_unknown; } diff --git a/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java b/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java index e48567689..6fe5f39c2 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java +++ b/app/src/main/java/com/dimowner/audiorecorder/util/AndroidUtils.java @@ -33,6 +33,9 @@ import android.media.MediaFormat; import android.net.Uri; import androidx.core.content.FileProvider; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import android.text.Editable; import android.text.Spannable; import android.text.SpannableString; @@ -476,6 +479,23 @@ public static void showDialogYesNo(Activity activity, v -> {}); } + public static void showDialogConfirmation(Activity activity, + int drawableRes, + String titleStr, + String contentStr, + final View.OnClickListener positiveBtnListener){ + showDialog(activity, + drawableRes, + activity.getString(R.string.btn_confirm), + activity.getString(R.string.btn_cancel), + titleStr, + contentStr, + -1, + true, + positiveBtnListener, + v -> {}); + } + private static void showDialog(Activity activity, int drawableRes, String positiveBtnText, @@ -622,6 +642,23 @@ public static String getAppVersion(Context context) { return versionName; } + public static void applyWindowInsets(Activity activity) { + View rootView = activity.getWindow().getDecorView(); + ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> { + Insets systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + // Apply insets as padding + v.setPadding( + systemBarsInsets.left, + systemBarsInsets.top, + systemBarsInsets.right, + systemBarsInsets.bottom + ); + // Return CONSUMED to stop the insets from passing to child views + return WindowInsetsCompat.CONSUMED; + }); + ViewCompat.requestApplyInsets(rootView); // Request the insets be applied + } + public interface OnSetNameClickListener { void onClick(String name); } diff --git a/app/src/main/java/com/dimowner/audiorecorder/util/Extensions.kt b/app/src/main/java/com/dimowner/audiorecorder/util/Extensions.kt index 7d69c3495..9a927db5a 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/util/Extensions.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/util/Extensions.kt @@ -3,6 +3,7 @@ package com.dimowner.audiorecorder.util import android.content.Context import android.content.res.Configuration import android.view.View +import kotlin.math.abs inline var View.isVisible: Boolean get() = visibility == View.VISIBLE @@ -18,3 +19,7 @@ fun isUsingNightModeResources(context: Context): Boolean { else -> false } } + +fun Double.equalsDelta(other: Double, delta: Double = 0.000001) = abs(this - other) < delta + +fun Float.equalsDelta(other: Float, delta: Float = 0.000001f) = abs(this - other) < delta diff --git a/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java b/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java index e30d369e9..a6eddfc85 100755 --- a/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java +++ b/app/src/main/java/com/dimowner/audiorecorder/util/FileUtil.java @@ -594,8 +594,11 @@ private static boolean deleteRecursivelyDirs(File file) { ok &= deleteRecursivelyDirs(new File(file, children[i])); } } - if (ok && file.delete()) { - Log.d(LOG_TAG, "File deleted: " + file.getAbsolutePath()); + if (ok) { + ok = file.delete(); + if (ok) { + Log.d(LOG_TAG, "File deleted: " + file.getAbsolutePath()); + } } } return ok; diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/DefaultValues.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/DefaultValues.kt new file mode 100644 index 000000000..1b786f07d --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/DefaultValues.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2 + +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.data.model.SortOrder + +object DefaultValues { + const val isAppV2: Boolean = true + const val isDarkTheme: Boolean = false + const val isDynamicTheme: Boolean = false + const val isAskToRename: Boolean = true + const val isKeepScreenOn: Boolean = false + + val DefaultSampleRate: SampleRate = SampleRate.SR44100 + val DefaultBitRate: BitRate = BitRate.BR128 + val DefaultChannelCount: ChannelCount = ChannelCount.Stereo + + val DefaultNameFormat: NameFormat = NameFormat.Record + val DefaultRecordingFormat: RecordingFormat = RecordingFormat.M4a + val DefaultSortOrder: SortOrder = SortOrder.DateAsc + + val Default3GpBitRate: Int = 12000 //TODO: Find a better solution for 3Gp bitrate + val Default3GpSampleRate: SampleRate = SampleRate.SR16000 + val Default3GpChannelCount: ChannelCount = ChannelCount.Mono + + const val DELETED_RECORD_MARK = ".deleted" +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppComponents.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppComponents.kt new file mode 100644 index 000000000..20b594fde --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppComponents.kt @@ -0,0 +1,615 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R + +@Composable +fun TextComponent( + textValue: String, + textSize: TextUnit, + fontWeight: FontWeight = FontWeight.Light +) { + Text( + text = textValue, + fontSize = textSize, + fontWeight = fontWeight, + ) +} + +@Preview(showBackground = true) +@Composable +fun TextComponentPreview() { + TextComponent(textValue = "Text to preview", textSize = 24.sp) +} + +@Composable +fun TextFieldComponent(onTextChanged: (name: String) -> Unit) { + + var currentValue by remember { + mutableStateOf("") + } + + val localFocusManager = LocalFocusManager.current + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = currentValue, + onValueChange = { + currentValue = it + onTextChanged(it) + }, + placeholder = { + Text(text = "Enter your name", fontSize = 18.sp) + }, + textStyle = TextStyle.Default.copy(fontSize = 24.sp), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions { + localFocusManager.clearFocus() + } + ) +} + +@Preview(showBackground = true) +@Composable +fun TextFieldComponentPreview() { + TextFieldComponent {} +} + +@Composable +fun AnimalCard(image: Int, selected: Boolean, onImageClicked: (animalName: String) -> Unit) { + val localFocusManager = LocalFocusManager.current + Card( + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .padding(16.dp) + .size(56.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .border( + width = 1.dp, + color = if (selected) Color.Green else Color.Transparent, + shape = RoundedCornerShape(8.dp), + ), + ) { + Image( + modifier = Modifier + .padding(16.dp) + .wrapContentWidth() + .wrapContentHeight() + .clickable { + val animalName = if (image == R.drawable.ic_audiotrack_64) "Cat" else "Dog" + onImageClicked(animalName) + localFocusManager.clearFocus() + }, + painter = painterResource(id = image), + contentDescription = "Animal image", + ) + } + } +} + +@Preview() +@Composable +fun AnimalCardPreview() { + AnimalCard(image = R.drawable.ic_color_lens, false) {} +} + +@Composable +fun InfoCard(animalSelected: String?) { + Card( + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .padding(16.dp), + elevation = CardDefaults.cardElevation() + ) { + Text( + modifier = Modifier.padding(18.dp, 24.dp), + text = if (animalSelected == "Dog") "Dog info" else if (animalSelected == "Cat") "Cat info" else "", + fontSize = 18.sp, + color = Color.Black, + fontWeight = FontWeight.Medium, + + ) + } +} + +@Preview() +@Composable +fun InfoCardPreview() { + InfoCard("This is information card to provide some info") +} + +@Composable +fun TitleBar( + title: String, + onBackPressed: () -> Unit, + actionButtonText: String = "", + onActionClick: (() -> Unit)? = null +) { +// val localFocusManager = LocalFocusManager.current + Row( + modifier = Modifier + .height(60.dp) + .fillMaxWidth() + .padding(0.dp, 4.dp, 0.dp, 0.dp) + .background(color = MaterialTheme.colorScheme.surface), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + IconButton( + onClick = onBackPressed, + modifier = Modifier + .padding(8.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Navigate back", + modifier = Modifier.size(24.dp) + ) + } + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 24.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + Spacer(modifier = Modifier.weight(1f)) + if (onActionClick != null) { + Button( + modifier = Modifier + .padding(8.dp) + .wrapContentSize(), + onClick = { onActionClick() } + ) { + Text( + text = actionButtonText, + fontSize = 16.sp, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun TitleBarPreview() { + TitleBar("Title bar", {}, "BtnText", {}) +} + +@Composable +fun InfoItem(label: String, value: String) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + Text( + modifier = Modifier + .padding(16.dp, 8.dp, 16.dp, 2.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 18.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + Text( + modifier = Modifier + .padding(16.dp, 2.dp, 16.dp, 8.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = value, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 22.sp, + fontWeight = FontWeight.Normal + ) + } +} + +@Preview(showBackground = true) +@Composable +fun InfoItemPreview() { + InfoItem("Label", "Value") +} + +@Composable +fun ConfirmationAlertDialog( + onDismissRequest: () -> Unit, + onConfirmation: () -> Unit, + dialogTitle: String, + dialogText: String, + painter: Painter, + positiveButton: String, + negativeButton: String, +) { + AlertDialog( + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.padding(0.dp, 0.dp, 16.dp, 0.dp), + painter = painter, + contentDescription = dialogTitle + ) + Text(text = dialogTitle) + } + }, + text = { + Text(text = dialogText, fontSize = 18.sp) + }, + onDismissRequest = { + onDismissRequest() + }, + confirmButton = { + TextButton( + onClick = { + onConfirmation() + } + ) { + Text(positiveButton) + } + + }, + dismissButton = { + TextButton( + onClick = { + onDismissRequest() + } + ) { + Text(negativeButton) + } + } + ) +} + +@Composable +fun InfoAlertDialog( + onDismissRequest: () -> Unit, + onConfirmation: () -> Unit, + dialogTitle: String, + dialogText: String, + icon: ImageVector, + dismissButton: String, +) { + AlertDialog( + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.padding(0.dp, 0.dp, 16.dp, 0.dp), + imageVector = icon, + contentDescription = dialogTitle + ) + Text(text = dialogTitle) + } + }, + text = { + Text( + text = dialogText, + style = TextStyle( + fontSize = 18.sp, + lineBreak = LineBreak.Heading, + fontWeight = FontWeight.Normal, + ) + ) + }, + onDismissRequest = { + onDismissRequest() + }, + confirmButton = { + TextButton( + onClick = { + onConfirmation() + } + ) { + Text(dismissButton) + } + }, + ) +} + +@Composable +fun RenameAlertDialog( + recordName: String, + onAcceptClick: (String) -> Unit, + onDismissClick: () -> Unit, + onDontAskAgain: (Boolean) -> Unit = {}, + showDontAskAgain: Boolean = false +) { + val currentValue = remember { mutableStateOf(recordName) } + val checkedState = remember { mutableStateOf(false) } + AlertDialog( + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.padding(0.dp, 0.dp, 16.dp, 0.dp), + painter = painterResource(id = R.drawable.ic_pencil), + contentDescription = stringResource(id = R.string.record_name) + ) + Text(text = stringResource(id = R.string.record_name)) + } + }, + text = { + val localFocusManager = LocalFocusManager.current + Column { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = currentValue.value, + onValueChange = { + currentValue.value = it + }, + placeholder = { + Text(text = stringResource(id = R.string.rename), fontSize = 18.sp) + }, + textStyle = TextStyle.Default.copy(fontSize = 20.sp), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions { + localFocusManager.clearFocus() + } + ) + if (showDontAskAgain) { + Row { + Checkbox( + checked = checkedState.value, + onCheckedChange = { checkedState.value = it }, + ) + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = stringResource(id = R.string.dont_ask_again), + fontSize = 16.sp, + ) + } + } + } + }, + onDismissRequest = { + onDismissClick() + }, + confirmButton = { + TextButton( + onClick = { + onAcceptClick(currentValue.value) + if (showDontAskAgain) { + onDontAskAgain(checkedState.value) + } + } + ) { + Text(stringResource(id = R.string.btn_save)) + } + + }, + dismissButton = { + TextButton( + onClick = { + onDismissClick() + } + ) { + Text(stringResource(id = R.string.btn_cancel)) + } + } + ) +} + +@Preview(showBackground = true) +@Composable +fun RenameAlertDialogPreview() { + RenameAlertDialog("Record-14", {}, {}, {}, true) +} + +@Composable +fun DropDownMenuItem( + text: String, + iconRes: Int, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .clickable { onClick() }, + ) { + Icon( + modifier = Modifier + .padding(16.dp) + .wrapContentWidth() + .wrapContentHeight(), + painter = painterResource(id = iconRes), + contentDescription = text, + ) + Text( + modifier = Modifier + .padding(0.dp, 16.dp, 16.dp, 16.dp) + .wrapContentSize(), + text = text, + fontSize = 18.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun DropDownMenuItemPreview() { + DropDownMenuItem("Label", R.drawable.ic_palette_outline, {}) +} + +@Composable +fun RecordsDropDownMenu( + items: List>, + onItemClick: (T) -> Unit, + expanded: MutableState +) { + DropdownMenu( + modifier = Modifier.wrapContentSize(), + expanded = expanded.value, + onDismissRequest = { expanded.value = false } + ) { + items.forEach { item -> + DropdownMenuItem( + onClick = {}, + text = { + DropDownMenuItem( + text = stringResource(id = item.textResId), + iconRes = item.imageResId, + onClick = { + onItemClick(item.id) + expanded.value = false + } + ) + } + ) + } + } +} + +@Composable +fun DeleteDialog( + dialogText: String, + onAcceptClick: () -> Unit, + onDismissClick: () -> Unit, +) { + ConfirmationAlertDialog( + onDismissRequest = { onDismissClick() }, + onConfirmation = { onAcceptClick() }, + dialogTitle = stringResource(id = R.string.warning), + dialogText = dialogText, + painter = painterResource(id = R.drawable.ic_delete_forever), + positiveButton = stringResource(id = R.string.btn_yes), + negativeButton = stringResource(id = R.string.btn_no) + ) +} + +@Preview(showBackground = true) +@Composable +fun DeleteDialogPreview() { + DeleteDialog(stringResource(id = R.string.delete_record,"Record-14"), {}, {}) +} + +@Composable +fun SaveAsDialog( + dialogText: String, + onAcceptClick: () -> Unit, + onDismissClick: () -> Unit, +) { + ConfirmationAlertDialog( + onDismissRequest = { onDismissClick() }, + onConfirmation = { onAcceptClick() }, + dialogTitle = stringResource(id = R.string.save_as), + dialogText = dialogText, + painter = painterResource(id = R.drawable.ic_save_alt), + positiveButton = stringResource(id = R.string.btn_yes), + negativeButton = stringResource(id = R.string.btn_no) + ) +} + +@Preview(showBackground = true) +@Composable +fun SaveAsDialogPreview() { + SaveAsDialog( + dialogText = stringResource(id = R.string.record_name_will_be_copied_into_downloads, "Record-14"), + {}, {} + ) +} + +data class DropDownMenuItem( + val id: T, + val textResId: Int, + val imageResId: Int +) + diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppExtensions.kt new file mode 100644 index 000000000..b6fb84d20 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/AppExtensions.kt @@ -0,0 +1,299 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app + +import android.content.Context +import android.content.res.Resources +import android.text.format.Formatter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.dimowner.audiorecorder.AppConstants +import com.dimowner.audiorecorder.AppConstantsV2 +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.settings.convertToText +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import java.util.Locale + +fun calculateScale( + mills: Long, + shortRecordDuration: Long = AppConstantsV2.SHORT_RECORD, + defaultWidthScale: Float = AppConstantsV2.DEFAULT_WIDTH_SCALE, +): Float { + return when { + mills >= shortRecordDuration -> { + defaultWidthScale + } + else -> { + mills * (defaultWidthScale / shortRecordDuration) + } + } +} + +@SuppressWarnings("MagicNumber") +fun calculateGridStep(durationMills: Long): Long { + var actualStepSec = (durationMills / 1000) / AppConstants.GRID_LINES_COUNT + var k = 1 + while (actualStepSec > 239) { + actualStepSec /= 2 + k *= 2 + } + //Ranges can be better optimised + val gridStep: Long = when (actualStepSec) { + in 0..2 -> 2000 + in 3..6 -> 5000 + in 7..14 -> 10000 + in 15..24 -> 20000 + in 25..44 -> 30000 + in 45..74 -> 60000 + in 75..104 -> 90000 + in 105..149 -> 120000 + in 150..209 -> 180000 + in 210..269 -> 240000 + in 270..329 -> 300000 + in 330..419 -> 360000 + in 420..539 -> 480000 + in 540..659 -> 600000 + in 660..809 -> 720000 + in 810..1049 -> 900000 + in 1050..1349 -> 1200000 + in 1350..1649 -> 1500000 + in 1650..2099 -> 1800000 + in 2100..2699 -> 2400000 + in 2700..3299 -> 3000000 + in 3300..3899 -> 3600000 + else -> 4200000 + } + return gridStep * k +} + +/** + * Readjust waveform amplitudes + * @param [IntArray] of int values where each element represents an amplitude + */ +@SuppressWarnings("MagicNumber") +fun adjustWaveformHeights(frameGains: IntArray): IntArray { + val numFrames = frameGains.size + + //Find the highest gain + var maxGain = 1.0f + for (i in 0 until numFrames) { + if (frameGains[i] > maxGain) { + maxGain = frameGains[i].toFloat() + } + } + // Make sure the range is no more than 0 - 255 + var scaleFactor = 1.0f + if (maxGain > 255.0) { + scaleFactor = 255 / maxGain + } + + // Build histogram of 256 bins and figure out the new scaled max + maxGain = 0.0f + val gainHist = IntArray(256) + for (i in 0 until numFrames) { + var smoothedGain = (frameGains[i] * scaleFactor).toInt() + if (smoothedGain < 0) smoothedGain = 0 + if (smoothedGain > 255) smoothedGain = 255 + if (smoothedGain > maxGain) maxGain = smoothedGain.toFloat() + gainHist[smoothedGain]++ + } + + // Re-calibrate the min to be 5% + var minGain = 0.0f + var sum = 0 + while (minGain < 255 && sum < numFrames / 20) { + sum += gainHist[minGain.toInt()] + minGain++ + } + + // Re-calibrate the max to be 99% + sum = 0 + while (maxGain > 2 && sum < numFrames / 100) { + sum += gainHist[maxGain.toInt()] + maxGain-- + } + + // Compute the heights + val heights = FloatArray(numFrames) + var range = maxGain - minGain + if (range <= 0) { + range = 1.0f + } + for (i in 0 until numFrames) { + var value = (frameGains[i] * scaleFactor - minGain) / range + if (value < 0.0) value = 0.0f + if (value > 1.0) value = 1.0f + heights[i] = value * value + } + val scale = AppConstantsV2.WAVEFORM_AMPLITUDE_MAX_VALUE + val waveformData = IntArray(numFrames) + for (i in 0 until numFrames) { + waveformData[i] = (heights[i] * scale).toInt() + } + //Array of int values where each value between 0 to WAVEFORM_AMPLITUDE_MAX_VALUE + return waveformData +} + +fun formatRecordingFormat( + formatStrings: Array, + recordingFormat: RecordingFormat?, +): String { + return recordingFormat?.convertToText(formatStrings) ?: "" +} + +fun formatSampleRate( + sampleRateStrings: Array, + sampleRate: SampleRate?, +): String { + return sampleRate?.convertToText(sampleRateStrings) ?: "" +} + +fun formatBitRate( + bitrateStrings: Array, + bitRate: BitRate?, +): String { + return bitRate?.convertToText(bitrateStrings) ?: "" +} + +fun formatChannelCount( + channelCountStrings: Array, + channelCount: ChannelCount?, +): String { + return channelCount?.convertToText(channelCountStrings) ?: "" +} + +fun recordingSettingsCombinedText( + recordingFormat: RecordingFormat?, + recordingFormatText: String, + sampleRateText: String, + bitRateText: String, + channelCountText: String +): String { + return when (recordingFormat) { + RecordingFormat.M4a -> { + "$recordingFormatText, $sampleRateText, $bitRateText, $channelCountText" + } + RecordingFormat.Wav, + RecordingFormat.ThreeGp -> { + "$recordingFormatText, $sampleRateText, $channelCountText" + } + else -> "" + } +} + +fun recordInfoCombinedShortText( + recordingFormat: String, + recordSizeText: String, + bitrateText: String, + sampleRateText: String, +): String { + return if (bitrateText.isNotEmpty()) { + "$recordSizeText, $recordingFormat, $bitrateText, $sampleRateText" + } else { + "$recordSizeText, $recordingFormat, $sampleRateText" + } +} + +fun Record.toInfoCombinedText(context: Context): String { + return recordInfoCombinedShortText( + recordingFormat = this.format, + recordSizeText = Formatter.formatShortFileSize(context, this.size), + bitrateText = if (this.bitrate > 0) { + context.getString(R.string.value_kbps, this.bitrate/1000) + } else "", + sampleRateText = context.getString( + R.string.value_khz, + this.sampleRate/1000 + ), + ) +} + +@Composable +fun ComposableLifecycle( + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, + onEvent: (LifecycleOwner, Lifecycle.Event) -> Unit +) { + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { source, event -> + onEvent(source, event) + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } +} + +@Composable +fun viewModel.observeLifecycleEvents(lifecycle: Lifecycle) { + DisposableEffect(lifecycle) { + lifecycle.addObserver(this@observeLifecycleEvents) + onDispose { + lifecycle.removeObserver(this@observeLifecycleEvents) + } + } +} + +@SuppressWarnings("MagicNumber") +fun formatDuration( + resources: Resources, + durationMillis: Long, +): String { + val totalSeconds = durationMillis / 1000 + val years = (totalSeconds / (365 * 24 * 60 * 60)).toInt() + val days = ((totalSeconds % (365 * 24 * 60 * 60)) / (24 * 60 * 60)).toInt() + val hours = (totalSeconds % (24 * 60 * 60)) / (60 * 60) + val minutes = (totalSeconds % (60 * 60)) / 60 + val seconds = totalSeconds % 60 + + val formattedParts = mutableListOf() + + if (years > 0) formattedParts.add("$years${resources.getQuantityString(R.plurals.years, years)}") + if (days > 0) formattedParts.add("$days${resources.getQuantityString(R.plurals.days, days)}") + if (hours > 0) { + formattedParts.add(String.format(Locale.getDefault(),"%02dh:%02dm:%02ds", hours, minutes, seconds)) + } else { + formattedParts.add(String.format(Locale.getDefault(),"%02dm:%02ds", minutes, seconds)) + } + return formattedParts.joinToString(" ") +} + +/** + * Permanently deletes all records from the recycle bin that have exceeded the + * maximum retention duration defined by [AppConstants.RECORD_IN_TRASH_MAX_DURATION]. + * + * @receiver The [RecordsDataSource] instance. + */ +suspend fun RecordsDataSource.removeOutdatedTrashRecords() { + val currentTime = System.currentTimeMillis() + this.getMovedToRecycleRecords().forEach { removedRecord -> + if (currentTime > removedRecord.removed + AppConstants.RECORD_IN_TRASH_MAX_DURATION) { + this.deleteRecordAndFileForever(removedRecord.id) + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePlaygroundScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePlaygroundScreen.kt new file mode 100644 index 000000000..b9c50e5be --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePlaygroundScreen.kt @@ -0,0 +1,241 @@ +package com.dimowner.audiorecorder.v2.app + +import android.content.Intent +import android.net.Uri +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.app.main.MainActivity +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.app.info.RecordInfoState +import com.dimowner.audiorecorder.v2.app.settings.ChipItem +import com.dimowner.audiorecorder.v2.app.settings.SettingSelector +import com.google.gson.Gson +import timber.log.Timber + +@Composable +fun ComposePlaygroundScreen( + userInputViewModel: UserInputViewModel = viewModel(), + showDetailsScreen: (Pair) -> Unit, + showRecordInfoScreen: (String) -> Unit, + showSettingsScreen: () -> Unit, + showHomeScreen: () -> Unit, + showRecordsScreen: () -> Unit, + showWelcomeScreen: () -> Unit, + showDeletedRecordsScreen: () -> Unit, +) { + val context = LocalContext.current + + val recordInfo = RecordInfoState( + name = "name666", + format = "format777", + duration = 150000000, + size = 1500000, + location = "location888", + created = System.currentTimeMillis(), + sampleRate = 44000, + channelCount = 1, + bitrate = 240000, + ) + + Surface( + modifier = Modifier.fillMaxSize() + ) { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(weight = 1f, fill = false) + .padding(16.dp) + ) { + TextComponent(textValue = "Name", textSize = 18.sp) + Spacer(modifier = Modifier.size(10.dp)) + TextFieldComponent(onTextChanged = { + userInputViewModel.onEvent(UserDataUiEvents.UserNameEntered(it)) + }) + Spacer(modifier = Modifier.size(10.dp)) + TextComponent(textValue = "What do you like?", textSize = 18.sp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + AnimalCard(image = R.drawable.ic_audiotrack_64, onImageClicked = { + userInputViewModel.onEvent(UserDataUiEvents.AnimalSelected(it)) + }, selected = userInputViewModel.uiState.value.animalSelected == "Cat") + AnimalCard(image = R.drawable.ic_color_lens, onImageClicked = { + userInputViewModel.onEvent(UserDataUiEvents.AnimalSelected(it)) + }, selected = userInputViewModel.uiState.value.animalSelected == "Dog") + } + Spacer(modifier = Modifier.weight(1f)) + if (userInputViewModel.isValidState()) { + Button(onClick = { + showDetailsScreen( + Pair( + userInputViewModel.uiState.value.nameEntered, + userInputViewModel.uiState.value.animalSelected + ) + ) + }) { + Text(text = "Go to details screen",) + } + } + Row { + Button(onClick = { + val json = Uri.encode(Gson().toJson(recordInfo)) + showRecordInfoScreen(json) + }) { + Text(text = "Record Info",) + } + Spacer(modifier = Modifier.width(16.dp)) + Button(onClick = { showSettingsScreen() }) { + Text(text = "Settings Screen",) + } + } + Row { + Button(onClick = { showHomeScreen() }) { + Text(text = "Home Screen",) + } + Spacer(modifier = Modifier.width(16.dp)) + Button(onClick = { showRecordsScreen() }) { + Text(text = "Records Screen",) + } + } + Row { + Button(onClick = { showWelcomeScreen() }) { + Text(text = "Welcome Screen",) + } + Spacer(modifier = Modifier.width(16.dp)) + Button(onClick = { showDeletedRecordsScreen() }) { + Text(text = "Deleted Records",) + } + } + + // Text variations + Text( + text = "Primary Text", + modifier = Modifier.padding(16.dp), + ) + Text( + text = "Secondary Text", + modifier = Modifier.padding(16.dp), + ) + Text( + text = "Error Text", + modifier = Modifier.padding(16.dp), + ) + + Spacer(modifier = Modifier.size(10.dp)) + + SettingSelector( + name = "Test Name", + chips = listOf( + ChipItem(id = 0, value = SampleRate.SR8000, name = "8000", false), + ChipItem(id = 1, value = SampleRate.SR16000, name = "16000", false), + ChipItem(id = 2, value = SampleRate.SR22500, name = "22500", true), + ChipItem(id = 3, value = SampleRate.SR32000, name = "32000", false), + ChipItem(id = 4, value = SampleRate.SR44100, name = "44100", false), + ChipItem(id = 5, value = SampleRate.SR48000, name = "48000", false), + ), + onSelect = { + Timber.v("onSelect = " + it.name) + }, + onClickInfo = { Timber.v("onClickInfo") } + ) + // Buttons with different states + Button( + onClick = { context.startActivity(Intent(context, MainActivity::class.java)) }, + colors = ButtonDefaults.buttonColors() + ) { + Text(text = "Audio Recorder",) + } + Button( + onClick = {}, + enabled = false, + colors = ButtonDefaults.buttonColors() + ) { + Text(text = "Disabled Button",) + } + Card(elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)) { + Text( + modifier = Modifier.padding(16.dp), + text = "Elevated Surface", + ) + } +// CircularProgressIndicator( +// modifier = Modifier.padding(16.dp) +// ) + LinearProgressIndicator( + progress = { 0.5f }, + modifier = Modifier.padding(16.dp), + ) + Slider( + value = 0.5f, + onValueChange = {}, + ) + Row(modifier = Modifier.fillMaxSize()) { + Switch( + checked = true, + onCheckedChange = {}, + enabled = true, + modifier = Modifier.padding(16.dp) + ) + Switch( + checked = false, + onCheckedChange = {}, + enabled = true, + modifier = Modifier.padding(16.dp) + ) + Switch( + checked = false, + onCheckedChange = {}, + enabled = false, + modifier = Modifier.padding(16.dp) + ) + } + HorizontalDivider( + modifier = Modifier.padding(16.dp) + ) + BottomAppBar( + content = { + Text( + text = "Bottom App Bar", + ) + } + ) + } + } + } +} + +@Preview +@Composable +fun ComposePlaygroundScreenPreview() { + ComposePlaygroundScreen(viewModel(), {}, {}, {}, {}, {}, {}, {}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePreviewData.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePreviewData.kt new file mode 100644 index 000000000..73bdd05ae --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/ComposePreviewData.kt @@ -0,0 +1,641 @@ +package com.dimowner.audiorecorder.v2.app + +import com.dimowner.audiorecorder.AppConstantsV2 +import com.dimowner.audiorecorder.v2.app.components.WaveformState + +fun getTestWaveformData(progress: Long = 30000L): WaveformState { + return WaveformState( + widthScale = calculateScale( + mills = TEST_WAVEFORM_DATA_DURATION_MILLS, + defaultWidthScale = AppConstantsV2.DEFAULT_WIDTH_SCALE + ), + durationMills = TEST_WAVEFORM_DATA_DURATION_MILLS, + progressMills = progress, + waveformData = TEST_WAVEFORM_DATA, + durationSample = TEST_WAVEFORM_DATA.size, + gridStepMills = calculateGridStep(TEST_WAVEFORM_DATA_DURATION_MILLS), + ) +} + +const val TEST_WAVEFORM_DATA_DURATION_MILLS = 58728L + +@SuppressWarnings("MagicNumber") +val TEST_WAVEFORM_DATA = intArrayOf( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 234, + 526, + 0, + 0, + 0, + 0, + 8424, + 4394, + 7514, + 23400, + 13754, + 10400, + 21118, + 12018, + 24986, + 6656, + 7514, + 10926, + 32767, + 24186, + 21118, + 16250, + 10400, + 7962, + 5850, + 4738, + 4394, + 26624, + 10926, + 5850, + 4738, + 5096, + 4062, + 11466, + 10926, + 14976, + 22626, + 21118, + 30056, + 21118, + 32767, + 23400, + 21866, + 21866, + 23400, + 27462, + 25798, + 24186, + 14976, + 18258, + 11466, + 15606, + 23400, + 29178, + 29178, + 26624, + 16906, + 12018, + 16906, + 12018, + 14358, + 7962, + 22626, + 22626, + 14358, + 6656, + 4738, + 3744, + 2866, + 4394, + 3744, + 4394, + 3146, + 2600, + 416, + 27462, + 28314, + 14976, + 14976, + 14976, + 29178, + 23400, + 24186, + 19662, + 28314, + 26624, + 13162, + 18258, + 18258, + 22626, + 30946, + 19662, + 9886, + 6246, + 5096, + 3744, + 3744, + 4394, + 24186, + 21118, + 7078, + 3146, + 1878, + 3438, + 1878, + 9386, + 21118, + 12584, + 14976, + 12584, + 17576, + 5850, + 29178, + 23400, + 4738, + 4062, + 26624, + 26624, + 18954, + 13754, + 14358, + 13162, + 13754, + 7962, + 17576, + 31850, + 13754, + 13162, + 13754, + 10400, + 8898, + 5850, + 5466, + 2866, + 23400, + 9386, + 7514, + 5850, + 7962, + 7514, + 5466, + 8424, + 6656, + 5850, + 4394, + 19662, + 3438, + 26624, + 22626, + 12018, + 7514, + 24986, + 24186, + 16250, + 30946, + 21118, + 11466, + 9886, + 12584, + 25798, + 30056, + 27462, + 17576, + 16906, + 20384, + 19662, + 18258, + 19662, + 9386, + 19662, + 10400, + 3744, + 3146, + 3744, + 8424, + 4062, + 7078, + 15606, + 22626, + 20384, + 24186, + 18258, + 27462, + 25798, + 12584, + 14358, + 18954, + 24986, + 24986, + 19662, + 20384, + 23400, + 15606, + 16906, + 17576, + 16906, + 13162, + 29178, + 8898, + 7514, + 4738, + 2600, + 2106, + 2866, + 3438, + 28314, + 22626, + 4394, + 1462, + 1098, + 2866, + 1878, + 4394, + 2600, + 2346, + 2346, + 5466, + 4738, + 526, + 29178, + 20384, + 19662, + 6656, + 28314, + 21866, + 20384, + 11466, + 21866, + 15606, + 18954, + 18954, + 16250, + 32767, + 22626, + 9886, + 18954, + 16906, + 13162, + 8898, + 6246, + 5466, + 30056, + 7962, + 5466, + 5850, + 23400, + 17576, + 29178, + 10400, + 14976, + 22626, + 19662, + 20384, + 14976, + 32767, + 13754, + 13162, + 13162, + 32767, + 20384, + 8898, + 7078, + 16250, + 18258, + 15606, + 13162, + 14358, + 29178, + 15606, + 5466, + 4394, + 2106, + 162, + 0, + 0, + 0, + 786, + 58, + 58, + 526, + 104, + 1462, + 526, + 786, + 26, + 0, + 0, + 0, + 0, + 29178, + 17576, + 24986, + 29178, + 28314, + 23400, + 20384, + 30056, + 27462, + 31850, + 32767, + 27462, + 32767, + 25798, + 32767, + 30056, + 29178, + 23400, + 22626, + 31850, + 14976, + 16906, + 30946, + 25798, + 27462, + 22626, + 15606, + 29178, + 13162, + 23400, + 21866, + 24186, + 21118, + 24186, + 28314, + 29178, + 30056, + 16250, + 18954, + 16906, + 29178, + 30056, + 27462, + 20384, + 29178, + 25798, + 12584, + 21118, + 20384, + 31850, + 21866, + 21866, + 26624, + 18954, + 14358, + 21866, + 24186, + 25798, + 27462, + 21118, + 22626, + 21118, + 24986, + 13754, + 13754, + 30056, + 22626, + 10400, + 27462, + 30056, + 24986, + 29178, + 20384, + 23400, + 28314, + 29178, + 29178, + 17576, + 20384, + 23400, + 27462, + 13162, + 24186, + 20384, + 31850, + 25798, + 25798, + 14976, + 24986, + 22626, + 24186, + 23400, + 30056, + 30946, + 17576, + 16250, + 13754, + 16250, + 24986, + 24986, + 17576, + 29178, + 20384, + 30056, + 19662, + 18258, + 24986, + 30056, + 18954, + 24186, + 30946, + 32767, + 32767, + 27462, + 30946, + 13162, + 24186, + 21866, + 31850, + 30056, + 24986, + 30946, + 26624, + 22626, + 21866, + 25798, + 28314, + 30946, + 32767, + 30056, + 30946, + 29178, + 23400, + 28314, + 30056, + 30946, + 26624, + 30946, + 29178, + 21118, + 29178, + 21866, + 29178, + 28314, + 21118, + 31850, + 32767, + 29178, + 31850, + 30946, + 31850, + 25798, + 26624, + 30946, + 32767, + 32767, + 30946, + 28314, + 28314, + 32767, + 30946, + 28314, + 28314, + 31850, + 30946, + 30946, + 30056, + 21866, + 18954, + 30056, + 19662, + 31850, + 29178, + 31850, + 22626, + 25798, + 28314, + 26624, + 29178, + 25798, + 28314, + 28314, + 29178, + 28314, + 27462, + 27462, + 25798, + 22626, + 18258, + 29178, + 23400, + 32767, + 21866, + 18954, + 24186, + 19662, + 14976, + 17576, + 23400, + 23400, + 24986, + 27462, + 26624, + 19662, + 25798, + 21866, + 30056, + 30056, + 30946, + 32767, + 18954, + 28314, + 10926, + 30056, + 24986, + 31850, + 25798, + 29178, + 27462, + 22626, + 24186, + 24986, + 25798, + 17576, + 28314, + 27462, + 29178, + 30056, + 20384, + 18258, + 21118, + 24986, + 24186, + 25798, + 29178, + 29178, + 27462, + 22626, + 16906, + 23400, + 21118, + 27462, + 26624, + 26624, + 31850, + 27462, + 23400, + 26624, + 21866, + 20384, + 25798, + 29178, + 20384, + 13754, + 9886, + 9886, + 10926, + 29178, + 25798, + 24986, + 22626, + 14358, + 1878, + 0, + 0, + 0, + 0, + 0, + 8898, + 2600, + 25798, + 9886, + 6656, + 7962, + 6246, + 6246, + 5096, + 5850, + 6656, + 5096, + 8424, + 8424, + 4062, + 4394, + 4394, + 5850, + 4394, + 9886, + 28314, + 8424, + 6246, + 58, + 6656, + 3438, + 0, + 12018, + 8898, + 7514, + 1878, + 1098, + 58, + 0, + 318, + 318, + 8424, + 12018, + 13162, + 7962, + 7514, + 7514, + 7514, + 6246, + 3146, + 18954, + 84 +) \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/HomeActivity.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/HomeActivity.kt new file mode 100644 index 000000000..5b4cac8f0 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/HomeActivity.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.lifecycleScope +import com.dimowner.audiorecorder.app.main.MainActivity +import com.dimowner.audiorecorder.v2.app.home.HomeViewModel +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.navigation.RecorderNavigationGraph +import com.dimowner.audiorecorder.v2.theme.AppTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +@AndroidEntryPoint +class HomeActivity: ComponentActivity() { + + private val viewModel: HomeViewModel by viewModels() + + @Inject + lateinit var prefs: PrefsV2 + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + installSplashScreen() + setContent { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + AppTheme( + dynamicColors = prefs.isDynamicTheme, + darkTheme = prefs.isDarkTheme + ) { RecorderApp(lifecycleScope) } + } else { + AppTheme(darkTheme = prefs.isDarkTheme) { RecorderApp(lifecycleScope) } + } + } + } + + @Composable + fun RecorderApp( + coroutineScope: CoroutineScope + ) { + RecorderNavigationGraph(coroutineScope, viewModel, onSwitchToLegacyApp = { + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + finish() + }) + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/UserInputViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/UserInputViewModel.kt new file mode 100644 index 000000000..b1dbe454e --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/UserInputViewModel.kt @@ -0,0 +1,39 @@ +package com.dimowner.audiorecorder.v2.app + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel + +class UserInputViewModel: ViewModel() { + + val uiState = mutableStateOf(UserInputScreenState()) + + fun onEvent(event: UserDataUiEvents) { + when (event) { + is UserDataUiEvents.UserNameEntered -> { + uiState.value = uiState.value.copy( + nameEntered = event.name + ) + } + is UserDataUiEvents.AnimalSelected -> { + uiState.value = uiState.value.copy( + animalSelected = event.animalValue + ) + } + } + + } + + fun isValidState(): Boolean { + return uiState.value.animalSelected.isNotEmpty() && uiState.value.nameEntered.isNotEmpty() + } +} + +data class UserInputScreenState( + val nameEntered: String = "", + val animalSelected: String = "" +) + +sealed class UserDataUiEvents { + data class UserNameEntered(val name: String): UserDataUiEvents() + data class AnimalSelected(val animalValue: String): UserDataUiEvents() +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/ComposeExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/ComposeExtensions.kt new file mode 100644 index 000000000..07411761a --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/ComposeExtensions.kt @@ -0,0 +1,56 @@ +package com.dimowner.audiorecorder.v2.app.components + +import androidx.compose.foundation.clickable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed + +/** + * Wraps an [onClick] lambda with another one that supports debounce clicks. + * The default debounce time is 300ms. + * + * @param debounceTimeMillis The minimum time interval (in milliseconds) required between click + * executions. Defaults to 300ms. + * @param onClick The action to be executed when a valid (non-debounced) click occurs. + * @return Debounced lambda onClick + */ +@Composable +fun onDebounceClick( + onClick: () -> Unit, + debounceTimeMillis: Long = 300L, +): () -> Unit { + var lastClickTimeMillis: Long by remember { mutableLongStateOf(0L) } + return { + val currentTimeMillis = System.currentTimeMillis() + + // Check if enough time has passed since the last click + if (currentTimeMillis - lastClickTimeMillis >= debounceTimeMillis) { + onClick() + lastClickTimeMillis = currentTimeMillis + } else { + //Do nothing + } + } +} + +/** + * A [Modifier] extension function that applies a debounced click listener to any Composable. + * + * @param debounceTimeMillis The minimum time interval (in milliseconds) required between click + * executions. Defaults to 300ms. + * @param onClick The action to be executed when a valid (non-debounced) click occurs. + * @return A [Modifier] that makes the Composable element clickable with debouncing logic. + */ +fun Modifier.onDebounceClickable( + debounceTimeMillis: Long = 300L, + onClick: () -> Unit +): Modifier { + return this.composed { + val clickable = onDebounceClick(debounceTimeMillis = debounceTimeMillis, onClick = { onClick() }) + this.clickable { clickable() } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/RecordPlaybackPanel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/RecordPlaybackPanel.kt new file mode 100644 index 000000000..b72e9fd29 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/RecordPlaybackPanel.kt @@ -0,0 +1,147 @@ +package com.dimowner.audiorecorder.v2.app.components + +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.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.v2.app.getTestWaveformData +import com.dimowner.audiorecorder.v2.app.home.HomeScreenState +import com.dimowner.audiorecorder.v2.app.home.LegacySlider +import com.dimowner.audiorecorder.v2.app.home.PlayPanel + +@Composable +internal fun RecordPlaybackPanel( + modifier: Modifier, + uiState: HomeScreenState, + onProgressChange: (Float) -> Unit, + onSeekStart: () -> Unit, + onSeekProgress: (Long) -> Unit, + onSeekEnd: (Long) -> Unit, + onPlayClick: () -> Unit, + onStopClick: () -> Unit, + onPauseClick: () -> Unit, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier + .wrapContentSize().padding(12.dp), + textAlign = TextAlign.Center, + text = uiState.time, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + WaveformComposeView( + modifier = Modifier.fillMaxWidth().height(48.dp), + state = uiState.waveformState, + showTimeline = false, + onSeekStart = { + onSeekStart() + }, + onSeekProgress = { mills -> + onSeekProgress(mills) + }, + onSeekEnd = { mills -> + onSeekEnd(mills) + } + ) + Row( + modifier = Modifier.padding(0.dp, 8.dp, 0.dp, 0.dp), + verticalAlignment = Alignment.Bottom, + ) { + Text( + modifier = Modifier + .wrapContentSize() + .padding(8.dp, 0.dp), + textAlign = TextAlign.Start, + text = uiState.startTime, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + ) + Text( + modifier = Modifier + .wrapContentHeight().weight(1f) + .padding(8.dp, 6.dp, 8.dp, 0.dp), + textAlign = TextAlign.Center, + text = uiState.recordName, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Normal + ) + Text( + modifier = Modifier + .wrapContentSize() + .padding(8.dp, 0.dp), + textAlign = TextAlign.Start, + text = uiState.endTime, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + } + LegacySlider( + progress = uiState.progress, + onProgressChange = onProgressChange + ) + PlayPanel( + modifier = Modifier.wrapContentHeight().fillMaxWidth(), + showPause = uiState.showPause, + showStop = uiState.showStop, + onPlayClick = { onPlayClick() }, + onStopClick = { onStopClick() }, + onPauseClick = { onPauseClick() } + ) + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Preview +@Composable +fun PlaybackPanelPreview() { + Surface( + modifier = Modifier.fillMaxSize() + ) { + RecordPlaybackPanel( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + uiState = HomeScreenState( + waveformState = getTestWaveformData(), + startTime = "00:00", + endTime = "3:42", + time = "1:51", + recordName = "Test Record Name", + recordInfo = "1.5 MB, mp4, 192 kbps, 48 kHz", + isContextMenuAvailable = true, + isStopRecordingButtonAvailable = true, + ), + onProgressChange = {}, + onSeekStart = {}, + onSeekProgress = {}, + onSeekEnd = {}, + onPlayClick = {}, + onStopClick = {}, + onPauseClick = {} + ) + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/TouchPanel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/TouchPanel.kt new file mode 100644 index 000000000..106f9c1aa --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/TouchPanel.kt @@ -0,0 +1,163 @@ +package com.dimowner.audiorecorder.v2.app.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Card +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.dimowner.audiorecorder.util.equalsDelta +import com.dimowner.audiorecorder.v2.app.home.HomeScreenState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue +import kotlin.math.atan +import kotlin.math.roundToInt + +private const val ANIMATION_DURATION = 500 +private const val MAX_MOVE = 250 +private const val PLAY_PANEL_HEIGHT_DP = 300 + +@Composable +fun TouchPanel( + showRecordPlaybackPanel: Boolean, + uiHomeState: HomeScreenState, + onProgressChange: (Float) -> Unit, + onSeekStart: () -> Unit, + onSeekProgress: (Long) -> Unit, + onSeekEnd: (Long) -> Unit, + onPlayClick: () -> Unit, + onStopClick: () -> Unit, + onPauseClick: () -> Unit, +) { + val density = LocalDensity.current + // State to keep track of the Card position + val offsetY = remember { mutableFloatStateOf(0f) } + val maxMove = with(density) { MAX_MOVE.dp.toPx() } + val k = (maxMove / (Math.PI / 2f)).toFloat() + + val startY = with(density) { 12.dp.toPx() } + + var cumulativeDrag = remember { 0f } + val animatableY = remember { Animatable(startY) } + + // Get a CoroutineScope tied to the Composable + val coroutineScope = rememberCoroutineScope() + + // Define a threshold for Y coordinate movement + val playPanelHeight = remember { mutableFloatStateOf(with(density) { PLAY_PANEL_HEIGHT_DP.dp.toPx() }) } + + // Modifier to make the text draggable + val modifier = Modifier + .offset { IntOffset(0, animatableY.value.roundToInt()) } + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { + offsetY.floatValue = startY + cumulativeDrag = startY + }, + onDragEnd = { + // Animate back to start position + if (offsetY.floatValue.absoluteValue > playPanelHeight.floatValue * 0.5) { + coroutineScope.launch { + animatableY.animateTo( +// TODO:Fix constants!! + playPanelHeight.floatValue * 1.5f, + animationSpec = tween(durationMillis = ANIMATION_DURATION) + ) { + val height = playPanelHeight.floatValue * 1.5f + + if (animatableY.value.equalsDelta(height)) { + coroutineScope.launch { + delay(600L) + animatableY.snapTo(startY) + } + } + } + offsetY.floatValue = startY + onStopClick() + } + } else { + coroutineScope.launch { + animatableY.animateTo( + startY, + animationSpec = tween(durationMillis = ANIMATION_DURATION) + ) + } + } + }, + onDragCancel = { + if (offsetY.floatValue.absoluteValue > playPanelHeight.floatValue * 0.5) { + coroutineScope.launch { + animatableY.animateTo( + playPanelHeight.floatValue * 1.5f, + animationSpec = tween(durationMillis = ANIMATION_DURATION) + ) + offsetY.floatValue = startY + onStopClick() + } + } else { + // Animate back to start position + coroutineScope.launch { + animatableY.animateTo( + startY, + animationSpec = tween(durationMillis = ANIMATION_DURATION) + ) + } + } + }, + onDrag = { change, dragAmount -> + change.consume() + cumulativeDrag += dragAmount.y + offsetY.floatValue = cumulativeDrag + offsetY.floatValue = k * atan(offsetY.floatValue / k) + coroutineScope.launch { + animatableY.snapTo(offsetY.floatValue) + } + } + ) + } + + AnimatedVisibility( + visible = showRecordPlaybackPanel, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }) + ) { + Card( + modifier = modifier + .wrapContentSize() + .onSizeChanged { + playPanelHeight.floatValue = it.height.toFloat() + }, + ) { + RecordPlaybackPanel( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + uiState = uiHomeState, + onProgressChange = onProgressChange, + onSeekStart = onSeekStart, + onSeekProgress = onSeekProgress, + onSeekEnd = onSeekEnd, + onPlayClick = onPlayClick, + onStopClick = onStopClick, + onPauseClick = onPauseClick, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/WaveformComposeView.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/WaveformComposeView.kt new file mode 100644 index 000000000..75af2ff7d --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/components/WaveformComposeView.kt @@ -0,0 +1,429 @@ +package com.dimowner.audiorecorder.v2.app.components + +import android.graphics.Paint +import android.graphics.Typeface +import android.text.TextPaint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import com.dimowner.audiorecorder.AppConstantsV2 +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.util.AndroidUtils +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.TEST_WAVEFORM_DATA +import com.dimowner.audiorecorder.v2.app.TEST_WAVEFORM_DATA_DURATION_MILLS +import com.dimowner.audiorecorder.v2.app.getTestWaveformData + +private val GIRD_SUBLINE_HEIGHT: Float = AndroidUtils.dpToPx(12) +private val PADD: Float = AndroidUtils.dpToPx(6) + +@Composable +fun WaveformComposeView( + modifier: Modifier, + state: WaveformState, + showTimeline: Boolean, + onSeekStart: () -> Unit, + onSeekEnd: (mills: Long) -> Unit, + onSeekProgress: (mills: Long) -> Unit +) { + val context = LocalContext.current + val density = LocalDensity.current + val viewState = remember { + mutableStateOf(WaveformViewState(drawLinesArray = floatArrayOf())) + } + val waveformColor = MaterialTheme.colorScheme.primary.toArgb() + val gridColor = MaterialTheme.colorScheme.secondary.toArgb() + val lineColor = MaterialTheme.colorScheme.inverseSurface.toArgb() + val textColor = MaterialTheme.colorScheme.onSurfaceVariant.toArgb() + + val paintState = remember { + mutableStateOf( + PaintState( + waveformPaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = 1.3f + isAntiAlias = true + alpha = 255 + color = waveformColor + }, + linePaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = AndroidUtils.dpToPx(1.5f) + isAntiAlias = true + color = lineColor + }, + gridPaint = Paint().apply { + style = Paint.Style.STROKE + color = gridColor + strokeWidth = AndroidUtils.dpToPx(1) / 2 + }, + scrubberPaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = AndroidUtils.dpToPx(2f) + isAntiAlias = false + color = ContextCompat.getColor(context, R.color.md_yellow_A700) + }, + textPaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = AndroidUtils.dpToPx(1f) + isAntiAlias = true + textAlign = Paint.Align.CENTER + color = textColor + typeface = Typeface.create("sans-serif-light", Typeface.NORMAL) + textSize = viewState.value.textHeight + }, + ) + ) + } + + Canvas(modifier = modifier + .onSizeChanged { + val durationPx = it.width * state.widthScale + val millsPerPx = state.durationMills / durationPx + val pxPerMill = durationPx / state.durationMills + val pxPerSample = durationPx / state.durationSample + val samplePerPx = state.durationSample / durationPx + val textHeight = with(density) { 14.sp.toPx() } + val waveformShiftPx = updateShift( + viewState.value, it, + -(state.progressMills * pxPerMill).toInt()+it.width/2 + ) + + viewState.value = viewState.value.copy( + textIndent = if (showTimeline) textHeight + PADD else 0f, + waveformShiftPx = waveformShiftPx, + durationPx = durationPx, + millsPerPx = millsPerPx, + pxPerMill = pxPerMill, + pxPerSample = pxPerSample, + samplePerPx = samplePerPx, + drawLinesArray = FloatArray(it.width * 4), + textHeight = textHeight + ) + } + .pointerInput(Unit) { + if (!state.isRecording) { + detectDragGestures( + onDragStart = { + onSeekStart() + }, + onDrag = { change, dragAmount -> + val shift = updateShift( + viewState.value, size, + (viewState.value.waveformShiftPx + dragAmount.x).toInt() + ) + val half = size.width / 2 + viewState.value = viewState.value.copy( + waveformShiftPx = shift + ) + onSeekProgress(((-shift + half) * viewState.value.millsPerPx).toLong()) + }, + onDragEnd = { + val shift = viewState.value.waveformShiftPx.toInt() + val half = size.width / 2 + onSeekEnd(((-shift + half) * viewState.value.millsPerPx).toLong()) + }, + ) + } + } + ) { + drawIntoCanvas { canvas -> + drawGrid(canvas, size, viewState.value, state, showTimeline, paintState.value) + drawStartAndEnd(canvas, size, viewState.value, state, paintState.value) + drawWaveform(canvas, size, viewState.value, state, paintState.value) + //Draw scrubber + canvas.nativeCanvas.drawLine( + size.width / 2f, + 0f, + size.width / 2f, + size.height, + paintState.value.scrubberPaint + ) + } + } +} + +private fun drawStartAndEnd( + canvas: Canvas, + size: Size, + viewState: WaveformViewState, + state: WaveformState, + paintState: PaintState +) { + //Draw waveform start indication + canvas.nativeCanvas.drawLine( + viewState.waveformShiftPx, + viewState.textIndent, + viewState.waveformShiftPx, + size.height - viewState.textIndent, + paintState.linePaint + ) + //Draw waveform end indication + canvas.nativeCanvas.drawLine( + viewState.waveformShiftPx + state.waveformData.size * viewState.pxPerSample, + viewState.textIndent, + viewState.waveformShiftPx + state.waveformData.size * viewState.pxPerSample, + size.height - viewState.textIndent, + paintState.linePaint + ) +} + +private fun drawGrid( + canvas: Canvas, + size: Size, + viewState: WaveformViewState, + state: WaveformState, + showTimeline: Boolean, + paintState: PaintState +) { + val subStepPx = (state.gridStepMills / 2) * viewState.pxPerMill + val halfWidthMills = (size.width / 2) * viewState.millsPerPx + val gridEndMills = state.durationMills + halfWidthMills.toInt() + state.gridStepMills + val halfScreenStepCount = (halfWidthMills/state.gridStepMills).toInt() + + for (indexMills in -halfScreenStepCount*state.gridStepMills until gridEndMills step state.gridStepMills) { + val sampleIndexPx = indexMills * viewState.pxPerMill + val xPos = (viewState.waveformShiftPx + sampleIndexPx) + if (xPos >= -state.gridStepMills && xPos <= size.width + state.gridStepMills) { + //Draw grid lines + //Draw main grid line + canvas.nativeCanvas.drawLine( + xPos, + viewState.textIndent, + xPos, + size.height - viewState.textIndent, + paintState.gridPaint + ) + val xSubPos = xPos + subStepPx + //Draw grid top sub-line + canvas.nativeCanvas.drawLine( + xSubPos, + viewState.textIndent, + xSubPos, + GIRD_SUBLINE_HEIGHT + viewState.textIndent, + paintState.gridPaint + ) + //Draw grid bottom sub-line + canvas.nativeCanvas.drawLine( + xSubPos, + size.height - GIRD_SUBLINE_HEIGHT - viewState.textIndent, + xSubPos, + size.height - viewState.textIndent, + paintState.gridPaint + ) + + if (showTimeline) { + //Draw timeline texts + if (indexMills >= 0) { + val text = TimeUtils.formatTimeIntervalHourMin(indexMills) + //Bottom timeline text + canvas.nativeCanvas.drawText(text, xPos, size.height - PADD, paintState.textPaint) + //Top timeline text + canvas.nativeCanvas.drawText(text, xPos, viewState.textHeight, paintState.textPaint) + } + } + } + } +} + +private fun drawWaveform( + canvas: Canvas, + size: Size, + viewState: WaveformViewState, + state: WaveformState, + paintState: PaintState +) { + if (state.waveformData.isNotEmpty()) { + for (i in viewState.drawLinesArray.indices) { + viewState.drawLinesArray[i] = 0f + } + val half = size.height / 2 + val textIndent = viewState.textIndent + var step = 0 + for (index in 0 until viewState.durationPx.toInt()) { + var sampleIndex = (index * viewState.samplePerPx).toInt() + if (sampleIndex >= state.waveformData.size) { + sampleIndex = state.waveformData.size - 1 + } + val xPos = viewState.waveformShiftPx + index + if (xPos >= 0 && xPos <= size.width && step + 3 < viewState.drawLinesArray.size) { + viewState.drawLinesArray[step] = xPos + viewState.drawLinesArray[step + 1] = (half + state.waveformData[sampleIndex]*(half-textIndent)/AppConstantsV2.WAVEFORM_AMPLITUDE_MAX_VALUE + 1) + viewState.drawLinesArray[step + 2] = xPos + viewState.drawLinesArray[step + 3] = (half - state.waveformData[sampleIndex]*(half-textIndent)/AppConstantsV2.WAVEFORM_AMPLITUDE_MAX_VALUE - 1) + step += 4 + } + } + canvas.nativeCanvas.drawLines(viewState.drawLinesArray, 0, + viewState.drawLinesArray.size, paintState.waveformPaint) + } +} + +private fun updateShift( + viewState: WaveformViewState, + size: IntSize, + px: Int +): Float { + var shift = px.toFloat() + val half = size.width/2 + if (shift <= -viewState.durationPx+half) { + shift = -viewState.durationPx+half + } + if (shift > half) { + shift = half.toFloat() + } + return shift +} + +@Preview(showBackground = true) +@Composable +fun WaveformComposeViewPreview() { + WaveformComposeView( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + state = getTestWaveformData(), + showTimeline = true, + onSeekStart = {}, + onSeekProgress = { mills -> + }, + onSeekEnd = { mills -> + } + ) +} + +@Preview(showBackground = true) +@Composable +fun WaveformComposeViewRecordingPreview() { + val scale = TEST_WAVEFORM_DATA_DURATION_MILLS * (AppConstantsV2.DEFAULT_WIDTH_SCALE / AppConstantsV2.SHORT_RECORD) + WaveformComposeView( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + state = WaveformState( + widthScale = scale, + progressMills = TEST_WAVEFORM_DATA_DURATION_MILLS, + durationMills = TEST_WAVEFORM_DATA_DURATION_MILLS, + waveformData = TEST_WAVEFORM_DATA, + durationSample = TEST_WAVEFORM_DATA.size, + gridStepMills = 2000 + ), + showTimeline = true, + onSeekStart = {}, + onSeekProgress = { mills -> + }, + onSeekEnd = { mills -> + } + ) +} + +data class PaintState( + val waveformPaint: Paint = Paint(), + val linePaint: Paint = Paint(), + val gridPaint: Paint = Paint(), + val scrubberPaint: Paint = Paint(), + val textPaint: Paint = TextPaint(), +) + +data class WaveformViewState( + val waveformShiftPx: Float = 0F, + val textHeight: Float = AndroidUtils.dpToPx(14), + val textIndent: Float = textHeight + PADD, + + val drawLinesArray: FloatArray, + val durationPx: Float = 0F, + val millsPerPx: Float = 0F, + val pxPerMill: Float = 0F, + val pxPerSample: Float = 0F, + val samplePerPx: Float = 0F, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WaveformViewState) return false + + if (waveformShiftPx != other.waveformShiftPx) return false + if (textHeight != other.textHeight) return false + if (textIndent != other.textIndent) return false + if (!drawLinesArray.contentEquals(other.drawLinesArray)) return false + if (durationPx != other.durationPx) return false + if (millsPerPx != other.millsPerPx) return false + if (pxPerMill != other.pxPerMill) return false + if (pxPerSample != other.pxPerSample) return false + if (samplePerPx != other.samplePerPx) return false + + return true + } + + override fun hashCode(): Int { + var result = waveformShiftPx.hashCode() + result = 31 * result + textHeight.hashCode() + result = 31 * result + textIndent.hashCode() + result = 31 * result + drawLinesArray.contentHashCode() + result = 31 * result + durationPx.hashCode() + result = 31 * result + millsPerPx.hashCode() + result = 31 * result + pxPerMill.hashCode() + result = 31 * result + pxPerSample.hashCode() + result = 31 * result + samplePerPx.hashCode() + return result + } +} + +data class WaveformState( + val durationMills: Long = 0L, + val progressMills: Long = 0L, + /** Waveform data where 1 element is a sample and value of the element is amplitude (value between 0-1000). */ + val waveformData: IntArray = intArrayOf(), + /** If true, view in Recording mode, otherwise view in Playback mode. Playback mode by default. */ + val isRecording: Boolean = false, + + /** 1 means that waveform will take whole view width. 2 means that waveform will take double view width to draw. */ + val widthScale: Float = 1.5f, + val durationSample: Int = 0, + val gridStepMills: Long = 4000, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WaveformState) return false + + if (durationMills != other.durationMills) return false + if (progressMills != other.progressMills) return false + if (!waveformData.contentEquals(other.waveformData)) return false + if (widthScale != other.widthScale) return false + if (isRecording != other.isRecording) return false + if (durationSample != other.durationSample) return false + if (gridStepMills != other.gridStepMills) return false + + return true + } + + override fun hashCode(): Int { + var result = durationMills.hashCode() + result = 31 * result + progressMills.hashCode() + result = 31 * result + waveformData.contentHashCode() + result = 31 * result + widthScale.hashCode() + result = 31 * result + isRecording.hashCode() + result = 31 * result + durationSample + result = 31 * result + gridStepMills.hashCode() + return result + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsScreen.kt new file mode 100644 index 000000000..98890628b --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsScreen.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.deleted + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.safeDrawing +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.ConfirmationAlertDialog +import com.dimowner.audiorecorder.v2.app.TitleBar +import com.dimowner.audiorecorder.v2.app.deleted.widget.DeletedRecordsListItemWidget +import com.google.gson.Gson +import timber.log.Timber + +@Composable +internal fun DeletedRecordsScreen( + onPopBackStack: () -> Unit, + showRecordInfoScreen: (String) -> Unit, + uiState: DeletedRecordsScreenState, + event: DeletedRecordsScreenEvent?, + onAction: (DeletedRecordsScreenAction) -> Unit, +) { + + val showDeleteAllDialog = remember { mutableStateOf(false) } + + LaunchedEffect(key1 = event) { + when (event) { + is DeletedRecordsScreenEvent.RecordInformationEvent -> { + val json = Uri.encode(Gson().toJson(event.recordInfo)) + Timber.v("ON EVENT: ShareRecord json = $json") + showRecordInfoScreen(json) + } + + else -> { + Timber.v("ON EVENT: Unknown") + //Do nothing + } + } + } + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + ) { innerPadding -> + Surface( + modifier = Modifier.fillMaxSize().padding(innerPadding) + ) { + Column(modifier = Modifier.fillMaxSize()) { + TitleBar( + title = stringResource(id = R.string.trash), + onBackPressed = { onPopBackStack() }, + actionButtonText = stringResource(id = R.string.delete_all2), + onActionClick = if (uiState.records.isNotEmpty()) { + { showDeleteAllDialog.value = true } + } else null + ) + Text( + modifier = Modifier + .padding(16.dp, 8.dp) + .wrapContentSize(), + text = stringResource(id = R.string.trash_info), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(color = MaterialTheme.colorScheme.inverseOnSurface) + ) + LazyColumn( + modifier = Modifier.fillMaxWidth(), + ) { + items(uiState.records) { record -> + DeletedRecordsListItemWidget( + name = record.name, + details = record.details, + onClickItem = { + onAction(DeletedRecordsScreenAction.ShowRecordInfo(record.recordId)) + }, + onClickRestore = { + onAction(DeletedRecordsScreenAction.RestoreRecord(record.recordId)) + }, + onClickDelete = { + onAction(DeletedRecordsScreenAction.DeleteForeverRecord(record.recordId)) + }, + ) + } + } + } + if (showDeleteAllDialog.value) { + ConfirmationAlertDialog( + onDismissRequest = { showDeleteAllDialog.value = false }, + onConfirmation = { + onAction(DeletedRecordsScreenAction.DeleteAllRecordsFromRecycle) + showDeleteAllDialog.value = false + }, + dialogTitle = stringResource(id = R.string.warning), + dialogText = stringResource(id = R.string.delete_all_records), + painter = painterResource(id = R.drawable.ic_delete_forever), + positiveButton = stringResource(id = R.string.btn_yes), + negativeButton = stringResource(id = R.string.btn_no) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun DeletedRecordsScreenPreview() { + DeletedRecordsScreen({}, {}, + uiState = DeletedRecordsScreenState( + records = listOf( + DeletedRecordListItem( + recordId = 0, + name = "Record Name 1", + details = "4.5 MB, mp3, 128 kbps, 32 kHz", + duration = "5:21", + isBookmarked = false + ), + DeletedRecordListItem( + recordId = 1, + name = "Record Name 2", + details = "9.2 MB, M4a, 192 kbps, 48 kHz", + duration = "2:43", + isBookmarked = true + ) + ) + ), null, {}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsViewModel.kt new file mode 100644 index 000000000..59df6f29f --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/DeletedRecordsViewModel.kt @@ -0,0 +1,174 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.app.deleted + +import android.app.Application +import android.content.Context +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.info.RecordInfoState +import com.dimowner.audiorecorder.v2.app.info.toRecordInfoState +import com.dimowner.audiorecorder.v2.app.toInfoCombinedText +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +internal class DeletedRecordsViewModel @Inject constructor( + private val recordsDataSource: RecordsDataSource, + @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context +) : AndroidViewModel(context as Application) { + + private val _state = mutableStateOf(DeletedRecordsScreenState()) + val state: State = _state + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event + + init { + updateState() + } + + private fun updateState() { + viewModelScope.launch(ioDispatcher) { + val records = recordsDataSource.getMovedToRecycleRecords() + withContext(mainDispatcher) { + val context: Context = getApplication().applicationContext + _state.value = DeletedRecordsScreenState( + records = records.map { it.toDeletedRecordListItem(context) } + ) + } + } + } + + fun deleteAllRecordsFromRecycle() { + viewModelScope.launch(ioDispatcher) { + if (recordsDataSource.clearRecycle()) { + withContext(mainDispatcher) { + _state.value = DeletedRecordsScreenState( + records = emptyList() + ) + } + } else { + //TODO: Show failed to remove records message + withContext(mainDispatcher) { + updateState() + } + } + } + } + + fun showRecordInfo(recordId: Long) { + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(recordId)?.toRecordInfoState()?.let { + emitEvent(DeletedRecordsScreenEvent.RecordInformationEvent(it)) + } + } + } + + fun restoreRecord(recordId: Long) { + viewModelScope.launch(ioDispatcher) { + if (recordId != -1L && recordsDataSource.restoreRecordFromRecycle(recordId)) { + //TODO: Show success message + withContext(mainDispatcher) { + _state.value = DeletedRecordsScreenState( + records = state.value.records.filter { it.recordId != recordId } + ) + } + } else { + //TODO: Show error message + } + } + } + + fun deleteForeverRecord(recordId: Long) { + viewModelScope.launch(ioDispatcher) { + if (recordId != -1L && recordsDataSource.deleteRecordAndFileForever(recordId)) { + //TODO: Show success message + withContext(mainDispatcher) { + _state.value = DeletedRecordsScreenState( + records = state.value.records.filter { it.recordId != recordId } + ) + } + } else { + //TODO: Show error message + } + } + } + + fun onAction(action: DeletedRecordsScreenAction) { + when (action) { + is DeletedRecordsScreenAction.ShowRecordInfo -> showRecordInfo(action.recordId) + is DeletedRecordsScreenAction.RestoreRecord -> restoreRecord(action.recordId) + is DeletedRecordsScreenAction.DeleteForeverRecord -> deleteForeverRecord(action.recordId) + DeletedRecordsScreenAction.DeleteAllRecordsFromRecycle -> deleteAllRecordsFromRecycle() + } + } + + private fun emitEvent(event: DeletedRecordsScreenEvent) { + viewModelScope.launch { + _event.emit(event) + } + } +} + +internal data class DeletedRecordsScreenState( + val records: List = emptyList(), +) + +internal data class DeletedRecordListItem( + val recordId: Long, + val name: String, + val details: String, + val duration: String, + val isBookmarked: Boolean +) + +internal sealed class DeletedRecordsScreenEvent { + data class RecordInformationEvent(val recordInfo: RecordInfoState) : DeletedRecordsScreenEvent() +} + +internal sealed class DeletedRecordsScreenAction { + data class ShowRecordInfo(val recordId: Long) : DeletedRecordsScreenAction() + data class RestoreRecord(val recordId: Long) : DeletedRecordsScreenAction() + data class DeleteForeverRecord(val recordId: Long) : DeletedRecordsScreenAction() + data object DeleteAllRecordsFromRecycle : DeletedRecordsScreenAction() +} + +internal fun Record.toDeletedRecordListItem(context: Context): DeletedRecordListItem { + return DeletedRecordListItem( + recordId = this.id, + name = this.name, + details = this.toInfoCombinedText(context), + duration = TimeUtils.formatTimeIntervalHourMinSec2(this.durationMills), + isBookmarked = this.isBookmarked + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/widget/DeletedRecordsListItemWidget.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/widget/DeletedRecordsListItemWidget.kt new file mode 100644 index 000000000..c111e0990 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/deleted/widget/DeletedRecordsListItemWidget.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.deleted.widget + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +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 +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.ConfirmationAlertDialog + +@Composable +fun DeletedRecordsListItemWidget( + name: String, + details: String, + onClickItem: () -> Unit, + onClickRestore: () -> Unit, + onClickDelete: () -> Unit, +) { + val showDeleteDialog = remember { mutableStateOf(false) } + val showRestoreDialog = remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .clickable { onClickItem() } + .fillMaxWidth() + .wrapContentHeight() + ) { + Column( + modifier = Modifier.wrapContentSize(), + horizontalAlignment = Alignment.Start + ) { + Text( + modifier = Modifier + .padding(12.dp, 8.dp, 12.dp, 2.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Medium + ) + Text( + modifier = Modifier + .padding(12.dp, 2.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = details, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + Row( + modifier = Modifier.align(Alignment.End) + ) { + Button( + modifier = Modifier.padding(4.dp).wrapContentSize(), + onClick = { showRestoreDialog.value = true } + ) { + Text( + text = stringResource(id = R.string.restore), + fontSize = 16.sp, + fontWeight = FontWeight.Light, + ) + } + Button( + modifier = Modifier.padding(4.dp).wrapContentSize(), + onClick = { showDeleteDialog.value = true } + ) { + Text( + text = stringResource(id = R.string.delete), + fontSize = 16.sp, + fontWeight = FontWeight.Light, + ) + } + } + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(color = MaterialTheme.colorScheme.inverseOnSurface) + ) + if (showDeleteDialog.value) { + ConfirmationAlertDialog( + onDismissRequest = { showDeleteDialog.value = false }, + onConfirmation = { + onClickDelete() + showDeleteDialog.value = false + }, + dialogTitle = stringResource(id = R.string.warning), + dialogText = stringResource(id = R.string.delete_record_forever, name), + painter = painterResource(id = R.drawable.ic_delete_forever), + positiveButton = stringResource(id = R.string.btn_yes), + negativeButton = stringResource(id = R.string.btn_no) + ) + } + if (showRestoreDialog.value) { + ConfirmationAlertDialog( + onDismissRequest = { showRestoreDialog.value = false }, + onConfirmation = { + onClickRestore() + showRestoreDialog.value = false + }, + dialogTitle = stringResource(id = R.string.warning), + dialogText = stringResource(id = R.string.restore_record, name), + painter = painterResource(id = R.drawable.ic_restore_from_trash), + positiveButton = stringResource(id = R.string.btn_yes), + negativeButton = stringResource(id = R.string.btn_no) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DeletedRecordsListItemWidgetPreview() { + DeletedRecordsListItemWidget("Label", "Value", {}, {}, {}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeComponents.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeComponents.kt new file mode 100644 index 000000000..84cf6eb6e --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeComponents.kt @@ -0,0 +1,615 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSizeIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.RecordsDropDownMenu +import com.dimowner.audiorecorder.v2.app.components.onDebounceClick + +@Composable +fun TopAppBar( + onImportClick: () -> Unit, + onHomeMenuItemClick: (HomeDropDownMenuItemId) -> Unit, + showMenuButton: Boolean = true +) { + val expanded = remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .height(60.dp) + .fillMaxWidth() + .padding(0.dp, 4.dp, 0.dp, 0.dp) + .background(color = MaterialTheme.colorScheme.surface), + ) { + IconButton( + onClick = onImportClick, + modifier = Modifier + .padding(8.dp) + .align(Alignment.CenterStart), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_import), + contentDescription = stringResource(id = R.string.btn_import), + modifier = Modifier.size(24.dp) + ) + } + Text( + modifier = Modifier + .wrapContentSize() + .align(Alignment.Center), + textAlign = TextAlign.Center, + text = stringResource(id = R.string.app_name), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 24.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Medium + ) + ), + ) + + if (showMenuButton) { + Box( + modifier = Modifier.align(Alignment.CenterEnd) + ) { + RecordsDropDownMenu( + items = remember { getHomeDroDownMenuItems() }, + onItemClick = { itemId -> + onHomeMenuItemClick(itemId) + }, + expanded = expanded + ) + IconButton( + onClick = { expanded.value = true }, + modifier = Modifier.padding(8.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_more_vert), + contentDescription = stringResource(id = androidx.compose.ui.R.string.dropdown_menu), + modifier = Modifier.size(24.dp) + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun TopAppBarPreview() { + TopAppBar({}, {}) +} + +@Composable +fun PlayPanel( + modifier: Modifier, + showStop: Boolean, + showPause: Boolean, + onPlayClick: () -> Unit, + onStopClick: () -> Unit, + onPauseClick: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + IconButton( + onClick = if (showPause) onPauseClick else onPlayClick, + modifier = Modifier + .size(42.dp) + .align(Alignment.CenterVertically), + ) { + val imageResourceId = if (showPause) { + R.drawable.ic_pause + } else { + R.drawable.ic_play + } + Icon( + painter = painterResource(id = imageResourceId), + contentDescription = stringResource(id = R.string.btn_play), + ) + } + if (showStop) { + Spacer(modifier = Modifier.size(8.dp)) + IconButton( + onClick = onStopClick, + modifier = Modifier + .size(42.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_stop), + contentDescription = stringResource(id = R.string.button_stop), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun PlayPanelPreview() { + PlayPanel( + modifier = Modifier + .wrapContentSize() + .padding(8.dp, 8.dp), + showPause = false, + showStop = true, + onPlayClick = {}, + onStopClick = {}, + onPauseClick = {}, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LegacySlider( + //Progress is value between 0 - 1f + progress: Float = 0f, + onProgressChange: (Float) -> Unit, + enabled: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val trackHeight = 4.dp + val thumbSize = DpSize(16.dp, 16.dp) + val zeroThumbSize = DpSize(0.dp, 0.dp) + + Slider( + interactionSource = interactionSource, + modifier = Modifier + .requiredSizeIn(minWidth = thumbSize.width, minHeight = trackHeight) + .padding(0.dp, 0.dp), + value = progress, + enabled = enabled, + onValueChange = { onProgressChange(it) }, + thumb = { + val modifier = Modifier + .size(if (enabled) thumbSize else zeroThumbSize) + .shadow(1.dp, CircleShape, clip = false) + .indication( + interactionSource = interactionSource, + indication = ripple(bounded = false, radius = 16.dp) + ) + SliderDefaults.Thumb(interactionSource = interactionSource, modifier = modifier) + }, + track = { + val modifier = Modifier + .height(trackHeight) + .padding(horizontal = if (enabled) 0.dp else 8.dp) + + SliderDefaults.Track( + sliderState = it, + modifier = modifier, + thumbTrackGapSize = 0.dp, + trackInsideCornerSize = 0.dp, + drawStopIndicator = null + ) + } + ) +} + +@Preview(showBackground = true) +@Composable +fun LegacySliderPreview() { + LegacySlider( + progress = 0.5f, + onProgressChange = {} + ) +} + +@Composable +fun BottomBar( + onSettingsClick: () -> Unit, + onRecordsListClick: () -> Unit, + onStartRecordingClick: () -> Unit, + onPauseRecordingClick: () -> Unit, + onResumeRecordingClick: () -> Unit, + onStopRecordingClick: () -> Unit, + onDeleteRecordingClick: () -> Unit, + bottomBarState: BottomBarState +) { + Row( + modifier = Modifier + .wrapContentHeight() + .padding(16.dp, 0.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + IconButton( + onClick = onDebounceClick(onSettingsClick), + modifier = Modifier + .size(42.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_settings), + contentDescription = stringResource(id = R.string.settings), + ) + } + Spacer(modifier = Modifier.weight(1f)) + when (bottomBarState) { + BottomBarState.READY_TO_START_RECORDING -> { + CircleButton( + modifier = Modifier.size(80.dp), + text = stringResource(R.string.button_record), + onClick = onDebounceClick(onStartRecordingClick), + ) + } + BottomBarState.RECORDING -> { + RecordingProgressPanel( + modifier = Modifier, + onPauseRecordingClick = onDebounceClick(onPauseRecordingClick), + onStopRecordingClick = onDebounceClick(onStopRecordingClick), + ) + } + BottomBarState.PAUSED -> { + RecordingPausePanel( + modifier = Modifier, + onResumeRecordingClick = onDebounceClick(onResumeRecordingClick), + onStopRecordingClick = onDebounceClick(onStopRecordingClick), + onDeleteRecordingClick = onDebounceClick(onDeleteRecordingClick), + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = onDebounceClick(onRecordsListClick), + modifier = Modifier + .size(42.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_list), + contentDescription = stringResource(id = R.string.records), + ) + } + } +} + +@Composable +fun CircleButton( + modifier: Modifier, + text: String, + onClick: () -> Unit +) { + Button( + onClick = onClick, + contentPadding = PaddingValues(0.dp), + shape = CircleShape, + modifier = modifier + ) { + Text( + text = text, + fontSize = 13.sp + ) + } +} + +@Preview(showBackground = true) +@Composable +fun CircleButtonPreview() { + CircleButton( + modifier = Modifier.size(64.dp), + text = "RECORD", + onClick = {} + ) +} + +@Composable +fun RecordingProgressPanel( + modifier: Modifier, + onPauseRecordingClick: () -> Unit, + onStopRecordingClick: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + + Spacer(modifier = Modifier.size(width = 62.dp, 54.dp)) + CircleButton( + modifier = Modifier.size(80.dp), + text = stringResource(R.string.button_pause), + onClick = onDebounceClick(onPauseRecordingClick), + ) + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = onDebounceClick(onStopRecordingClick), + modifier = Modifier + .size(54.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_stop), + contentDescription = "Stop recording", //TODO: Use string resource + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun RecordingProgressPanelPreview() { + RecordingProgressPanel(Modifier, {}, {}) +} + +@Composable +fun RecordingPausePanel( + modifier: Modifier, + onResumeRecordingClick: () -> Unit, + onStopRecordingClick: () -> Unit, + onDeleteRecordingClick: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + modifier = Modifier.size(86.dp, 48.dp), + onClick = onDebounceClick(onDeleteRecordingClick), + contentPadding = PaddingValues(6.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFD01716), + contentColor = Color.White + ), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, // Removes the default shadow + pressedElevation = 0.dp // Prevents a shadow when pressed + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.End, + text = stringResource(R.string.delete), + fontSize = 13.sp + ) + Icon( + modifier = Modifier.size(32.dp).padding(4.dp), + painter = painterResource(id = R.drawable.ic_delete_forever_36), + contentDescription = stringResource(id = R.string.delete), + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + CircleButton( + modifier = Modifier.size(80.dp), + text = stringResource(R.string.button_resume), + onClick = onDebounceClick(onResumeRecordingClick), + ) + Spacer(modifier = Modifier.width(8.dp)) + Button( + modifier = Modifier.size(86.dp, 48.dp), + onClick = onDebounceClick(onStopRecordingClick), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF48A54B), + contentColor = Color.White + ), + contentPadding = PaddingValues(6.dp), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, // Removes the default shadow + pressedElevation = 0.dp // Prevents a shadow when pressed + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + modifier = Modifier.size(32.dp).padding(4.dp), + painter = painterResource(id = R.drawable.ic_stop), + contentDescription = stringResource(R.string.button_stop), + ) + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Start, + text = stringResource(R.string.button_stop), + fontSize = 13.sp + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun RecordingPausePanelPreview() { + RecordingPausePanel(Modifier, {}, {}, {}) +} + +@Preview(showBackground = true) +@Composable +fun BottomBarReadyPreview() { + BottomBar({}, {}, {}, {}, {}, {}, {}, BottomBarState.READY_TO_START_RECORDING) +} + +@Preview(showBackground = true) +@Composable +fun BottomBarRecordingPreview() { + BottomBar({}, {}, {}, {}, {}, {}, {}, BottomBarState.RECORDING) +} + +@Preview(showBackground = true) +@Composable +fun BottomBarPausedPreview() { + BottomBar({}, {}, {}, {}, {}, {}, {}, BottomBarState.PAUSED) +} + +@Composable +fun TimePanel( + recordName: String, + recordInfo: String, + recordDuration: String, + timeStart: String, + timeEnd: String, + progress: Float, + isSliderEnabled: Boolean, + onRenameClick: () -> Unit, + onProgressChange: (Float) -> Unit +) { + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier + .wrapContentSize(), + textAlign = TextAlign.Center, + text = recordDuration, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 60.sp, + fontWeight = FontWeight.Bold + ) + Text( + modifier = Modifier + .wrapContentSize() + .padding(0.dp, 0.dp, 0.dp, 4.dp), + textAlign = TextAlign.Center, + text = recordName, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 22.sp, + fontWeight = FontWeight.Normal + ) + Row { + Text( + modifier = Modifier + .wrapContentSize() + .padding(4.dp, 0.dp), + textAlign = TextAlign.Start, + text = timeStart, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier + .wrapContentSize(), + textAlign = TextAlign.Center, + text = recordInfo, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp, + fontWeight = FontWeight.Light + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier + .wrapContentSize() + .padding(4.dp, 0.dp), + textAlign = TextAlign.Start, + text = timeEnd, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + } + LegacySlider( + progress = progress, + onProgressChange = onProgressChange, + enabled = isSliderEnabled + ) + } +} + +@Preview(showBackground = true) +@Composable +fun TimePanelPreview() { + TimePanel( + "Record-14", + "1.2Mb, M4a, " + + "44.1kHz", + "02:23", + "00:00", + "05:32", + 0.3f, + isSliderEnabled = true, + onRenameClick = {}, + onProgressChange = { prgress ->}, + ) +} + +@Preview(showBackground = true) +@Composable +fun TimePanelRecordingProgressPreview() { + TimePanel( + "Recording...", + "1.2Mb, M4a, " + + "44.1kHz", + "02:23", + "", + "", + 0.0f, + isSliderEnabled = false, + onRenameClick = {}, + onProgressChange = { prgress ->}, + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeDropDownMenuItemId.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeDropDownMenuItemId.kt new file mode 100644 index 000000000..c8bf9d811 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeDropDownMenuItemId.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.home + +enum class HomeDropDownMenuItemId { + SHARE, INFORMATION, RENAME, OPEN_WITH, SAVE_AS, DELETE +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeExtensions.kt new file mode 100644 index 000000000..0f4139a42 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeExtensions.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.home + +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.DropDownMenuItem + +fun getHomeDroDownMenuItems(): List> { + return HomeDropDownMenuItemId.entries.map { + when (it) { + HomeDropDownMenuItemId.SHARE -> DropDownMenuItem( + id = it, textResId = R.string.share, imageResId = R.drawable.ic_share + ) + HomeDropDownMenuItemId.INFORMATION -> DropDownMenuItem( + id = it, textResId = R.string.info, imageResId = R.drawable.ic_info + ) + HomeDropDownMenuItemId.RENAME -> DropDownMenuItem( + id = it, textResId = R.string.rename, imageResId = R.drawable.ic_pencil + ) + HomeDropDownMenuItemId.OPEN_WITH -> DropDownMenuItem( + id = it, textResId = R.string.open_with, imageResId = R.drawable.ic_open_with + ) + HomeDropDownMenuItemId.SAVE_AS -> DropDownMenuItem( + id = it, textResId = R.string.save_as, imageResId = R.drawable.ic_save_alt + ) + HomeDropDownMenuItemId.DELETE -> DropDownMenuItem( + id = it, textResId = R.string.delete, imageResId = R.drawable.ic_delete_forever + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeScreen.kt new file mode 100644 index 000000000..64d8b3cdf --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeScreen.kt @@ -0,0 +1,370 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.home + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.safeDrawing +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.ComposableLifecycle +import com.dimowner.audiorecorder.v2.app.DeleteDialog +import com.dimowner.audiorecorder.v2.app.RenameAlertDialog +import com.dimowner.audiorecorder.v2.app.SaveAsDialog +import com.dimowner.audiorecorder.v2.app.components.WaveformComposeView +import com.dimowner.audiorecorder.v2.app.components.WaveformState +import com.dimowner.audiorecorder.v2.app.getTestWaveformData +import com.google.gson.Gson +import kotlinx.coroutines.launch +import timber.log.Timber + +@Composable +internal fun HomeScreen( + showRecordsScreen: () -> Unit, + showSettingsScreen: () -> Unit, + showRecordInfoScreen: (String) -> Unit, + uiState: HomeScreenState, + event: HomeScreenEvent?, + onAction: (HomeScreenAction) -> Unit +) { + + ComposableLifecycle { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + Timber.d("HomeScreen: On Start") + onAction(HomeScreenAction.InitHomeScreen) + } + else -> {} + } + } + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + val showRenameDialog = remember { mutableStateOf(false) } + val showDeleteDialog = remember { mutableStateOf(false) } + val showSaveAsDialog = remember { mutableStateOf(false) } + + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + // Handle the selected document URI here + if (uri != null) { + onAction(HomeScreenAction.ImportAudioFile(uri)) + } + } + + val context = LocalContext.current + + LaunchedEffect(key1 = event) { + when (event) { + HomeScreenEvent.ShowImportErrorError -> { + Timber.v("ON EVENT: ShowImportErrorError") + } + + is HomeScreenEvent.RecordInformationEvent -> { + val json = Uri.encode(Gson().toJson(event.recordInfo)) + Timber.v("ON EVENT: ShareRecord json = $json") + showRecordInfoScreen(json) + } + is HomeScreenEvent.RecordMovedToRecycleSnack -> { + scope.launch { + val message = if (event.recordName != null) { + context.getString(R.string.msg_recording_moved_to_trash, event.recordName) + } else { + context.getString(R.string.msg_recording_canceled) + } + val result = snackbarHostState + .showSnackbar( + message = message, + actionLabel = context.getString(R.string.action_undo), + duration = SnackbarDuration.Short + ) + when (result) { + SnackbarResult.ActionPerformed -> { + onAction(HomeScreenAction.RestoreRecordFromRecycle(event.recordId)) + } + SnackbarResult.Dismissed -> { + /* Handle snackbar dismissed */ + } + } + } + } + is HomeScreenEvent.ShowInfoSnack -> { + scope.launch { + snackbarHostState + .showSnackbar( + message = event.message, + duration = SnackbarDuration.Short + ) + } + } + is HomeScreenEvent.ShowErrorSnack -> { + scope.launch { + snackbarHostState + .showSnackbar( + message = event.message, + duration = SnackbarDuration.Short + ) + } + } + + else -> { + Timber.v("ON EVENT: Unknown") + //Do nothing + } + } + } + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TopAppBar( + onImportClick = { + launcher.launch("audio/*") + }, + onHomeMenuItemClick = { + when (it) { + HomeDropDownMenuItemId.SHARE -> { + onAction(HomeScreenAction.ShareActiveRecord) + } + + HomeDropDownMenuItemId.INFORMATION -> { + onAction(HomeScreenAction.ShowActiveRecordInfo) + } + + HomeDropDownMenuItemId.RENAME -> { + showRenameDialog.value = true + } + + HomeDropDownMenuItemId.OPEN_WITH -> { + onAction(HomeScreenAction.OpenActiveRecordWithAnotherApp) + } + + HomeDropDownMenuItemId.SAVE_AS -> { + showSaveAsDialog.value = true + } + + HomeDropDownMenuItemId.DELETE -> { + showDeleteDialog.value = true + } + } + }, + showMenuButton = uiState.isContextMenuAvailable + ) + Spacer( + modifier = Modifier + .weight(1f) + .wrapContentHeight() + ) + if (uiState.isShowLoadingProgress) { + //Show nothing because of progress takes very short period of time + } else if (uiState.isShowWaveform) { + WaveformComposeView( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + state = uiState.waveformState, + showTimeline = true, + onSeekStart = { + onAction(HomeScreenAction.OnSeekStart) + }, + onSeekProgress = { mills -> + onAction(HomeScreenAction.OnSeekProgress(mills)) + }, + onSeekEnd = { mills -> + onAction(HomeScreenAction.OnSeekEnd(mills)) + } + ) + PlayPanel( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(8.dp, 8.dp), + showPause = uiState.showPause, + showStop = uiState.showStop, + onPlayClick = { onAction(HomeScreenAction.OnPlayClick) }, + onStopClick = { onAction(HomeScreenAction.OnStopClick) }, + onPauseClick = { onAction(HomeScreenAction.OnPauseClick) } + ) + } else { + Image( + painter = painterResource(id = R.drawable.waveform), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier.wrapContentSize(), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + } + Spacer( + modifier = Modifier + .weight(1f) + .wrapContentHeight() + ) + TimePanel( + uiState.recordName, + uiState.recordInfo, + uiState.time, + uiState.startTime, + uiState.endTime, + uiState.progress, + uiState.isShowWaveform, + onRenameClick = {}, + onProgressChange = { onAction(HomeScreenAction.OnProgressBarStateChange(it)) } + ) + BottomBar( + onSettingsClick = { showSettingsScreen() }, + onRecordsListClick = { showRecordsScreen() }, + onStartRecordingClick = { onAction(HomeScreenAction.OnStartRecordingClick) }, + onPauseRecordingClick = { onAction(HomeScreenAction.OnPauseRecordingClick) }, + onResumeRecordingClick = { onAction(HomeScreenAction.OnResumeRecordingClick) }, + onStopRecordingClick = { onAction(HomeScreenAction.OnStopRecordingClick) }, + onDeleteRecordingClick = { onAction(HomeScreenAction.OnDeleteRecordingProgressClick) }, + bottomBarState = uiState.bottomBarState + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + ) + if (showDeleteDialog.value) { + DeleteDialog( + dialogText = stringResource(id = R.string.delete_record, uiState.recordName), + onAcceptClick = { + showDeleteDialog.value = false + onAction(HomeScreenAction.DeleteActiveRecord) + }, onDismissClick = { + showDeleteDialog.value = false + } + ) + } else if (showSaveAsDialog.value) { + SaveAsDialog( + dialogText = stringResource( + id = R.string.record_name_will_be_copied_into_downloads, uiState.recordName), + onAcceptClick = { + showSaveAsDialog.value = false + onAction(HomeScreenAction.SaveActiveRecordAs) + }, onDismissClick = { + showSaveAsDialog.value = false + } + ) + } else if (showRenameDialog.value) { + RenameAlertDialog( + uiState.recordName, + onAcceptClick = { + showRenameDialog.value = false + onAction(HomeScreenAction.RenameActiveRecord(it)) + }, onDismissClick = { + showRenameDialog.value = false + } + ) + } + } + } + } +} + +@Preview +@Composable +fun HomeScreenPreview() { + HomeScreen( + {}, {}, {}, uiState = HomeScreenState( + waveformState = getTestWaveformData(), + progress = 0.4f, + startTime = "00:00", + endTime = "3:42", + time = "1:51", + recordName = "Test Record Name", + recordInfo = "1.5 MB, mp4, 192 kbps, 48 kHz", + isContextMenuAvailable = true, + isStopRecordingButtonAvailable = true, + isShowWaveform = true, + ), null, {}) +} + +@Preview +@Composable +fun HomeScreenEmptyPreview() { + HomeScreen({}, {}, {}, uiState = HomeScreenState(), null, {}) +} + +@Preview +@Composable +fun HomeScreenShowProgressPreview() { + HomeScreen({}, {}, {}, uiState = HomeScreenState( + isShowLoadingProgress = true + ), null, {}) +} + +@Preview +@Composable +fun HomeScreenShowRecordingProgressPreview() { + HomeScreen( + {}, {}, {}, + uiState = HomeScreenState( + isShowLoadingProgress = false, + isShowWaveform = false, + bottomBarState = BottomBarState.RECORDING, + waveformState = WaveformState(), + time = TimeUtils.formatTimeIntervalHourMinSec2(15000L), + showPause = false, + showStop = false, + recordName = stringResource(R.string.recording_progress), + recordInfo = "1.5 MB, mp4, 192 kbps, 48 kHz", + ), + null, {}, + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt new file mode 100644 index 000000000..ca5d5e9ad --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt @@ -0,0 +1,901 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.app.home + +import android.animation.TypeEvaluator +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Application +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.net.Uri +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.view.animation.DecelerateInterpolator +import androidx.annotation.StringRes +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.dimowner.audiorecorder.ARApplication +import com.dimowner.audiorecorder.AppConstants +import com.dimowner.audiorecorder.AppConstantsV2 +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.app.DecodeService +import com.dimowner.audiorecorder.app.DecodeServiceListener +import com.dimowner.audiorecorder.app.DownloadService +import com.dimowner.audiorecorder.audio.AudioDecoder +import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.exception.AppException +import com.dimowner.audiorecorder.exception.CantCreateFileException +import com.dimowner.audiorecorder.exception.ErrorParser +import com.dimowner.audiorecorder.util.AndroidUtils +import com.dimowner.audiorecorder.util.FileUtil +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.adjustWaveformHeights +import com.dimowner.audiorecorder.v2.app.calculateGridStep +import com.dimowner.audiorecorder.v2.app.calculateScale +import com.dimowner.audiorecorder.v2.app.components.WaveformState +import com.dimowner.audiorecorder.v2.app.info.RecordInfoState +import com.dimowner.audiorecorder.v2.app.info.toRecordInfoState +import com.dimowner.audiorecorder.v2.app.toInfoCombinedText +import com.dimowner.audiorecorder.v2.audio.RecorderEvent +import com.dimowner.audiorecorder.v2.audio.RecorderV2 +import com.dimowner.audiorecorder.v2.data.FileDataSource +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.IOException +import javax.inject.Inject + +private const val ANIMATION_DURATION = 330L //mills. + +@SuppressWarnings("LongParameterList") +@HiltViewModel +class HomeViewModel @Inject constructor( + private val recordsDataSource: RecordsDataSource, + private val fileDataSource: FileDataSource, + private val prefs: PrefsV2, + private val audioPlayer: PlayerContractNew.Player, + private val audioRecorder: RecorderV2, + @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context, +) : AndroidViewModel(context as Application) { + + private val _state = mutableStateOf(HomeScreenState()) + val state: State = _state + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event + + private val connection: ServiceConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as DecodeService.LocalBinder + val decodeService = binder.getService() + decodeService.setDecodeListener(object : DecodeServiceListener { + override fun onStartProcessing() { + //Do nothing + } + + override fun onFinishProcessing(decodedData: IntArray) { + viewModelScope.launch(ioDispatcher) { + //TODO: Handle the case when active racord has changed during decoding. + recordsDataSource.getActiveRecord()?.let { + recordsDataSource.updateRecord( + it.copy( + amps = decodedData + ) + ) + } + } + } + }) + } + + override fun onServiceDisconnected(arg0: ComponentName) { + //Do nothing + } + + override fun onBindingDied(name: ComponentName) { + //Do nothing + } + } + + init { + viewModelScope.launch { + //TODO: these events should be handled in a service + subscribeRecorderUpdates() + } + subscribePlayerUpdates() + } + + private suspend fun subscribeRecorderUpdates() { + val context: Context = getApplication().applicationContext + audioRecorder.subscribeRecorderEvents().collect { event -> + Timber.d("HomeViewModel audioRecorder: event: $event") + when (event) { + is RecorderEvent.OnError -> { + handleError(event.exception) + } + + RecorderEvent.OnStartRecording -> { + _state.value = state.value.copy( + bottomBarState = BottomBarState.RECORDING, + waveformState = WaveformState(), + isShowWaveform = false, + startTime = "", + endTime = "", + recordName = context.getString(R.string.recording_progress), + ) + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(prefs.recordedRecordId)?.let { + _state.value = state.value.copy( + recordInfo = it.toInfoCombinedText(context) + ) + } + } + } + is RecorderEvent.OnRecordingProgress -> { + _state.value = _state.value.copy( + time = TimeUtils.formatTimeIntervalHourMinSec2(event.durationMills), + showPause = false, + showStop = false, + isShowWaveform = false + ) + } + RecorderEvent.OnPauseRecording -> { + _state.value = state.value.copy( + bottomBarState = BottomBarState.PAUSED, + recordName = context.getString(R.string.recording_paused), + ) + } + RecorderEvent.OnResumeRecording -> { + _state.value = state.value.copy( + bottomBarState = BottomBarState.RECORDING, + recordName = context.getString(R.string.recording_progress), + ) + } + RecorderEvent.OnStopRecording -> { + handleRecordingStopped() + } + } + } + } + + private fun subscribePlayerUpdates() { + audioPlayer.addPlayerCallback(callback = object : PlayerContractNew.PlayerCallback { + override fun onStartPlay() { + _state.value = _state.value.copy( + showPause = true, + showStop = true, + ) + } + + override fun onPlayProgress(mills: Long) { + if (!_state.value.isSeek) { + _state.value = _state.value.copy( + waveformState = _state.value.waveformState.copy( + progressMills = mills + ), + progress = millsToProgress(mills, _state.value.waveformState.durationMills), + time = TimeUtils.formatTimeIntervalHourMinSec2(mills), + showPause = true, + showStop = true, + ) + } + } + + override fun onPausePlay() { + _state.value = _state.value.copy( + showPause = false, + showStop = true + ) + } + + override fun onSeek(mills: Long) { + //Do nothing + } + + override fun onStopPlay() { + _state.value = _state.value.copy( + showPause = false, + showStop = false + ) + moveToStart() + } + + override fun onError(throwable: AppException) { + Timber.e(throwable) + handleError(throwable) + } + }) + } + + private fun handleRecordingStopped() { + // - Read recorded file info + // - Update recorded file duration, size, format, bitrate, sample rate, channel count + // - Move updated to recycle if requested to delete the record, otherwise set it as active record + viewModelScope.launch(ioDispatcher) { + val recordedRecordId = prefs.recordedRecordId + if (recordedRecordId >= 0) { + val record = recordsDataSource.getRecord(recordedRecordId) + if (record != null) { + val output = File(record.path) + val info = AudioDecoder.readRecordInfo(output); + val success = recordsDataSource.updateRecord( + record.copy( + durationMills = info.duration / 1000, + format = info.format, + size = info.size, + sampleRate = info.sampleRate, + channelCount = info.channelCount, + bitrate = info.bitrate, + ) + ) + if (_state.value.isDeleteRecordingProgressRequested) { + moveRecordToRecycle(recordedRecordId, false) + } else { + if (success) { + prefs.activeRecordId = recordedRecordId + //Record saved successfully + showInfoMessage(R.string.msg_recording_saved) + } else { + //Failed to save record + showInfoMessage(R.string.msg_save_recording_failed) + } + updateState() + } + } else { + if (!_state.value.isDeleteRecordingProgressRequested) { + //Failed to save record + showInfoMessage(R.string.msg_save_recording_failed) + } + updateState() + } + prefs.recordedRecordId = -1 + } + } + } + + private fun handleError(exception: AppException) { + val context: Context = getApplication().applicationContext + emitEvent( + HomeScreenEvent.ShowErrorSnack( + context.getString(ErrorParser.parseException(exception)) + ) + ) + } + + private fun showInfoMessage(@StringRes resId: Int) { + val context: Context = getApplication().applicationContext + emitEvent( + HomeScreenEvent.ShowInfoSnack( + context.getString(resId) + ) + ) + } + + private fun showInfoMessage(@StringRes resId: Int, vararg formatArgs: Any) { + val context: Context = getApplication().applicationContext + emitEvent( + HomeScreenEvent.ShowInfoSnack( + context.getString(resId, *formatArgs) + ) + ) + } + + fun init() { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + updateState(false) + } + + val context: Context = getApplication().applicationContext + val intent = Intent(context, DecodeService::class.java) + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + + private suspend fun updateState(resetPlayProgress: Boolean = true) { + val context: Context = getApplication().applicationContext + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + withContext(mainDispatcher) { + _state.value = _state.value.copy( + waveformState = _state.value.waveformState.copy( + widthScale = calculateScale( + activeRecord.durationMills, + defaultWidthScale = AppConstantsV2.DEFAULT_WIDTH_SCALE + ), + durationMills = activeRecord.durationMills, + progressMills = if (resetPlayProgress) 0L else _state.value.waveformState.progressMills, + waveformData = adjustWaveformHeights(activeRecord.amps), + durationSample = activeRecord.amps.size, + gridStepMills = calculateGridStep(activeRecord.durationMills) + ), + startTime = context.getString(R.string.zero_time), + endTime = TimeUtils.formatTimeIntervalHourMinSec2(activeRecord.durationMills), + time = context.getString(R.string.zero_time), + recordName = activeRecord.name, + recordInfo = activeRecord.toInfoCombinedText(context), + isContextMenuAvailable = true, + isShowWaveform = true, + isShowLoadingProgress = false, + isDeleteRecordingProgressRequested = false, + ) + } + } else { + val bottomBarState = audioRecorder.toBottomBarState() + if (audioRecorder.isRecording) { + recordsDataSource.getRecord(prefs.recordedRecordId)?.let { + val recordInfo = it.toInfoCombinedText(context) + if (bottomBarState == BottomBarState.RECORDING) { + _state.value = state.value.copy( + bottomBarState = BottomBarState.RECORDING, + waveformState = WaveformState(), + isShowLoadingProgress = false, + isShowWaveform = false, + startTime = "", + endTime = "", + recordInfo = recordInfo, + recordName = context.getString(R.string.recording_progress), + ) + } else if (bottomBarState == BottomBarState.PAUSED) { + _state.value = state.value.copy( + bottomBarState = BottomBarState.PAUSED, + waveformState = WaveformState(), + isShowLoadingProgress = false, + isShowWaveform = false, + startTime = "", + endTime = "", + recordInfo = recordInfo, + recordName = context.getString(R.string.recording_paused), + ) + } + } + } else { + withContext(mainDispatcher) { + //isShowProgress = false is default value. So it cancels progress + _state.value = HomeScreenState( + bottomBarState = bottomBarState + ) + } + } + } + } + + private fun RecorderV2.toBottomBarState(): BottomBarState { + return if (this.isPaused) { + BottomBarState.PAUSED + } else if (this.isRecording) { + BottomBarState.RECORDING + } else { + BottomBarState.READY_TO_START_RECORDING + } + } + + @SuppressLint("Recycle") + fun importAudioFile(uri: Uri) { + val context: Context = getApplication().applicationContext + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + try { + val parcelFileDescriptor: ParcelFileDescriptor? = + context.contentResolver.openFileDescriptor(uri, "r") + val fileDescriptor = parcelFileDescriptor?.fileDescriptor + val name: String? = DocumentFile.fromSingleUri(context, uri)?.name + if (name != null) { + val newFile: File = fileDataSource.createRecordFile(name) + if (FileUtil.copyFile(fileDescriptor, newFile)) { //TODO: Fix + val info = AudioDecoder.readRecordInfo(newFile) + + //Do 2 step import: 1) Import record with empty waveform. + //2) Process and update waveform in background. + val record = Record( + id = 0, + name = FileUtil.removeFileExtension(newFile.name), //TODO: Fix + durationMills = if (info.duration >= 0) info.duration / 1000 else 0, + created = newFile.lastModified(), + added = System.currentTimeMillis(), + removed = -1, + path = newFile.absolutePath, + format = info.format, + size = info.size, + sampleRate = info.sampleRate, + channelCount = info.channelCount, + bitrate = info.bitrate, + isBookmarked = false, + isWaveformProcessed = false, + isMovedToRecycle = false, + amps = IntArray(ARApplication.longWaveformSampleCount), + ) + val id = recordsDataSource.insertRecord(record) + withContext(mainDispatcher) { + audioPlayer.stop() + } + prefs.activeRecordId = id + updateState() + decodeRecord(record.path, record.durationMills) + } + } else { + //TODO: Show an error + } + } catch (e: SecurityException) { + Timber.e(e) + } catch (e: IOException) { + Timber.e(e) + } catch (e: OutOfMemoryError) { + Timber.e(e) + } catch (e: IllegalStateException) { + Timber.e(e) + } catch (ex: CantCreateFileException) { + Timber.e(ex) + } + } + } + + private fun decodeRecord(path: String, durationMills: Long) { + DecodeService.startNotificationV2( + getApplication().applicationContext, + path, + durationMills + ) + } + + fun shareActiveRecord() { + viewModelScope.launch(ioDispatcher) { + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + withContext(mainDispatcher) { + AndroidUtils.shareAudioFile( + getApplication().applicationContext, + activeRecord.path, + activeRecord.name, + activeRecord.format + ) + } + } + } + } + + fun showActiveRecordInfo() { + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getActiveRecord()?.toRecordInfoState()?.let { + emitEvent(HomeScreenEvent.RecordInformationEvent(it)) + } + } + } + + fun renameActiveRecord(newName: String) { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + recordsDataSource.renameRecord(activeRecord, newName) + updateState(false) + } + } + } + + fun openActiveRecordWithAnotherApp() { + viewModelScope.launch(ioDispatcher) { + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + withContext(mainDispatcher) { + AndroidUtils.openAudioFile( + getApplication().applicationContext, + activeRecord.path, + activeRecord.name + ) + } + } + } + } + + fun saveActiveRecordAs() { + viewModelScope.launch(ioDispatcher) { + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + DownloadService.startNotification( + getApplication().applicationContext, + activeRecord.path + ) + } + } + } + + fun deleteActiveRecord() { + moveRecordToRecycle(prefs.activeRecordId) + } + + private fun moveRecordToRecycle(recordId: Long, showName: Boolean = true) { + if (audioPlayer.isPlaying()) { + audioPlayer.stop() + } + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + val record = recordsDataSource.getRecord(recordId) + if (record != null && recordsDataSource.moveRecordToRecycle(recordId)) { + prefs.activeRecordId = -1 + updateState() + emitEvent( + HomeScreenEvent.RecordMovedToRecycleSnack( + recordId, + if (showName) record.name else null + ) + ) + } else { + val context: Context = getApplication().applicationContext + emitEvent( + HomeScreenEvent.ShowErrorSnack( + context.getString(R.string.msg_move_to_trash_failed) + ) + ) + + withContext(mainDispatcher) { + showLoadingProgress(false) + } + } + } + } + + fun handleSeekStart() { + _state.value = _state.value.copy( + isSeek = true + ) + } + + fun handleSeekProgress(mills: Long) { + _state.value = _state.value.copy( + time = TimeUtils.formatTimeIntervalHourMinSec2(mills), + progress = millsToProgress(mills, _state.value.waveformState.durationMills), + waveformState = _state.value.waveformState.copy( + progressMills = mills + ) + ) + } + + fun handleSeekEnd(mills: Long) { + _state.value = _state.value.copy( + time = TimeUtils.formatTimeIntervalHourMinSec2(mills), + progress = millsToProgress(mills, _state.value.waveformState.durationMills), + isSeek = false, + waveformState = _state.value.waveformState.copy( + progressMills = mills, + ) + ) + if (!audioPlayer.isPlaying()) { + _state.value = _state.value.copy( + showPause = false, + showStop = true + ) + } + audioPlayer.seek(mills) + } + + fun handleProgressBarStateChange(value: Float) { + val mills = (_state.value.waveformState.durationMills * value).toLong() + _state.value = _state.value.copy( + time = TimeUtils.formatTimeIntervalHourMinSec2(mills), + progress = millsToProgress(mills, _state.value.waveformState.durationMills), + waveformState = _state.value.waveformState.copy( + progressMills = mills + ) + ) + audioPlayer.seek(mills) + } + + fun handlePlayClick() { + if (!audioPlayer.isPlaying()) { + viewModelScope.launch(ioDispatcher) { + val activeRecord = recordsDataSource.getActiveRecord() + if (activeRecord != null) { + withContext(mainDispatcher) { + audioPlayer.play(activeRecord.path) + } + } + } + } else { + Timber.e("Playback did not started because already playing") + } + } + + fun handlePlaybackPauseClick() { + audioPlayer.pause() + } + + fun handlePlaybackStopClick() { + audioPlayer.stop() + } + + // - Has available space + // - Is already recoding + // - If is playing, stop playback + // - Create a record file + // - Create empty record in the database with created file path + // - Set it as active record + // - Start recording + fun handleStartRecordingClick() { + audioPlayer.stop() + val availableTimeSeconds = convertSpaceBytesToTimeInSeconds( + spaceBytes = fileDataSource.getAvailableSpace(), + recordingFormat = prefs.settingRecordingFormat, + sampleRate = prefs.settingSampleRate.value, + bitrate = prefs.settingBitrate.value, + channels = prefs.settingChannelCount.value, + ) + viewModelScope.launch(ioDispatcher) { + if (availableTimeSeconds > AppConstants.MIN_REMAIN_RECORDING_TIME && !audioRecorder.isRecording) { + if (audioPlayer.isPlaying()) { + audioPlayer.stop() + } + val recordName = getNewRecordName() + val recordFile = fileDataSource.createRecordFile(addExtension(recordName)) + val record = Record( + id = 0, + name = recordName, + durationMills = 0, + created = recordFile.lastModified(), + added = System.currentTimeMillis(), + removed = -1, + path = recordFile.absolutePath, + format = prefs.settingRecordingFormat.value, + size = 0, + sampleRate = prefs.settingSampleRate.value, + channelCount = prefs.settingChannelCount.value, + bitrate = prefs.settingBitrate.value, + isBookmarked = false, + isWaveformProcessed = false, + isMovedToRecycle = false, + amps = IntArray(ARApplication.longWaveformSampleCount) + ) + val id = recordsDataSource.insertRecord(record) + prefs.activeRecordId = -1 + prefs.recordedRecordId = id + + audioRecorder.startRecording( + outputFile = recordFile, + channelCount = prefs.settingChannelCount.value, + sampleRate = prefs.settingSampleRate.value, + bitrate = prefs.settingBitrate.value, + ) + } + } + } + + fun handlePauseRecordingClick() { + audioRecorder.pauseRecording() + } + + fun handleResumeRecordingClick() { + audioRecorder.resumeRecording() + } + + fun handleStopRecordingClick() { + audioRecorder.stopRecording() + _state.value = state.value.copy( + waveformState = _state.value.waveformState.copy(isRecording = false), + bottomBarState = BottomBarState.READY_TO_START_RECORDING + ) + } + + fun handleOnDeleteRecordingProgressClick() { + audioRecorder.stopRecording() + _state.value = state.value.copy( + isDeleteRecordingProgressRequested = true + ) + } + + fun handleRestoreRecordFromRecycle(recordId: Long) { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + if (recordsDataSource.restoreRecordFromRecycle(recordId)) { + prefs.activeRecordId = recordId + val record = recordsDataSource.getRecord(recordId) + showInfoMessage(R.string.msg_recording_restored, record?.name ?: "") + updateState() + } else { + showInfoMessage(R.string.msg_operation_failed_generic) + + withContext(mainDispatcher) { + showLoadingProgress(false) + } + } + } + } + + private fun millsToProgress(mills: Long, duration: Long): Float { + return if (duration <= 0) { + 0f + } else { + mills / duration.toFloat() + } + } + + fun moveToStart() { + val moveAnimator = ValueAnimator.ofObject( + LongEvaluator(), + _state.value.waveformState.progressMills, + 0L + ) + moveAnimator.interpolator = DecelerateInterpolator() + moveAnimator.duration = ANIMATION_DURATION + moveAnimator.addUpdateListener { animation: ValueAnimator -> + val moveValMills = animation.animatedValue as Long + handleSeekProgress(moveValMills) + } + moveAnimator.start() + } + + fun showLoadingProgress(value: Boolean) { + _state.value = _state.value.copy(isShowLoadingProgress = value) + } + + fun getNewRecordName(): String { + val recordName = when (prefs.settingNamingFormat) { + NameFormat.Record -> { + prefs.incrementRecordCounter() + FileUtil.generateRecordNameCounted(prefs.recordCounter) + } + NameFormat.Date -> FileUtil.generateRecordNameDateVariant() + NameFormat.DateUs -> FileUtil.generateRecordNameDateUS() + NameFormat.DateIso8601 -> FileUtil.generateRecordNameDateISO8601() + NameFormat.Timestamp -> FileUtil.generateRecordNameMills() + } + + return recordName + } + + fun addExtension(name: String): String { + return FileUtil.addExtension(name, prefs.settingRecordingFormat.value) + } + + //TODO: This function shouldn't be here + private fun convertSpaceBytesToTimeInSeconds( + spaceBytes: Long, + recordingFormat: RecordingFormat, + sampleRate: Int, + bitrate: Int, + channels: Int + ): Long { + return when (recordingFormat) { + RecordingFormat.ThreeGp -> 1000L * (spaceBytes / (AppConstants.RECORD_ENCODING_BITRATE_12000 / 8)) + RecordingFormat.M4a -> 1000L * (spaceBytes / (bitrate / 8)) + RecordingFormat.Wav -> 1000L * (spaceBytes / (sampleRate * channels * 2)) + } + } + + @SuppressWarnings("CyclomaticComplexMethod") + fun onAction(action: HomeScreenAction) { + when (action) { + HomeScreenAction.InitHomeScreen -> init() + is HomeScreenAction.ImportAudioFile -> importAudioFile(action.uri) + HomeScreenAction.ShareActiveRecord -> shareActiveRecord() + HomeScreenAction.ShowActiveRecordInfo -> showActiveRecordInfo() + HomeScreenAction.OpenActiveRecordWithAnotherApp -> openActiveRecordWithAnotherApp() + HomeScreenAction.DeleteActiveRecord -> deleteActiveRecord() + HomeScreenAction.SaveActiveRecordAs -> saveActiveRecordAs() + is HomeScreenAction.RenameActiveRecord -> renameActiveRecord(action.newName) + HomeScreenAction.OnSeekStart -> handleSeekStart() + is HomeScreenAction.OnSeekProgress -> handleSeekProgress(action.mills) + is HomeScreenAction.OnSeekEnd -> handleSeekEnd(action.mills) + is HomeScreenAction.OnProgressBarStateChange -> handleProgressBarStateChange(action.value) + HomeScreenAction.OnPauseClick -> handlePlaybackPauseClick() + HomeScreenAction.OnPlayClick -> handlePlayClick() + HomeScreenAction.OnStopClick -> handlePlaybackStopClick() + //Recording + HomeScreenAction.OnStartRecordingClick -> handleStartRecordingClick() + HomeScreenAction.OnPauseRecordingClick -> handlePauseRecordingClick() + HomeScreenAction.OnResumeRecordingClick -> handleResumeRecordingClick() + HomeScreenAction.OnStopRecordingClick -> handleStopRecordingClick() + HomeScreenAction.OnDeleteRecordingProgressClick -> handleOnDeleteRecordingProgressClick() + is HomeScreenAction.RestoreRecordFromRecycle -> handleRestoreRecordFromRecycle(action.recordId) + } + } + + private fun emitEvent(event: HomeScreenEvent) { + viewModelScope.launch { + _event.emit(event) + } + } +} + +data class HomeScreenState( + val waveformState: WaveformState = WaveformState(), + val startTime: String = "", + val endTime: String = "", + val time: String = TimeUtils.formatTimeIntervalHourMinSec2(0), + //Progress is value between 0 - 1f + val progress: Float = 0f, + val recordName: String = "", + val recordInfo: String = "", + val isShowWaveform: Boolean = false, + // Indicates loading progress + val isShowLoadingProgress: Boolean = false, + val isContextMenuAvailable: Boolean = false, + val isStopRecordingButtonAvailable: Boolean = false, + val bottomBarState: BottomBarState = BottomBarState.READY_TO_START_RECORDING, + val showPause: Boolean = false, + val showStop: Boolean = false, + val isSeek: Boolean = false, + val isDeleteRecordingProgressRequested: Boolean = false, +) { + fun isRecording(): Boolean { + return this.bottomBarState == BottomBarState.RECORDING || this.bottomBarState == BottomBarState.PAUSED + } +} + +enum class BottomBarState { + READY_TO_START_RECORDING, + RECORDING, + PAUSED, +} + +sealed class HomeScreenAction { + data object InitHomeScreen : HomeScreenAction() + data class ImportAudioFile(val uri: Uri) : HomeScreenAction() + data object ShareActiveRecord : HomeScreenAction() + data object ShowActiveRecordInfo : HomeScreenAction() + data object OpenActiveRecordWithAnotherApp : HomeScreenAction() + data object DeleteActiveRecord : HomeScreenAction() + data class RestoreRecordFromRecycle(val recordId: Long) : HomeScreenAction() + data object SaveActiveRecordAs : HomeScreenAction() + data class RenameActiveRecord(val newName: String) : HomeScreenAction() + data object OnSeekStart : HomeScreenAction() + data object OnPlayClick : HomeScreenAction() + data object OnPauseClick : HomeScreenAction() + data object OnStopClick : HomeScreenAction() + data object OnStartRecordingClick : HomeScreenAction() + data object OnPauseRecordingClick : HomeScreenAction() + data object OnResumeRecordingClick : HomeScreenAction() + data object OnStopRecordingClick : HomeScreenAction() + data object OnDeleteRecordingProgressClick : HomeScreenAction() + data class OnSeekProgress(val mills: Long) : HomeScreenAction() + data class OnSeekEnd(val mills: Long) : HomeScreenAction() + data class OnProgressBarStateChange(val value: Float) : HomeScreenAction() +} + +sealed class HomeScreenEvent { + data class RecordMovedToRecycleSnack(val recordId: Long, val recordName: String?) : + HomeScreenEvent() + data object ShowImportErrorError : HomeScreenEvent() + data class ShowErrorSnack(val message: String) : HomeScreenEvent() + data class ShowInfoSnack(val message: String) : HomeScreenEvent() + data class RecordInformationEvent(val recordInfo: RecordInfoState) : HomeScreenEvent() +} + +private class LongEvaluator : TypeEvaluator { + override fun evaluate(fraction: Float, startValue: Long, endValue: Long): Long { + return startValue + ((endValue - startValue) * fraction).toLong() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/AssetParamType.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/AssetParamType.kt new file mode 100644 index 000000000..a92e1b42c --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/AssetParamType.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.info + +import android.os.Build +import android.os.Bundle +import androidx.navigation.NavType +import com.google.gson.Gson + +class AssetParamType : NavType(isNullableAllowed = false) { + + override fun get(bundle: Bundle, key: String): RecordInfoState? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + bundle.getParcelable(key, RecordInfoState::class.java) + } else { + bundle.getParcelable(key) + } + } + + override fun parseValue(value: String): RecordInfoState { + return Gson().fromJson(value, RecordInfoState::class.java) + } + + override fun put(bundle: Bundle, key: String, value: RecordInfoState) { + bundle.putParcelable(key, value) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/Mapper.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/Mapper.kt new file mode 100644 index 000000000..58c1a7c20 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/Mapper.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.info + +import com.dimowner.audiorecorder.v2.data.model.Record + +fun Record.toRecordInfoState(): RecordInfoState { + return RecordInfoState( + name = this.name, + format = this.format, + duration = this.durationMills, + size = this.size, + location = this.path, + created = this.created, + sampleRate = this.sampleRate, + channelCount = this.channelCount, + bitrate = this.bitrate, + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoScreen.kt new file mode 100644 index 000000000..999c767ae --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoScreen.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.info + +import android.text.format.Formatter +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.InfoItem +import com.dimowner.audiorecorder.v2.app.TitleBar + +@Composable +fun RecordInfoScreen( + onPopBackStack: () -> Unit, + recordInfo: RecordInfoState? +) { + val context = LocalContext.current + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + ) { innerPadding -> + Surface( + modifier = Modifier.fillMaxSize().padding(innerPadding) + ) { + Column(modifier = Modifier.fillMaxSize()) { + TitleBar( + stringResource(id = R.string.info), + onBackPressed = { onPopBackStack() } + ) + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(weight = 1f, fill = false) + ) { + Spacer(modifier = Modifier.size(8.dp)) + if (recordInfo != null) { + InfoItem(stringResource(R.string.rec_name), recordInfo.name) + InfoItem(stringResource(R.string.rec_format), recordInfo.format) + if (recordInfo.bitrate > 0) { + InfoItem( + stringResource(R.string.bitrate), + stringResource(id = R.string.value_kbps, recordInfo.bitrate / 1000) + ) + } + InfoItem( + stringResource(R.string.channels), + stringResource( + when (recordInfo.channelCount) { + 1 -> R.string.mono + 2 -> R.string.stereo + else -> R.string.empty + } + ) + ) + InfoItem( + stringResource(R.string.sample_rate), + stringResource(id = R.string.value_khz, recordInfo.sampleRate / 1000) + ) + if (recordInfo.duration > 0) { + InfoItem( + stringResource(R.string.rec_duration), + TimeUtils.formatTimeIntervalHourMinSec2(recordInfo.duration) + ) + } + InfoItem( + stringResource(R.string.rec_size), + Formatter.formatShortFileSize(context, recordInfo.size) + ) + InfoItem(stringResource(R.string.rec_location), recordInfo.location) + InfoItem( + stringResource(R.string.rec_created), + TimeUtils.formatDateTimeLocale(recordInfo.created) + ) + } else { + Text( + modifier = Modifier.fillMaxSize().align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.error_unknown), + textAlign = TextAlign.Center + ) + } + Spacer(modifier = Modifier.size(8.dp)) + } + } + } + } +} + +@Preview +@Composable +fun RecordInfoScreenPreview() { + RecordInfoScreen({}, RecordInfoState( + name = "name666", + format = "format777", + duration = 150000000, + size = 1500000, + location = "location888", + created = System.currentTimeMillis(), + sampleRate = 44000, + channelCount = 1, + bitrate = 240000, + )) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoState.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoState.kt new file mode 100644 index 000000000..a3496a177 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/info/RecordInfoState.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.info + +import android.os.Parcelable +import com.dimowner.audiorecorder.AppConstants +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RecordInfoState( + val name: String, + val format: String, + val duration: Long, + val size: Long, + val location: String, + val created: Long, + val sampleRate: Int, + val channelCount: Int, + val bitrate: Int, +) : Parcelable { + + val nameWithExtension: String + get() = name + AppConstants.EXTENSION_SEPARATOR + format +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsComponents.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsComponents.kt new file mode 100644 index 000000000..38057631a --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsComponents.kt @@ -0,0 +1,398 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.records + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +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 +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.RecordsDropDownMenu +import com.dimowner.audiorecorder.v2.app.records.models.RecordDropDownMenuItemId +import com.dimowner.audiorecorder.v2.app.records.models.SortDropDownMenuItemId +import timber.log.Timber + +@Composable +fun RecordsTopBar( + title: String, + subTitle: String, + bookmarksSelected: Boolean, + onBackPressed: () -> Unit, + onSortItemClick: (SortDropDownMenuItemId) -> Unit, + onBookmarksClick: (Boolean) -> Unit, +) { + val expanded = remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .height(64.dp) + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.surface), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + IconButton( + onClick = onBackPressed, + modifier = Modifier + .padding(8.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Navigate back", + modifier = Modifier.size(24.dp) + ) + } + + Column { + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 22.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + Text( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + text = subTitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Box { + RecordsDropDownMenu( + items = remember { getSortDroDownMenuItems() }, + onItemClick = { itemId -> + onSortItemClick(itemId) + Timber.v("On Drop Down Menu item click id = $itemId") + }, + expanded = expanded + ) + IconButton( + onClick = { + expanded.value = true + }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_sort), + contentDescription = stringResource(id = androidx.compose.ui.R.string.dropdown_menu), + modifier = Modifier + .size(36.dp) + .padding(6.dp) + ) + } + } + IconButton( + onClick = { + onBookmarksClick(!bookmarksSelected) + }, + ) { + Icon( + painter = if (bookmarksSelected) { + painterResource(id = R.drawable.ic_bookmark) + } else { + painterResource(id = R.drawable.ic_bookmark_bordered) + }, + contentDescription = stringResource(id = androidx.compose.ui.R.string.dropdown_menu), + modifier = Modifier + .size(36.dp) + .padding(6.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun RecordsTopBarPreview() { + RecordsTopBar("Title bar", "By date", false, {}, {}, {}) +} + +@Composable +fun RecordListItemView( + name: String, + details: String, + duration: String, + isBookmarked: Boolean, + isSelected: Boolean, + isShowMenuButton: Boolean, + onClickItem: () -> Unit, + onLongClickItem: () -> Unit, + onClickBookmark: (Boolean) -> Unit, + onClickMenu: (RecordDropDownMenuItemId) -> Unit, +) { + val expanded = remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .background(color = if (isSelected) colorResource(R.color.selected_item_color) else Color.Transparent) + .combinedClickable( + onClick = { + onClickItem() + }, + onLongClick = { + onLongClickItem() + } + ) + .fillMaxWidth() + .wrapContentHeight() + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.Start + ) { + Text( + modifier = Modifier + .padding(16.dp, 10.dp, 12.dp, 2.dp) + .fillMaxWidth() + .wrapContentHeight(), + text = name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Medium + ) + Text( + modifier = Modifier + .padding(16.dp, 2.dp, 12.dp, 10.dp) + .fillMaxWidth() + .wrapContentHeight(), + text = details, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + Column( + modifier = Modifier + .wrapContentSize() + .padding(0.dp, 4.dp), + horizontalAlignment = Alignment.End + ) { + IconButton( + onClick = { onClickBookmark(!isBookmarked) }, + modifier = Modifier + .width(36.dp) + .height(32.dp) + ) { + Icon( + painter = if (isBookmarked) { + painterResource(id = R.drawable.ic_bookmark_small) + } else { + painterResource(id = R.drawable.ic_bookmark_bordered_small) + }, + contentDescription = stringResource(id = R.string.bookmarks), + modifier = Modifier + .padding(6.dp) + .size(36.dp) + .fillMaxHeight() + ) + } + Text( + modifier = Modifier + .padding(0.dp, 2.dp, 8.dp, 12.dp) + .wrapContentWidth() + .wrapContentHeight(), + text = duration, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + if (isShowMenuButton) { + Box( + modifier = Modifier.align(Alignment.CenterVertically), + ) { + // The DropdownMenu composable + RecordsDropDownMenu( + items = remember { getRecordsDroDownMenuItems() }, + onItemClick = { itemId -> + Timber.v("On Drop Down Menu item click id = $itemId") + onClickMenu(itemId) + }, + expanded = expanded + ) + IconButton( + onClick = { expanded.value = !expanded.value }, + modifier = Modifier + .width(36.dp) + .height(60.dp) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(id = androidx.compose.ui.R.string.dropdown_menu), + modifier = Modifier + .width(36.dp) + .padding(4.dp) + .fillMaxHeight() + ) + } + } + } else { + Spacer(modifier = Modifier.width(36.dp).height(60.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun RecordListItemPreview() { + RecordListItemView("Label", "Value", "Duration", true, true, true, {}, {}, {}, {}) +} + +@Composable +fun MultiSelectMenu( + selectedItemsCount: Int, + onCancelClick: () -> Unit, + onShareClick: () -> Unit, + onDownloadClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + Row( + modifier = Modifier + .height(64.dp) + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.surface), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + IconButton( + onClick = onCancelClick, + modifier = Modifier + .padding(8.dp) + .align(Alignment.CenterVertically), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Navigate back", + modifier = Modifier.size(24.dp) + ) + } + + Text( + text = stringResource(R.string.selected, selectedItemsCount), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 22.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + + Spacer(modifier = Modifier.weight(1f)) + + IconButton( + onClick = onShareClick, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_share), + contentDescription = stringResource(id = R.string.share), + modifier = Modifier + .size(36.dp) + .padding(6.dp) + ) + } + IconButton( + onClick = onDownloadClick, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_save_alt), + contentDescription = stringResource(id = R.string.save_as), + modifier = Modifier + .size(36.dp) + .padding(6.dp) + ) + } + IconButton( + onClick = onDeleteClick, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = stringResource(id = R.string.delete), + modifier = Modifier + .size(36.dp) + .padding(6.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun MultiSelectMenuPreview() { + MultiSelectMenu(3, {},{}, {}, {}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensions.kt new file mode 100644 index 000000000..9aa345953 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsExtensions.kt @@ -0,0 +1,251 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.records + +import android.content.Context +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.util.TimeUtils.formatDateSmartLocale +import com.dimowner.audiorecorder.v2.app.DropDownMenuItem +import com.dimowner.audiorecorder.v2.app.records.models.RecordDropDownMenuItemId +import com.dimowner.audiorecorder.v2.app.records.models.SortDropDownMenuItemId +import com.dimowner.audiorecorder.v2.data.model.SortOrder + +fun getRecordsDroDownMenuItems(): List> { + return RecordDropDownMenuItemId.entries.map { + when (it) { + RecordDropDownMenuItemId.SHARE -> DropDownMenuItem( + id = it, textResId = R.string.share, imageResId = R.drawable.ic_share + ) + RecordDropDownMenuItemId.INFORMATION -> DropDownMenuItem( + id = it, textResId = R.string.info, imageResId = R.drawable.ic_info + ) + RecordDropDownMenuItemId.RENAME -> DropDownMenuItem( + id = it, textResId = R.string.rename, imageResId = R.drawable.ic_pencil + ) + RecordDropDownMenuItemId.OPEN_WITH -> DropDownMenuItem( + id = it, textResId = R.string.open_with, imageResId = R.drawable.ic_open_with + ) + RecordDropDownMenuItemId.SAVE_AS -> DropDownMenuItem( + id = it, textResId = R.string.save_as, imageResId = R.drawable.ic_save_alt + ) + RecordDropDownMenuItemId.DELETE -> DropDownMenuItem( + id = it, textResId = R.string.delete, imageResId = R.drawable.ic_delete_forever + ) + } + } +} + +fun getSortDroDownMenuItems(): List> { + return SortDropDownMenuItemId.entries.map { + when (it) { + SortDropDownMenuItemId.DATE_DESC -> DropDownMenuItem( + id = it, textResId = R.string.by_date, imageResId = R.drawable.ic_calendar_today + ) + SortDropDownMenuItemId.DATE_ASC -> DropDownMenuItem( + id = it, textResId = R.string.by_date_desc, imageResId = R.drawable.ic_calendar_today + ) + SortDropDownMenuItemId.NAME -> DropDownMenuItem( + id = it, textResId = R.string.by_name, imageResId = R.drawable.ic_sort_by_alpha + ) + SortDropDownMenuItemId.NAME_DESC -> DropDownMenuItem( + id = it, textResId = R.string.by_name_desc, imageResId = R.drawable.ic_sort_by_alpha + ) + SortDropDownMenuItemId.DURATION_DESC -> DropDownMenuItem( + id = it, textResId = R.string.by_duration, imageResId = R.drawable.ic_access_time + ) + SortDropDownMenuItemId.DURATION -> DropDownMenuItem( + id = it, textResId = R.string.by_duration_desc, imageResId = R.drawable.ic_access_time + ) + } + } +} + +fun SortOrder.toText(context: Context): String { + return when (this) { + SortOrder.DateDesc -> context.getString(R.string.by_date) + SortOrder.DateAsc -> context.getString(R.string.by_date_desc) + SortOrder.NameAsc -> context.getString(R.string.by_name) + SortOrder.NameDesc -> context.getString(R.string.by_name_desc) + SortOrder.DurationShortest -> context.getString(R.string.by_duration_desc) + SortOrder.DurationLongest -> context.getString(R.string.by_duration) + } +} + +fun SortDropDownMenuItemId.toSortOrder(): SortOrder { + return when (this) { + SortDropDownMenuItemId.DATE_DESC -> SortOrder.DateDesc + SortDropDownMenuItemId.DATE_ASC -> SortOrder.DateAsc + SortDropDownMenuItemId.NAME -> SortOrder.NameAsc + SortDropDownMenuItemId.NAME_DESC -> SortOrder.NameDesc + SortDropDownMenuItemId.DURATION -> SortOrder.DurationShortest + SortDropDownMenuItemId.DURATION_DESC -> SortOrder.DurationLongest + } +} + +/** + * Updates the [recordsMap] within the current [RecordsScreenState] + * and returning a new [RecordsScreenState] instance. + * + * This ensures the entire state object remains immutable while reflecting the change + * to a single record in the nested map structure. + * + * @param recordId The unique ID of the record to find and update. + * @param onUpdate A function that takes the old [RecordListItem] and returns the new, updated one. + * @return A new [RecordsScreenState] with the updated record map. + */ +fun RecordsScreenState.updateRecordInMap( + recordId: Long, + onUpdate: (oldRecord: RecordListItem) -> RecordListItem +): RecordsScreenState { + return this.copy( + recordsMap = this.recordsMap.mapRecordInMap(recordId) { record -> + onUpdate(record) + } + ) +} + +/** + * Immutably finds and updates a single [RecordListItem] within the nested map structure. + * + * It iterates through each list in the map and applies the [onUpdate] lambda only to the + * record whose [recordId] matches the provided ID, preserving all other records and groups. + * + * @param recordId The unique ID of the record to find and update. + * @param onUpdate A function that takes the old [RecordListItem] and returns the new, updated one. + * @return A new [Map] with the single record updated. + */ +fun Map>.mapRecordInMap( + recordId: Long, + onUpdate: (oldRecord: RecordListItem) -> RecordListItem +): Map> { + return this.mapValues { (_, recordList) -> + recordList.map { record -> + if (record.recordId == recordId) { + onUpdate(record) + } else { + record + } + } + } +} + +/** + * Immutably removes a single [RecordListItem] with the matching [recordId] from the map. + * + * This function performs two filtering steps: + * 1. Filters the records within each list, removing the targeted record. + * 2. Filters the map itself, removing any date entries (keys) whose list of records + * became empty after the first step. + * + * @param recordId The unique ID of the record to remove. + * @return A new [Map] with the record removed, and potentially, an empty list group removed. + */ +fun Map>.removeRecordFromMap( + recordId: Long, +): Map> { + + // Filtering out the record to be removed. + val mapWithFilteredLists = this.mapValues { (_, recordList) -> + recordList.filter { record -> + record.recordId != recordId + } + } + + // Filtering out any date keys that now have an empty list. + return mapWithFilteredLists.filterValues { recordList -> + recordList.isNotEmpty() + } +} + +/** + * Immutably adds a single [RecordListItem] to the map, placing it in the correct date group, + * Ensures the group remains sorted. + * 1. Identifies the correct group key using the provided [sortOrder]. + * 2. Appends the record to the existing list for that key, or creates a new list if the key doesn't exist. + * 3. Sort the list based on the active SortOrder. + * 4. Returns a new Map containing the updated data. + */ +fun Map>.addRecordToMap( + context: Context, + record: RecordListItem, + sortOrder: SortOrder +): Map> { + // Determine the key where this record belongs + val key = if (sortOrder.isSortOrderByDate()) { + formatDateSmartLocale(record.added, context) + } else { + "" + } + + // Get the current list for that key (or empty if it's a new date) + val currentList = this[key] ?: emptyList() + + // Add the new record + val newList = currentList + record + + // Sort the list based on the active SortOrder + val sortedList = newList.sort(sortOrder) + + return this + (key to sortedList) +} + +/** + * Returns a new list of [RecordListItem] sorted according to the specified [SortOrder]. + * @param sortOrder The strategy used to determine the element sequence. + * @return A sorted copy of the original list. + */ +fun List.sort(sortOrder: SortOrder): List { + return when (sortOrder) { + SortOrder.DateAsc -> this.sortedBy { it.added } + SortOrder.DateDesc -> this.sortedByDescending { it.added } + SortOrder.NameAsc -> this.sortedBy { it.name } + SortOrder.NameDesc -> this.sortedByDescending { it.name } + SortOrder.DurationShortest -> this.sortedBy { it.duration } + SortOrder.DurationLongest -> this.sortedByDescending { it.duration } + } +} + +/** + * Groups the list of [RecordListItem] objects into groups of records divided by date. + * This function is designed to support a UI with conditional sticky headers. + * @param context The [Context] required by [TimeUtils.formatDateSmartLocale] to generate + * localized date strings. + * @param sortOrder Records list sort order. + * @return A [Map] where keys are the formatted date strings and values are the + * corresponding lists of [RecordListItem] objects. + */ +fun List.groupRecordsByDate( + context: Context, + sortOrder: SortOrder +): Map> { + return this.groupBy { + if (sortOrder.isSortOrderByDate()) { + formatDateSmartLocale(it.added, context) + } else { + "" + } + } +} + +/** + * Checks if the current [SortOrder] is related to sorting by date + * @return `true` if the sort order is [SortOrder.DateAsc] or [SortOrder.DateDesc], `false` otherwise. + */ +fun SortOrder.isSortOrderByDate(): Boolean { + return this == SortOrder.DateAsc || this == SortOrder.DateDesc +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsScreen.kt new file mode 100644 index 000000000..5c3f78cd1 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsScreen.kt @@ -0,0 +1,629 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.records + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.ComposableLifecycle +import com.dimowner.audiorecorder.v2.app.DeleteDialog +import com.dimowner.audiorecorder.v2.app.RenameAlertDialog +import com.dimowner.audiorecorder.v2.app.SaveAsDialog +import com.dimowner.audiorecorder.v2.app.components.TouchPanel +import com.dimowner.audiorecorder.v2.app.getTestWaveformData +import com.dimowner.audiorecorder.v2.app.home.HomeScreenAction +import com.dimowner.audiorecorder.v2.app.home.HomeScreenState +import com.dimowner.audiorecorder.v2.app.records.models.RecordDropDownMenuItemId +import com.dimowner.audiorecorder.v2.app.settings.SettingsItem +import com.google.gson.Gson +import kotlinx.coroutines.launch +import timber.log.Timber + +private const val ANIMATION_DURATION = 500 +private const val MAX_MOVE = 250 + + +//TODO: Add simple waveform to each record item +//TODO: Make app bar with 'Trash' button scrollable together with records list. + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RecordsScreen( + onPopBackStack: () -> Unit, + showRecordInfoScreen: (String) -> Unit, + showDeletedRecordsScreen: () -> Unit, + uiState: RecordsScreenState, + event: RecordsScreenEvent?, + onAction: (RecordsScreenAction) -> Unit, + uiHomeState: HomeScreenState, + onHomeAction: (HomeScreenAction) -> Unit, +) { +// val density = LocalDensity.current +// // State to keep track of the Card position +// val offsetY = remember { mutableFloatStateOf(0f) } +// val maxMove = with(density) { MAX_MOVE.dp.toPx() } +// val k = (maxMove / (Math.PI / 2f)).toFloat() +// val startY = with(density) { 12.dp.toPx() } +// +// val animatableY = remember { Animatable(startY) } +// + // Get a CoroutineScope tied to the Composable + val coroutineScope = rememberCoroutineScope() +// +// // Define a threshold for Y coordinate movement +// val playPanelHeight = remember { mutableFloatStateOf(with(density) { 300.dp.toPx() }) } +// +// // Modifier to make the text draggable +// val modifier = Modifier +// .offset { IntOffset(0, animatableY.value.roundToInt()) } +// .pointerInput(Unit) { +// detectDragGestures( +// onDragStart = { +// offsetY.floatValue = startY +// }, +// onDragEnd = { +// // Animate back to start position +// if (offsetY.floatValue.absoluteValue > playPanelHeight.floatValue * 0.5) { +// coroutineScope.launch { +// animatableY.animateTo( +//// TODO:Fix constants!! +// playPanelHeight.floatValue * 1.5f, +// animationSpec = tween(durationMillis = ANIMATION_DURATION) +// ) +// offsetY.floatValue = startY +// onHomeAction(HomeScreenAction.OnStopClick) +// } +// } else { +// coroutineScope.launch { +// animatableY.animateTo( +// startY, +// animationSpec = tween(durationMillis = ANIMATION_DURATION) +// ) +// } +// } +// }, +// onDragCancel = { +// if (offsetY.floatValue.absoluteValue > playPanelHeight.floatValue * 0.5) { +// coroutineScope.launch { +// animatableY.animateTo( +// playPanelHeight.floatValue * 1.5f, +// animationSpec = tween(durationMillis = ANIMATION_DURATION) +// ) +// offsetY.floatValue = startY +// onHomeAction(HomeScreenAction.OnStopClick) +// } +// } else { +// // Animate back to start position +// coroutineScope.launch { +// animatableY.animateTo( +// startY, +// animationSpec = tween(durationMillis = ANIMATION_DURATION) +// ) +// } +// } +// }, +// onDrag = { change, dragAmount -> +// change.consume() +// offsetY.floatValue += change.position.y +// offsetY.floatValue = k * atan(offsetY.floatValue / k) +// coroutineScope.launch { +// animatableY.snapTo(offsetY.floatValue) +// } +// } +// ) +// } + + val context = LocalContext.current + + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + ComposableLifecycle { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + Timber.d("RecordsScreen: On Start") + onAction(RecordsScreenAction.InitRecordsScreen( + showPlayPanel = uiHomeState.waveformState.progressMills > 0) + ) + } + Lifecycle.Event.ON_STOP -> { + Timber.d("RecordsScreen: On Stop") + onAction(RecordsScreenAction.OnStopRecordsScreen) + } + else -> {} + } + } + LaunchedEffect(key1 = event) { + when (event) { + is RecordsScreenEvent.RecordInformationEvent -> { + val json = Uri.encode(Gson().toJson(event.recordInfo)) + Timber.v("ON EVENT: ShareRecord json = $json") + showRecordInfoScreen(json) + } + is RecordsScreenEvent.RecordMovedToRecycleSnack -> { + scope.launch { + val message = context.getString(R.string.msg_recording_moved_to_trash, event.recordName) + + val result = snackbarHostState + .showSnackbar( + message = message, + actionLabel = context.getString(R.string.action_undo), + duration = SnackbarDuration.Short + ) + when (result) { + SnackbarResult.ActionPerformed -> { + onAction(RecordsScreenAction.RestoreRecordFromRecycle(event.recordId)) + } + SnackbarResult.Dismissed -> { + /* Handle snackbar dismissed */ + } + } + } + } + is RecordsScreenEvent.FewRecordsMovedToRecycleSnack -> { + scope.launch { + snackbarHostState + .showSnackbar( + message = context.getString( + R.string.msg_few_recordings_moved_to_trash, + event.movedCount, + event.expectedCount + ), + duration = SnackbarDuration.Short + ) + } + } + is RecordsScreenEvent.ShowInfoSnack -> { + scope.launch { + snackbarHostState + .showSnackbar( + message = event.message, + duration = SnackbarDuration.Short + ) + } + } + is RecordsScreenEvent.ShowErrorSnack -> { + scope.launch { + snackbarHostState + .showSnackbar( + message = event.message, + duration = SnackbarDuration.Short + ) + } + } + + else -> { + Timber.v("ON EVENT: Unknown") + //Do nothing + } + } + } + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomStart, + ) { + if (uiState.isShowLoadingProgress) { + //Show nothing because of progress takes very short period of time + } else if (uiState.recordsMap.isEmpty()) { + Column( + modifier = Modifier + .wrapContentSize() + .align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + modifier = Modifier.wrapContentSize(), + painter = painterResource(id = R.drawable.ic_audiotrack_64), + contentDescription = "Image Description", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + Text( + modifier = Modifier.wrapContentSize(), + text = if (uiState.bookmarksSelected) { + stringResource(R.string.no_bookmarks) + } else { + stringResource(R.string.no_records) + }, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Normal + ) + } + } + Column(modifier = Modifier.fillMaxSize()) { + if (uiState.selectedRecords.isEmpty()) { + RecordsTopBar( + stringResource(id = R.string.records), + uiState.sortOrder.toText(context), + bookmarksSelected = uiState.bookmarksSelected, + onBackPressed = { onPopBackStack() }, + onSortItemClick = { order -> + onAction(RecordsScreenAction.UpdateListWithSortOrder(order)) + }, + onBookmarksClick = { bookmarksSelected -> + onAction( + RecordsScreenAction.UpdateListWithBookmarks( + bookmarksSelected + ) + ) + } + ) + } else { + MultiSelectMenu( + selectedItemsCount = uiState.selectedRecords.size, + onCancelClick = { onAction(RecordsScreenAction.MultiSelectCancel)}, + onShareClick = { + onAction(RecordsScreenAction.MultiSelectShare(uiState.selectedRecords)) + }, + onDownloadClick = { + onAction(RecordsScreenAction.MultiSelectSaveAsRequest) + }, + onDeleteClick = { + onAction(RecordsScreenAction.MultiSelectMoveToRecycleRequest) + }, + ) + } + if (uiState.showDeletedRecordsButton) { + SettingsItem(stringResource(R.string.trash), R.drawable.ic_delete) { + showDeletedRecordsScreen() + } + } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + uiState.recordsMap.forEach { (date, recordsOnDate) -> + //Sticky date header + stickyHeader { + if (date.isEmpty()) { + Box(modifier = Modifier) {} + } else { + Surface( + modifier = Modifier.fillParentMaxWidth(), + ) { + Text( + text = date, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + 16.dp, 10.dp, 16.dp, 2.dp + ), + textAlign = TextAlign.Center + ) + } + } + } + //The list of items for that specific date + items(recordsOnDate) { record -> + RecordListItemView( + name = record.name, + details = record.details, + duration = record.duration, + isBookmarked = record.isBookmarked, + isSelected = record.recordId == uiState.activeRecord?.recordId + || uiState.selectedRecords.contains(record), + isShowMenuButton = uiState.selectedRecords.isEmpty() + && record.recordId != uiState.recordedRecordId, + onClickItem = { + if (!uiState.isRecording) { + if (uiState.selectedRecords.isEmpty()) { + onAction(RecordsScreenAction.OnItemSelect(record)) + onHomeAction(HomeScreenAction.InitHomeScreen) + onHomeAction(HomeScreenAction.OnPlayClick) + } else { + onAction(RecordsScreenAction.MultiSelectAddItem(record)) + } + } + }, + onLongClickItem = { + if (!uiState.isRecording) { + onAction(RecordsScreenAction.MultiSelectAddItem(record)) + } + }, + onClickBookmark = { isBookmarked -> + onAction( + RecordsScreenAction.BookmarkRecord( + record.recordId, + isBookmarked + ) + ) + }, + onClickMenu = { + when (it) { + RecordDropDownMenuItemId.SHARE -> { + onAction(RecordsScreenAction.ShareRecord(record.recordId)) + } + + RecordDropDownMenuItemId.INFORMATION -> { + onAction(RecordsScreenAction.ShowRecordInfo(record.recordId)) + } + + RecordDropDownMenuItemId.RENAME -> { + onAction( + RecordsScreenAction.OnRenameRecordRequest( + record + ) + ) + } + + RecordDropDownMenuItemId.OPEN_WITH -> { + onAction( + RecordsScreenAction.OpenRecordWithAnotherApp( + record.recordId + ) + ) + } + + RecordDropDownMenuItemId.SAVE_AS -> { + onAction(RecordsScreenAction.OnSaveAsRequest(record)) + } + + RecordDropDownMenuItemId.DELETE -> { + onAction( + RecordsScreenAction.OnMoveToRecycleRecordRequest( + record + ) + ) + } + } + }, + ) + } + } + } + if (uiState.showMoveToRecycleDialog) { + uiState.operationSelectedRecord?.let { record -> + DeleteDialog( + dialogText = stringResource(id = R.string.delete_record, record.name), + onAcceptClick = { + onAction(RecordsScreenAction.MoveRecordToRecycle(record.recordId)) + }, onDismissClick = { + onAction(RecordsScreenAction.OnMoveToRecycleRecordDismiss) + } + ) + } + } else if (uiState.showSaveAsDialog) { + uiState.operationSelectedRecord?.let { record -> + SaveAsDialog( + dialogText = stringResource( + id = R.string.record_name_will_be_copied_into_downloads, + record.name), + onAcceptClick = { + onAction(RecordsScreenAction.SaveRecordAs(record.recordId)) + }, onDismissClick = { + onAction(RecordsScreenAction.OnSaveAsDismiss) + } + ) + } + } else if (uiState.showRenameDialog) { + uiState.operationSelectedRecord?.let { record -> + RenameAlertDialog(record.name, onAcceptClick = { + onAction(RecordsScreenAction.RenameRecord(record.recordId, it)) + }, onDismissClick = { + onAction(RecordsScreenAction.OnRenameRecordDismiss) + }) + } + } else if (uiState.showMoveToRecycleMultipleDialog) { + val count = uiState.selectedRecords.size + val titleText = pluralStringResource( + id = R.plurals.delete_selected_records, + count = count, count) + DeleteDialog(titleText, onAcceptClick = { + onAction(RecordsScreenAction.MultiSelectMoveToRecycle) + }, onDismissClick = { + onAction(RecordsScreenAction.MultiSelectMoveToRecycleDismiss) + }) + } else if (uiState.showSaveAsMultipleDialog) { + val count = uiState.selectedRecords.size + val titleText = pluralStringResource( + id = R.plurals.download_selected_records, + count = count, count) + + SaveAsDialog(titleText, + onAcceptClick = { + onAction(RecordsScreenAction.MultiSelectSaveAs) + }, onDismissClick = { + onAction(RecordsScreenAction.MultiSelectSaveAsDismiss) + } + ) + } + } + TouchPanel( + showRecordPlaybackPanel = uiState.showRecordPlaybackPanel, + uiHomeState = uiHomeState, + onProgressChange = { + onHomeAction( + HomeScreenAction.OnProgressBarStateChange( + it + ) + ) + }, + onSeekStart = { onHomeAction(HomeScreenAction.OnSeekStart) }, + onSeekProgress = { onHomeAction(HomeScreenAction.OnSeekProgress(it)) }, + onSeekEnd = { onHomeAction(HomeScreenAction.OnSeekEnd(it)) }, + onPlayClick = { onHomeAction(HomeScreenAction.OnPlayClick) }, + onStopClick = { + coroutineScope.launch { + onHomeAction(HomeScreenAction.OnStopClick) + } + }, + onPauseClick = { onHomeAction(HomeScreenAction.OnPauseClick) }, + ) +// AnimatedVisibility( +// visible = uiState.showRecordPlaybackPanel, +// enter = slideInVertically(initialOffsetY = { it }), +// exit = slideOutVertically(targetOffsetY = { it }) +// ) { +// Card( +// modifier = modifier +// .wrapContentSize() +// .onSizeChanged { +// playPanelHeight.floatValue = it.height.toFloat() +// }, +// ) { +// RecordPlaybackPanel( +// modifier = Modifier +// .fillMaxWidth() +// .wrapContentHeight(), +// uiState = uiHomeState, +// onProgressChange = { +// onHomeAction( +// HomeScreenAction.OnProgressBarStateChange( +// it +// ) +// ) +// }, +// onSeekStart = { onHomeAction(HomeScreenAction.OnSeekStart) }, +// onSeekProgress = { onHomeAction(HomeScreenAction.OnSeekProgress(it)) }, +// onSeekEnd = { onHomeAction(HomeScreenAction.OnSeekEnd(it)) }, +// onPlayClick = { onHomeAction(HomeScreenAction.OnPlayClick) }, +// onStopClick = { +// coroutineScope.launch { +// onHomeAction(HomeScreenAction.OnStopClick) +// } +// }, +// onPauseClick = { onHomeAction(HomeScreenAction.OnPauseClick) }, +// ) +// } +// } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun RecordsScreenPreview() { + RecordsScreen({}, {}, {}, + RecordsScreenState( + recordsMap = mapOf( + Pair("Today", listOf( + RecordListItem( + recordId = 1, + name = "Test record 1", + details = "1.5 MB, mp4, 192 kbps, 48 kHz", + duration = "3:15", + added = 100000000, + isBookmarked = true + ), + RecordListItem( + recordId = 2, + name = "Test record 2", + details = "4.5 MB, mp3, 128 kbps, 32 kHz", + duration = "8:15", + added = 0, + isBookmarked = false + ) + )) + ), + showDeletedRecordsButton = true, + showRenameDialog = false, + showMoveToRecycleDialog = false, + showSaveAsDialog = false, + operationSelectedRecord = RecordListItem( + recordId = 2, + name = "Test record 2", + details = "4.5 MB, mp3, 128 kbps, 32 kHz", + duration = "8:15", + added = 0, + isBookmarked = false + ) + ), + null, {}, + uiHomeState = HomeScreenState( + waveformState = getTestWaveformData(), + startTime = "00:00", + endTime = "3:42", + time = "1:51", + recordName = "Test Record Name", + recordInfo = "1.5 MB, mp4, 192 kbps, 48 kHz", + isContextMenuAvailable = true, + isStopRecordingButtonAvailable = true, + ), + onHomeAction = {} + ) +} + +@Preview(showBackground = true) +@Composable +fun RecordsScreenEmptyPreview() { + RecordsScreen({}, {}, {}, + RecordsScreenState(), + null, {}, + uiHomeState = HomeScreenState(), + onHomeAction = {} + ) +} + + +@Preview(showBackground = true) +@Composable +fun RecordsScreenLoadingPreview() { + RecordsScreen({}, {}, {}, + RecordsScreenState(isShowLoadingProgress = true), + null, {}, + uiHomeState = HomeScreenState(), + onHomeAction = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt new file mode 100644 index 000000000..5e7ad6d59 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt @@ -0,0 +1,716 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.app.records + +import android.app.Application +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.app.DownloadService +import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.audio.player.PlayerContractNew.PlayerCallback +import com.dimowner.audiorecorder.exception.AppException +import com.dimowner.audiorecorder.util.AndroidUtils +import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.info.RecordInfoState +import com.dimowner.audiorecorder.v2.app.info.toRecordInfoState +import com.dimowner.audiorecorder.v2.app.records.models.SortDropDownMenuItemId +import com.dimowner.audiorecorder.v2.app.toInfoCombinedText +import com.dimowner.audiorecorder.v2.audio.RecorderV2 +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.SortOrder +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +const val DEFAULT_PAGE_SIZE = 200 + +@HiltViewModel +internal class RecordsViewModel @Inject constructor( + private val recordsDataSource: RecordsDataSource, + private val prefs: PrefsV2, + private val audioPlayer: PlayerContractNew.Player, + private val audioRecorder: RecorderV2, + @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context, +) : AndroidViewModel(context as Application) { + + private val _state = mutableStateOf(RecordsScreenState()) + val state: State = _state + + private val _event = MutableSharedFlow() + val event: SharedFlow = _event + + private val playerCallback: PlayerCallback = object : PlayerCallback { + override fun onStartPlay() { + _state.value = _state.value.copy( + showRecordPlaybackPanel = true, + ) + } + override fun onPlayProgress(mills: Long) { + //Do nothing + } + override fun onPausePlay() { + //Do nothing + } + override fun onSeek(mills: Long) { + //Do nothing + } + override fun onStopPlay() { + _state.value = _state.value.copy( + showRecordPlaybackPanel = false, + activeRecord = null, + ) + } + override fun onError(throwable: AppException) { + //Do nothing + } + } + + fun init(showPlayPanel: Boolean) { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + initState(showPlayPanel) + } + audioPlayer.addPlayerCallback(playerCallback) + } + + fun onStop() { + audioPlayer.removePlayerCallback(playerCallback) + } + + private suspend fun initState(showPlayPanel: Boolean) { + val context: Context = getApplication().applicationContext + val sortOrder = state.value.sortOrder + val records = recordsDataSource.getRecords( + sortOrder = sortOrder, + page = 1, + pageSize = DEFAULT_PAGE_SIZE, + isBookmarked = false, + ) + val deletedRecordsCount = recordsDataSource.getMovedToRecycleRecordsCount() + withContext(mainDispatcher) { + _state.value = RecordsScreenState( + sortOrder = sortOrder, + recordsMap = records.map { + it.toRecordListItem(context) + }.groupRecordsByDate(context, sortOrder), + showDeletedRecordsButton = deletedRecordsCount > 0, + deletedRecordsCount = deletedRecordsCount, + showRecordPlaybackPanel = showPlayPanel, + isRecording = audioRecorder.isRecording, + recordedRecordId = prefs.recordedRecordId + ) + showLoadingProgress(false) + } + } + + fun updateListWithBookmarks(bookmarksSelected: Boolean) { + viewModelScope.launch(ioDispatcher) { + val sortOrder = state.value.sortOrder + val records = recordsDataSource.getRecords( + sortOrder = sortOrder, + page = 1, + pageSize = DEFAULT_PAGE_SIZE, + isBookmarked = bookmarksSelected, + ) + val context = getApplication().applicationContext + withContext(mainDispatcher) { + _state.value = _state.value.copy( + recordsMap = records.map { + it.toRecordListItem(context) + }.groupRecordsByDate(context, sortOrder), + bookmarksSelected = bookmarksSelected + ) + } + } + } + + fun bookmarkRecord(recordId: Long, addToBookmarks: Boolean) { + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(recordId)?.let { + recordsDataSource.updateRecord(it.copy(isBookmarked = addToBookmarks)) + } + val updated = recordsDataSource.getRecord(recordId) + if (updated != null) { + withContext(mainDispatcher) { + _state.value = _state.value.updateRecordInMap(recordId) { oldRecord -> + oldRecord.copy(isBookmarked = addToBookmarks) + } + } + } + } + } + + fun onItemSelect(record: RecordListItem) { + multiSelectCancel() + audioPlayer.stop() + prefs.activeRecordId = record.recordId + _state.value = _state.value.copy( + activeRecord = record + ) + } + + fun updateListWithSortOrder(sortOrderId: SortDropDownMenuItemId) { + viewModelScope.launch(ioDispatcher) { + val sortOrder = sortOrderId.toSortOrder() + val records = recordsDataSource.getRecords( + sortOrder = sortOrder, + page = 1, + pageSize = DEFAULT_PAGE_SIZE, + isBookmarked = _state.value.bookmarksSelected, + ) + val context = getApplication().applicationContext + withContext(mainDispatcher) { + _state.value = _state.value.copy( + recordsMap = records.map { + it.toRecordListItem(context) + }.groupRecordsByDate(context, sortOrder), + sortOrder = sortOrder + ) + } + } + } + + fun shareRecord(recordId: Long) { + multiSelectCancel() + viewModelScope.launch(ioDispatcher) { + val record = recordsDataSource.getRecord(recordId) + if (record != null) { + withContext(mainDispatcher) { + AndroidUtils.shareAudioFile( + getApplication().applicationContext, + record.path, + record.name, + record.format + ) + } + } + } + } + + fun showRecordInfo(recordId: Long) { + multiSelectCancel() + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(recordId)?.toRecordInfoState()?.let { + emitEvent(RecordsScreenEvent.RecordInformationEvent(it)) + } + } + } + + fun onRenameRecordRequest(record: RecordListItem) { + multiSelectCancel() + _state.value = _state.value.copy( + showRenameDialog = true, + operationSelectedRecord = record + ) + } + + fun onRenameRecordDismiss() { + _state.value = _state.value.copy( + showRenameDialog = false, + ) + } + + fun renameRecord(recordId: Long, newName: String) { + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(recordId)?.let { record -> + if (recordsDataSource.renameRecord(record, newName)) { + _state.value = _state.value.copy( + showRenameDialog = false, + operationSelectedRecord = null, + recordsMap = _state.value.recordsMap.mapRecordInMap(recordId) { oldRecord -> + if (recordId == record.id) { + oldRecord.copy(name = newName) + } else { + oldRecord + } + } + ) + } else { + _state.value = _state.value.copy( + showRenameDialog = false, + operationSelectedRecord = null + ) + } + } + } + } + + fun openRecordWithAnotherApp(recordId: Long) { + multiSelectCancel() + audioPlayer.stop() + viewModelScope.launch(ioDispatcher) { + val record = recordsDataSource.getRecord(recordId) + if (record != null) { + withContext(mainDispatcher) { + AndroidUtils.openAudioFile( + getApplication().applicationContext, + record.path, + record.name + ) + } + } + } + } + + fun onSaveAsRequest(record: RecordListItem) { + multiSelectCancel() + _state.value = _state.value.copy( + showSaveAsDialog = true, + operationSelectedRecord = record + ) + } + + fun onSaveAsDismiss() { + _state.value = _state.value.copy( + showSaveAsDialog = false, + ) + } + + fun saveRecordAs(recordId: Long) { + multiSelectCancel() + viewModelScope.launch(ioDispatcher) { + recordsDataSource.getRecord(recordId)?.let { + DownloadService.startNotification( + getApplication().applicationContext, + it.path + ) + } + _state.value = _state.value.copy( + showSaveAsDialog = false, + operationSelectedRecord = null + ) + } + } + + fun onMoveToRecycleRecordRequest(record: RecordListItem) { + multiSelectCancel() + _state.value = _state.value.copy( + showMoveToRecycleDialog = true, + operationSelectedRecord = record + ) + } + + fun onMoveToRecycleRecordDismiss() { + _state.value = _state.value.copy( + showMoveToRecycleDialog = false, + ) + } + + private fun moveRecordToRecycle(recordId: Long) { + val activeRecordId = prefs.activeRecordId + if (audioPlayer.isPlaying() && recordId == activeRecordId) { + audioPlayer.stop() + } + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + val record = recordsDataSource.getRecord(recordId) + if (record != null && recordsDataSource.moveRecordToRecycle(recordId)) { + if (recordId == activeRecordId) { + prefs.activeRecordId = -1 + } + withContext(mainDispatcher) { + _state.value = _state.value.copy( + recordsMap = _state.value.recordsMap.removeRecordFromMap(recordId), + showMoveToRecycleDialog = false, + showDeletedRecordsButton = true, + operationSelectedRecord = null, + activeRecord = if (recordId == activeRecordId) null else _state.value.activeRecord, + isShowLoadingProgress = false + ) + } + emitEvent( + RecordsScreenEvent.RecordMovedToRecycleSnack( + recordId, + record.name + ) + ) + } else { + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowErrorSnack( + context.getString(R.string.msg_move_to_trash_failed) + ) + ) + + withContext(mainDispatcher) { + showLoadingProgress(false) + } + } + } + } + + fun handleRestoreRecordFromRecycle(recordId: Long) { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + if (recordsDataSource.restoreRecordFromRecycle(recordId)) { + prefs.activeRecordId = recordId + val record = recordsDataSource.getRecord(recordId) + showInfoMessage(R.string.msg_recording_restored, record?.name ?: "") + + //Update list state. Put removed record back into the list. + if (record != null) { + withContext(mainDispatcher) { + val context: Context = getApplication().applicationContext + _state.value = _state.value.copy( + recordsMap = _state.value.recordsMap.addRecordToMap( + context, + record.toRecordListItem(context), + state.value.sortOrder + ), + ) + } + } + } else { + showInfoMessage(R.string.msg_operation_failed_generic) + + withContext(mainDispatcher) { + showLoadingProgress(false) + } + } + } + } + + private fun showLoadingProgress(value: Boolean) { + _state.value = _state.value.copy(isShowLoadingProgress = value) + } + + private fun showInfoMessage(@StringRes resId: Int) { + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowInfoSnack( + context.getString(resId) + ) + ) + } + + private fun showInfoMessage(@StringRes resId: Int, vararg formatArgs: Any) { + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowInfoSnack( + context.getString(resId, *formatArgs) + ) + ) + } + + @SuppressWarnings("CyclomaticComplexMethod") + fun onAction(action: RecordsScreenAction) { + when (action) { + is RecordsScreenAction.InitRecordsScreen -> init(action.showPlayPanel) + is RecordsScreenAction.OnStopRecordsScreen -> onStop() + is RecordsScreenAction.UpdateListWithSortOrder -> updateListWithSortOrder(action.sortOrderId) + is RecordsScreenAction.UpdateListWithBookmarks -> updateListWithBookmarks(action.bookmarksSelected) + is RecordsScreenAction.BookmarkRecord -> bookmarkRecord(action.recordId, action.addToBookmarks) + is RecordsScreenAction.OnItemSelect -> onItemSelect(action.record) + is RecordsScreenAction.ShareRecord -> shareRecord(action.recordId) + is RecordsScreenAction.ShowRecordInfo -> showRecordInfo(action.recordId) + is RecordsScreenAction.OnRenameRecordRequest -> onRenameRecordRequest(action.record) + is RecordsScreenAction.OpenRecordWithAnotherApp -> openRecordWithAnotherApp(action.recordId) + is RecordsScreenAction.OnSaveAsRequest -> onSaveAsRequest(action.record) + is RecordsScreenAction.OnMoveToRecycleRecordRequest -> onMoveToRecycleRecordRequest(action.record) + is RecordsScreenAction.MoveRecordToRecycle -> moveRecordToRecycle(action.recordId) + RecordsScreenAction.OnMoveToRecycleRecordDismiss -> onMoveToRecycleRecordDismiss() + is RecordsScreenAction.RestoreRecordFromRecycle -> handleRestoreRecordFromRecycle(action.recordId) + is RecordsScreenAction.SaveRecordAs -> saveRecordAs(action.recordId) + RecordsScreenAction.OnSaveAsDismiss -> onSaveAsDismiss() + is RecordsScreenAction.RenameRecord -> renameRecord(action.recordId, action.newName) + RecordsScreenAction.OnRenameRecordDismiss -> onRenameRecordDismiss() + is RecordsScreenAction.MultiSelectAddItem -> multiSelectAdd(action.selectedRecord) + RecordsScreenAction.MultiSelectCancel -> multiSelectCancel() + is RecordsScreenAction.MultiSelectMoveToRecycle -> multiSelectMoveToRecycle() + is RecordsScreenAction.MultiSelectMoveToRecycleRequest -> + multiSelectMoveToRecycleRequest() + RecordsScreenAction.MultiSelectMoveToRecycleDismiss -> multiSelectMoveToRecycleDismiss() + is RecordsScreenAction.MultiSelectSaveAs -> multiSelectSaveAs() + is RecordsScreenAction.MultiSelectSaveAsRequest -> multiSelectSaveAsRequest() + RecordsScreenAction.MultiSelectSaveAsDismiss -> multiSelectSaveAsDismiss() + is RecordsScreenAction.MultiSelectShare -> multiSelectShare(action.selectedRecords) + } + } + + private fun multiSelectAdd(selected: RecordListItem) { + audioPlayer.stop() + val records = _state.value.selectedRecords.toMutableList() + if (records.contains(selected)) { + records.remove(selected) + } else { + records.add(selected) + } + _state.value = _state.value.copy( + selectedRecords = records, + ) + } + + private fun multiSelectCancel() { + _state.value = _state.value.copy( + selectedRecords = emptyList(), + ) + } + + private fun multiSelectShare(selectedRecords: List) { + viewModelScope.launch(ioDispatcher) { + val recordList = recordsDataSource.getRecords(selectedRecords.map { it.recordId }) + if (recordList.isNotEmpty()) { + withContext(mainDispatcher) { + AndroidUtils.shareAudioFiles( + getApplication().applicationContext, + recordList.map { it.path } + ) + multiSelectCancel() + } + } else { + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowErrorSnack( + context.getString(R.string.error_unknown) + ) + ) + } + } + } + + private fun multiSelectSaveAsRequest() { + _state.value = _state.value.copy( + showSaveAsMultipleDialog = true, + ) + } + + private fun multiSelectSaveAs() { + viewModelScope.launch(ioDispatcher) { + val recordList = recordsDataSource.getRecords(state.value.selectedRecords.map { it.recordId }) + if (recordList.isNotEmpty()) { + withContext(mainDispatcher) { + //Download record file with Service + DownloadService.startNotification( + getApplication().applicationContext, + recordList + .map { it.path } + .toCollection(ArrayList()) + ) + multiSelectCancel() + _state.value = _state.value.copy( + showSaveAsMultipleDialog = false, + ) + } + } else { + val context: Context = getApplication().applicationContext + emitEvent( + RecordsScreenEvent.ShowErrorSnack( + context.getString(R.string.error_unknown) + ) + ) + } + } + } + + private fun multiSelectMoveToRecycleRequest() { + _state.value = _state.value.copy( + showMoveToRecycleMultipleDialog = true, + ) + } + + private fun multiSelectMoveToRecycleDismiss() { + _state.value = _state.value.copy( + showMoveToRecycleMultipleDialog = false, + ) + } + + private fun multiSelectSaveAsDismiss() { + _state.value = _state.value.copy( + showSaveAsMultipleDialog = false, + ) + } + + private fun multiSelectMoveToRecycle() { + showLoadingProgress(true) + viewModelScope.launch(ioDispatcher) { + val deletedCount = recordsDataSource.moveRecordsToRecycle(state.value.selectedRecords.map { it.recordId }) + if (deletedCount > 0) { + val context: Context = getApplication().applicationContext + val sortOrder = state.value.sortOrder + val records = recordsDataSource.getRecords( + sortOrder = sortOrder, + page = 1, + pageSize = DEFAULT_PAGE_SIZE, + isBookmarked = state.value.bookmarksSelected, + ) + val recordsInRecycleCount = recordsDataSource.getMovedToRecycleRecordsCount() + val selectedRecords = state.value.selectedRecords + val isActiveRecordDeleted = selectedRecords.map { it.recordId }.contains(prefs.activeRecordId) + withContext(mainDispatcher) { + multiSelectCancel() + _state.value = _state.value.copy( + recordsMap = records.map { + it.toRecordListItem(context) + }.groupRecordsByDate(context, sortOrder), + showDeletedRecordsButton = recordsInRecycleCount > 0, + deletedRecordsCount = recordsInRecycleCount, + showMoveToRecycleMultipleDialog = false, + activeRecord = if (isActiveRecordDeleted) null else _state.value.activeRecord, + isShowLoadingProgress = false + ) + } + multipleMoveToRecycleSuccessSnack(selectedRecords, deletedCount) + } else { + multipleMoveToRecycleFailSnack() + withContext(mainDispatcher) { + showLoadingProgress(false) + } + } + } + } + + private fun multipleMoveToRecycleSuccessSnack(selectedRecords: List, deletedRecordsCount: Int) { + if (selectedRecords.size == 1) { + val record = selectedRecords.first() + emitEvent( + RecordsScreenEvent.RecordMovedToRecycleSnack( + record.recordId, + record.name + ) + ) + } else { + emitEvent( + RecordsScreenEvent.FewRecordsMovedToRecycleSnack( + deletedRecordsCount, + selectedRecords.size + ) + ) + } + } + + private fun multipleMoveToRecycleFailSnack() { + val context: Context = getApplication().applicationContext + if (state.value.selectedRecords.size > 1) { + emitEvent( + RecordsScreenEvent.ShowErrorSnack( + context.getString(R.string.msg_multiple_move_to_trash_failed) + ) + ) + } else { + emitEvent( + RecordsScreenEvent.ShowErrorSnack( + context.getString(R.string.msg_move_to_trash_failed) + ) + ) + } + } + + private fun emitEvent(event: RecordsScreenEvent) { + viewModelScope.launch { + _event.emit(event) + } + } +} + +data class RecordsScreenState( + val recordsMap: Map> = emptyMap(), + val selectedRecords: List = emptyList(), + val sortOrder: SortOrder = SortOrder.DateDesc, + val bookmarksSelected: Boolean = false, + val showDeletedRecordsButton: Boolean = false, + val showRecordPlaybackPanel: Boolean = false, + val deletedRecordsCount: Int = 0, + val isShowLoadingProgress: Boolean = false, + + val showRenameDialog: Boolean = false, + val showMoveToRecycleDialog: Boolean = false, + val showMoveToRecycleMultipleDialog: Boolean = false, + val showSaveAsDialog: Boolean = false, + val showSaveAsMultipleDialog: Boolean = false, + //A record for which some operation requested (rename, save as, delete) + val operationSelectedRecord: RecordListItem? = null, + val activeRecord: RecordListItem? = null, + val isRecording: Boolean = false, + val recordedRecordId: Long = -1, +) + +data class RecordListItem( + val recordId: Long, + val name: String, + val details: String, + val duration: String, + val added: Long, + val isBookmarked: Boolean +) + +internal sealed class RecordsScreenEvent { + data class RecordInformationEvent(val recordInfo: RecordInfoState) : RecordsScreenEvent() + data class RecordMovedToRecycleSnack(val recordId: Long, val recordName: String) : + RecordsScreenEvent() + data class FewRecordsMovedToRecycleSnack(val movedCount: Int, val expectedCount: Int) : + RecordsScreenEvent() + data class ShowErrorSnack(val message: String) : RecordsScreenEvent() + data class ShowInfoSnack(val message: String) : RecordsScreenEvent() +} + +internal sealed class RecordsScreenAction { + data class InitRecordsScreen(val showPlayPanel: Boolean) : RecordsScreenAction() + data object OnStopRecordsScreen : RecordsScreenAction() + data class UpdateListWithSortOrder(val sortOrderId: SortDropDownMenuItemId) : RecordsScreenAction() + data class UpdateListWithBookmarks(val bookmarksSelected: Boolean) : RecordsScreenAction() + data class OnItemSelect(val record: RecordListItem) : RecordsScreenAction() + data class BookmarkRecord(val recordId: Long, val addToBookmarks: Boolean) : RecordsScreenAction() + data class ShareRecord(val recordId: Long) : RecordsScreenAction() + data class ShowRecordInfo(val recordId: Long) : RecordsScreenAction() + data class OnRenameRecordRequest(val record: RecordListItem) : RecordsScreenAction() + data class OpenRecordWithAnotherApp(val recordId: Long) : RecordsScreenAction() + data class OnSaveAsRequest(val record: RecordListItem) : RecordsScreenAction() + data class OnMoveToRecycleRecordRequest(val record: RecordListItem) : RecordsScreenAction() + data class MoveRecordToRecycle(val recordId: Long) : RecordsScreenAction() + data class RestoreRecordFromRecycle(val recordId: Long) : RecordsScreenAction() + data object OnMoveToRecycleRecordDismiss : RecordsScreenAction() + data class SaveRecordAs(val recordId: Long) : RecordsScreenAction() + data object OnSaveAsDismiss : RecordsScreenAction() + data class RenameRecord(val recordId: Long, val newName: String) : RecordsScreenAction() + data object OnRenameRecordDismiss : RecordsScreenAction() + data class MultiSelectAddItem(val selectedRecord: RecordListItem) : RecordsScreenAction() + data object MultiSelectCancel : RecordsScreenAction() + data class MultiSelectShare(val selectedRecords: List) : RecordsScreenAction() + data object MultiSelectSaveAs : RecordsScreenAction() + data object MultiSelectSaveAsRequest : RecordsScreenAction() + data object MultiSelectSaveAsDismiss : RecordsScreenAction() + data object MultiSelectMoveToRecycle : RecordsScreenAction() + data object MultiSelectMoveToRecycleRequest : RecordsScreenAction() + data object MultiSelectMoveToRecycleDismiss : RecordsScreenAction() +} + +internal fun Record.toRecordListItem(context: Context): RecordListItem { + return RecordListItem( + recordId = this.id, + name = this.name, + details = this.toInfoCombinedText(context), + duration = TimeUtils.formatTimeIntervalHourMinSec2(this.durationMills), + added = this.added, + isBookmarked = this.isBookmarked + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/RecordDropDownMenuItemId.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/RecordDropDownMenuItemId.kt new file mode 100644 index 000000000..9f3f83581 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/RecordDropDownMenuItemId.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.records.models + +enum class RecordDropDownMenuItemId { + SHARE, INFORMATION, RENAME, OPEN_WITH, SAVE_AS, DELETE +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/SortDropDownMenuItemId.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/SortDropDownMenuItemId.kt new file mode 100644 index 000000000..f029140ed --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/models/SortDropDownMenuItemId.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.records.models + +enum class SortDropDownMenuItemId { + DATE_DESC, DATE_ASC, NAME, NAME_DESC, DURATION, DURATION_DESC +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsComponents.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsComponents.kt new file mode 100644 index 000000000..c5c42047f --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsComponents.kt @@ -0,0 +1,655 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.os.Parcelable +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.DeviceFontFamilyName +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.InfoAlertDialog +import com.dimowner.audiorecorder.v2.data.model.SampleRate + +@Composable +fun SettingsItem( + label: String, + iconRes: Int, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .clickable { onClick() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(24.dp, 16.dp, 16.dp, 16.dp) + .wrapContentWidth() + .wrapContentHeight(), + painter = painterResource(id = iconRes), + contentDescription = label, + ) + SettingsItemText(text = label) + } +} + +@Composable +fun SettingsItemText(text: String) { + Text( + modifier = Modifier + .padding(0.dp, 12.dp, 0.dp, 12.dp) + .fillMaxWidth() + .wrapContentHeight(), + text = text, + fontSize = 20.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) +} + +@Preview(showBackground = true) +@Composable +fun SettingsItemPreview() { + SettingsItem("Label", R.drawable.ic_color_lens, {}) +} + +@Composable +fun SettingsItemCheckBox( + checked: Boolean, + label: String, + iconRes: Int, + onCheckedChange: ((Boolean) -> Unit), + enabled: Boolean = true, +) { + val checkState = remember { mutableStateOf(checked) } + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(24.dp, 16.dp, 16.dp, 16.dp) + .wrapContentWidth() + .wrapContentHeight(), + painter = painterResource(id = iconRes), + contentDescription = label + ) + Text( + modifier = Modifier + .padding(0.dp, 12.dp, 0.dp, 12.dp) + .weight(1f) + .wrapContentHeight(), + text = label, + fontSize = 20.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + Switch( + checked = checkState.value, + onCheckedChange = { + checkState.value = it + onCheckedChange(it) + }, + enabled = enabled, + modifier = Modifier.padding(8.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SettingsItemCheckBoxPreview() { + SettingsItemCheckBox(true,"Label", R.drawable.ic_color_lens, {}) +} + +@Composable +fun AppInfoView(appName: String, version: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + textAlign = TextAlign.Center, + text = appName, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 20.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Medium + ) + ), + ) + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + textAlign = TextAlign.Center, + text = version, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Light + ) + } +} + +@Preview(showBackground = true) +@Composable +fun AppInfoViewPreview() { + AppInfoView("App Name", "App Version") +} + +@Composable +fun InfoTextView(value: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp, 0.dp, 16.dp, 0.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + text = value, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + fontWeight = FontWeight.Light + ) + } +} + +@Preview(showBackground = true) +@Composable +fun InfoTextViewPreview() { + InfoTextView("InfoTextView") +} + +@Composable +fun ResetRecordingSettingsPanel( + sizePerMin: String, + recordingSettingsText: String, + onClick: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(4.dp), + ) { + Row( + modifier = Modifier.wrapContentSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .wrapContentHeight() + .padding(8.dp), + ) { + Text( + textAlign = TextAlign.Start, + text = sizePerMin, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Light + ) + Text( + textAlign = TextAlign.Start, + text = recordingSettingsText, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Light + ) + } + Button( + modifier = Modifier + .padding(8.dp) + .wrapContentSize(), + + onClick = { onClick() } + ) { + Text( + text = stringResource(id = R.string.btn_reset), + fontSize = 18.sp, + fontWeight = FontWeight.Light, + ) + } + } + + } +} + +@Preview(showBackground = true) +@Composable +fun ResetRecordingSettingsPanelPreview() { + ResetRecordingSettingsPanel("ResetRecordingSettingsPanel", "m4a, mono", {}) +} + +@Composable +fun SettingSelector( + name: String, + chips: List>, + onSelect: (ChipItem) -> Unit, + onClickInfo: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + .wrapContentHeight() + ) { + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + verticalAlignment = Alignment.Bottom + ) { + Text( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .weight(1f) + .padding(4.dp), + textAlign = TextAlign.Start, + text = name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Light + ) + IconButton( + onClick = onClickInfo, + modifier = Modifier + .align(Alignment.CenterVertically), + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = R.drawable.ic_info), + contentDescription = name, + ) + } + } + + ChipsPanel( + modifier = Modifier.wrapContentSize(), + chips = chips, + onSelect = onSelect + ) + } +} + +@Composable +fun ChipComponent( + modifier: Modifier = Modifier, + item: ChipItem, + onSelect: (ChipItem) -> Unit, +) { + Card( + modifier = modifier + .wrapContentSize() + .padding(2.dp, 0.dp), + shape = RoundedCornerShape(18.dp), + border = if (item.isSelected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { null }, + onClick = { onSelect(item) } + ) { + Row( + modifier = Modifier + .wrapContentSize() + .padding(if (item.isSelected) 12.dp else 25.dp, 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (item.isSelected) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = item.name, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(26.dp) + .padding(0.dp, 3.dp, 3.dp, 3.dp) + .align(Alignment.CenterVertically) + ) + } + Text( + modifier = Modifier + .wrapContentSize() + .padding(2.dp), + textAlign = TextAlign.Start, + text = item.name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 17.sp, + fontWeight = FontWeight.Normal + ) + } + } + +} + +@Preview(showBackground = true) +@Composable +fun ChipComponentPreview() { + ChipComponent( + Modifier.wrapContentSize(), + ChipItem(id = 0, value = SampleRate.SR8000, name = "8000", false) + ) {} +} + +//@Composable +//fun calculateChipsPositions(chips: List, screenWidth: Float): Map { +// val map = mutableMapOf() +// var row = 0 +// var col = 0 +// var cumulativeWidth = 0f +// val textStyle = TextStyle( +// fontSize = 17.sp, +// fontWeight = FontWeight.Normal, +// textAlign = TextAlign.Start +// ) +// chips.forEach { chip -> +// val chipWidthExtra = 58 //Dp +// val chipWidth = measureTextWidth(chip.name, textStyle).value + chipWidthExtra.dp.value +// cumulativeWidth += chipWidth +// if (cumulativeWidth > screenWidth) { +// map[row] = col +// row++ +// col = 1 +// cumulativeWidth = chipWidth +// } else { +// col++ +// map[row] = col +// } +// } +// return map +//} + +@Composable +fun measureTextWidth(text: String, style: TextStyle): Dp { + val textMeasurer = rememberTextMeasurer() + val widthInPixels = textMeasurer.measure(text, style).size.width + return with(LocalDensity.current) { widthInPixels.toDp() } +} + +@Preview(showBackground = true) +@Composable +fun SettingSelectorPreview() { + SettingSelector("SettingsSelector", chips = getTestChips(), {}, {}) +} + +@Composable +fun ChipsPanel( + modifier: Modifier = Modifier, + chips: List>, + onSelect: (ChipItem) -> Unit, +) { + Layout( + modifier = modifier, + content = { chips.map { + ChipComponent(modifier = Modifier.wrapContentSize(), item = it, onSelect = onSelect) } + } + ) { measurables, constraints -> + // List of measured children + val placeables = measurables.map { measurable -> + // Measure each children + measurable.measure(constraints) + } + val totalHeight = calculatePositionsDefault(placeables, constraints.maxWidth) + + // Set the size of the layout as big as it can + layout(constraints.maxWidth, totalHeight) { + calculatePositionsDefault( + temp = placeables, + constraints.maxWidth, + ) { placeable, x, y -> placeable.place(x, y) } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ChipsPanelPreview() { + ChipsPanel(Modifier.wrapContentSize(), getTestChips()) {} +} + +fun calculatePositionsDefault( + temp: List, + viewWidth: Int, + onPlace: ((Placeable, x: Int, y: Int) -> Unit)? = null +): Int { + var posY = 0 + if (temp.isNotEmpty()) { + var posX = 0 + val rowHeight = temp.first().measuredHeight + var rowCount = 0 + rowCount++ + if (temp.isNotEmpty()) { + var availableWidth = viewWidth + for (i in temp.indices) { + if (availableWidth < temp[i].measuredWidth) { + rowCount++ + posY += rowHeight + availableWidth = viewWidth + posX = 0 + } + onPlace?.invoke(temp[i], posX, posY) + val width: Int = (temp[i].measuredWidth) + posX += width + availableWidth -= width + } + posY += rowHeight + } + } + return posY +} + +@Composable +fun DropDownSetting( + items: List, + selectedItem: NameFormatItem?, + onSelect: (NameFormatItem) -> Unit, +) { + val expanded = remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentSize(Alignment.TopStart) + ) { + // The DropdownMenu composable + DropdownMenu( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + expanded = expanded.value, + onDismissRequest = { expanded.value = false } + ) { + items.forEach { item -> + DropdownMenuItem( + onClick = { + onSelect(item) + expanded.value = false + }, text = { + SettingsItemText(text = item.nameText) + } + ) + } + } + val text = selectedItem?.nameText ?: stringResource(id = R.string.empty) + Row( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .clickable { expanded.value = !expanded.value }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(24.dp, 16.dp, 16.dp, 16.dp) + .wrapContentSize(), + painter = painterResource(id = R.drawable.ic_title), + contentDescription = text, + ) + Column( + modifier = Modifier + .padding(0.dp, 12.dp) + .weight(1f) + ) { + Text( + text = stringResource(id = R.string.record_name_format), + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Bold + ) + ), + ) + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + text = text, + fontSize = 20.sp, + fontFamily = FontFamily( + Font( + DeviceFontFamilyName("sans-serif"), + weight = FontWeight.Light + ) + ), + ) + } + Icon( + modifier = Modifier + .wrapContentSize() + .padding(0.dp, 0.dp, 12.dp, 0.dp), + painter = painterResource(id = R.drawable.ic_arrow_down), + contentDescription = text, + ) + } + } +} + +@Composable +fun SettingsInfoDialog(openDialog: MutableState, message: String) { + if (openDialog.value) { + InfoAlertDialog( + onDismissRequest = { openDialog.value = false }, + onConfirmation = { + openDialog.value = false + }, + dialogTitle = stringResource(id = R.string.info), + dialogText = message, + icon = Icons.Default.Info, + dismissButton = stringResource(id = R.string.btn_ok) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SettingsInfoDialogPreview() { + SettingsInfoDialog(remember {mutableStateOf(true) }, "Information massage") +} + +@Composable +fun SettingsWarningDialog(openDialog: MutableState, message: String) { + if (openDialog.value) { + InfoAlertDialog( + onDismissRequest = { openDialog.value = false }, + onConfirmation = { + openDialog.value = false + }, + dialogTitle = stringResource(id = R.string.warning), + dialogText = message, + icon = Icons.Default.Warning, + dismissButton = stringResource(id = R.string.btn_ok) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SettingsWarningDialogPreview() { + SettingsWarningDialog(remember {mutableStateOf(true) }, "Warning message") +} + +private fun getTestChips(): List> { + return listOf( + ChipItem(id = 0, value = SampleRate.SR8000, name = "8000", false), + ChipItem(id = 1, value = SampleRate.SR16000, name = "16000", false), + ChipItem(id = 2, value = SampleRate.SR22500, name = "22500", true), + ChipItem(id = 3, value = SampleRate.SR32000, name = "32000", false), + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsExtensions.kt new file mode 100644 index 000000000..2fa00ad8c --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsExtensions.kt @@ -0,0 +1,271 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.graphics.Typeface +import android.net.Uri +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import com.dimowner.audiorecorder.AppConstants +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.util.AndroidUtils +import com.dimowner.audiorecorder.util.FileUtil +import com.dimowner.audiorecorder.v2.DefaultValues +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import timber.log.Timber + +fun makeNameFormats(): List { + return listOf( + NameFormatItem( + NameFormat.Record, FileUtil.generateRecordNameCounted(1) + ".m4a" + ), + NameFormatItem( + NameFormat.Date, FileUtil.generateRecordNameDateVariant() + ".m4a" + ), + NameFormatItem( + NameFormat.DateUs, FileUtil.generateRecordNameDateUS() + ".m4a" + ), + NameFormatItem( + NameFormat.DateIso8601, FileUtil.generateRecordNameDateISO8601() + ".m4a" + ), + NameFormatItem( + NameFormat.Timestamp, FileUtil.generateRecordNameMills() + ".m4a" + ), + ) +} + +fun NameFormat.toNameFormatItem(): NameFormatItem { + val text = when (this) { + NameFormat.Record -> FileUtil.generateRecordNameCounted(1) + ".m4a" + NameFormat.Date -> FileUtil.generateRecordNameDateVariant() + ".m4a" + NameFormat.DateUs -> FileUtil.generateRecordNameDateUS() + ".m4a" + NameFormat.DateIso8601 -> FileUtil.generateRecordNameDateISO8601() + ".m4a" + NameFormat.Timestamp -> FileUtil.generateRecordNameMills() + ".m4a" + } + return NameFormatItem(this, text) +} + +private fun rateIntentForUrl(url: String, context: Context): Intent { + val intent = Intent( + Intent.ACTION_VIEW, Uri.parse(String.format("%s?id=%s", url, context.packageName)) + ) + var flags = Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + flags = flags or Intent.FLAG_ACTIVITY_NEW_DOCUMENT + intent.addFlags(flags) + return intent +} + + +fun rateApp(context: Context) { + try { + val rateIntent = rateIntentForUrl("market://details", context) + context.startActivity(rateIntent) + } catch (e: ActivityNotFoundException) { + Timber.e(e) + val rateIntent = rateIntentForUrl("https://play.google.com/store/apps/details", context) + context.startActivity(rateIntent) + } +} + +fun requestFeature(context: Context, onError: (String) -> Unit) { + val i = Intent(Intent.ACTION_SEND) + i.setType("message/rfc822") + i.putExtra(Intent.EXTRA_EMAIL, arrayOf(AppConstants.REQUESTS_RECEIVER)) + i.putExtra( + Intent.EXTRA_SUBJECT, + "[" + context.getString(R.string.app_name) + + "] " + AndroidUtils.getAppVersion(context) + + " - " + context.getString(R.string.request) + ) + try { + val chooser = Intent.createChooser(i, context.getString(R.string.send_email)) + chooser.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooser) + } catch (ex: ActivityNotFoundException) { + Timber.e(ex) + onError(context.getString(R.string.email_clients_not_found)) + } +} + +/** + * Converts a [Spanned] into an [AnnotatedString] trying to keep as much formatting as possible. + * + * Currently supports `bold`, `italic`, `underline` and `color`. + */ +fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString { + val spanned = this@toAnnotatedString + append(spanned.toString()) + getSpans(0, spanned.length, Any::class.java).forEach { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + when (span) { + is StyleSpan -> when (span.style) { + Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + Typeface.BOLD_ITALIC -> { + addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end) + } + } + is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) + is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end) + } + } +} + +fun RecordingFormat.convertToText(formatStrings: Array): String { + return formatStrings[this.index] +} + +fun BitRate.convertToText(bitrateStrings: Array): String { + return bitrateStrings[this.index] +} + +fun ChannelCount.convertToText(channelCountStrings: Array): String { + return channelCountStrings[this.index] +} + +fun SampleRate.convertToText(channelCountStrings: Array): String { + return channelCountStrings[this.index] +} + +@SuppressWarnings("MagicNumber") +fun sizeMbPerMin( + recordingFormat: RecordingFormat?, + sampleRate: SampleRate?, + bitrate: BitRate?, + channels: ChannelCount? +): Float { + if (recordingFormat == null) return 0F + return when (recordingFormat) { + RecordingFormat.M4a -> { + if (bitrate == null) { + 0F + } else { + (60F * (bitrate.value / 8F)) / 1000000f + } + } + RecordingFormat.ThreeGp -> (60F * (DefaultValues.Default3GpBitRate / 8F)) / 1000000f + RecordingFormat.Wav -> { + if (sampleRate != null && channels != null) { + (60F * (sampleRate.value * channels.value * 2F)) / 1000000f + } else { + 0F + } + } + } +} + +fun getChannelCounts( + format: RecordingFormat, + selected: ChannelCount?, + strings: Array +): List> { + return when (format) { + RecordingFormat.M4a, + RecordingFormat.Wav -> { + ChannelCount.entries.toList().mapIndexed { i, channelCount -> + ChipItem( + id = i, + value = channelCount, + name = strings[i], + isSelected = channelCount == selected + ) + } + } + RecordingFormat.ThreeGp -> { + listOf( + ChipItem( + id = 0, + value = ChannelCount.Mono, + name = strings[1], + isSelected = ChannelCount.Mono == selected + ) + ) + } + } +} + +fun getBitRates( + format: RecordingFormat, + selected: BitRate?, + strings: Array +): List> { + return when (format) { + RecordingFormat.M4a -> { + BitRate.entries.toList().mapIndexed { i, bitRate -> + ChipItem( + id = i, + value = bitRate, + name = strings[i], + isSelected = bitRate == selected + ) + } + } + RecordingFormat.Wav, + RecordingFormat.ThreeGp -> listOf() + } +} + +fun getSampleRates( + format: RecordingFormat, + selected: SampleRate?, + strings: Array +): List> { + return when (format) { + RecordingFormat.M4a, + RecordingFormat.Wav-> { + SampleRate.entries.toList().mapIndexed { i, sampleRate -> + ChipItem( + id = i, + value = sampleRate, + name = strings[i], + isSelected = sampleRate == selected + ) + } + } + RecordingFormat.ThreeGp -> listOf( + ChipItem( + id = 0, + value = SampleRate.SR8000, + name = strings[0], + isSelected = SampleRate.SR8000 == selected + ), + ChipItem( + id = 1, + value = SampleRate.SR16000, + name = strings[1], + isSelected = SampleRate.SR16000 == selected + ) + ) + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt new file mode 100644 index 000000000..e052d7d1f --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt @@ -0,0 +1,334 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.ComposableLifecycle +import com.dimowner.audiorecorder.v2.app.TitleBar +import com.dimowner.audiorecorder.v2.app.formatDuration +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import timber.log.Timber + +@Composable +internal fun SettingsScreen( + onPopBackStack: () -> Unit, + showDeletedRecordsScreen: () -> Unit, + uiState: SettingsState, + onAction: (SettingsScreenAction) -> Unit, +) { + val context = LocalContext.current + + val openInfoDialog = remember { mutableStateOf(false) } + val openWarningDialog = remember { mutableStateOf(false) } + val infoText = remember { mutableStateOf("") } + val warningText = remember { mutableStateOf("") } + + val isExpandedBitRatePanel = remember { mutableStateOf(true) } + + ComposableLifecycle { _, event -> + when (event) { + Lifecycle.Event.ON_CREATE -> { + Timber.d("SettingsScreen: onCreate") + onAction(SettingsScreenAction.InitSettingsScreen) + } + Lifecycle.Event.ON_START -> { + Timber.d("SettingsScreen: On Start") + } + + Lifecycle.Event.ON_RESUME -> { + Timber.d("SettingsScreen: On Resume") + } + + Lifecycle.Event.ON_PAUSE -> { + Timber.d("SettingsScreen: On Pause") + } + + Lifecycle.Event.ON_STOP -> { + Timber.d("SettingsScreen: On Stop") + } + + Lifecycle.Event.ON_DESTROY -> { + Timber.d("SettingsScreen: On Destroy") + } + else -> {} + } + } + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + ) { innerPadding -> + Surface( + modifier = Modifier.fillMaxSize().padding(innerPadding) + ) { + Column(modifier = Modifier.fillMaxSize()) { + TitleBar( + stringResource(R.string.settings), + onBackPressed = { + onPopBackStack() + }) + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(weight = 1f, fill = false) + ) { + Spacer(modifier = Modifier.size(8.dp)) + SettingsItem(stringResource(R.string.trash), R.drawable.ic_delete) { + showDeletedRecordsScreen() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + SettingsItemCheckBox( + uiState.isDynamicColors, + stringResource(R.string.dynamic_theme_colors), + R.drawable.ic_palette_outline, + { + onAction(SettingsScreenAction.SetDynamicTheme(it)) + }) + } + SettingsItemCheckBox( + uiState.isDarkTheme, + stringResource(R.string.dark_theme), + R.drawable.ic_dark_mode, + { + onAction(SettingsScreenAction.SetDarkTheme(it)) + }) + SettingsItemCheckBox( + uiState.isKeepScreenOn, + stringResource(R.string.keep_screen_on), + R.drawable.ic_lightbulb_on, + { + onAction(SettingsScreenAction.SetKeepScreenOn(it)) + }) + SettingsItemCheckBox( + uiState.isShowRenameDialog, + stringResource(R.string.ask_to_rename), + R.drawable.ic_pencil, + { + onAction(SettingsScreenAction.SetShowRenamingDialog(it)) + }) + DropDownSetting( + items = uiState.nameFormats, + selectedItem = uiState.selectedNameFormat, + onSelect = { + onAction(SettingsScreenAction.SetNameFormat(it)) + } + ) + Spacer(modifier = Modifier.size(8.dp)) + ResetRecordingSettingsPanel( + stringResource(id = R.string.size_per_min, uiState.sizePerMin), + uiState.recordingSettingsText + ) { + onAction(SettingsScreenAction.ResetRecordingSettings) + } + SettingSelector( + name = stringResource(id = R.string.recording_format), + chips = uiState.recordingSettings.map { it.recordingFormat }, + onSelect = { + onAction(SettingsScreenAction.SelectRecordingFormat(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_format) + openInfoDialog.value = true + } + ) + val selectedFormat = + uiState.recordingSettings.firstOrNull { it.recordingFormat.isSelected } + SettingSelector( + name = stringResource(id = R.string.sample_rate), + chips = selectedFormat?.sampleRates ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectSampleRate(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_frequency) + openInfoDialog.value = true + } + ) + if (isExpandedBitRatePanel.value != !selectedFormat?.bitRates.isNullOrEmpty()) { + isExpandedBitRatePanel.value = !selectedFormat?.bitRates.isNullOrEmpty() + } + AnimatedVisibility(visible = isExpandedBitRatePanel.value) { + SettingSelector( + name = stringResource(id = R.string.bitrate), + chips = selectedFormat?.bitRates ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectBitrate(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_bitrate) + openInfoDialog.value = true + } + ) + } + SettingSelector( + name = stringResource(id = R.string.channels), + chips = selectedFormat?.channelCounts ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectChannelCount(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_channels) + openInfoDialog.value = true + } + ) + Spacer(modifier = Modifier.size(8.dp)) + SettingsItem(stringResource(R.string.rate_app), R.drawable.ic_thumbs) { + rateApp(context) + } + SettingsItem(stringResource(R.string.request), R.drawable.ic_chat_bubble) { + requestFeature(context) { + warningText.value = it + openWarningDialog.value = true + } + } + Spacer(modifier = Modifier.size(8.dp)) + InfoTextView( + stringResource( + id = R.string.total_record_count, + (uiState.totalRecordCount) + ) + ) + InfoTextView( + stringResource( + id = R.string.total_duration, + formatDuration(context.resources, (uiState.totalRecordDuration)) + ) + ) + InfoTextView( + stringResource( + id = R.string.available_space, + (uiState.availableSpace) + ) + ) + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Start, + text = stringResource(R.string.switch_to_legacy_app), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Light + ) + Button( + modifier = Modifier + .wrapContentSize(), + onClick = { + onAction(SettingsScreenAction.SetAppV2(false)) + } + ) { + Text( + text = stringResource(id = R.string.btn_apply), + fontSize = 18.sp, + fontWeight = FontWeight.Light, + ) + } + } + AppInfoView(uiState.appName, uiState.appVersion) + Spacer(modifier = Modifier.size(8.dp)) + } + if (openInfoDialog.value) { + SettingsInfoDialog(openInfoDialog, infoText.value) + } + if (openWarningDialog.value) { + SettingsWarningDialog(openWarningDialog, warningText.value) + } + } + } + } +} + +@Preview +@Composable +fun SettingsScreenPreview() { + SettingsScreen({}, {}, uiState = SettingsState( + isDynamicColors = true, + isDarkTheme = false, + isAppV2 = false, + isKeepScreenOn = false, + isShowRenameDialog = true, + nameFormats = listOf(NameFormatItem(NameFormat.Record, "Name text")), + selectedNameFormat = NameFormatItem(NameFormat.Record, "Name text"), + recordingSettings = listOf(RecordingSetting( + recordingFormat = ChipItem(id = 0, value = RecordingFormat.M4a, name = "M4a", isSelected = true), + sampleRates = listOf( + ChipItem(id = 0, value = SampleRate.SR16000, name = "16 kHz", isSelected = false), + ChipItem(id = 0, value = SampleRate.SR22500, name = "22.5 kHz", isSelected = false), + ChipItem(id = 0, value = SampleRate.SR32000, name = "32 kHz", isSelected = false), + ChipItem(id = 1, value = SampleRate.SR44100, name = "44.1 kHz", isSelected = true), + ChipItem(id = 1, value = SampleRate.SR48000, name = "48 kHz", isSelected = false), + ), + bitRates = listOf( + ChipItem(id = 0, value = BitRate.BR48, name = "48 kbps", isSelected = false), + ChipItem(id = 0, value = BitRate.BR96, name = "96 kbps", isSelected = false), + ChipItem(id = 1, value = BitRate.BR128, name = "128 kbps", isSelected = true), + ChipItem(id = 1, value = BitRate.BR192, name = "192 kbps", isSelected = false), + ), + channelCounts = listOf( + ChipItem(id = 0, value = ChannelCount.Mono, name = "Mono", isSelected = false), + ChipItem(id = 1, value = ChannelCount.Stereo, name = "Stereo", isSelected = true), + ) + ), + ), + sizePerMin = "10", + recordingSettingsText = "recordingSettingsText", + rateAppLink = "rateAppLink", + feedbackEmail = "feedbackEmail", + totalRecordCount = 10, + totalRecordDuration = 1000500, + availableSpace = 1010101010, + appName = "App Name", + appVersion = "1.0.0", + ), {}) +} \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt new file mode 100644 index 000000000..1097dd1da --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.os.Parcelable +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SettingsState( + val isDynamicColors: Boolean, + val isDarkTheme: Boolean, + val isAppV2: Boolean, + val isKeepScreenOn: Boolean, + val isShowRenameDialog: Boolean, + val nameFormats: List, + val selectedNameFormat: NameFormatItem, + val recordingSettings: List, + val sizePerMin: String, + val recordingSettingsText: String, + val rateAppLink: String, + val feedbackEmail: String, + val totalRecordCount: Int, + val totalRecordDuration: Long, + val availableSpace: Long, + val appName: String, + val appVersion: String, +) : Parcelable + +@Parcelize +data class RecordingSetting( + val recordingFormat: ChipItem, + val sampleRates: List>, + val bitRates: List>, + val channelCounts: List>, +) : Parcelable + +@Parcelize +data class ChipItem( + val id: Int, + val value: T, + val name: String, + val isSelected: Boolean +) : Parcelable + +@Parcelize +data class NameFormatItem( + val nameFormat: NameFormat, + val nameText: String, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt new file mode 100644 index 000000000..6fa3d6398 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt @@ -0,0 +1,418 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.content.Context +import android.os.Parcelable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.util.AndroidUtils +import com.dimowner.audiorecorder.v2.DefaultValues +import com.dimowner.audiorecorder.v2.app.formatBitRate +import com.dimowner.audiorecorder.v2.app.formatChannelCount +import com.dimowner.audiorecorder.v2.app.formatRecordingFormat +import com.dimowner.audiorecorder.v2.app.formatSampleRate +import com.dimowner.audiorecorder.v2.app.recordingSettingsCombinedText +import com.dimowner.audiorecorder.v2.app.removeOutdatedTrashRecords +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +internal class SettingsViewModel @Inject constructor( + private val prefs: PrefsV2, + private val recordsDataSource: RecordsDataSource, + private val audioPlayer: PlayerContractNew.Player, + @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context, +) : ViewModel() { + + private val decimalFormat: DecimalFormat + + private val selectedFormat = prefs.settingRecordingFormat + private val selectedSampleRate = prefs.settingSampleRate + private val selectedBitRate = prefs.settingBitrate + private val selectedChannelCount = prefs.settingChannelCount + + private val formatsStrings = context.resources.getStringArray(R.array.formats2) + private val sampleRateStrings = context.resources.getStringArray(R.array.sample_rates2) + private val bitRateStrings = context.resources.getStringArray(R.array.bit_rates2) + private val channelCountsStrings = context.resources.getStringArray(R.array.channels) + + init { + val formatSymbols = DecimalFormatSymbols(Locale.getDefault()) + formatSymbols.decimalSeparator = '.' + decimalFormat = DecimalFormat("#.#", formatSymbols) + } + + private val _state: MutableState = mutableStateOf( + //TODO: Move default state creation into a function + SettingsState( + isDynamicColors = prefs.isDynamicTheme, + isDarkTheme = prefs.isDarkTheme, + isAppV2 = prefs.isAppV2, + isKeepScreenOn = prefs.isKeepScreenOn, + isShowRenameDialog = prefs.askToRenameAfterRecordingStopped, + selectedNameFormat = prefs.settingNamingFormat.toNameFormatItem(), + nameFormats = makeNameFormats(), + recordingSettings = RecordingFormat.entries.toList().mapIndexed { index, format -> + RecordingSetting( + recordingFormat = ChipItem( + id = index, + value = format, + name = formatsStrings[index], + isSelected = format == selectedFormat + ), + sampleRates = getSampleRates( + selectedFormat, + selectedSampleRate, + sampleRateStrings + ), + bitRates = getBitRates( + selectedFormat, + selectedBitRate, + bitRateStrings + ), + channelCounts = getChannelCounts( + selectedFormat, + selectedChannelCount, + channelCountsStrings + ), + ) + }, + sizePerMin = decimalFormat.format( + sizeMbPerMin( + selectedFormat, + selectedSampleRate, + selectedBitRate, + selectedChannelCount + ) + ), + recordingSettingsText = recordingSettingsCombinedText( + selectedFormat, + formatRecordingFormat(formatsStrings, selectedFormat), + formatSampleRate(sampleRateStrings, selectedSampleRate), + formatBitRate(bitRateStrings, selectedBitRate), + formatChannelCount(channelCountsStrings, selectedChannelCount), + ), + rateAppLink = "link",//TODO: Fix hardcoded value + feedbackEmail = "email",//TODO: Fix hardcoded value + totalRecordCount = 0, + totalRecordDuration = 0, + availableSpace = 0, + appName = context.getString(R.string.app_name), + appVersion = context.getString(R.string.version, AndroidUtils.getAppVersion(context)), + ) + ) + + val state: State = _state + + fun initSettings() { + viewModelScope.launch(ioDispatcher) { + val recordsCount = recordsDataSource.getRecordsCount() + val recordsDuration = recordsDataSource.getRecordTotalDuration() + withContext(mainDispatcher) { + _state.value = _state.value.copy( + totalRecordCount = recordsCount, totalRecordDuration = recordsDuration + ) + } + recordsDataSource.removeOutdatedTrashRecords() + } + } + + fun executeFirstRun() { + if (prefs.isFirstRun) { + prefs.confirmFirstRunExecuted() + } + } + + fun handleUseAppV2(value: Boolean) { + if (prefs.isAppV2 != value) { + prefs.isAppV2 = value + } + audioPlayer.stop() + } + + fun setDarkTheme(value: Boolean) { + if (prefs.isDarkTheme != value) { + prefs.isDarkTheme = value + } + } + + fun setDynamicTheme(value: Boolean) { + if (prefs.isDynamicTheme != value) { + prefs.isDynamicTheme = value + } + } + + fun setKeepScreenOn(value: Boolean) { + prefs.isKeepScreenOn = value + _state.value = _state.value.copy(isKeepScreenOn = value) + } + + fun setShowRenamingDialog(value: Boolean) { + prefs.askToRenameAfterRecordingStopped = value + _state.value = _state.value.copy(isShowRenameDialog = value) + } + + fun setNameFormat(value: NameFormatItem) { + prefs.settingNamingFormat = value.nameFormat + _state.value = _state.value.copy(selectedNameFormat = value) + } + + fun resetRecordingSettings() { + prefs.settingRecordingFormat = DefaultValues.DefaultRecordingFormat + prefs.settingSampleRate = DefaultValues.DefaultSampleRate + prefs.settingBitrate = DefaultValues.DefaultBitRate + prefs.settingChannelCount = DefaultValues.DefaultChannelCount + _state.value = _state.value.copy( + recordingSettings = _state.value.recordingSettings.map { formatSetting -> + RecordingSetting( + recordingFormat = formatSetting.recordingFormat.updateSelected( + DefaultValues.DefaultRecordingFormat + ), + sampleRates = getSampleRates( + DefaultValues.DefaultRecordingFormat, + DefaultValues.DefaultSampleRate, + sampleRateStrings + ), + bitRates = getBitRates( + DefaultValues.DefaultRecordingFormat, + DefaultValues.DefaultBitRate, + bitRateStrings + ), + channelCounts = getChannelCounts( + DefaultValues.DefaultRecordingFormat, + DefaultValues.DefaultChannelCount, + channelCountsStrings + ), + ) + }, + ).recordingSettingsUpdated() + } + + fun selectRecordingFormat(value: RecordingFormat) { + prefs.settingRecordingFormat = value + _state.value = _state.value.copy( + recordingSettings = _state.value.recordingSettings.map { item -> + item.copy( + recordingFormat = item.recordingFormat.updateSelected(value), + sampleRates = getSampleRates( + value, + prefs.settingSampleRate, + sampleRateStrings + ), + bitRates = getBitRates( + value, + prefs.settingBitrate, + bitRateStrings + ), + channelCounts = getChannelCounts( + value, + prefs.settingChannelCount, + channelCountsStrings + ), + ) + } + ).validate3GpSelectedAndAdjust(value) + .recordingSettingsUpdated() + } + + private fun SettingsState.validate3GpSelectedAndAdjust(format: RecordingFormat): SettingsState { + return if (format == RecordingFormat.ThreeGp) { + val formatSetting = this.recordingSettings.firstOrNull { + it.recordingFormat.value == format + } + val hasSelectedSampleRate = formatSetting?.sampleRates?.any { it.isSelected } ?: false + val hasSelectedChannelCount = formatSetting?.channelCounts?.any { it.isSelected } ?: false + if (!hasSelectedSampleRate || !hasSelectedChannelCount) { + this.copy( + recordingSettings = recordingSettings.map { recordingSetting -> + if (recordingSetting.recordingFormat.value == format) { + recordingSetting.copy( + sampleRates = if (hasSelectedSampleRate) { + recordingSetting.sampleRates + } else { + prefs.settingSampleRate = DefaultValues.Default3GpSampleRate + recordingSetting.sampleRates.map { + if (it.value == DefaultValues.Default3GpSampleRate) { + it.copy(isSelected = true) + } else { + it + } + } + }, + channelCounts = if (hasSelectedChannelCount) { + recordingSetting.channelCounts + } else { + prefs.settingChannelCount = DefaultValues.Default3GpChannelCount + recordingSetting.channelCounts.map { + if (it.value == DefaultValues.Default3GpChannelCount) { + it.copy(isSelected = true) + } else { + it + } + } + } + ) + } else { + recordingSetting + } + } + ) + } else { + this + } + } else { + return this + } + } + + fun selectSampleRate(value: SampleRate) { + prefs.settingSampleRate = value + _state.value = _state.value.copy( + recordingSettings = _state.value.recordingSettings.map { formatSetting -> + if (formatSetting.recordingFormat.isSelected) { + formatSetting.copy( + sampleRates = formatSetting.sampleRates.map { item -> + item.updateSelected(value) + } + ) + } else { + formatSetting + } + } + ).recordingSettingsUpdated() + } + + fun selectBitrate(value: BitRate) { + prefs.settingBitrate = value + _state.value = _state.value.copy( + recordingSettings = _state.value.recordingSettings.map { formatSetting -> + if (formatSetting.recordingFormat.isSelected) { + formatSetting.copy( + bitRates = formatSetting.bitRates.map { item -> + item.updateSelected(value) + } + ) + } else { + formatSetting + } + } + ).recordingSettingsUpdated() + } + + fun selectChannelCount(value: ChannelCount) { + prefs.settingChannelCount = value + _state.value = _state.value.copy( + recordingSettings = _state.value.recordingSettings.map { formatSetting -> + if (formatSetting.recordingFormat.isSelected) { + formatSetting.copy( + channelCounts = formatSetting.channelCounts.map { item -> + item.updateSelected(value) + } + ) + } else { + formatSetting + } + } + ).recordingSettingsUpdated() + } + + fun onAction(action: SettingsScreenAction) { + when (action) { + SettingsScreenAction.InitSettingsScreen -> initSettings() + is SettingsScreenAction.SetDynamicTheme -> setDynamicTheme(action.value) + is SettingsScreenAction.SetDarkTheme -> setDarkTheme(action.value) + is SettingsScreenAction.SetKeepScreenOn -> setKeepScreenOn(action.value) + is SettingsScreenAction.SetShowRenamingDialog -> setShowRenamingDialog(action.value) + is SettingsScreenAction.SetNameFormat -> setNameFormat(action.value) + SettingsScreenAction.ResetRecordingSettings -> resetRecordingSettings() + is SettingsScreenAction.SelectRecordingFormat -> selectRecordingFormat(action.value) + is SettingsScreenAction.SelectSampleRate -> selectSampleRate(action.value) + is SettingsScreenAction.SelectBitrate -> selectBitrate(action.value) + is SettingsScreenAction.SelectChannelCount -> selectChannelCount(action.value) + SettingsScreenAction.ExecuteFirstRun -> executeFirstRun() + is SettingsScreenAction.SetAppV2 -> handleUseAppV2(action.value) + } + } + + private fun SettingsState.recordingSettingsUpdated(): SettingsState { + val settings = this.recordingSettings.firstOrNull { it.recordingFormat.isSelected } + return this.copy( + sizePerMin = decimalFormat.format( + sizeMbPerMin( + settings?.recordingFormat?.value, + settings?.sampleRates?.firstOrNull { it.isSelected }?.value, + settings?.bitRates?.firstOrNull { it.isSelected }?.value, + settings?.channelCounts?.firstOrNull { it.isSelected }?.value, + ) + ), + recordingSettingsText = recordingSettingsCombinedText( + settings?.recordingFormat?.value, + formatRecordingFormat(formatsStrings, settings?.recordingFormat?.value), + formatSampleRate(sampleRateStrings, settings?.sampleRates?.firstOrNull { it.isSelected }?.value), + formatBitRate(bitRateStrings, settings?.bitRates?.firstOrNull { it.isSelected }?.value), + formatChannelCount(channelCountsStrings, settings?.channelCounts?.firstOrNull { it.isSelected }?.value), + ) + ) + } + + private fun ChipItem.updateSelected(value: T): ChipItem { + return if (this.value == value) { + this.copy(isSelected = true) + } else { + this.copy(isSelected = false) + } + } +} + +internal sealed class SettingsScreenAction { + data object InitSettingsScreen : SettingsScreenAction() + data class SetAppV2(val value: Boolean) : SettingsScreenAction() + data class SetDynamicTheme(val value: Boolean) : SettingsScreenAction() + data class SetDarkTheme(val value: Boolean) : SettingsScreenAction() + data class SetKeepScreenOn(val value: Boolean) : SettingsScreenAction() + data class SetShowRenamingDialog(val value: Boolean) : SettingsScreenAction() + data class SetNameFormat(val value: NameFormatItem) : SettingsScreenAction() + data object ResetRecordingSettings : SettingsScreenAction() + data class SelectRecordingFormat(val value: RecordingFormat) : SettingsScreenAction() + data class SelectSampleRate(val value: SampleRate) : SettingsScreenAction() + data class SelectBitrate(val value: BitRate) : SettingsScreenAction() + data class SelectChannelCount(val value: ChannelCount) : SettingsScreenAction() + data object ExecuteFirstRun : SettingsScreenAction() +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt new file mode 100644 index 000000000..d1dc559b5 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt @@ -0,0 +1,301 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.settings + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.ComposableLifecycle +import com.dimowner.audiorecorder.v2.app.TitleBar +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import timber.log.Timber + +@Composable +internal fun WelcomeSetupSettingsScreen( + onPopBackStack: () -> Unit, + onApplySettings: () -> Unit, + uiState: SettingsState, + onAction: (SettingsScreenAction) -> Unit, +) { + val context = LocalContext.current + + val openInfoDialog = remember { mutableStateOf(false) } + val infoText = remember { mutableStateOf("") } + + val isExpandedBitRatePanel = remember { mutableStateOf(true) } + + ComposableLifecycle { _, event -> + when (event) { + Lifecycle.Event.ON_CREATE -> { + Timber.d("SettingsScreen: onCreate") + onAction(SettingsScreenAction.InitSettingsScreen) + } + else -> {} + } + } + + Surface( + modifier = Modifier.fillMaxSize() + ) { + Column(modifier = Modifier.fillMaxSize()) { + TitleBar( + stringResource(R.string.setup), + onBackPressed = { onPopBackStack() }) + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(weight = 1f, fill = true) + ) { + Spacer(modifier = Modifier.size(8.dp)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + SettingsItemCheckBox( + uiState.isDynamicColors, + stringResource(R.string.dynamic_theme_colors), + R.drawable.ic_palette_outline, + { + onAction(SettingsScreenAction.SetDynamicTheme(it)) + }) + } + SettingsItemCheckBox( + uiState.isDarkTheme, + stringResource(R.string.dark_theme), + R.drawable.ic_dark_mode, + { + onAction(SettingsScreenAction.SetDarkTheme(it)) + }) + DropDownSetting( + items = uiState.nameFormats, + selectedItem = uiState.selectedNameFormat, + onSelect = { + onAction(SettingsScreenAction.SetNameFormat(it)) + } + ) + SettingSelector( + name = stringResource(id = R.string.recording_format), + chips = uiState.recordingSettings.map { it.recordingFormat }, + onSelect = { + onAction(SettingsScreenAction.SelectRecordingFormat(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_format) + openInfoDialog.value = true + } + ) + val selectedFormat = uiState.recordingSettings.firstOrNull { it.recordingFormat.isSelected } + SettingSelector( + name = stringResource(id = R.string.sample_rate), + chips = selectedFormat?.sampleRates ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectSampleRate(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_frequency) + openInfoDialog.value = true + } + ) + if (isExpandedBitRatePanel.value != !selectedFormat?.bitRates.isNullOrEmpty()) { + isExpandedBitRatePanel.value = !selectedFormat?.bitRates.isNullOrEmpty() + } + AnimatedVisibility(visible = isExpandedBitRatePanel.value) { + SettingSelector( + name = stringResource(id = R.string.bitrate), + chips = selectedFormat?.bitRates ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectBitrate(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_bitrate) + openInfoDialog.value = true + } + ) + } + SettingSelector( + name = stringResource(id = R.string.channels), + chips = selectedFormat?.channelCounts ?: emptyList(), + onSelect = { + onAction(SettingsScreenAction.SelectChannelCount(it.value)) + }, + onClickInfo = { + infoText.value = context.getString(R.string.info_channels) + openInfoDialog.value = true + } + ) + Spacer(modifier = Modifier.size(8.dp)) + } + Column { + Row { + Icon( + modifier = Modifier + .padding(4.dp) + .wrapContentSize() + .align(Alignment.CenterVertically), + painter = painterResource(id = R.drawable.ic_info), + contentDescription = stringResource(id = R.string.info) + ) + Column { + Text( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(4.dp, 8.dp, 4.dp, 0.dp), + textAlign = TextAlign.Start, + text = stringResource( + id = R.string.size_per_min, + uiState.sizePerMin + ), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + ) + val selectedFormat = uiState.recordingSettings.firstOrNull { + it.recordingFormat.isSelected + } + Text( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(4.dp, 4.dp, 4.dp, 0.dp), + textAlign = TextAlign.Start, + text = selectedFormat?.recordingFormat?.value?.toFormatInfo() ?: "", + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + ) + } + } + Row { + Button( + modifier = Modifier + .padding(8.dp) + .wrapContentHeight() + .weight(1F), + onClick = { + onAction(SettingsScreenAction.ResetRecordingSettings) + } + ) { + Text( + text = stringResource(id = R.string.btn_reset), + fontSize = 18.sp, + ) + } + Button( + modifier = Modifier + .padding(8.dp) + .wrapContentHeight() + .weight(1F), + onClick = { + onAction(SettingsScreenAction.ExecuteFirstRun) + onApplySettings() + } + ) { + Text( + text = stringResource(id = R.string.btn_apply), + fontSize = 18.sp, + ) + } + } + } + if (openInfoDialog.value) { + SettingsInfoDialog(openInfoDialog, infoText.value) + } + } + } +} + +@Composable +fun RecordingFormat.toFormatInfo(): String { + return when (this) { + RecordingFormat.M4a -> stringResource(id = R.string.info_m4a) + RecordingFormat.Wav -> stringResource(id = R.string.info_wav) + RecordingFormat.ThreeGp -> stringResource(id = R.string.info_3gp) + } +} + +@Preview +@Composable +fun WelcomeSetupSettingsScreenPreview() { + WelcomeSetupSettingsScreen({}, {}, uiState = SettingsState( + isDynamicColors = true, + isAppV2 = false, + isDarkTheme = false, + isKeepScreenOn = false, + isShowRenameDialog = true, + nameFormats = listOf(NameFormatItem(NameFormat.Record, "Name text")), + selectedNameFormat = NameFormatItem(NameFormat.Record, "Name text"), + recordingSettings = listOf(RecordingSetting( + recordingFormat = ChipItem(id = 0, value = RecordingFormat.M4a, name = "M4a", isSelected = true), + sampleRates = listOf( + ChipItem(id = 0, value = SampleRate.SR16000, name = "16 kHz", isSelected = false), + ChipItem(id = 0, value = SampleRate.SR22500, name = "22.5 kHz", isSelected = false), + ChipItem(id = 0, value = SampleRate.SR32000, name = "32 kHz", isSelected = false), + ChipItem(id = 1, value = SampleRate.SR44100, name = "44.1 kHz", isSelected = true), + ChipItem(id = 1, value = SampleRate.SR48000, name = "48 kHz", isSelected = false), + ), + bitRates = listOf( + ChipItem(id = 0, value = BitRate.BR48, name = "48 kbps", isSelected = false), + ChipItem(id = 0, value = BitRate.BR96, name = "96 kbps", isSelected = false), + ChipItem(id = 1, value = BitRate.BR128, name = "128 kbps", isSelected = true), + ChipItem(id = 1, value = BitRate.BR192, name = "192 kbps", isSelected = false), + ), + channelCounts = listOf( + ChipItem(id = 0, value = ChannelCount.Mono, name = "Mono", isSelected = false), + ChipItem(id = 1, value = ChannelCount.Stereo, name = "Stereo", isSelected = true), + ) + ), + ), + sizePerMin = "10", + recordingSettingsText = "recordingSettingsText", + rateAppLink = "rateAppLink", + feedbackEmail = "feedbackEmail", + totalRecordCount = 10, + totalRecordDuration = 1000500, + availableSpace = 1010101010, + appName = "App Name", + appVersion = "1.0.0", + ), {}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/welcome/WelcomeScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/welcome/WelcomeScreen.kt new file mode 100644 index 000000000..de7e33fbe --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/welcome/WelcomeScreen.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.app.welcome + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dimowner.audiorecorder.R + +@Composable +fun WelcomeScreen( + onGetStarted: () -> Unit +) { + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + ) { innerPadding -> + Surface( + modifier = Modifier.fillMaxSize().padding(innerPadding) + ) { + Column(modifier = Modifier.wrapContentSize()) { + Icon( + modifier = Modifier + .padding(16.dp) + .wrapContentSize() + .align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.waveform), + contentDescription = stringResource(id = R.string.app_name) + ) + Text( + modifier = Modifier + .padding(8.dp) + .wrapContentSize() + .align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.app_name), + fontSize = 36.sp, + fontWeight = FontWeight.Light, + textAlign = TextAlign.Center + ) + Text( + modifier = Modifier + .wrapContentSize() + .align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.welcome_1), + fontSize = 24.sp, + fontWeight = FontWeight.Light, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(42.dp)) + Button( + modifier = Modifier + .padding(8.dp) + .wrapContentSize() + .align(Alignment.CenterHorizontally), + onClick = { onGetStarted() } + ) { + Text( + text = stringResource(id = R.string.btn_get_started), + fontSize = 18.sp, + ) + } + } + } + } +} + +@Preview +@Composable +fun WelcomeScreenPreview() { + WelcomeScreen({}) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderDelegate.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderDelegate.kt new file mode 100644 index 000000000..f1f9f471d --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderDelegate.kt @@ -0,0 +1,21 @@ +package com.dimowner.audiorecorder.v2.audio + +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AudioRecorderDelegate @Inject constructor( + private val prefs: PrefsV2, + private val audioRecorder: AudioRecorderV2, +) { + + fun provideAudioRecorder(): RecorderV2 { + return when (prefs.settingRecordingFormat) { + RecordingFormat.M4a -> audioRecorder + RecordingFormat.Wav -> TODO("Not implemented") + RecordingFormat.ThreeGp -> TODO("Not implemented") + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderV2.kt new file mode 100644 index 000000000..96cb199fb --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecorderV2.kt @@ -0,0 +1,221 @@ +package com.dimowner.audiorecorder.v2.audio + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.os.Handler +import android.os.Looper +import com.dimowner.audiorecorder.AppConstants.RECORDING_VISUALIZATION_INTERVAL +import com.dimowner.audiorecorder.exception.AlreadyRecordingException +import com.dimowner.audiorecorder.exception.InvalidOutputFile +import com.dimowner.audiorecorder.exception.RecorderInitException +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AudioRecorderV2 @Inject constructor( + @param:ApplicationContext private val applicationContext: Context, + private val coroutineScope: CoroutineScope, +) : RecorderV2 { + + private var mediaRecorder: MediaRecorder? = null + private var recordFile: File? = null + private var updateTime: Long = 0 + private var durationMills: Long = 0 + + private var _isRecording: Boolean = false + private var _isPaused: Boolean = false + + override val isRecording: Boolean + get() = _isRecording + override val isPaused: Boolean + get() = _isPaused + + // Using Handler tied to the main Looper for UI thread synchronization and timing updates + private val handler = Handler(Looper.getMainLooper()) + + private val _event = MutableSharedFlow() + override fun subscribeRecorderEvents(): Flow { + return _event + } + + override fun startRecording(outputFile: File, channelCount: Int, sampleRate: Int, bitrate: Int): Boolean { + if (_isRecording) { + Timber.e("Recording is already in progress.") + emitEvent(RecorderEvent.OnError(AlreadyRecordingException())) + return false + } + return if (outputFile.exists() && outputFile.isFile) { + recordFile = outputFile + + val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(applicationContext) + } else { + MediaRecorder() + } + this.mediaRecorder = recorder + + recorder.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioChannels(channelCount) + setAudioSamplingRate(sampleRate) + setAudioEncodingBitRate(bitrate) + setMaxDuration(-1) // Unlimited duration + setOutputFile(outputFile.absolutePath) + } + + try { + recorder.prepare() + recorder.start() + updateTime = System.currentTimeMillis() + _isRecording = true + scheduleRecordingTimeUpdate() + emitEvent(RecorderEvent.OnStartRecording) + _isPaused = false + true + } catch (e: IOException) { + Timber.e(e, "prepare() failed") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + false + } catch (e: IllegalStateException) { + Timber.e(e, "start() failed due to illegal state") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + false + } + } else { + emitEvent(RecorderEvent.OnError(InvalidOutputFile())) + false + } + } + + override fun resumeRecording(): Boolean { + if (!_isRecording || !_isPaused) return false + + return try { + mediaRecorder?.let { recorder -> + recorder.resume() + updateTime = System.currentTimeMillis() + scheduleRecordingTimeUpdate() + emitEvent(RecorderEvent.OnResumeRecording) + _isPaused = false + true + } ?: false + } catch (e: IllegalStateException) { + Timber.e(e, "resumeRecording() failed") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + false + } + } + + override fun pauseRecording(): Boolean { + if (!_isRecording) { + Timber.e("Recording has already stopped or hasn't started") + return false + } + return if (!_isPaused) { + try { + mediaRecorder?.let { recorder -> + recorder.pause() + durationMills += System.currentTimeMillis() - updateTime + pauseRecordingTimer() + emitEvent(RecorderEvent.OnPauseRecording) + _isPaused = true + true + } ?: false + } catch (e: IllegalStateException) { + Timber.e(e, "pauseRecording() failed") + emitEvent(RecorderEvent.OnError(RecorderInitException())) + false + } + } else { + Timber.e("Recording has already paused") + false + } + } + + override fun stopRecording(): Boolean { + if (!_isRecording) { + Timber.e("Recording has already stopped or hasn't started") + return false + } + + stopRecordingTimer() + val isStopSucceed = try { + mediaRecorder?.let { + it.stop() + true + }?: false + } catch (e: IllegalStateException) { + // This can happen if start() failed and stop() is called, or if the recorder + // was never fully prepared/started. + Timber.e(e, "stopRecording() problems") + false + } finally { + // Always release resources + mediaRecorder?.release() + } + + emitEvent(RecorderEvent.OnStopRecording) + + // Reset all state + durationMills = 0 + recordFile = null + _isRecording = false + _isPaused = false + mediaRecorder = null + return isStopSucceed + } + + private fun emitEvent(event: RecorderEvent) { + coroutineScope.launch { + _event.emit(event) + } + } + + /** + * Runnable logic to update recording progress and amplitude. + */ + private val recordingTimeUpdateRunnable = Runnable { + if (isRecording && !isPaused) { + val currentRecorder = mediaRecorder + if (currentRecorder != null) { + try { + val curTime = System.currentTimeMillis() + durationMills += curTime - updateTime + updateTime = curTime + val amplitude = currentRecorder.maxAmplitude + emitEvent(RecorderEvent.OnRecordingProgress(durationMills = durationMills, amplitude = amplitude)) + } catch (e: IllegalStateException) { + Timber.e(e, "Error reading amplitude or updating progress") + } + scheduleRecordingTimeUpdate() + } + } + } + + private fun scheduleRecordingTimeUpdate() { + // Remove any pending messages before scheduling a new one + handler.removeCallbacks(recordingTimeUpdateRunnable) + handler.postDelayed(recordingTimeUpdateRunnable, RECORDING_VISUALIZATION_INTERVAL.toLong()) + } + + private fun stopRecordingTimer() { + handler.removeCallbacks(recordingTimeUpdateRunnable) + updateTime = 0 + } + + private fun pauseRecordingTimer() { + handler.removeCallbacks(recordingTimeUpdateRunnable) + updateTime = 0 + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecorderV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecorderV2.kt new file mode 100644 index 000000000..5921b2b11 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecorderV2.kt @@ -0,0 +1,25 @@ +package com.dimowner.audiorecorder.v2.audio + +import com.dimowner.audiorecorder.exception.AppException; +import kotlinx.coroutines.flow.Flow + +import java.io.File; + +interface RecorderV2 { + fun subscribeRecorderEvents(): Flow + fun startRecording(outputFile: File, channelCount: Int, sampleRate: Int, bitrate: Int): Boolean + fun resumeRecording(): Boolean + fun pauseRecording(): Boolean + fun stopRecording(): Boolean + val isRecording: Boolean + val isPaused: Boolean +} + +sealed class RecorderEvent { + object OnStartRecording: RecorderEvent() + object OnPauseRecording: RecorderEvent() + object OnResumeRecording: RecorderEvent() + data class OnRecordingProgress(val durationMills: Long, val amplitude: Int): RecorderEvent() + object OnStopRecording: RecorderEvent() + data class OnError(val exception: AppException): RecorderEvent() +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt new file mode 100644 index 000000000..6f306db36 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSource.kt @@ -0,0 +1,42 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data + +import android.content.Context +import com.dimowner.audiorecorder.exception.CantCreateFileException +import java.io.File + +interface FileDataSource { + + fun getRecordingDir(): File? + + @Throws(CantCreateFileException::class) + fun createRecordFile(fileName: String): File + + fun deleteRecordFile(path: String): Boolean + + fun markAsRecordDeleted(path: String): String? + + fun unmarkRecordAsDeleted(path: String): String? + + fun renameFile(path: String, newName: String): File? + + @Throws(IllegalArgumentException::class) + fun getAvailableSpace(): Long + + fun requestSystemMoreMemory(context: Context, file: File, requiredSpace: Long) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt new file mode 100644 index 000000000..19b233770 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/FileDataSourceImpl.kt @@ -0,0 +1,82 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data + +import android.annotation.SuppressLint +import android.content.Context +import com.dimowner.audiorecorder.AppConstants +import com.dimowner.audiorecorder.exception.CantCreateFileException +import com.dimowner.audiorecorder.v2.data.extensions.createFile +import com.dimowner.audiorecorder.v2.data.extensions.deleteFileAndChildren +import com.dimowner.audiorecorder.v2.data.extensions.getPrivateMusicStorageDir +import com.dimowner.audiorecorder.v2.data.extensions.markFileAsDeleted +import com.dimowner.audiorecorder.v2.data.extensions.renameFileWithExtension +import com.dimowner.audiorecorder.v2.data.extensions.requestAllocateSpace +import com.dimowner.audiorecorder.v2.data.extensions.unmarkFileAsDeleted +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FileDataSourceImpl @Inject internal constructor( + @ApplicationContext context: Context +): FileDataSource { + + private val recordDirectory: File? by lazy { + getPrivateMusicStorageDir(context, AppConstants.RECORDS_DIR) + } + + override fun getRecordingDir(): File? { + return recordDirectory + } + + override fun createRecordFile(fileName: String): File { + val recordFile = recordDirectory?.let { + createFile(it, fileName) + } + if (recordFile != null) { + return recordFile + } + throw CantCreateFileException() + } + + override fun deleteRecordFile(path: String): Boolean { + return deleteFileAndChildren(File(path)) + } + + override fun markAsRecordDeleted(path: String): String? { + return markFileAsDeleted(File(path))?.absolutePath + } + + override fun unmarkRecordAsDeleted(path: String): String? { + return unmarkFileAsDeleted(File(path))?.absolutePath + } + + override fun renameFile(path: String, newName: String): File? { + return renameFileWithExtension(File(path), newName) + } + + @SuppressLint("UsableSpace") + override fun getAvailableSpace(): Long { + return recordDirectory?.usableSpace ?: 0 + } + + override fun requestSystemMoreMemory(context: Context, file: File, requiredSpace: Long) { + requestAllocateSpace(context, file, requiredSpace) + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/Mappers.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/Mappers.kt new file mode 100644 index 000000000..52ed7b794 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/Mappers.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data + +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.room.RecordEntity + +fun RecordEntity.toRecord(): Record { + return Record( + id = id, + name = name, + durationMills = duration, + created = created, + added = added, + removed = removed, + path = path, + format = format, + size = size, + sampleRate = sampleRate, + channelCount = channelCount, + bitrate = bitrate, + isBookmarked = isBookmarked, + isWaveformProcessed = isWaveformProcessed, + isMovedToRecycle = isMovedToRecycle, + amps = amps, + ) +} + +fun Record.toRecordEntity(): RecordEntity { + return RecordEntity( + id = this.id, + name = this.name, + duration = this.durationMills, + created = this.created, + added = this.added, + removed = this.removed, + path = this.path, + format = this.format, + size = this.size, + sampleRate = this.sampleRate, + channelCount = this.channelCount, + bitrate = this.bitrate, + isBookmarked = this.isBookmarked, + isWaveformProcessed = this.isWaveformProcessed, + isMovedToRecycle = this.isMovedToRecycle, + amps = this.amps, + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt new file mode 100644 index 000000000..9ca25efdc --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data + +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.data.model.SortOrder + +interface PrefsV2 { + val isFirstRun: Boolean + fun confirmFirstRunExecuted() + + var askToRenameAfterRecordingStopped: Boolean + + var activeRecordId: Long + var recordedRecordId: Long + + val recordCounter: Long + fun incrementRecordCounter() + + var isKeepScreenOn: Boolean + + var recordsSortOrder: SortOrder + + var isDynamicTheme: Boolean + var isDarkTheme: Boolean + var isAppV2: Boolean + + var settingNamingFormat: NameFormat + var settingRecordingFormat: RecordingFormat + var settingSampleRate: SampleRate + var settingBitrate: BitRate + var settingChannelCount: ChannelCount + + fun resetRecordingSettings() + + fun fullPreferenceReset() +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt new file mode 100644 index 000000000..af6cfd626 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt @@ -0,0 +1,235 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data + +import android.content.Context +import android.content.SharedPreferences +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_IS_APP_V2 +import com.dimowner.audiorecorder.AppConstants.PREF_NAME +import com.dimowner.audiorecorder.v2.DefaultValues +import com.dimowner.audiorecorder.v2.data.model.BitRate +import com.dimowner.audiorecorder.v2.data.model.ChannelCount +import com.dimowner.audiorecorder.v2.data.model.NameFormat +import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.SampleRate +import com.dimowner.audiorecorder.v2.data.model.SortOrder +import com.dimowner.audiorecorder.v2.data.model.convertToBitRate +import com.dimowner.audiorecorder.v2.data.model.convertToChannelCount +import com.dimowner.audiorecorder.v2.data.model.convertToNameFormat +import com.dimowner.audiorecorder.v2.data.model.convertToRecordingFormat +import com.dimowner.audiorecorder.v2.data.model.convertToSampleRate +import com.dimowner.audiorecorder.v2.data.model.convertToSortOrder +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import androidx.core.content.edit +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_IS_FIRST_RUN +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_KEEP_SCREEN_ON +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_RECORD_COUNTER +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_BITRATE +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_CHANNEL_COUNT +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_NAMING_FORMAT +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_RECORDING_FORMAT +import com.dimowner.audiorecorder.AppConstants.PREF_KEY_SETTING_SAMPLE_RATE + +/** + * App V2 preferences implementation + */ +@Singleton +class PrefsV2Impl @Inject internal constructor(@ApplicationContext context: Context) : PrefsV2 { + + private val sharedPreferences: SharedPreferences = + context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + + override val isFirstRun: Boolean + get() = sharedPreferences.getBoolean(PREF_KEY_IS_FIRST_RUN, true) + + override fun confirmFirstRunExecuted() { + sharedPreferences.edit { + putBoolean(PREF_KEY_IS_FIRST_RUN, false) //Set to False, because next app start won't be first + } + } + + override var askToRenameAfterRecordingStopped: Boolean + get() = sharedPreferences.getBoolean( + PREF_KEY_ASK_TO_RENAME_AFTER_RECORDING_STOPPED, DefaultValues.isAskToRename + ) + set(value) { + sharedPreferences.edit { + putBoolean(PREF_KEY_ASK_TO_RENAME_AFTER_RECORDING_STOPPED, value) + } + } + override var activeRecordId: Long + get() = sharedPreferences.getLong(PREF_KEY_ACTIVE_RECORD_ID, -1) + set(value) { + sharedPreferences.edit { + putLong(PREF_KEY_ACTIVE_RECORD_ID, value) + } + } + + override var recordedRecordId: Long + get() = sharedPreferences.getLong(PREF_KEY_RECORDED_RECORD_ID, -1) + set(value) { + sharedPreferences.edit { + putLong(PREF_KEY_RECORDED_RECORD_ID, value) + } + } + + override val recordCounter: Long + get() = sharedPreferences.getLong(PREF_KEY_RECORD_COUNTER, 1) + + override fun incrementRecordCounter() { + sharedPreferences.edit { + putLong(PREF_KEY_RECORD_COUNTER, recordCounter + 1) + } + } + + override var isKeepScreenOn: Boolean + get() = sharedPreferences.getBoolean(PREF_KEY_KEEP_SCREEN_ON, DefaultValues.isKeepScreenOn) + set(value) { + sharedPreferences.edit { + putBoolean(PREF_KEY_KEEP_SCREEN_ON, value) + } + } + + override var recordsSortOrder: SortOrder + get() = sharedPreferences.getString( + PREF_KEY_RECORDS_SORT_ORDER, + SortOrder.DateAsc.toString() + )?.convertToSortOrder() ?: SortOrder.DateAsc + set(value) { + sharedPreferences.edit { + putString(PREF_KEY_RECORDS_SORT_ORDER, value.toString()) + } + } + + override var isDynamicTheme: Boolean + get() = sharedPreferences.getBoolean(PREF_KEY_IS_DYNAMIC_THEME, DefaultValues.isDynamicTheme) + set(value) { + sharedPreferences.edit { + putBoolean(PREF_KEY_IS_DYNAMIC_THEME, value) + } + } + + override var isDarkTheme: Boolean + get() = sharedPreferences.getBoolean(PREF_KEY_IS_DARK_THEME, DefaultValues.isDarkTheme) + set(value) { + sharedPreferences.edit { + putBoolean(PREF_KEY_IS_DARK_THEME, value) + } + } + + override var isAppV2: Boolean + get() = sharedPreferences.getBoolean(PREF_KEY_IS_APP_V2, DefaultValues.isAppV2) + set(value) { + sharedPreferences.edit { + putBoolean(PREF_KEY_IS_APP_V2, value) + } + } + + override var settingNamingFormat: NameFormat + get() = sharedPreferences.getString( + PREF_KEY_SETTING_NAMING_FORMAT, + DefaultValues.DefaultNameFormat.name + )?.convertToNameFormat() ?: DefaultValues.DefaultNameFormat + set(value) { + sharedPreferences.edit { + putString(PREF_KEY_SETTING_NAMING_FORMAT, value.name) + } + } + + override var settingRecordingFormat: RecordingFormat + get() = sharedPreferences.getString( + PREF_KEY_SETTING_RECORDING_FORMAT, + RecordingFormat.M4a.value + )?.convertToRecordingFormat() ?: RecordingFormat.M4a + set(value) { + sharedPreferences.edit { + putString(PREF_KEY_SETTING_RECORDING_FORMAT, value.value) + } + } + + override var settingSampleRate: SampleRate + get() = sharedPreferences.getInt( + PREF_KEY_SETTING_SAMPLE_RATE, + DefaultValues.DefaultSampleRate.value + ).convertToSampleRate() ?: DefaultValues.DefaultSampleRate + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_SETTING_SAMPLE_RATE, value.value) + } + } + + override var settingBitrate: BitRate + get() = sharedPreferences.getInt( + PREF_KEY_SETTING_BITRATE, + DefaultValues.DefaultBitRate.value + ).convertToBitRate() ?: DefaultValues.DefaultBitRate + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_SETTING_BITRATE, value.value) + } + } + + override var settingChannelCount: ChannelCount + get() = sharedPreferences.getInt( + PREF_KEY_SETTING_CHANNEL_COUNT, + DefaultValues.DefaultChannelCount.value + ).convertToChannelCount() ?: DefaultValues.DefaultChannelCount + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_SETTING_CHANNEL_COUNT, value.value) + } + } + + override fun resetRecordingSettings() { + sharedPreferences.edit { + putString( + PREF_KEY_SETTING_RECORDING_FORMAT, + DefaultValues.DefaultRecordingFormat.value + ) + putInt( + PREF_KEY_SETTING_SAMPLE_RATE, + DefaultValues.DefaultSampleRate.value + ) + putInt( + PREF_KEY_SETTING_BITRATE, + DefaultValues.DefaultBitRate.value + ) + putInt( + PREF_KEY_SETTING_CHANNEL_COUNT, + DefaultValues.DefaultChannelCount.value + ) + } + } + + override fun fullPreferenceReset() { + sharedPreferences.edit { + clear() + } + } + + companion object { + private const val PREF_KEY_ASK_TO_RENAME_AFTER_RECORDING_STOPPED = + "ask_to_rename_after_recording_stopped" + private const val PREF_KEY_ACTIVE_RECORD_ID = "active_record_id" + private const val PREF_KEY_RECORDED_RECORD_ID = "recorded_record_id" + private const val PREF_KEY_RECORDS_SORT_ORDER = "pref_records_sort_order" + private const val PREF_KEY_IS_DYNAMIC_THEME = "pref_is_dynamic_theme" + private const val PREF_KEY_IS_DARK_THEME = "pref_is_dark_theme" + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSource.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSource.kt new file mode 100644 index 000000000..fd5625025 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSource.kt @@ -0,0 +1,61 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data + +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.SortOrder + +interface RecordsDataSource { + + suspend fun getRecord(id: Long): Record? + suspend fun getRecords(ids: List): List + + suspend fun getActiveRecord(): Record? + + suspend fun getAllRecords(): List + suspend fun getMovedToRecycleRecords(): List + suspend fun getMovedToRecycleRecordsCount(): Int + + suspend fun getRecords( + page: Int, + pageSize: Int, + sortOrder: SortOrder = SortOrder.DateDesc, + isBookmarked: Boolean = false, + ): List + + suspend fun insertRecord(record: Record): Long + + suspend fun updateRecord(record: Record): Boolean + + suspend fun updateRecords(records: List): Int + + suspend fun renameRecord(record: Record, newName: String): Boolean + + suspend fun getRecordsCount(): Int + + suspend fun getRecordTotalDuration(): Long + + suspend fun deleteRecordAndFileForever(id: Long): Boolean + + suspend fun moveRecordToRecycle(id: Long): Boolean + + suspend fun moveRecordsToRecycle(ids: List): Int + + suspend fun restoreRecordFromRecycle(id: Long): Boolean + + suspend fun clearRecycle(): Boolean +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt new file mode 100644 index 000000000..d6041681d --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/RecordsDataSourceImpl.kt @@ -0,0 +1,424 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data + +import androidx.sqlite.db.SimpleSQLiteQuery +import com.dimowner.audiorecorder.v2.data.extensions.toRecordsSortColumnName +import com.dimowner.audiorecorder.v2.data.extensions.toSqlSortOrder +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.RecordEditOperation +import com.dimowner.audiorecorder.v2.data.model.SortOrder +import com.dimowner.audiorecorder.v2.data.room.RecordDao +import com.dimowner.audiorecorder.v2.data.room.RecordEditDao +import com.dimowner.audiorecorder.v2.data.room.RecordEditEntity +import com.dimowner.audiorecorder.v2.data.room.RecordEntity +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@SuppressWarnings("TooGenericExceptionCaught") +@Singleton +class RecordsDataSourceImpl @Inject internal constructor( + private val prefs: PrefsV2, + private val recordDao: RecordDao, + private val recordEditDao: RecordEditDao, + private val fileDataSource: FileDataSource, +) : RecordsDataSource { + + override suspend fun getRecord(id: Long): Record? { + return if (id >= 0) { + recordDao.getRecordById(id)?.toRecord() + } else { + null + } + } + + override suspend fun getRecords(ids: List): List { + val validIds = ids.filter { it >= 0 } + return if (validIds.isNotEmpty()) { + recordDao.getRecordsByIds(ids).map{ it.toRecord() } + } else { + emptyList() + } + } + + override suspend fun getActiveRecord(): Record? { + val id = prefs.activeRecordId + return if (id >= 0) { + recordDao.getRecordById(id)?.toRecord() + } else { + null + } + } + + override suspend fun getAllRecords(): List { + return recordDao.getAllRecords().map { it.toRecord() } + } + + override suspend fun getMovedToRecycleRecords(): List { + return recordDao.getMovedToRecycleRecords().map { it.toRecord() } + } + + override suspend fun getMovedToRecycleRecordsCount(): Int { + return recordDao.getMovedToRecycleRecordsCount() + } + + override suspend fun getRecords( + page: Int, + pageSize: Int, + sortOrder: SortOrder, + isBookmarked: Boolean + ): List { + val sb = StringBuilder() + sb.append("SELECT * FROM records") + sb.append(" WHERE isMovedToRecycle = 0") + if (isBookmarked) { + sb.append(" AND isBookmarked = 1") + } + sb.append(" ORDER BY ${sortOrder.toRecordsSortColumnName()} ${sortOrder.toSqlSortOrder()}") + sb.append(" LIMIT $pageSize") + sb.append(" OFFSET " + ((page - 1) * pageSize)) + return recordDao.getRecordsRewQuery(SimpleSQLiteQuery(sb.toString())).map { it.toRecord() } + } + + override suspend fun insertRecord(record: Record): Long { + return recordDao.insertRecord(record.toRecordEntity()) + } + + override suspend fun updateRecord(record: Record): Boolean { + return recordDao.updateRecord(record.toRecordEntity()) == 1 + } + + override suspend fun updateRecords(records: List): Int { + return recordDao.updateRecords(records.map { it.toRecordEntity() }) + } + + override suspend fun renameRecord(record: Record, newName: String): Boolean { + //TODO: this function requires improvements + try { + val transactionId = recordEditDao.insertRecordsEditOperation( + createRenameEditOperation(record.id, newName) + ) + val renamed = try { + fileDataSource.renameFile(record.path, newName) + } catch (e: Exception) { + Timber.e(e) + null + } + val result = if (renamed == null) { + //The first step has failed. Finish edit operation and return an error. + deleteEditRecordOperation(transactionId) + false + } else { + val isUpdated = try { + //Perform the step 2 + recordDao.updateRecord( + record.copy( + name = newName, + path = renamed.absolutePath + ).toRecordEntity() + ) + deleteEditRecordOperation(transactionId) + true + } catch (e: Exception) { + Timber.e(e) + //The second step has failed. Rollback the first step - rename file back. + val rolledBack = try { + fileDataSource.renameFile(renamed.absolutePath, record.name) + } catch (e: Exception) { + Timber.e(e) + null + } + if (rolledBack != null) { + //File name rolled back successfully. Finish edit operation and return an error. + deleteEditRecordOperation(transactionId) + } else { + //Failed to rollback file. Keep edit operation in the database to repeat it later. + } + false + } + isUpdated + } + return result + } catch (e: Exception) { + Timber.e(e) + return false + } + } + + override suspend fun getRecordsCount(): Int { + return recordDao.getRecordsCount() + } + + override suspend fun getRecordTotalDuration(): Long { + return recordDao.getRecordTotalDuration() + } + + override suspend fun deleteRecordAndFileForever(id: Long): Boolean { + return recordDao.getRecordById(id)?.let { recordToDelete -> + return@let deleteRecordAndFileForever(recordToDelete) + } ?: false + } + + private fun deleteRecordAndFileForever(record: RecordEntity): Boolean { + try { + val transactionId = recordEditDao.insertRecordsEditOperation( + createDeleteForeverEditOperation(record.id) + ) + + //The first step - delete record from database + val isRecordDeleted = try { + recordDao.deleteRecordById(record.id) + true + } catch (e: Exception) { + Timber.e(e) + false + } + val result = if (isRecordDeleted) { + //The second step - delete record file + val isFileDeleted = try { + fileDataSource.deleteRecordFile(record.path) + } catch (e: Exception) { + Timber.e(e) + false + } + if (isFileDeleted) { + //The second step has succeed. Finish edit operation and return an success. + deleteEditRecordOperation(transactionId) + true + } else { + //Failed to delete file. Keep edit operation in the database to repeat it later. + false + } + } else { + //The first step has failed. Finish edit operation and return an error. + deleteEditRecordOperation(transactionId) + false + } + return result + } catch (e: Exception) { + Timber.e(e) + return false + } + } + + override suspend fun moveRecordToRecycle(id: Long): Boolean { + return recordDao.getRecordById(id)?.let { recordToRecycle -> + return@let recordDao.updateRecord( + recordToRecycle.copy(isMovedToRecycle = true, removed = System.currentTimeMillis()) + ) == 1 + } ?: false + } + + override suspend fun moveRecordsToRecycle(ids: List): Int { + val recordsToRecycle = recordDao.getRecordsByIds(ids).map { + it.copy(isMovedToRecycle = true, removed = System.currentTimeMillis()) + } + return recordDao.updateRecords(recordsToRecycle) + } + + @Deprecated("Too complex logic. We don't need to mark record file as deleted") + internal fun moveRecordToRecycle(recordToRecycle: RecordEntity): Boolean { + try { + //Save edit operation. Start transaction + val transactionId = recordEditDao.insertRecordsEditOperation( + createMoveToRecycleEditOperation(recordToRecycle.id) + ) + //The first step. Mark record file as deleted. + val path = try { + fileDataSource.markAsRecordDeleted(recordToRecycle.path) + } catch (e: Exception) { + Timber.e(e) + null + } + val result = if (path != null) { + //The second step. Update record in the database + val isUpdated = try { + recordDao.updateRecord( + recordToRecycle.copy( + path = path, + isMovedToRecycle = true, + removed = System.currentTimeMillis(), + ) + ) + true + } catch (e: Exception) { + Timber.e(e) + false + } + if (isUpdated) { + //The second step has succeed. Finish edit operation and return an success. + deleteEditRecordOperation(transactionId) + true + } else { + //The second step has failed. Rollback the first step. + val unmarkPath = try { + fileDataSource.unmarkRecordAsDeleted(path) + } catch (e: Exception) { + Timber.e(e) + null + } + if (unmarkPath != null) { + //File rolled back successfully. Finish edit operation and return an error. + deleteEditRecordOperation(transactionId) + } else { + //Failed to rollback file. Keep edit operation in the database to repeat it later. + } + false + } + } else { + //The first step has failed. Finish edit operation and return an error. + //Rollback not needed. + deleteEditRecordOperation(transactionId) + false + } + return result + } catch (e: Exception) { + Timber.e(e) + return false + } + } + + override suspend fun restoreRecordFromRecycle(id: Long): Boolean { + return recordDao.getRecordById(id)?.let { recordToRestore -> + return@let restoreRecordFromRecycle(recordToRestore) + } ?: false + } + + private fun restoreRecordFromRecycle(recordToRestore: RecordEntity): Boolean { + try { + //Save edit operation. Start transaction + val transactionId = recordEditDao.insertRecordsEditOperation( + createRestoreFromRecycleEditOperation(recordToRestore.id) + ) + //The first step. Unmark record file as deleted. + val path = try { + fileDataSource.unmarkRecordAsDeleted(recordToRestore.path) + } catch (e: Exception) { + Timber.e(e) + null + } + val result = if (path != null) { + //The second step. Update record in the database + val isUpdated = try { + recordDao.updateRecord( + recordToRestore.copy( + path = path, + isMovedToRecycle = false + ) + ) + true + } catch (e: Exception) { + Timber.e(e) + false + } + if (isUpdated) { + //The second step has succeed. Finish edit operation and return an success. + deleteEditRecordOperation(transactionId) + true + } else { + //The second step has failed. Rollback the first step. + val unmarkPath = try { + fileDataSource.markAsRecordDeleted(path) + } catch (e: Exception) { + Timber.e(e) + null + } + if (unmarkPath != null) { + //File rolled back successfully. Finish edit operation and return an error. + deleteEditRecordOperation(transactionId) + } else { + //Failed to rollback file. Keep edit operation in the database to repeat it later. + } + false + } + } else { + //The first step has failed. Finish edit operation and return an error. + //Rollback not needed. + deleteEditRecordOperation(transactionId) + false + } + return result + } catch (e: Exception) { + Timber.e(e) + return false + } + } + + override suspend fun clearRecycle(): Boolean { + val records = recordDao.getMovedToRecycleRecords() + return if (records.isNotEmpty()) { + var result = true + for (recordToDelete in records) { + if (!deleteRecordAndFileForever(recordToDelete)) { + result = false + } + } + result + } else { + false + } + } + + private fun createRenameEditOperation(recordId: Long, renameName: String): RecordEditEntity { + return RecordEditEntity( + recordId = recordId, + editOperation = RecordEditOperation.Rename, + renameName = renameName, + created = System.currentTimeMillis(), + retryCount = 0, + ) + } + + private fun createMoveToRecycleEditOperation(recordId: Long): RecordEditEntity { + return RecordEditEntity( + recordId = recordId, + editOperation = RecordEditOperation.MoveToRecycle, + renameName = null, + created = System.currentTimeMillis(), + retryCount = 0, + ) + } + + private fun createRestoreFromRecycleEditOperation(recordId: Long): RecordEditEntity { + return RecordEditEntity( + recordId = recordId, + editOperation = RecordEditOperation.RestoreFromRecycle, + renameName = null, + created = System.currentTimeMillis(), + retryCount = 0, + ) + } + + private fun createDeleteForeverEditOperation(recordId: Long): RecordEditEntity { + return RecordEditEntity( + recordId = recordId, + editOperation = RecordEditOperation.DeleteForever, + renameName = null, + created = System.currentTimeMillis(), + retryCount = 0, + ) + } + + private fun deleteEditRecordOperation(id: Long) { + try { + recordEditDao.deleteRecordEditOperationById(id) + } catch (e: Exception) { + Timber.e(e) + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensions.kt new file mode 100644 index 000000000..46554f27f --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/DataExtensions.kt @@ -0,0 +1,29 @@ +package com.dimowner.audiorecorder.v2.data.extensions + +import com.dimowner.audiorecorder.v2.data.model.SortOrder + +const val RECORDS_COLUMN_ADDED = "added" +const val RECORDS_COLUMN_NAME = "name" +const val RECORDS_COLUMN_DURATION = "duration" + +fun SortOrder.toSqlSortOrder(): String { + return when (this) { + SortOrder.DateDesc, + SortOrder.NameDesc, + SortOrder.DurationLongest -> "DESC" + SortOrder.DateAsc, + SortOrder.NameAsc, + SortOrder.DurationShortest -> "ASC" + } +} + +fun SortOrder.toRecordsSortColumnName(): String { + return when (this) { + SortOrder.DateAsc, + SortOrder.DateDesc -> RECORDS_COLUMN_ADDED + SortOrder.NameAsc, + SortOrder.NameDesc -> RECORDS_COLUMN_NAME + SortOrder.DurationShortest, + SortOrder.DurationLongest -> RECORDS_COLUMN_DURATION + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt new file mode 100644 index 000000000..232b72454 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/extensions/FileExtensions.kt @@ -0,0 +1,211 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.extensions + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.Environment +import android.os.ParcelFileDescriptor +import android.os.storage.StorageManager +import androidx.core.net.toUri +import com.dimowner.audiorecorder.v2.DefaultValues.DELETED_RECORD_MARK +import timber.log.Timber +import java.io.File +import java.io.IOException + +private const val RETRY_COUNT = 3 + +/** + * Create a file. + * Also create parent directories if they are not exist. + * If file with specified name already exists, add suffix (-1 or -2 or -3...) to the file name. + * @param directory Path to directory. + * @param fileName File name. + */ +@Throws(IOException::class) +fun createFile(directory: File, fileName: String): File { + if (!directory.exists()) { + directory.mkdirs() // Create the directory if it doesn't exist + } + + var newFileName = fileName + var suffix = 1 + + // Check if the file with the same name already exists + while (File(directory, newFileName).exists()) { + // Append a numeric suffix to the file name + newFileName = "${fileName.substringBeforeLast('.')}-$suffix.${fileName.substringAfterLast('.')}" + suffix++ + } + + val file = File(directory, newFileName) + try { + file.createNewFile() + } catch (e: IOException) { + // Handle any exceptions related to file creation + Timber.e(e) + throw e + } + file.verifyCanReadWrite() + return file +} + +@Throws(IOException::class) +fun File.verifyCanReadWrite() { + if (!this.canRead()) { + throw IOException("Can't read file") + } else if (!this.canWrite()) { + throw IOException("Can't write file") + } +} + +fun deleteFileAndChildren(file: File): Boolean { + if (!file.exists()) { + // File doesn't exist, so nothing to delete + return false + } + + if (file.isDirectory) { + // Recursively delete all files and subdirectories + file.listFiles()?.forEach { child -> + if (!deleteFileAndChildren(child)) { + // Failed to delete a child file or directory + return false + } + } + } + + // Delete the current file or empty directory + return file.delete() +} + +/** + * Rename a file + * Example: + * fileToRename: /data/Files/FileName.txt newName: RenamedFile + * return: /data/Files/RenamedFile.txt + * @param fileToRename File that needs to be renamed. + * @param newName New file name + * @return Renamed file + */ +fun renameFileWithExtension(fileToRename: File, newName: String): File? { + if (!fileToRename.exists() || fileToRename.nameWithoutExtension == newName) { + // Source file doesn't exist + return null + } + + // Get the original extension + val originalExtension = fileToRename.extension + + // Append the original extension to the renamed file + val newFileName = "${newName}.$originalExtension" + val newFile = File(fileToRename.parentFile, newFileName) + + // Try renaming the file up to 3 times + repeat(RETRY_COUNT) { + if (fileToRename.renameTo(newFile)) { + return newFile + } + } + return null +} + +fun markFileAsDeleted(file: File): File? { + if (!file.exists()) { + // File doesn't exist, so nothing to mark as deleted + return null + } + + val trashSuffix = DELETED_RECORD_MARK + val originalName = file.name + val trashName = "${originalName.removeSuffix(trashSuffix)}$trashSuffix" + + val trashFile = File(file.parentFile, trashName) + + // Rename the file to move it to the trash + repeat(RETRY_COUNT) { + if (file.renameTo(trashFile)) { + return trashFile + } + } + return null +} + +fun unmarkFileAsDeleted(trashFile: File): File? { + if (!trashFile.exists()) { + // Trash file doesn't exist, nothing to unmark + return null + } + + val trashSuffix = DELETED_RECORD_MARK + val originalName = trashFile.name.removeSuffix(trashSuffix) + val restoredFile = File(trashFile.parentFile, originalName) + + // Rename the trash file back to its original name + if (!trashFile.renameTo(restoredFile)) { + if (!trashFile.renameTo(restoredFile)) { + return if (trashFile.renameTo(restoredFile)) { + restoredFile + } else { + null + } + } + } + return restoredFile +} + +@SuppressLint("SdCardPath") +fun getPrivateMusicStorageDir(context: Context, directoryName: String): File? { + // Get the app-specific directory for music files + val musicDir = context.getExternalFilesDir(Environment.DIRECTORY_MUSIC) + ?: context.filesDir // Fallback to internal storage if external storage is not available + + val directory = File(musicDir, directoryName) + // Create the directory if it doesn't exist + val result: File? = if (!directory.exists() && !directory.mkdirs()) { + //App dir now is not available. + //If nothing helped then hardcode recording dir + val lastResortDirectory = File("/data/data/${context.packageName}/files/$directoryName") + if (!lastResortDirectory.exists() && !lastResortDirectory.mkdirs()) { + null + } else { + lastResortDirectory + } + } else { + directory + } + return result +} + +/** + * Request system for free space. Call this function request system clear cached files + * belonging to other apps (as needed) to meet request. + * */ +fun requestAllocateSpace(context: Context, file: File, requiredSpace: Long) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager + val parcelFileDescriptor: ParcelFileDescriptor? = + context.contentResolver.openFileDescriptor(file.toUri(), "r") + try { + storageManager.allocateBytes(parcelFileDescriptor?.fileDescriptor, requiredSpace) + } catch (e: IOException) { + Timber.e(e) + } + parcelFileDescriptor?.close() + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/BitRate.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/BitRate.kt new file mode 100644 index 000000000..876b44cd1 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/BitRate.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Bitrate in Kbps (thousands bits per second) + */ +@SuppressWarnings("MagicNumber") +@Parcelize +enum class BitRate(val value: Int, val index: Int): Parcelable { + BR48(48000, 0), + BR96(96000, 1), + BR128(128000, 2), + BR192(192000, 3), + BR256(256000, 4), +} + +fun Int.convertToBitRate(): BitRate? { + return if (this == BitRate.BR48.value) BitRate.BR48 + else if (this == BitRate.BR96.value) BitRate.BR96 + else if (this == BitRate.BR128.value) BitRate.BR128 + else if (this == BitRate.BR192.value) BitRate.BR192 + else if (this == BitRate.BR256.value) BitRate.BR256 + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/ChannelCount.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/ChannelCount.kt new file mode 100644 index 000000000..b1cce2a43 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/ChannelCount.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class ChannelCount(val value: Int, val index: Int): Parcelable { + Stereo(value = 2, index = 0), + Mono(value = 1, index = 1), +} + +fun Int.convertToChannelCount(): ChannelCount? { + return if (this == ChannelCount.Mono.value) ChannelCount.Mono + else if (this == ChannelCount.Stereo.value) ChannelCount.Stereo + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/NameFormat.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/NameFormat.kt new file mode 100644 index 000000000..c7fbac7f5 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/NameFormat.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +enum class NameFormat { + Record, Timestamp, Date, DateUs, DateIso8601 +} + +fun String.convertToNameFormat(): NameFormat? { + return if (this.equals(NameFormat.Record.toString(), true)) NameFormat.Record + else if (this.equals(NameFormat.Timestamp.toString(), true)) NameFormat.Timestamp + else if (this.equals(NameFormat.Date.toString(), true)) NameFormat.Date + else if (this.equals(NameFormat.DateUs.toString(), true)) NameFormat.DateUs + else if (this.equals(NameFormat.DateIso8601.toString(), true)) NameFormat.DateIso8601 + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/Record.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/Record.kt new file mode 100644 index 000000000..fbb4c908b --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/Record.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data.model + +data class Record( + val id: Long, + val name: String, + val durationMills: Long, + val created: Long, + val added: Long, + /** Date when record removed. Required to be able to remove the record automatically from Trash after it expired. */ + val removed: Long, + var path: String, + val format: String, + val size: Long, + val sampleRate: Int, + val channelCount: Int, + val bitrate: Int, + val isBookmarked: Boolean, + val isWaveformProcessed: Boolean, + val isMovedToRecycle: Boolean, + val amps: IntArray, +) { + + @SuppressWarnings("CyclomaticComplexMethod") + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Record + + if (id != other.id) return false + if (name != other.name) return false + if (durationMills != other.durationMills) return false + if (created != other.created) return false + if (added != other.added) return false + if (removed != other.removed) return false + if (path != other.path) return false + if (format != other.format) return false + if (size != other.size) return false + if (sampleRate != other.sampleRate) return false + if (channelCount != other.channelCount) return false + if (bitrate != other.bitrate) return false + if (isBookmarked != other.isBookmarked) return false + if (isWaveformProcessed != other.isWaveformProcessed) return false + if (isMovedToRecycle != other.isMovedToRecycle) return false + return amps.contentEquals(other.amps) + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + durationMills.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + added.hashCode() + result = 31 * result + removed.hashCode() + result = 31 * result + path.hashCode() + result = 31 * result + format.hashCode() + result = 31 * result + size.hashCode() + result = 31 * result + sampleRate + result = 31 * result + channelCount + result = 31 * result + bitrate + result = 31 * result + isBookmarked.hashCode() + result = 31 * result + isWaveformProcessed.hashCode() + result = 31 * result + isMovedToRecycle.hashCode() + result = 31 * result + amps.contentHashCode() + return result + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordEditOperation.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordEditOperation.kt new file mode 100644 index 000000000..6cc8db7aa --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordEditOperation.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dimowner.audiorecorder.v2.data.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class RecordEditOperation: Parcelable { + Rename, + MoveToRecycle, + RestoreFromRecycle, + DeleteForever, +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordingFormat.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordingFormat.kt new file mode 100644 index 000000000..63db4d906 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RecordingFormat.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class RecordingFormat(val value: String, val index: Int) : Parcelable { + M4a("m4a", 0), Wav("wav", 1), ThreeGp("3gp", 2) +} + +fun String.convertToRecordingFormat(): RecordingFormat? { + return if (this.equals(RecordingFormat.M4a.value, true)) RecordingFormat.M4a + else if (this.equals(RecordingFormat.Wav.value, true)) RecordingFormat.Wav + else if (this.equals(RecordingFormat.ThreeGp.value, true)) RecordingFormat.ThreeGp + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SampleRate.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SampleRate.kt new file mode 100644 index 000000000..43e54fd46 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SampleRate.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class SampleRate(val value: Int, val index: Int): Parcelable { + SR8000(value = 8000, index = 0), + SR16000(value = 16000, index = 1), + SR22500(value = 22500, index = 2), + SR32000(value = 32000, index = 3), + SR44100(value = 44100, index = 4), + SR48000(value = 48000, index = 5), +} + +fun Int.convertToSampleRate(): SampleRate? { + return if (this == SampleRate.SR8000.value) SampleRate.SR8000 + else if (this == SampleRate.SR16000.value) SampleRate.SR16000 + else if (this == SampleRate.SR22500.value) SampleRate.SR22500 + else if (this == SampleRate.SR32000.value) SampleRate.SR32000 + else if (this == SampleRate.SR44100.value) SampleRate.SR44100 + else if (this == SampleRate.SR48000.value) SampleRate.SR48000 + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SortOrder.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SortOrder.kt new file mode 100644 index 000000000..5ab3b03e3 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/SortOrder.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.data.model + +/** + * Represents the available sorting options for records list, + * defining both the criteria (e.g., Date, Name, Duration) and the + * direction (e.g., Ascending, Descending). + */ +enum class SortOrder { + /** Sorts by date in ascending order (oldest to newest). */ + DateAsc, + + /** Sorts by date in descending order (newest to oldest). */ + DateDesc, + + /** Sorts by name in ascending order (alphabetical, A-Z). */ + NameAsc, + + /** Sorts by name in descending order (reverse alphabetical, Z-A). */ + NameDesc, + + /** Sorts by duration in ascending order (shortest to longest). */ + DurationShortest, + + /** Sorts by duration in descending order (longest to shortest). */ + DurationLongest +} + +fun String.convertToSortOrder(): SortOrder? { + return if (this == SortOrder.DateAsc.toString()) SortOrder.DateAsc + else if (this == SortOrder.DateDesc.toString()) SortOrder.DateDesc + else if (this == SortOrder.NameAsc.toString()) SortOrder.NameAsc + else if (this == SortOrder.NameDesc.toString()) SortOrder.NameDesc + else if (this == SortOrder.DurationShortest.toString()) SortOrder.DurationShortest + else if (this == SortOrder.DurationLongest.toString()) SortOrder.DurationLongest + else null +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/AppDatabase.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/AppDatabase.kt new file mode 100644 index 000000000..71e305dd0 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/AppDatabase.kt @@ -0,0 +1,49 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +const val DATABASE_NAME = "app_database" + +@Database(entities = [RecordEntity::class, RecordEditEntity::class], version = 1, exportSchema = false) +abstract class AppDatabase : RoomDatabase() { + + abstract fun recordDao(): RecordDao + + abstract fun recordEditDao(): RecordEditDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + DATABASE_NAME + ).build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/Converters.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/Converters.kt new file mode 100644 index 000000000..53605d906 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/Converters.kt @@ -0,0 +1,44 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import androidx.room.TypeConverter +import com.dimowner.audiorecorder.v2.data.model.RecordEditOperation + +class Converters { + + @TypeConverter + fun fromIntArray(intArray: IntArray): String { + return intArray.joinToString(separator = ",") + } + + @TypeConverter + fun toIntArray(value: String): IntArray { + return if (value.isBlank()) { + // If the input string is blank, return an empty IntArray. + intArrayOf() + } else { + value.split(",").map { it.toInt() }.toIntArray() + } + } + + @TypeConverter + fun toRecordEditOperation(value: String) = enumValueOf(value) + + @TypeConverter + fun fromRecordEditOperation(value: RecordEditOperation) = value.name +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordDao.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordDao.kt new file mode 100644 index 000000000..03dbd159d --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordDao.kt @@ -0,0 +1,75 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RawQuery +import androidx.room.Update +import androidx.sqlite.db.SupportSQLiteQuery + +@Dao +interface RecordDao { + + @Query("SELECT * FROM records WHERE id = :recordId") + fun getRecordById(recordId: Long): RecordEntity? + + @Query("SELECT * FROM records WHERE id IN (:recordIds)") + fun getRecordsByIds(recordIds: List): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertRecord(record: RecordEntity): Long + + @Update + fun updateRecord(record: RecordEntity): Int // Returns the number of updated rows + + @Update + fun updateRecords(records: List): Int // Returns the total number of updated rows + + @Delete + fun deleteRecord(record: RecordEntity) + + @Query("DELETE FROM records WHERE id = :recordId") + fun deleteRecordById(recordId: Long) + + @Query("DELETE FROM records") + fun deleteAllRecords() + + @Query("SELECT COUNT(*) FROM records WHERE isMovedToRecycle = 0") + fun getRecordsCount(): Int + + @Query("SELECT SUM(duration) AS total_duration FROM records WHERE isMovedToRecycle = 0") + fun getRecordTotalDuration(): Long + + @Query("SELECT * FROM records WHERE isMovedToRecycle = 0 ORDER BY added DESC LIMIT :pageSize OFFSET :offset") + fun getRecordsByPage(pageSize: Int, offset: Int): List + + @Query("SELECT * FROM records WHERE isMovedToRecycle = 0 ORDER BY added DESC") + fun getAllRecords(): List + + @Query("SELECT * FROM records WHERE isMovedToRecycle = 1 ORDER BY removed DESC") + fun getMovedToRecycleRecords(): List + + @Query("SELECT COUNT(*) FROM records WHERE isMovedToRecycle = 1") + fun getMovedToRecycleRecordsCount(): Int + + @RawQuery + fun getRecordsRewQuery(query: SupportSQLiteQuery): List +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDao.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDao.kt new file mode 100644 index 000000000..cf9890c28 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditDao.kt @@ -0,0 +1,49 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update + +@Dao +interface RecordEditDao { + + @Query("SELECT * FROM record_edit ORDER BY created DESC") + fun getAllRecordsEditOperations(): List + + @Query("SELECT * FROM record_edit WHERE id = :recordId") + fun getRecordsEditOperationById(recordId: Long): RecordEditEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertRecordsEditOperation(record: RecordEditEntity): Long + + @Update + fun updateRecordsEditOperation(record: RecordEditEntity) + + @Delete + fun deleteRecordsEditOperation(record: RecordEditEntity) + + @Query("DELETE FROM record_edit") + fun deleteAllRecordsEditOperations() + + @Query("DELETE FROM record_edit WHERE id = :editOperationId") + fun deleteRecordEditOperationById(editOperationId: Long) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditEntity.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditEntity.kt new file mode 100644 index 000000000..76634eac7 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEditEntity.kt @@ -0,0 +1,34 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.dimowner.audiorecorder.v2.data.model.RecordEditOperation + +@Entity(tableName = "record_edit") +@TypeConverters(Converters::class) +data class RecordEditEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "recordId") val recordId: Long, + @ColumnInfo(name = "editOperation") val editOperation: RecordEditOperation, + @ColumnInfo(name = "renameName") val renameName: String?, + @ColumnInfo(name = "created") val created: Long, + @ColumnInfo(name = "retryCount") val retryCount: Int, +) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEntity.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEntity.kt new file mode 100644 index 000000000..6624852e5 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/room/RecordEntity.kt @@ -0,0 +1,88 @@ +/* +* Copyright 2024 Dmytro Ponomarenko +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package com.dimowner.audiorecorder.v2.data.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters + +@Entity(tableName = "records") +@TypeConverters(Converters::class) +data class RecordEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "duration") val duration: Long, + @ColumnInfo(name = "created") val created: Long, + @ColumnInfo(name = "added") val added: Long, + @ColumnInfo(name = "removed") val removed: Long, + @ColumnInfo(name = "path") val path: String, + @ColumnInfo(name = "format") val format: String, + @ColumnInfo(name = "size") val size: Long, + @ColumnInfo(name = "sampleRate") val sampleRate: Int, + @ColumnInfo(name = "channelCount") val channelCount: Int, + @ColumnInfo(name = "bitrate") val bitrate: Int, + @ColumnInfo(name = "isBookmarked") val isBookmarked: Boolean, + @ColumnInfo(name = "isWaveformProcessed") val isWaveformProcessed: Boolean, + @ColumnInfo(name = "isMovedToRecycle") val isMovedToRecycle: Boolean, + @ColumnInfo(name = "amps") val amps: IntArray, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RecordEntity + + if (id != other.id) return false + if (name != other.name) return false + if (duration != other.duration) return false + if (created != other.created) return false + if (added != other.added) return false + if (removed != other.removed) return false + if (path != other.path) return false + if (format != other.format) return false + if (size != other.size) return false + if (sampleRate != other.sampleRate) return false + if (channelCount != other.channelCount) return false + if (bitrate != other.bitrate) return false + if (isBookmarked != other.isBookmarked) return false + if (isWaveformProcessed != other.isWaveformProcessed) return false + if (isMovedToRecycle != other.isMovedToRecycle) return false + return amps.contentEquals(other.amps) + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + duration.hashCode() + result = 31 * result + created.hashCode() + result = 31 * result + added.hashCode() + result = 31 * result + removed.hashCode() + result = 31 * result + path.hashCode() + result = 31 * result + format.hashCode() + result = 31 * result + size.hashCode() + result = 31 * result + sampleRate + result = 31 * result + channelCount + result = 31 * result + bitrate + result = 31 * result + isBookmarked.hashCode() + result = 31 * result + isWaveformProcessed.hashCode() + result = 31 * result + isMovedToRecycle.hashCode() + result = 31 * result + amps.contentHashCode() + return result + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/AppModule.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/AppModule.kt new file mode 100644 index 000000000..ddbf26ef1 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/AppModule.kt @@ -0,0 +1,60 @@ +package com.dimowner.audiorecorder.v2.di + +import com.dimowner.audiorecorder.audio.player.AudioPlayerNew +import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.v2.audio.AudioRecorderDelegate +import com.dimowner.audiorecorder.v2.audio.RecorderV2 +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import com.dimowner.audiorecorder.v2.di.qualifiers.MainDispatcher +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class AppModule { + + @IoDispatcher + @Provides + fun provideIoDispatcher(): CoroutineDispatcher { + return Dispatchers.IO + } + + @MainDispatcher + @Provides + fun provideMainDispatcher(): CoroutineDispatcher { + return Dispatchers.Main + } + + @Singleton + @Provides + fun providePlayerContractNew(): PlayerContractNew.Player { + return AudioPlayerNew() + } + + @Singleton + @Provides + fun provideRecorderV2( + audioRecorderDelegate: AudioRecorderDelegate + ): RecorderV2 { + return audioRecorderDelegate.provideAudioRecorder() + } + + /** + * Provides a CoroutineScope scoped to the application's lifetime. + * It uses a SupervisorJob so that a failure of a child coroutine does not cancel others. + * It uses Dispatchers.Default for background work. + */ + @Provides + @Singleton + fun provideApplicationScope(): CoroutineScope { + // Use SupervisorJob() to prevent child coroutine failures from propagating + return CoroutineScope(SupervisorJob() + Dispatchers.Default) + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/DataSourceModule.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/DataSourceModule.kt new file mode 100644 index 000000000..9d5e70713 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/DataSourceModule.kt @@ -0,0 +1,30 @@ +package com.dimowner.audiorecorder.v2.di + +import com.dimowner.audiorecorder.v2.data.FileDataSource +import com.dimowner.audiorecorder.v2.data.FileDataSourceImpl +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.PrefsV2Impl +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.RecordsDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +abstract class DataSourceModule { + + @Singleton + @Binds + abstract fun bindPrefs(impl: PrefsV2Impl): PrefsV2 + + @Singleton + @Binds + abstract fun bindFileDataSource(impl: FileDataSourceImpl): FileDataSource + + @Singleton + @Binds + abstract fun bindRecordsDataSource(impl: RecordsDataSourceImpl): RecordsDataSource +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/DatabaseModule.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/DatabaseModule.kt new file mode 100644 index 000000000..adeb25e0c --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/DatabaseModule.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2024 Dmytro Ponomarenko + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dimowner.audiorecorder.v2.di + +import android.content.Context +import com.dimowner.audiorecorder.v2.data.room.AppDatabase +import com.dimowner.audiorecorder.v2.data.room.RecordDao +import com.dimowner.audiorecorder.v2.data.room.RecordEditDao +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 + +@InstallIn(SingletonComponent::class) +@Module +class DatabaseModule { + + @Singleton + @Provides + fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { + return AppDatabase.getDatabase(context) + } + + @Provides + fun providePlantDao(appDatabase: AppDatabase): RecordDao { + return appDatabase.recordDao() + } + + @Provides + fun provideRecordEditDao(appDatabase: AppDatabase): RecordEditDao { + return appDatabase.recordEditDao() + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/IoDispatcher.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/IoDispatcher.kt new file mode 100644 index 000000000..f22268835 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/IoDispatcher.kt @@ -0,0 +1,7 @@ +package com.dimowner.audiorecorder.v2.di.qualifiers + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class IoDispatcher \ No newline at end of file diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/MainDispatcher.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/MainDispatcher.kt new file mode 100644 index 000000000..7e78a8f3b --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/qualifiers/MainDispatcher.kt @@ -0,0 +1,7 @@ +package com.dimowner.audiorecorder.v2.di.qualifiers + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class MainDispatcher diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/RecorerNavigationGraph.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/RecorerNavigationGraph.kt new file mode 100644 index 000000000..dc2be0821 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/RecorerNavigationGraph.kt @@ -0,0 +1,171 @@ +package com.dimowner.audiorecorder.v2.navigation + +import android.os.Build +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.EaseIn +import androidx.compose.animation.core.EaseOut +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.dimowner.audiorecorder.v2.app.deleted.DeletedRecordsScreen +import com.dimowner.audiorecorder.v2.app.deleted.DeletedRecordsViewModel +import com.dimowner.audiorecorder.v2.app.home.HomeScreen +import com.dimowner.audiorecorder.v2.app.home.HomeViewModel +import com.dimowner.audiorecorder.v2.app.info.AssetParamType +import com.dimowner.audiorecorder.v2.app.info.RecordInfoState +import com.dimowner.audiorecorder.v2.app.info.RecordInfoScreen +import com.dimowner.audiorecorder.v2.app.records.RecordsScreen +import com.dimowner.audiorecorder.v2.app.records.RecordsViewModel +import com.dimowner.audiorecorder.v2.app.settings.SettingsScreen +import com.dimowner.audiorecorder.v2.app.settings.SettingsScreenAction +import com.dimowner.audiorecorder.v2.app.settings.SettingsViewModel +import com.dimowner.audiorecorder.v2.app.settings.WelcomeSetupSettingsScreen +import com.dimowner.audiorecorder.v2.app.welcome.WelcomeScreen +import kotlinx.coroutines.CoroutineScope + +private const val ANIMATION_DURATION = 120 + +@Composable +fun RecorderNavigationGraph( + coroutineScope: CoroutineScope, + homeViewModel: HomeViewModel, + onSwitchToLegacyApp: () -> Unit, +) { + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = Routes.HOME_SCREEN, + enterTransition = { enterTransition(this) }, + exitTransition = { exitTransition(this) }, + popEnterTransition = { popEnterTransition(this) }, + popExitTransition = { popExitTransition(this) } + ) { + composable(Routes.HOME_SCREEN) { + HomeScreen( + showRecordsScreen = { navController.navigate(Routes.RECORDS_SCREEN) }, + showSettingsScreen = { navController.navigate(Routes.SETTINGS_SCREEN) }, + showRecordInfoScreen = { json -> + navController.navigate(Routes.RECORD_INFO_SCREEN +"/${json}") + }, uiState = homeViewModel.state.value, + event = homeViewModel.event.collectAsState(null).value, + onAction = { homeViewModel.onAction(it) } + ) + } + composable(Routes.RECORDS_SCREEN) { + val recordsViewModel: RecordsViewModel = hiltViewModel() + RecordsScreen( + onPopBackStack = { + navController.popBackStack() + }, + showRecordInfoScreen = { json -> + navController.navigate(Routes.RECORD_INFO_SCREEN +"/${json}") + }, showDeletedRecordsScreen = { + navController.navigate(Routes.DELETED_RECORDS_SCREEN) + }, uiState = recordsViewModel.state.value, + event = recordsViewModel.event.collectAsState(null).value, + onAction = { + recordsViewModel.onAction(it) + }, + uiHomeState = homeViewModel.state.value, + onHomeAction = { homeViewModel.onAction(it) } + ) + } + composable(Routes.DELETED_RECORDS_SCREEN) { + val deletedViewModel: DeletedRecordsViewModel = hiltViewModel() + DeletedRecordsScreen(onPopBackStack = { + navController.popBackStack() + }, + showRecordInfoScreen = { json -> + navController.navigate(Routes.RECORD_INFO_SCREEN +"/${json}") + }, uiState = deletedViewModel.state.value, + event = deletedViewModel.event.collectAsState(null).value, + onAction = { deletedViewModel.onAction(it) } + ) + } + composable(Routes.SETTINGS_SCREEN) { + val settingsViewModel: SettingsViewModel = hiltViewModel() + SettingsScreen(onPopBackStack = { + navController.popBackStack() + }, showDeletedRecordsScreen = { + navController.navigate(Routes.DELETED_RECORDS_SCREEN) + }, uiState = settingsViewModel.state.value, + onAction = { + settingsViewModel.onAction(it) + if (it is SettingsScreenAction.SetAppV2) { + onSwitchToLegacyApp() + } + } + ) + } + composable(Routes.WELCOME_SCREEN) { + WelcomeScreen(onGetStarted = { + navController.navigate(Routes.WELCOME_SETUP_SETTINGS_SCREEN) + }) + } + composable(Routes.WELCOME_SETUP_SETTINGS_SCREEN) { + val settingsViewModel: SettingsViewModel = hiltViewModel() + WelcomeSetupSettingsScreen(onPopBackStack = { + navController.popBackStack() + }, onApplySettings = { + navController.navigate(Routes.HOME_SCREEN) { + popUpTo(0) + } + }, uiState = settingsViewModel.state.value, + onAction = { settingsViewModel.onAction(it) } + ) + } + composable( + "${Routes.RECORD_INFO_SCREEN}/{${Routes.RECORD_INFO}}", + arguments = listOf( + navArgument(Routes.RECORD_INFO) { + type = AssetParamType() + } + ), + ) { + val recordInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + it.arguments?.getParcelable(Routes.RECORD_INFO, RecordInfoState::class.java) + } else { + it.arguments?.getParcelable(Routes.RECORD_INFO) + } + RecordInfoScreen(onPopBackStack = { + navController.popBackStack() + }, recordInfo) + } + } +} + +private fun enterTransition(scope: AnimatedContentTransitionScope): EnterTransition { + return scope.slideIntoContainer( + animationSpec = tween(ANIMATION_DURATION, easing = EaseIn), + towards = AnimatedContentTransitionScope.SlideDirection.Start + ) +} + +private fun exitTransition(scope: AnimatedContentTransitionScope): ExitTransition { + return scope.slideOutOfContainer( + animationSpec = tween(ANIMATION_DURATION, easing = EaseIn), + towards = AnimatedContentTransitionScope.SlideDirection.Start + ) +} + +private fun popEnterTransition(scope: AnimatedContentTransitionScope): EnterTransition { + return scope.slideIntoContainer( + animationSpec = tween(ANIMATION_DURATION, easing = EaseOut), + towards = AnimatedContentTransitionScope.SlideDirection.End + ) +} + +private fun popExitTransition(scope: AnimatedContentTransitionScope): ExitTransition { + return scope.slideOutOfContainer( + animationSpec = tween(ANIMATION_DURATION, easing = EaseOut), + towards = AnimatedContentTransitionScope.SlideDirection.End + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/Routes.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/Routes.kt new file mode 100644 index 000000000..ce1b21763 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/navigation/Routes.kt @@ -0,0 +1,14 @@ +package com.dimowner.audiorecorder.v2.navigation + +object Routes { + + const val HOME_SCREEN = "HOME_SCREEN" + const val RECORDS_SCREEN = "RECORDS_SCREEN" + const val SETTINGS_SCREEN = "SETTINGS_SCREEN" + const val RECORD_INFO_SCREEN = "RECORD_INFO_SCREEN" + const val DELETED_RECORDS_SCREEN = "DELETED_RECORDS_SCREEN" + const val WELCOME_SCREEN = "WELCOME_SCREEN" + const val WELCOME_SETUP_SETTINGS_SCREEN = "WELCOME_SETUP_SETTINGS_SCREEN" + + const val RECORD_INFO = "RECORD_INFO" +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Color.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Color.kt new file mode 100644 index 000000000..b1552b496 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Color.kt @@ -0,0 +1,92 @@ +package com.dimowner.audiorecorder.v2.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val BlueGray700 = Color(0xFF1C2733) +val BlueGray500 = Color(0xFF253343) +val WhiteTransparent88 = Color(0x1EFFFFFF) +val BlackTransparent88 = Color(0x1E000000) + +val Pink500 = Color(0xFFE91E63) +val Purple500 = Color(0xFF9C27B0) +val DeepPurple500 = Color(0xFF673AB7) +val Blue500 = Color(0xFF5677FC) +val Teal500 = Color(0xFF009688) +val Green500 = Color(0xFF4CAF50) +val YellowA700 = Color(0xFFFFD600) +val Amber800 = Color(0xFFFF8F00) +val DeepOrange500 = Color(0xFFFF5722) +val Brown500 = Color(0xFF795548) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) + +//val GreyLight = Color(0xFFBBBBBB) +//val BlackTransparent80 = Color(0x32000000) + +val md_theme_light_primary = Color(0xFF00629F) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFD0E4FF) +val md_theme_light_onPrimaryContainer = Color(0xFF001D34) +val md_theme_light_secondary = Color(0xFF00629F) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFD0E4FF) +val md_theme_light_onSecondaryContainer = Color(0xFF001D34) +val md_theme_light_tertiary = Color(0xFF9C3C61) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFFFD9E2) +val md_theme_light_onTertiaryContainer = Color(0xFF3E001D) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFFAFCFF) +val md_theme_light_onBackground = Color(0xFF001F2A) +val md_theme_light_surface = Color(0xFFFAFCFF) +val md_theme_light_onSurface = Color(0xFF001F2A) +val md_theme_light_surfaceVariant = Color(0x1E000000) +val md_theme_light_onSurfaceVariant = Color(0xFF42474E) +val md_theme_light_outline = Color(0xFF73777F) +val md_theme_light_inverseOnSurface = Color(0xFFE1F4FF) +val md_theme_light_inverseSurface = Color(0xFF003547) +val md_theme_light_inversePrimary = Color(0xFF9ACBFF) +val md_theme_light_shadow = Color(0xFF000000) +val md_theme_light_surfaceTint = Color(0xFF00629F) +val md_theme_light_outlineVariant = Color(0xFFC2C7CF) +val md_theme_light_scrim = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFF9ACBFF) +val md_theme_dark_onPrimary = Color(0xFF003355) +val md_theme_dark_primaryContainer = Color(0xFF004A79) +val md_theme_dark_onPrimaryContainer = Color(0xFFD0E4FF) +val md_theme_dark_secondary = Color(0xFF9ACBFF) +val md_theme_dark_onSecondary = Color(0xFF003355) +val md_theme_dark_secondaryContainer = Color(0xFF004A79) +val md_theme_dark_onSecondaryContainer = Color(0xFFD0E4FF) +val md_theme_dark_tertiary = Color(0xFFFFB0C8) +val md_theme_dark_onTertiary = Color(0xFF610A33) +val md_theme_dark_tertiaryContainer = Color(0xFF7E244A) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFD9E2) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF001F2A) +val md_theme_dark_onBackground = Color(0xFFBFE9FF) +val md_theme_dark_surface = Color(0xFF001F2A) +val md_theme_dark_onSurface = Color(0xFFBFE9FF) +val md_theme_dark_surfaceVariant = Color(0x1EFFFFFF) +val md_theme_dark_onSurfaceVariant = Color(0xFFC2C7CF) +val md_theme_dark_outline = Color(0xFF8C9199) +val md_theme_dark_inverseOnSurface = Color(0xFF001F2A) +val md_theme_dark_inverseSurface = Color(0xFFBFE9FF) +val md_theme_dark_inversePrimary = Color(0xFF00629F) +val md_theme_dark_shadow = Color(0xFF000000) +val md_theme_dark_surfaceTint = Color(0xFF9ACBFF) +val md_theme_dark_outlineVariant = Color(0xFF42474E) +val md_theme_dark_scrim = Color(0xFF000000) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Shapes.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Shapes.kt new file mode 100644 index 000000000..6a21c3399 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Shapes.kt @@ -0,0 +1,13 @@ +package com.dimowner.audiorecorder.v2.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val shapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(32.dp) +) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Theme.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Theme.kt new file mode 100644 index 000000000..75156471b --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Theme.kt @@ -0,0 +1,188 @@ +package com.dimowner.audiorecorder.v2.theme + +import android.app.Activity +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +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 = BlueGray700, + secondary = Color.Red, + tertiary = Pink80, + background = BlueGray700, + onPrimary = Pink500, + primaryContainer = Purple500, + onPrimaryContainer = DeepPurple500, + inversePrimary = Blue500, + onSecondary = Teal500, + secondaryContainer = Green500, + onSecondaryContainer = YellowA700, + onTertiary = Amber800, + tertiaryContainer = DeepOrange500, + onTertiaryContainer = Brown500, + onBackground = Pink500, + surface = Purple500, + onSurface = DeepPurple500, + surfaceVariant = Blue500, + onSurfaceVariant = Teal500, + surfaceTint = Green500, + inverseSurface = YellowA700, + inverseOnSurface = Amber800, + error = DeepOrange500, + onError = Brown500, + errorContainer = Pink500, + onErrorContainer = Purple500, + outline = DeepPurple500, + outlineVariant = Blue500, + scrim = Teal500, + surfaceBright = Green500, + surfaceContainer = YellowA700, + surfaceContainerHigh = Amber800, + surfaceContainerHighest = DeepOrange500, + surfaceContainerLow = Brown500, + surfaceContainerLowest = Pink500, + surfaceDim = Purple500, +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +// Material 3 color schemes +private val LightColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, +) + +private val DarkColors = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, +) + +@Composable +@RequiresApi(Build.VERSION_CODES.S) +fun AppTheme( + dynamicColors: Boolean, + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val context = LocalContext.current + val colors = when { + dynamicColors && darkTheme -> dynamicDarkColorScheme(context) + dynamicColors && !darkTheme -> dynamicLightColorScheme(context) + !dynamicColors && !darkTheme -> LightColors + else -> DarkColors + } + AppTheme(colors, darkTheme, content) +} + +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (darkTheme) DarkColors else LightColors + AppTheme(colors, darkTheme, content) +} + +@Composable +private fun AppTheme( + colors: ColorScheme, + darkTheme: Boolean, + content: @Composable () -> Unit +) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colors.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + MaterialTheme( + colorScheme = colors, + typography = typography, + shapes = shapes, + content = content + ) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Type.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Type.kt new file mode 100644 index 000000000..ae8aa9903 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/theme/Type.kt @@ -0,0 +1,41 @@ +package com.dimowner.audiorecorder.v2.theme + + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Material 3 typography +val typography = Typography( + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + labelMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/app/src/main/res/drawable/ic_apps.xml b/app/src/main/res/drawable/ic_apps.xml new file mode 100644 index 000000000..83b3afb43 --- /dev/null +++ b/app/src/main/res/drawable/ic_apps.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_down.xml b/app/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 000000000..b2ab526d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_dark_mode.xml b/app/src/main/res/drawable/ic_dark_mode.xml new file mode 100644 index 000000000..8942d658f --- /dev/null +++ b/app/src/main/res/drawable/ic_dark_mode.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_palette_outline.xml b/app/src/main/res/drawable/ic_palette_outline.xml new file mode 100644 index 000000000..cb4385b36 --- /dev/null +++ b/app/src/main/res/drawable/ic_palette_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index ec80da84f..7e96c3e7f 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -87,6 +87,41 @@ android:text="@string/view" /> + + + + + + + Rate app Store records in a public dir Record in Stereo + + Dynamic theme colors + Dark theme + Record name format + Version %s + + + year + years + + + day + days + + + + Recording \'%1$s\' permanently deleted + + Recording \'%1$s\' moved to Trash + + %1$d of %2$d recordings moved to Trash + + Failed to move recording to Trash + + Failed to move recordings to Trash. + + Canceled recording moved to Trash + + Recording \'%1$s\' restored + + Recording \'%1$s\' renamed to \'%2$s\' + + Recording saved + + Failed to save recording. + + File operation failed. Please try again + + Name cannot be empty + + UNDO + + Operation failed + Keep screen ON while recording Total recorded duration: %s Total records count: %d @@ -92,6 +141,7 @@ Failed to move %d records The record will be copied to the \'Downloads\' directory + The record \'%s\' will be copied to the \'Downloads\' directory Failed to move: %s Failed to copy: %s Failed to copy. File with name %s already exists @@ -152,6 +202,7 @@ Apply Reset Next + Confirm View View records Move records needed @@ -201,6 +252,7 @@ Channel count: Sample rate: %s Mb/min expected size + %d kHz %d Hz %d kbps Stereo @@ -228,6 +280,7 @@ Recording error! Failed to restore the record! Failed to delete the record! + Recording already started! Move public storage records is needed Later @@ -236,8 +289,12 @@ Record Stop + Pause Resume Finish + Switch to Legacy app + Try new Audio Recorder + Switch to a new Audio Recorder updated with improved features while keeping all your recordings. You can always go back to the old version. +