diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 4cd16b6..c2622ee 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,13 +13,7 @@ - - - - - + diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 5fc23f8..8177882 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -14,10 +14,10 @@ - diff --git a/.idea/ktfmt.xml b/.idea/ktfmt.xml new file mode 100644 index 0000000..1859451 --- /dev/null +++ b/.idea/ktfmt.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e88f74f..b889184 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,36 +11,32 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.compose.compiler) + alias(libs.plugins.ktfmt.gradle) } -val localProperties = Properties().apply { - load(project.rootDir.resolve("local.properties").inputStream()) -} +val localProperties = + Properties().apply { load(project.rootDir.resolve("local.properties").inputStream()) } android { namespace = "com.bobbyesp.metadator" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.bobbyesp.metadator" minSdk = 24 - targetSdk = 35 + targetSdk = 36 versionCode = rootProject.extra["versionCode"] as Int versionName = rootProject.extra["versionName"] as String testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary = true - } + vectorDrawables { useSupportLibrary = true } manifestPlaceholders["redirectHostName"] = "metadator" manifestPlaceholders["redirectSchemeName"] = "metadator" } - androidResources { - generateLocaleConfig = true - } + androidResources { generateLocaleConfig = true } signingConfigs { create("release") { @@ -56,14 +52,19 @@ android { buildTypes { release { buildConfigField( - "String", "CLIENT_ID", "\"${localProperties.getProperty("CLIENT_ID")}\"" + "String", + "CLIENT_ID", + "\"${localProperties.getProperty("CLIENT_ID")}\"", ) buildConfigField( - "String", "CLIENT_SECRET", "\"${localProperties.getProperty("CLIENT_SECRET")}\"" + "String", + "CLIENT_SECRET", + "\"${localProperties.getProperty("CLIENT_SECRET")}\"", ) isMinifyEnabled = true proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", ) if (System.getenv("RELEASE_STORE_FILE") != null) { signingConfig = signingConfigs["release"] @@ -71,13 +72,17 @@ android { } debug { buildConfigField( - "String", "CLIENT_ID", "\"${localProperties.getProperty("CLIENT_ID")}\"" + "String", + "CLIENT_ID", + "\"${localProperties.getProperty("CLIENT_ID")}\"", ) buildConfigField( - "String", "CLIENT_SECRET", "\"${localProperties.getProperty("CLIENT_SECRET")}\"" + "String", + "CLIENT_SECRET", + "\"${localProperties.getProperty("CLIENT_SECRET")}\"", ) isMinifyEnabled = false -// applicationIdSuffix = ".debug" + // applicationIdSuffix = ".debug" signingConfig = signingConfigs["debug"] } } @@ -96,59 +101,46 @@ android { nativeSymbolUploadEnabled = true } } - } - create("foss") { - dimension = "version" - } + create("foss") { dimension = "version" } } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } - kotlinOptions { - jvmTarget = "21" - //freeCompilerArgs = listOf("-Xcontext-receivers", "-XXLanguage:+ExplicitBackingFields") - } buildFeatures { compose = true buildConfig = true } - composeCompiler { - reportsDestination = layout.buildDirectory.dir("compose_compiler") - } - kotlin { - sourceSets.all { - languageSettings { - languageVersion = "2.0" - } - } - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } + composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") } + kotlin { sourceSets.all { languageSettings { languageVersion = "2.0" } } } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } applicationVariants.all { val variantName = name sourceSets { - getByName("main") { - java.srcDir(File("build/generated/ksp/$variantName/kotlin")) - } + getByName("main") { java.srcDir(File("build/generated/ksp/$variantName/kotlin")) } } outputs.all { if (githubBuild) { - (this as com.android.build.gradle.internal.api.BaseVariantOutputImpl).outputFileName = - "Metadator-${defaultConfig.versionName}-${name}_(GitHub).apk" + (this as com.android.build.gradle.internal.api.BaseVariantOutputImpl) + .outputFileName = "Metadator-${defaultConfig.versionName}-${name}_(GitHub).apk" } else { - (this as com.android.build.gradle.internal.api.BaseVariantOutputImpl).outputFileName = - "Metadator-${defaultConfig.versionName}-${name}.apk" + (this as com.android.build.gradle.internal.api.BaseVariantOutputImpl) + .outputFileName = "Metadator-${defaultConfig.versionName}-${name}.apk" } } } +} +ktfmt { + // Google style - 2 space indentation & automatically adds/removes trailing commas + // googleStyle() + + // KotlinLang style - 4 space indentation - From + // https://kotlinlang.org/docs/coding-conventions.html + kotlinLangStyle() } ksp { @@ -158,69 +150,72 @@ ksp { dependencies { implementation(project(":app:utilities")) + implementation(project(":core-utilities")) implementation(project(":app:ui")) -//---------------Core----------------// - implementation(libs.bundles.core) //⚠️ This contains core kotlinx libraries, lifecycle runtime and Activity Compose support + // ---------------Core----------------// + implementation( + libs.bundles.core + ) // ⚠️ This contains core kotlinx libraries, lifecycle runtime and Activity Compose support implementation(libs.bundles.coroutines) -//---------------User Interface---------------// -//Core UI libraries + // ---------------User Interface---------------// + // Core UI libraries api(platform(libs.compose.bom)) -//Accompanist libraries + // Accompanist libraries implementation(libs.bundles.accompanist) -//Compose libraries + // Compose libraries implementation(libs.bundles.compose) implementation(libs.materialKolor) -//Pagination + // Pagination implementation(libs.bundles.pagination) -//-------------------Network-------------------// + // -------------------Network-------------------// implementation(libs.bundles.ktor) - //---------------Media3---------------// + // ---------------Media3---------------// implementation(libs.bundles.media3) implementation(project(":app:mediaplayer")) -//---------------Dependency Injection---------------// + // ---------------Dependency Injection---------------// implementation(libs.bundles.koin) -//-------------------Database-------------------// + // -------------------Database-------------------// implementation(libs.room.runtime) implementation(libs.room.ktx) implementation(libs.room.paging) annotationProcessor(libs.room.compiler) -//-------------------Key-value Storage-------------------// + // -------------------Key-value Storage-------------------// implementation(libs.datastore.preferences) -//-------------------Image Loading-------------------// + // -------------------Image Loading-------------------// implementation(libs.landscapist.coil) -//-------------------FIREBASE-------------------// + // -------------------FIREBASE-------------------// "playstoreApi"(platform(libs.firebase.bom)) "playstoreImplementation"(libs.firebase.analytics) "playstoreImplementation"(libs.firebase.crashlytics) -//-------------------Utilities-------------------// + // -------------------Utilities-------------------// implementation(libs.kotlinx.collections.immutable) implementation(libs.profileinstaller) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) - implementation(libs.taglib) + implementation(files("libs/taglib_1.0.2.aar")) implementation(libs.scrollbar) implementation(libs.sonner) implementation(libs.spotify.api.android) implementation(project(":crashhandler")) -//-------------------Testing-------------------// -//Android testing libraries + // -------------------Testing-------------------// + // Android testing libraries testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) -//Compose testing and tooling libraries + // Compose testing and tooling libraries androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.compose.test.junit4) implementation(libs.compose.tooling.preview) @@ -240,4 +235,4 @@ class RoomSchemaArgProvider( } return listOf("room.schemaLocation=${schemaDir.path}") } -} \ No newline at end of file +} diff --git a/app/libs/taglib-release-1.0.3.aar b/app/libs/taglib-release-1.0.3.aar new file mode 100644 index 0000000..1766170 Binary files /dev/null and b/app/libs/taglib-release-1.0.3.aar differ diff --git a/app/libs/taglib_1.0.2.aar b/app/libs/taglib_1.0.2.aar new file mode 100644 index 0000000..9aed234 Binary files /dev/null and b/app/libs/taglib_1.0.2.aar differ diff --git a/app/mediaplayer/build.gradle.kts b/app/mediaplayer/build.gradle.kts index 663bc66..c5e6eed 100644 --- a/app/mediaplayer/build.gradle.kts +++ b/app/mediaplayer/build.gradle.kts @@ -2,12 +2,15 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.android.kotlin) alias(libs.plugins.kotlin.ksp) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.compose.compiler) + alias(libs.plugins.ktfmt.gradle) } android { namespace = "com.bobbyesp.mediaplayer" - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 24 @@ -21,46 +24,54 @@ android { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) } } libraryVariants.all { val variantName = name sourceSets { - getByName("main") { - java.srcDir(File("build/generated/ksp/$variantName/kotlin")) - } + getByName("main") { java.srcDir(File("build/generated/ksp/$variantName/kotlin")) } } } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } - kotlinOptions { - jvmTarget = "21" - } } -ksp { - arg("KOIN_CONFIG_CHECK", "true") +ktfmt { + // Google style - 2 space indentation & automatically adds/removes trailing commas + // googleStyle() + + // KotlinLang style - 4 space indentation - From + // https://kotlinlang.org/docs/coding-conventions.html + kotlinLangStyle() } +ksp { arg("KOIN_CONFIG_CHECK", "true") } + dependencies { implementation(libs.core.ktx) implementation(libs.core.appcompat) - implementation(libs.androidx.legacy.support.v4) // Needed MediaSessionCompat.Token + implementation(libs.androidx.legacy.support.v4) + implementation(project(":core-utilities")) // Needed MediaSessionCompat.Token - //DI (Dependency Injection - Koin) + // Todo create a top level utilities + + // DI (Dependency Injection - Koin) implementation(libs.bundles.koin) - //Media3 + // Media3 implementation(libs.bundles.media3) - //Coil + // KotlinX Serialization + implementation(libs.kotlinx.serialization.json) + + // Coil implementation(libs.coil) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) -} \ No newline at end of file +} diff --git a/app/mediaplayer/src/androidTest/java/com/bobbyesp/mediaplayer/ExampleInstrumentedTest.kt b/app/mediaplayer/src/androidTest/java/com/bobbyesp/mediaplayer/ExampleInstrumentedTest.kt index b48b919..5e2f889 100644 --- a/app/mediaplayer/src/androidTest/java/com/bobbyesp/mediaplayer/ExampleInstrumentedTest.kt +++ b/app/mediaplayer/src/androidTest/java/com/bobbyesp/mediaplayer/ExampleInstrumentedTest.kt @@ -19,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.bobbyesp.mediaplayer.test", appContext.packageName) } -} \ No newline at end of file +} diff --git a/app/mediaplayer/src/main/AndroidManifest.xml b/app/mediaplayer/src/main/AndroidManifest.xml index d46789c..487a25d 100644 --- a/app/mediaplayer/src/main/AndroidManifest.xml +++ b/app/mediaplayer/src/main/AndroidManifest.xml @@ -3,17 +3,18 @@ xmlns:tools="http://schemas.android.com/tools"> + - - + + diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/data/repository/MediaStoreFolderScannerImpl.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/data/repository/MediaStoreFolderScannerImpl.kt new file mode 100644 index 0000000..d769284 --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/data/repository/MediaStoreFolderScannerImpl.kt @@ -0,0 +1,46 @@ +package com.bobbyesp.mediaplayer.data.repository + +import android.content.Context +import android.media.MediaScannerConnection +import com.bobbyesp.mediaplayer.domain.model.MusicTrack +import com.bobbyesp.mediaplayer.domain.repository.MediaStoreFolderScanner +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class MediaStoreFolderScannerImpl : MediaStoreFolderScanner { + override suspend fun scanCustomFolder(path: String): List = + withContext(Dispatchers.IO) { + val folder = File(path) + if (!folder.exists() || !folder.isDirectory) return@withContext emptyList() + + return@withContext folder + .walkTopDown() + .filter { it.isFile && it.extension.lowercase() in allowedExtensions } + .map { file -> + MusicTrack( + id = file.hashCode().toLong(), + title = file.nameWithoutExtension, + path = file.absolutePath, + ) + } + .toList() + } + + override fun forceMediaStoreFolderScanning(context: Context, path: String) { + val file = File(path) + if (!file.exists() || !file.isDirectory) + throw IllegalArgumentException( + "An invalid path was provided to be scanned by MediaStore" + ) + + val paths = + file.walkTopDown().filter { it.isFile }.map { it.absolutePath }.toList().toTypedArray() + + if (paths.isNotEmpty()) { + MediaScannerConnection.scanFile(context, paths, arrayOf("audio/*")) { audioPath, uri -> + println("Scanned: $audioPath, URI: $uri") + } + } + } +} diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/data/repository/MusicLibraryRepositoryImpl.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/data/repository/MusicLibraryRepositoryImpl.kt new file mode 100644 index 0000000..0a81460 --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/data/repository/MusicLibraryRepositoryImpl.kt @@ -0,0 +1,358 @@ +package com.bobbyesp.mediaplayer.data.repository + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.util.Log +import androidx.core.database.getStringOrNull +import androidx.core.net.toUri +import com.bobbyesp.coreutilities.observe +import com.bobbyesp.mediaplayer.domain.enums.MediaStoreSearchFilter +import com.bobbyesp.mediaplayer.domain.model.Genre +import com.bobbyesp.mediaplayer.domain.model.MusicTrack +import com.bobbyesp.mediaplayer.domain.repository.MusicLibraryRepository +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +private const val TAG = "MusicLibraryRepositoryImpl" + +class MusicLibraryRepositoryImpl(private val context: Context) : MusicLibraryRepository { + + override suspend fun getMusicLibrary( + query: String?, + searchFilters: List?, + ): List = + withContext(Dispatchers.IO) { + val musicList = mutableListOf() + val projection = projectionBasedOnVersion() + val selection = buildSelection(query, searchFilters) + val selectionArgs = buildSelectionArgs(query, searchFilters) + val sortOrder = MediaStore.Audio.Media.TITLE + + context.contentResolver + .advancedQuery( + uri = musicUri, + projection = projection, + selection = selection, + args = selectionArgs, + order = sortOrder, + ascending = true, + ) + ?.use { cursor -> musicList.addAll(parseCursor(cursor)) } + musicList.toList() // Convert to immutable list + } + + override fun observeMusicLibrary( + query: String?, + searchFilters: List?, + ): Flow> = + context.contentResolver + .observe(musicUri) + .map { getMusicLibrary(query, searchFilters) } + .flowOn(Dispatchers.IO) + + override fun getGenres(): List { + val projection = arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME) + + val query = context.contentResolver.query(musicUri, projection, null, null, null) + + val genres = mutableListOf() + query?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID) + val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME) + + while (cursor.moveToNext()) { + val id = getLong(cursor, idColumn) ?: continue + val name = getString(cursor, nameColumn) ?: "" + + genres.add(Genre(id = id, name = name)) + } + } + + return genres + } + + override fun getTrackIdMapToGenreName(): Map { + val trackIdToGenreMap = mutableMapOf() + + getGenres().forEach { genre -> + val collection = MediaStore.Audio.Genres.Members.getContentUri("external", genre.id) + val projection = arrayOf(MediaStore.Audio.Genres.Members.AUDIO_ID) + + context.contentResolver.query(collection, projection, null, null, null)?.use { cursor -> + val audioIdColumn = + cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members.AUDIO_ID) + while (cursor.moveToNext()) { + trackIdToGenreMap[cursor.getLong(audioIdColumn)] = genre.name + } + } + } + + return trackIdToGenreMap + } + + override fun getFoldersWithAudio(): Set { + val projection = arrayOf(MediaStore.Audio.Media.DATA) + + val selection = "${MediaStore.Audio.Media.DURATION} >= ?" + + // 30 seconds will be the minimum duration for the audios to be considered tracks. + // This may be considered a preference in the future. + val selectionArgs = arrayOf(TimeUnit.MILLISECONDS.convert(30, TimeUnit.SECONDS).toString()) + + val query = + context.contentResolver.query( + musicUri, + projection, + selection, // .takeIf { settings.ignoreShortTracks }, + selectionArgs, // . takeIf { settings.ignoreShortTracks }, + null, + ) + + val paths = mutableSetOf() + query?.use { cursor -> + val dataColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DATA) + if (dataColumn < 0) return emptySet() + + while (cursor.moveToNext()) { + val data = cursor.getStringOrNull(dataColumn) ?: continue + paths += data.substringBeforeLast('/') + } + } + + return paths + } + + private fun projectionBasedOnVersion(): Array = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.TRACK, + MediaStore.Audio.Media.YEAR, + MediaStore.Audio.Media.DATA, + MediaStore.Audio.Media.SIZE, + MediaStore.Audio.Media.DISC_NUMBER, + MediaStore.Audio.Media.DATE_ADDED, + MediaStore.Audio.Media.DATE_MODIFIED, + MediaStore.Audio.Media.GENRE, + ) + } else { + arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.TRACK, + MediaStore.Audio.Media.YEAR, + MediaStore.Audio.Media.DATA, + MediaStore.Audio.Media.SIZE, + MediaStore.Audio.Media.DISC_NUMBER, + MediaStore.Audio.Media.DATE_ADDED, + MediaStore.Audio.Media.DATE_MODIFIED, + ) + } + + // TODO: Add support for more filters (like ignore some folders, genres, etc...) + private fun buildSelection( + searchTerm: String?, + filters: List?, + ): String { + val selection = StringBuilder(MUSIC_SELECTION) + + searchTerm?.let { term -> + selection.append(" AND (") + if (!filters.isNullOrEmpty()) { + val filterSelection = filters.joinToString(" OR ") { "${it.column} LIKE '%$term%'" } + selection.append(filterSelection) + } else { + selection.append( + "${MediaStore.Audio.Media.TITLE} LIKE '%$term%' OR " + + "${MediaStore.Audio.Media.ARTIST} LIKE '%$term%' OR " + + "${MediaStore.Audio.Media.ALBUM} LIKE '%$term%'" + ) + } + selection.append(")") + } + return selection.toString() + } + + private fun buildSelectionArgs( + searchTerm: String?, + filters: List?, + ): Array? = + searchTerm?.let { term -> + Array(if (filters.isNullOrEmpty()) 3 else filters.size) { "%$term%" } + } + + private fun parseCursor(cursor: Cursor): List { + val musicList = mutableListOf() + + val columnIndices = MusicColumnIndices(cursor) + + while (cursor.moveToNext()) { + val id = + getLong(cursor, columnIndices.id) + ?: continue // throw IllegalStateException("ID cannot be null") + val albumId = getLong(cursor, columnIndices.albumId) + val path = + getString(cursor, columnIndices.path) + ?: continue // throw IllegalStateException("Path cannot be null") + val title = getString(cursor, columnIndices.title) ?: path.substringAfterLast("/") + val artist = getString(cursor, columnIndices.artist) + val album = getString(cursor, columnIndices.album) + val duration = getLong(cursor, columnIndices.duration) + val trackNumber = getInt(cursor, columnIndices.trackNumber) + val discNumber = getInt(cursor, columnIndices.discNumber) + val year = getInt(cursor, columnIndices.year) + val addedTimestamp = getLong(cursor, columnIndices.addedTimestamp) + val modifiedTimestamp = getLong(cursor, columnIndices.modifiedTimestamp) + val genre = columnIndices.genre?.let { getString(cursor, it) } + val size = getLong(cursor, columnIndices.size) + val artworkUrl = + ContentUris.withAppendedId( + "content://media/external/audio/albumart".toUri(), + albumId ?: 0, + ) + + musicList.add( + MusicTrack( + id = id, + title = title, + artist = artist, + album = album, + duration = duration, + trackNumber = trackNumber, + discNumber = discNumber, + year = year, + path = path, + addedTimestamp = addedTimestamp, + modifiedTimestamp = modifiedTimestamp, + genre = genre, + size = size, + artworkUri = artworkUrl.toString(), + ) + ) + } + return musicList.toList() // Convert to immutable list + } + + private data class MusicColumnIndices(val cursor: Cursor) { + val id = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val title = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) + val artist = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) + val album = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) + val albumId = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) + val duration = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val trackNumber = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TRACK) + val discNumber = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISC_NUMBER) + val year = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.YEAR) + val path = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) + val addedTimestamp = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED) + val modifiedTimestamp = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED) + val genre = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + cursor.getColumnIndex( + MediaStore.Audio.Media.GENRE + ) // Use getColumnIndex to avoid exception if not present + } else null + val size = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) + } + + private fun getString(cursor: Cursor, columnIndex: Int): String? { + return try { + cursor.getString(columnIndex) + } catch (e: Exception) { + Log.e(TAG, "Error getting String from cursor at index $columnIndex", e) + null + } + } + + private fun getLong(cursor: Cursor, columnIndex: Int): Long? { + return try { + cursor.getLong(columnIndex) + } catch (e: Exception) { + Log.e(TAG, "Error getting Long from cursor at index $columnIndex", e) + null + } + } + + private fun getInt(cursor: Cursor, columnIndex: Int): Int? { + return try { + cursor.getInt(columnIndex) + } catch (e: Exception) { + Log.e(TAG, "Error getting Int from cursor at index $columnIndex", e) + null + } + } + + /** + * Performs an advanced query on the content resolver. + * + * @param uri The URI to query. + * @param projection The list of columns to put into the cursor. + * @param selection A filter declaring which rows to return. + * @param args You may include ?s in selection, which will be replaced by the values from + * selectionArgs. + * @param order How to order the rows, formatted as an SQL ORDER BY clause. + * @param ascending Whether the results should be in ascending order. + * @param offset The offset of the first row to return. + * @param limit The maximum number of rows to return. + * @return A Cursor object, which is positioned before the first entry. + */ + @SuppressLint("Recycle") + private suspend fun ContentResolver.advancedQuery( + uri: Uri, + projection: Array? = null, + selection: String, + args: Array? = null, + order: String = MediaStore.MediaColumns._ID, + ascending: Boolean = true, + offset: Int = 0, + limit: Int = Int.MAX_VALUE, + ): Cursor? = + withContext(Dispatchers.IO) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val queryArgs = + Bundle().apply { + putInt(ContentResolver.QUERY_ARG_LIMIT, limit) + putInt(ContentResolver.QUERY_ARG_OFFSET, offset) + putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, arrayOf(order)) + putInt( + ContentResolver.QUERY_ARG_SORT_DIRECTION, + if (ascending) ContentResolver.QUERY_SORT_DIRECTION_ASCENDING + else ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, + ) + args?.let { + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, it) + } + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection) + } + query(uri, projection, queryArgs, null) + } else { + val orderClause = + "$order ${if (ascending) "ASC" else "DESC"} LIMIT $limit OFFSET $offset" + query(uri, projection, selection, args, orderClause) + } + } + + companion object { + private const val MUSIC_SELECTION = "${MediaStore.Audio.Media.IS_MUSIC} != 0" + } +} diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/data/service/PlaybackService.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/data/service/PlaybackService.kt new file mode 100644 index 0000000..3c305f5 --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/data/service/PlaybackService.kt @@ -0,0 +1,56 @@ +package com.bobbyesp.mediaplayer.data.service + +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.parameter.parametersOf + +class PlaybackService : KoinComponent, MediaLibraryService() { + + // We inject the ExoPlayer instance + private val player: ExoPlayer by inject() + + // Callback for MediaLibrarySession + private val callback = + object : MediaLibrarySession.Callback { + // Whatever we need... + } + + // MediaLibrarySession has to be lazily created because it needs the callback + private var mediaLibrarySession: MediaLibrarySession? = null + + override fun onCreate() { + super.onCreate() + mediaLibrarySession = + getKoin().get { + parametersOf(callback) + } // We pass the callback to the MediaLibrarySession constructor + // player.addListener( + // object : Player.Listener { + // override fun onPlaybackStateChanged(playbackState: Int) { + // if (playbackState == Player.STATE_READY || playbackState == + // Player.STATE_BUFFERING) { + // val audioSessionId = player.audioSessionId + // if (audioSessionId != C.AUDIO_SESSION_ID_UNSET) { + // equalizerController.updateEqualizer(audioSessionId) + // } + // } + // } + // } + // ) + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = + mediaLibrarySession + + override fun onDestroy() { + mediaLibrarySession?.run { + release() + mediaLibrarySession = null + } + player.release() + super.onDestroy() + } +} diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/di/MediaPlayerModule.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/di/MediaPlayerModule.kt index 0ee3eb4..97f0b99 100644 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/di/MediaPlayerModule.kt +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/di/MediaPlayerModule.kt @@ -1,18 +1,13 @@ -import android.app.PendingIntent -import android.os.Build import androidx.annotation.OptIn import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService.MediaLibrarySession -import com.bobbyesp.mediaplayer.service.ConnectionHandler -import com.bobbyesp.mediaplayer.service.MediaLibrarySessionCallback -import com.bobbyesp.mediaplayer.service.MediaServiceHandler -import com.bobbyesp.mediaplayer.service.notifications.MediaNotificationManager -import com.bobbyesp.mediaplayer.service.notifications.customLayout.MediaSessionLayoutHandler -import com.bobbyesp.mediaplayer.service.notifications.customLayout.MediaSessionLayoutHandlerImpl +import com.bobbyesp.mediaplayer.data.repository.MusicLibraryRepositoryImpl +import com.bobbyesp.mediaplayer.domain.repository.MusicLibraryRepository import org.koin.android.ext.koin.androidContext import org.koin.core.module.Module import org.koin.dsl.module @@ -30,52 +25,15 @@ val mediaplayerInternalsModule: Module = module { ExoPlayer.Builder(androidContext()) .setSeekBackIncrementMs(5000) .setSeekForwardIncrementMs(5000) - .setAudioAttributes(get(), true) .setHandleAudioBecomingNoisy(true) .setTrackSelector(DefaultTrackSelector(androidContext())) - .setAudioAttributes( - get(), - true - ) + .setAudioAttributes(get(), true) .build() } - single { - MediaServiceHandler( - player = get() - ) - } - - single { ConnectionHandler() } - - single { - MediaLibrarySession.Builder( - androidContext(), - get(), - MediaLibrarySessionCallback(androidContext()) - ).setSessionActivity( - PendingIntent.getActivity( - androidContext(), - 0, - androidContext().packageManager.getLaunchIntentForPackage(androidContext().packageName), - PendingIntent.FLAG_IMMUTABLE - ) - ).build() - } - - single { - MediaSessionLayoutHandlerImpl( - androidContext(), - get() - ) + single { (callback: MediaLibrarySession.Callback) -> + MediaLibrarySession.Builder(get(), get(), callback).build() } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - single { - MediaNotificationManager( - context = androidContext(), - player = get() - ) - } - } + single { MusicLibraryRepositoryImpl(context = androidContext()) } } diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/enums/AudioChannels.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/enums/AudioChannels.kt new file mode 100644 index 0000000..35c0df6 --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/enums/AudioChannels.kt @@ -0,0 +1,8 @@ +package com.bobbyesp.mediaplayer.domain.enums + +enum class AudioChannels(val value: Int) { + MONO(1), + STEREO(2), + SURROUND_5_1(6), + SURROUND_7_1(8), +} diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/enums/MediaStoreSearchFilter.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/enums/MediaStoreSearchFilter.kt new file mode 100644 index 0000000..152c79e --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/enums/MediaStoreSearchFilter.kt @@ -0,0 +1,10 @@ +package com.bobbyesp.mediaplayer.domain.enums + +import android.provider.MediaStore + +enum class MediaStoreSearchFilter(val column: String) { + TITLE(MediaStore.Audio.Media.TITLE), + ARTIST(MediaStore.Audio.Media.ARTIST), + YEAR(MediaStore.Audio.Media.YEAR), + ALBUM(MediaStore.Audio.Media.ALBUM), +} diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/model/Genre.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/model/Genre.kt new file mode 100644 index 0000000..b083063 --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/model/Genre.kt @@ -0,0 +1,8 @@ +package com.bobbyesp.mediaplayer.domain.model + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Immutable @Serializable @Parcelize data class Genre(val id: Long, val name: String) : Parcelable diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/model/MusicTrack.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/model/MusicTrack.kt new file mode 100644 index 0000000..7534e21 --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/model/MusicTrack.kt @@ -0,0 +1,48 @@ +package com.bobbyesp.mediaplayer.domain.model + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * Data class representing a music track. + * + * @property id Unique ID (can be generated by MediaStore or a hash). + * @property title Title of the song. + * @property artist Artist of the song. + * @property album Album of the song. + * @property genre Genre of the song (can be null if not available). + * @property duration Duration of the song in milliseconds. + * @property trackNumber Track number in the album (if available). + * @property discNumber Disc number (for albums with multiple discs). + * @property year Year of release. + * @property lyrics Lyrics of the song (if available). + * @property path Full path of the file in storage. + * @property artworkUri URI of the album artwork. + * @property addedTimestamp Date when the song was added to the library. + * @property modifiedTimestamp Last modification date of the file. + * @property size Size of the file in bytes. + * @property trackTechnicalDetails Technical details of the track (bitrate, sample rate, channels). + */ +@Parcelize +@Immutable +@Serializable +data class MusicTrack( + val id: Long, + val title: String, + val artist: String? = null, + val album: String? = null, + val genre: String? = null, + val duration: Long? = null, + val trackNumber: Int? = null, + val discNumber: Int? = null, + val year: Int? = null, + val lyrics: String? = null, + val path: String, + val artworkUri: String? = null, + val addedTimestamp: Long? = null, + val modifiedTimestamp: Long? = null, + val size: Long? = null, + val trackTechnicalDetails: TrackTechnicalDetails? = null, +) : Parcelable diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/model/TrackTechnicalDetails.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/model/TrackTechnicalDetails.kt new file mode 100644 index 0000000..4049ab5 --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/model/TrackTechnicalDetails.kt @@ -0,0 +1,16 @@ +package com.bobbyesp.mediaplayer.domain.model + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import com.bobbyesp.mediaplayer.domain.enums.AudioChannels +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Parcelize +@Immutable +@Serializable +data class TrackTechnicalDetails( + val bitrate: Int, + val sampleRate: Int, + val channels: AudioChannels, +) : Parcelable diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/repository/MediaStoreFolderScanner.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/repository/MediaStoreFolderScanner.kt new file mode 100644 index 0000000..49e8bc8 --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/repository/MediaStoreFolderScanner.kt @@ -0,0 +1,13 @@ +package com.bobbyesp.mediaplayer.domain.repository + +import android.content.Context +import com.bobbyesp.mediaplayer.domain.model.MusicTrack + +interface MediaStoreFolderScanner { + val allowedExtensions: Set + get() = setOf("mp3", "flac", "wav", "ogg", "m4a", "aac", "opus") + + suspend fun scanCustomFolder(path: String): List + + fun forceMediaStoreFolderScanning(context: Context, path: String) +} diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/repository/MusicLibraryRepository.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/repository/MusicLibraryRepository.kt new file mode 100644 index 0000000..f20250c --- /dev/null +++ b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/domain/repository/MusicLibraryRepository.kt @@ -0,0 +1,29 @@ +package com.bobbyesp.mediaplayer.domain.repository + +import android.net.Uri +import android.provider.MediaStore +import com.bobbyesp.mediaplayer.domain.enums.MediaStoreSearchFilter +import com.bobbyesp.mediaplayer.domain.model.Genre +import com.bobbyesp.mediaplayer.domain.model.MusicTrack +import kotlinx.coroutines.flow.Flow + +interface MusicLibraryRepository { + val musicUri: Uri + get() = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + + suspend fun getMusicLibrary( + query: String? = null, + searchFilters: List? = null, + ): List + + fun observeMusicLibrary( + query: String?, + searchFilters: List?, + ): Flow> + + fun getGenres(): List + + fun getTrackIdMapToGenreName(): Map + + fun getFoldersWithAudio(): Set +} diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/ext/MediaMetadata.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/ext/MediaMetadata.kt deleted file mode 100644 index a1dbb62..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/ext/MediaMetadata.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.bobbyesp.mediaplayer.ext - -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata - -fun MediaMetadata.toMediaItem(): MediaItem { - return MediaItem.Builder() - .setMediaMetadata(this) - .build() -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/ConnectionState.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/ConnectionState.kt deleted file mode 100644 index 5e4faab..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/ConnectionState.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.bobbyesp.mediaplayer.service - -import android.util.Log -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update - -@OptIn(UnstableApi::class) -sealed class ConnectionState { - data object Disconnected : ConnectionState() - data class Connected(val serviceHandler: MediaServiceHandler) : ConnectionState() -} - -@OptIn(UnstableApi::class) -class ConnectionHandler { - private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) - val connectionState: StateFlow = _connectionState - .onEach { newState -> - Log.d("ConnectionHandler", "Connection state changed: $newState") - } - .stateIn( - CoroutineScope(Dispatchers.Default), - started = SharingStarted.WhileSubscribed(5000), - initialValue = ConnectionState.Disconnected - ) - - fun connect(serviceHandler: MediaServiceHandler) { - _connectionState.update { - ConnectionState.Connected(serviceHandler) - } - } - - fun disconnect() { - _connectionState.update { - ConnectionState.Disconnected - } - } -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaLibrarySessionCallback.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaLibrarySessionCallback.kt deleted file mode 100644 index 1a584a5..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaLibrarySessionCallback.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.bobbyesp.mediaplayer.service - -import android.content.Context -import android.os.Bundle -import androidx.annotation.OptIn -import androidx.media3.common.Player.REPEAT_MODE_ALL -import androidx.media3.common.Player.REPEAT_MODE_OFF -import androidx.media3.common.Player.REPEAT_MODE_ONE -import androidx.media3.common.util.UnstableApi -import androidx.media3.session.MediaLibraryService.MediaLibrarySession -import androidx.media3.session.MediaSession -import androidx.media3.session.SessionCommand -import androidx.media3.session.SessionResult -import com.bobbyesp.mediaplayer.service.MediaSessionConstants.ACTION_TOGGLE_REPEAT_MODE -import com.bobbyesp.mediaplayer.service.MediaSessionConstants.ACTION_TOGGLE_SHUFFLE -import com.bobbyesp.mediaplayer.service.MediaSessionConstants.CommandToggleRepeatMode -import com.bobbyesp.mediaplayer.service.MediaSessionConstants.CommandToggleShuffle -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture -import org.koin.core.component.KoinComponent - -class MediaLibrarySessionCallback( - val context: Context, -) : KoinComponent, MediaLibrarySession.Callback { - - private val availableCommands = listOf( - CommandToggleShuffle, - CommandToggleRepeatMode - ) - - override fun onConnect( - session: MediaSession, //I've checked the MediaSession class and it is correct. Not the problem of the player - controller: MediaSession.ControllerInfo - ): MediaSession.ConnectionResult { - val connectionResult = super.onConnect(session, controller) - val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() - - availableCommands.forEach { - availableSessionCommands.add(it) - } - - return MediaSession.ConnectionResult.accept( - availableSessionCommands.build(), - connectionResult.availablePlayerCommands - ) - } - - @OptIn(UnstableApi::class) - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle, - ): ListenableFuture { - when (customCommand.customAction) { - ACTION_TOGGLE_SHUFFLE -> session.player.shuffleModeEnabled = - !session.player.shuffleModeEnabled - - ACTION_TOGGLE_REPEAT_MODE -> session.player.repeatMode = - when (session.player.repeatMode) { - REPEAT_MODE_OFF -> REPEAT_MODE_ONE - REPEAT_MODE_ONE -> REPEAT_MODE_ALL - REPEAT_MODE_ALL -> REPEAT_MODE_OFF - else -> REPEAT_MODE_OFF - } - } - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaServiceHandler.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaServiceHandler.kt deleted file mode 100644 index 58e3e6f..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaServiceHandler.kt +++ /dev/null @@ -1,323 +0,0 @@ -package com.bobbyesp.mediaplayer.service - -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.Player.EVENT_POSITION_DISCONTINUITY -import androidx.media3.common.Player.EVENT_TIMELINE_CHANGED -import androidx.media3.common.Player.REPEAT_MODE_ALL -import androidx.media3.common.Player.REPEAT_MODE_OFF -import androidx.media3.common.Player.REPEAT_MODE_ONE -import androidx.media3.common.Player.STATE_IDLE -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.exoplayer.analytics.PlaybackStats -import androidx.media3.exoplayer.analytics.PlaybackStatsListener -import androidx.media3.exoplayer.source.ShuffleOrder -import com.bobbyesp.mediaplayer.ext.toMediaItem -import com.bobbyesp.mediaplayer.service.notifications.customLayout.MediaSessionLayoutHandler -import com.bobbyesp.mediaplayer.service.queue.EmptyQueue -import com.bobbyesp.mediaplayer.service.queue.Queue -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.koin.core.component.KoinComponent - -/** - * This class is responsible for handling media playback events and managing the state of the media player. - * It provides methods to control the media player such as play, pause, stop, and seek. - * It also provides methods to manage the media queue such as set, add, and remove media items. - */ -@UnstableApi -class MediaServiceHandler( - private val player: ExoPlayer -) : KoinComponent, Player.Listener, PlaybackStatsListener.Callback { - - private val _mediaState = MutableStateFlow(MediaState.Idle) - val mediaState = _mediaState.asStateFlow() - - private var currentQueue: Queue = EmptyQueue - var queueTitle: String? = null - - val currentMediaItem = MutableStateFlow(null) - - val isPlaying: MutableStateFlow = MutableStateFlow(false) - val shuffleModeEnabled = MutableStateFlow(false) - val repeatMode = MutableStateFlow(REPEAT_MODE_OFF) - val canSkipNext: Boolean - get() = player.hasNextMediaItem() - - val canSkipPrevious: Boolean = false - - private var job: Job? = null - private val scope = CoroutineScope(Dispatchers.Main) - private lateinit var mediaSessionInterface: MediaSessionLayoutHandler - - fun setMediaSessionInterface(mediaSessionInterface: MediaSessionLayoutHandler) { - this.mediaSessionInterface = mediaSessionInterface - } - - init { - player.addListener(this) - repeatMode.update { player.repeatMode } - shuffleModeEnabled.update { player.shuffleModeEnabled } - job = Job() - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - mediaSessionInterface.updateNotificationLayout() - _mediaState.update { - MediaState.Playing(isPlaying) - } - this.isPlaying.update { - isPlaying - } - super.onIsPlayingChanged(isPlaying) - } - - /** - * Stops the player, clears the media queue, and releases resources. - */ - fun killPlayer() { - player.removeListener(this) - player.stop() - player.clearMediaItems() - player.release() - } - - /** - * Sets a single media item as the current item and prepares the player for playback. - * @param mediaItem The media item to be set. - */ - fun setMediaItem(mediaItem: MediaItem) { - player.setMediaItem(mediaItem) - player.prepare() - } - - fun playQueue(queue: Queue, playWhenReady: Boolean = true) { - currentQueue = queue - queueTitle = null - if (queue.preloadItem != null) { - setMediaItem(queue.preloadItem!!.toMediaItem()) - player.playWhenReady = playWhenReady - } - - // Launch a new coroutine in the main thread - scope.launch { - // Get the initial state of the queue in the IO thread - // This is a suspending operation and will not block the main thread - val initialState = withContext(Dispatchers.IO) { queue.getInitialData() } - - // Set the title of the queue - queueTitle = initialState.title - - // Check if the initial state has any items and if the player is not idle or the preload item is null - // If both conditions are met, proceed with the rest of the code - if (initialState.items.isNotEmpty() && !(queue.preloadItem != null && player.playbackState == STATE_IDLE)) { - // If the preload item is not null, add media items to the player - if (queue.preloadItem != null) { - // Add media items from the start of the list to the current media item index - player.addMediaItems( - 0, - initialState.items.subList(0, initialState.mediaItemIndex) - ) - // Add media items from the current media item index to the end of the list - player.addMediaItems( - initialState.items.subList( - initialState.mediaItemIndex + 1, - initialState.items.size - ) - ) - } else { - // If the preload item is null, set media items to the player - // If the media item index is greater than 0, use it as the start position - // Otherwise, use 0 as the start position - player.setMediaItems( - initialState.items, - if (initialState.mediaItemIndex > 0) initialState.mediaItemIndex else 0, - initialState.position - ) - // Prepare the player for playback - player.prepare() - // Set the player to start playback when it's ready - player.playWhenReady = playWhenReady - } - } - } - } - - /** - * Seeks to a specific position in the current media item. - * @param positionMs The position to seek to, in milliseconds. - */ - fun seekTo(positionMs: Long) { - player.seekTo(positionMs) - } - - suspend fun onPlayerEvent(playerEvent: PlayerEvent) { - when (playerEvent) { - is PlayerEvent.PlayPause -> { - if (player.isPlaying) { - player.pause() - stopProgressUpdate() - } else { - player.play() - startProgressUpdate() - } - } - - is PlayerEvent.Stop -> { - killPlayer() - stopProgressUpdate() - } - - is PlayerEvent.Next -> { - player.seekToNext() - } - - is PlayerEvent.Previous -> { - player.seekToPrevious() - } - - is PlayerEvent.UpdateProgress -> { - player.seekTo(playerEvent.updatedProgress) - } - - PlayerEvent.ToggleRepeat -> { - val repeatMode = when (player.repeatMode) { - REPEAT_MODE_OFF -> REPEAT_MODE_ONE - REPEAT_MODE_ONE -> REPEAT_MODE_ALL - REPEAT_MODE_ALL -> REPEAT_MODE_OFF - else -> REPEAT_MODE_OFF - } - player.repeatMode = repeatMode - } - - PlayerEvent.ToggleShuffle -> { - player.shuffleModeEnabled = !player.shuffleModeEnabled - } - } - } - - override fun onEvents(player: Player, events: Player.Events) { - super.onEvents(player, events) - if (events.containsAny(EVENT_TIMELINE_CHANGED, EVENT_POSITION_DISCONTINUITY)) { - currentMediaItem.value = player.currentMediaItem - } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - when (playbackState) { - ExoPlayer.STATE_BUFFERING -> _mediaState.update { - MediaState.Buffering(player.duration) - } - - ExoPlayer.STATE_READY -> _mediaState.update { - MediaState.Ready(player.duration) - } - - ExoPlayer.STATE_ENDED -> _mediaState.update { - MediaState.Idle - } - - ExoPlayer.STATE_IDLE -> _mediaState.update { - MediaState.Idle - } - } - } - - override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { - super.onPlayWhenReadyChanged(playWhenReady, reason) - if (reason == STATE_IDLE) { - currentQueue = EmptyQueue - queueTitle = null - } - } - - /** - * This method is triggered when the shuffle mode is enabled or disabled in the player. - * It updates the notification layout and, if shuffle mode is enabled, it shuffles the media items in the player. - * - * @param shuffleModeEnabled A boolean indicating whether shuffle mode is enabled. - */ - override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { - // Update the notification layout - mediaSessionInterface.updateNotificationLayout() - this.shuffleModeEnabled.update { shuffleModeEnabled } - - // If shuffle mode is enabled - if (shuffleModeEnabled) { - // Always put current playing item at first - // Create an array of indices representing the media items in the player - val shuffledIndices = IntArray(player.mediaItemCount) { it } - - // Shuffle the indices - shuffledIndices.shuffle() - - // Swap the current media item index with the first index - shuffledIndices[shuffledIndices.indexOf(player.currentMediaItemIndex)] = - shuffledIndices[0] - shuffledIndices[0] = player.currentMediaItemIndex - - // Set the shuffle order in the player using the shuffled indices - player.setShuffleOrder( - ShuffleOrder.DefaultShuffleOrder( - shuffledIndices, - System.currentTimeMillis() - ) - ) - } - } - - override fun onRepeatModeChanged(repeatMode: Int) { - mediaSessionInterface.updateNotificationLayout() - this.repeatMode.update { repeatMode } - } - - private suspend fun startProgressUpdate() = job.run { - while (true) { - delay(250) - _mediaState.update { - MediaState.Progress(player.currentPosition) - } - } - } - - private fun stopProgressUpdate() { - job?.cancel() - _mediaState.update { - MediaState.Playing(false) - } - } - - override fun onPlaybackStatsReady( - eventTime: AnalyticsListener.EventTime, - playbackStats: PlaybackStats - ) { - TODO("Not yet implemented") - } -} - -sealed class PlayerEvent { - data object PlayPause : PlayerEvent() - data object Stop : PlayerEvent() - data object Next : PlayerEvent() - data object Previous : PlayerEvent() - data object ToggleShuffle : PlayerEvent() - data object ToggleRepeat : PlayerEvent() - data class UpdateProgress(val updatedProgress: Long) : PlayerEvent() -} - -sealed class MediaState { //TODO: NOT USE SEALED CLASSES - data object Idle : MediaState() - data class Ready(val duration: Long) : MediaState() - data class Progress(val progress: Long) : MediaState() - data class Buffering(val progress: Long) : MediaState() - data class Playing(val isPlaying: Boolean) : MediaState() -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaSessionConstants.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaSessionConstants.kt deleted file mode 100644 index 27c63d4..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaSessionConstants.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.bobbyesp.mediaplayer.service - -import android.os.Bundle -import androidx.media3.session.SessionCommand - -object MediaSessionConstants { - const val ACTION_TOGGLE_LIBRARY = "TOGGLE_LIBRARY" - const val ACTION_TOGGLE_LIKE = "TOGGLE_LIKE" - const val ACTION_TOGGLE_SHUFFLE = "TOGGLE_SHUFFLE" - const val ACTION_TOGGLE_REPEAT_MODE = "TOGGLE_REPEAT_MODE" - val CommandToggleLibrary = SessionCommand(ACTION_TOGGLE_LIBRARY, Bundle.EMPTY) - val CommandToggleLike = SessionCommand(ACTION_TOGGLE_LIKE, Bundle.EMPTY) - val CommandToggleShuffle = SessionCommand(ACTION_TOGGLE_SHUFFLE, Bundle.EMPTY) - val CommandToggleRepeatMode = SessionCommand(ACTION_TOGGLE_REPEAT_MODE, Bundle.EMPTY) -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaplayerService.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaplayerService.kt deleted file mode 100644 index c78160b..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/MediaplayerService.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.bobbyesp.mediaplayer.service - -import android.content.Intent -import android.os.Binder -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.session.MediaController -import androidx.media3.session.MediaLibraryService -import androidx.media3.session.MediaSession -import com.bobbyesp.mediaplayer.service.notifications.MediaNotificationManager -import com.bobbyesp.mediaplayer.service.notifications.customLayout.MediaSessionLayoutHandler -import com.google.common.util.concurrent.MoreExecutors -import org.koin.android.ext.android.inject - -@UnstableApi -class MediaplayerService : MediaLibraryService(), MediaController.Listener { - - val mediaSession: MediaLibrarySession by inject() - val mediaServiceHandler: MediaServiceHandler by inject() - val notificationManager: MediaNotificationManager by inject() - val mediaSessionLayoutHandler: MediaSessionLayoutHandler by inject() - val connectionHandler: ConnectionHandler by inject() - - /** - * This method is called by the system every time a client explicitly starts the service by calling - * [android.content.Context.startService], providing the arguments it supplied and a unique integer - * token representing the start request. - */ - @UnstableApi - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - // Connects the media service handler to the connection handler - connectionHandler.connect(mediaServiceHandler) - - // Starts the notification service with the current media session service and media session - notificationManager.startNotificationService( - mediaSessionService = this, mediaSession = mediaSession - ) - - // Sets the media session interface for the media service handler - mediaServiceHandler.setMediaSessionInterface(mediaSessionLayoutHandler) - - // Builds a media controller asynchronously with the current media session token - val controllerFuture = MediaController.Builder(this, mediaSession.token).buildAsync() - - // Adds a listener to the controller future that gets the result of the future when it's ready - controllerFuture.addListener({ controllerFuture.get() }, MoreExecutors.directExecutor()) - - // Calls the super implementation of onStartCommand - return super.onStartCommand(intent, flags, startId) - } - - override fun onDestroy() { - connectionHandler.disconnect() - mediaSession.run { - release() - clearListener() - if (player.playbackState != Player.STATE_IDLE) { - player.seekTo(0) - player.clearMediaItems() - player.playWhenReady = false - player.stop() - } - } - super.onDestroy() - } - - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - if (!mediaSession.player.playWhenReady || mediaSession.player.mediaItemCount == 0) { - stopSelf() - } - } - - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = - mediaSession - - inner class MusicBinder : Binder() { - val service: MediaplayerService - get() = this@MediaplayerService - } -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaCustomActionReceiver.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaCustomActionReceiver.kt deleted file mode 100644 index bcddc9e..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaCustomActionReceiver.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.bobbyesp.mediaplayer.service.notifications - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import androidx.core.app.NotificationCompat -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.PlayerNotificationManager.CustomActionReceiver -import com.bobbyesp.mediaplayer.R - -@UnstableApi -class MediaCustomActionReceiver : CustomActionReceiver { - companion object { - const val CUSTOM_ACTION_SHUFFLE = - "com.bobbyesp.mediaplayer.service.notifications.ACTION_SHUFFLE" - const val CUSTOM_ACTION_REPEAT = - "com.bobbyesp.mediaplayer.service.notifications.ACTION_REPEAT" - } - - override fun createCustomActions( - context: Context, - instanceId: Int - ): MutableMap { - val shuffleAction = NotificationCompat.Action.Builder( - R.drawable.shuffle, - context.getString(R.string.action_shuffle_on), - PendingIntent.getBroadcast( - context, - instanceId, - Intent(CUSTOM_ACTION_SHUFFLE).setPackage(context.packageName), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - ).build() - - val repeatAction = NotificationCompat.Action.Builder( - R.drawable.repeat, - context.getString(R.string.repeat_mode_all), - PendingIntent.getBroadcast( - context, - instanceId, - Intent(CUSTOM_ACTION_REPEAT).setPackage(context.packageName), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - ).build() - - return mutableMapOf( - CUSTOM_ACTION_SHUFFLE to shuffleAction, - CUSTOM_ACTION_REPEAT to repeatAction - ) - } - - override fun getCustomActions(player: Player): MutableList { - return mutableListOf(CUSTOM_ACTION_SHUFFLE, CUSTOM_ACTION_REPEAT) - } - - override fun onCustomAction(player: Player, action: String, intent: Intent) { - when (action) { - CUSTOM_ACTION_SHUFFLE -> player.shuffleModeEnabled = !player.shuffleModeEnabled - CUSTOM_ACTION_REPEAT -> { - when (player.repeatMode) { - Player.REPEAT_MODE_OFF -> player.repeatMode = Player.REPEAT_MODE_ALL - Player.REPEAT_MODE_ALL -> player.repeatMode = Player.REPEAT_MODE_ONE - Player.REPEAT_MODE_ONE -> player.repeatMode = Player.REPEAT_MODE_OFF - } - } - } - } -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaNotificationAdapter.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaNotificationAdapter.kt deleted file mode 100644 index 5d6a831..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaNotificationAdapter.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.bobbyesp.mediaplayer.service.notifications - -import android.app.PendingIntent -import android.content.Context -import android.graphics.Bitmap -import androidx.core.graphics.drawable.toBitmap -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.PlayerNotificationManager -import coil.ImageLoader - -@UnstableApi -class MediaNotificationAdapter( - private val context: Context, - private val pendingIntent: PendingIntent? -) : PlayerNotificationManager.MediaDescriptionAdapter { - - override fun getCurrentContentTitle(player: Player): CharSequence { - return player.mediaMetadata.albumTitle - ?: context.getString(androidx.media3.ui.R.string.exo_track_unknown) - } - - override fun createCurrentContentIntent(player: Player): PendingIntent? { - return pendingIntent - } - - override fun getCurrentContentText(player: Player): CharSequence { - return player.mediaMetadata.displayTitle - ?: context.getString(androidx.media3.ui.R.string.exo_track_unknown) - } - - override fun getCurrentLargeIcon( - player: Player, - callback: PlayerNotificationManager.BitmapCallback - ): Bitmap? { - val loader = ImageLoader(context) - val request = coil.request.ImageRequest.Builder(context) - .data(player.mediaMetadata.artworkData) - .target { drawable -> - callback.onBitmap(drawable.toBitmap()) - } - .build() - loader.enqueue(request) - - return null - } - -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaNotificationManager.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaNotificationManager.kt deleted file mode 100644 index d581655..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/MediaNotificationManager.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.bobbyesp.mediaplayer.service.notifications - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.os.Build -import android.util.Log -import androidx.annotation.OptIn -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.media3.common.util.UnstableApi -import androidx.media3.common.util.Util -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSessionService -import androidx.media3.ui.PlayerNotificationManager -import com.bobbyesp.mediaplayer.R -import org.koin.core.component.KoinComponent - -@UnstableApi -class MediaNotificationManager @OptIn(UnstableApi::class) constructor( - private val context: Context, - private val player: ExoPlayer -) : KoinComponent { - private val notificationManager: NotificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - val notificationManagerCompat = NotificationManagerCompat.from(context) - - init { - ensureNotificationChannel(notificationManagerCompat) - } - - val notificationListener = object : PlayerNotificationManager.NotificationListener { - override fun onNotificationCancelled( - notificationId: Int, - dismissedByUser: Boolean - ) { - Log.d( - "MediaNotificationManager", - "onNotificationCancelled: notification cancelled" - ) - } - - override fun onNotificationPosted( - notificationId: Int, - notification: Notification, - ongoing: Boolean - ) { - Log.d( - "MediaNotificationManager", - "onNotificationPosted: notification posted" - ) - } - } - - @UnstableApi - fun startNotificationService( - mediaSessionService: MediaSessionService, - mediaSession: MediaSession - ) { - buildNotification(mediaSession) - startForegroundNotification(mediaSessionService) - } - - @UnstableApi - private fun buildNotification(mediaSession: MediaSession) { - PlayerNotificationManager.Builder(context, NOTIFICATION_ID, NOTIFICATION_CHANNEL_ID) - .setMediaDescriptionAdapter( - MediaNotificationAdapter( - context = context, - pendingIntent = mediaSession.sessionActivity - ) - ) - .setCustomActionReceiver(MediaCustomActionReceiver()) - .setSmallIconResourceId(R.drawable.metadator_logo_player) - .setNotificationListener(notificationListener) - .build() - .also { - with(it) { - setMediaSessionToken(mediaSession.sessionCompatToken) - setPriority(NotificationCompat.PRIORITY_LOW) - setPlayer(mediaSession.player) - } - } - } - - private fun startForegroundNotification(mediaSessionService: MediaSessionService) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Log.d( - "MediaNotificationManager", - "startForegroundNotification: creating notification for API >= 26" - ) - val notification = Notification.Builder(context, NOTIFICATION_CHANNEL_ID) - .setCategory(Notification.CATEGORY_SERVICE) - .build() - - mediaSessionService.startForeground(NOTIFICATION_ID, notification) - } else { - Log.d( - "MediaNotificationManager", - "startForegroundNotification: creating notification for API < 26" - ) - val notification = NotificationCompat.Builder(context) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .build() - - mediaSessionService.startForeground(NOTIFICATION_ID, notification) - } - } - - @OptIn(UnstableApi::class) - private fun ensureNotificationChannel(notificationManagerCompat: NotificationManagerCompat) { - if (Util.SDK_INT < 26 || notificationManagerCompat.getNotificationChannel( - NOTIFICATION_CHANNEL_ID - ) != null - ) { - return - } - - val channel = NotificationChannel( - NOTIFICATION_CHANNEL_ID, - NOTIFICATION_CHANNEL_NAME, - NotificationManager.IMPORTANCE_DEFAULT - ) - notificationManagerCompat.createNotificationChannel(channel) - } - - - companion object { - private const val NOTIFICATION_ID = 200 - private const val NOTIFICATION_CHANNEL_NAME = "notification_channel_1" - private const val NOTIFICATION_CHANNEL_ID = "notification_channel_id_1" - } -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/customLayout/MediaSessionLayoutHandler.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/customLayout/MediaSessionLayoutHandler.kt deleted file mode 100644 index ca4c381..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/customLayout/MediaSessionLayoutHandler.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.bobbyesp.mediaplayer.service.notifications.customLayout - -interface MediaSessionLayoutHandler { - fun updateNotificationLayout() -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/customLayout/MediaSessionLayoutHandlerImpl.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/customLayout/MediaSessionLayoutHandlerImpl.kt deleted file mode 100644 index 1833883..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/notifications/customLayout/MediaSessionLayoutHandlerImpl.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.bobbyesp.mediaplayer.service.notifications.customLayout - -import android.content.Context -import androidx.core.content.ContextCompat.getString -import androidx.media3.common.Player.REPEAT_MODE_ALL -import androidx.media3.common.Player.REPEAT_MODE_OFF -import androidx.media3.common.Player.REPEAT_MODE_ONE -import androidx.media3.session.CommandButton -import androidx.media3.session.MediaLibraryService -import com.bobbyesp.mediaplayer.R -import com.bobbyesp.mediaplayer.service.MediaSessionConstants.CommandToggleRepeatMode -import com.bobbyesp.mediaplayer.service.MediaSessionConstants.CommandToggleShuffle -import com.google.common.collect.ImmutableList -import org.koin.core.component.KoinComponent - -class MediaSessionLayoutHandlerImpl( - private val context: Context, - private val mediaSession: MediaLibraryService.MediaLibrarySession, -) : KoinComponent, MediaSessionLayoutHandler { - - override fun updateNotificationLayout() { - val commandButtons = ImmutableList.of( - CommandButton.Builder() - .setDisplayName( - getString( - context, - if (mediaSession.player.shuffleModeEnabled) R.string.action_shuffle_on else R.string.action_shuffle_off - ) - ) - .setIconResId(if (mediaSession.player.shuffleModeEnabled) R.drawable.shuffle_on else R.drawable.shuffle) - .setSessionCommand(CommandToggleShuffle).build(), - CommandButton.Builder() - .setDisplayName( - getString( - context, - when (mediaSession.player.repeatMode) { - REPEAT_MODE_OFF -> R.string.repeat_mode_off - REPEAT_MODE_ONE -> R.string.repeat_mode_one - REPEAT_MODE_ALL -> R.string.repeat_mode_all - else -> throw IllegalStateException() - } - ) - ).setIconResId( - when (mediaSession.player.repeatMode) { - REPEAT_MODE_OFF -> R.drawable.repeat - REPEAT_MODE_ONE -> R.drawable.repeat_one_on - REPEAT_MODE_ALL -> R.drawable.repeat_on - else -> throw IllegalStateException() - } - ).setSessionCommand(CommandToggleRepeatMode).build() - ) - - mediaSession.setCustomLayout(commandButtons) - } -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/queue/EmptyQueue.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/queue/EmptyQueue.kt deleted file mode 100644 index cdf97db..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/queue/EmptyQueue.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.bobbyesp.mediaplayer.service.queue - -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata - -object EmptyQueue : Queue { - override val preloadItem: MediaMetadata? - get() = null - - override suspend fun getInitialData(): Queue.Data = Queue.Data.empty() - override fun hasNextPage(): Boolean = false - override suspend fun nextPage(): List = emptyList() -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/queue/Queue.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/queue/Queue.kt deleted file mode 100644 index 138a47d..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/queue/Queue.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.bobbyesp.mediaplayer.service.queue - -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata - -interface Queue { - val preloadItem: MediaMetadata? - suspend fun getInitialData(): Data - fun hasNextPage(): Boolean - suspend fun nextPage(): List - - data class Data( - val title: String?, - val items: List, - val mediaItemIndex: Int, - val position: Long = 0L, - ) { - companion object { - fun empty() = Data(null, emptyList(), -1, 0L) - } - } -} \ No newline at end of file diff --git a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/queue/SongsQueue.kt b/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/queue/SongsQueue.kt deleted file mode 100644 index 2a5894f..0000000 --- a/app/mediaplayer/src/main/java/com/bobbyesp/mediaplayer/service/queue/SongsQueue.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.bobbyesp.mediaplayer.service.queue - -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata - -data class SongsQueue( - val title: String? = null, - val items: List, - val startIndex: Int = 0, - val position: Long = 0L, -) : Queue { - override val preloadItem: MediaMetadata? = null - override suspend fun getInitialData(): Queue.Data = - Queue.Data(title, items, startIndex, position) - - override fun hasNextPage(): Boolean = false - override suspend fun nextPage() = throw UnsupportedOperationException() -} diff --git a/app/mediaplayer/src/test/java/com/bobbyesp/mediaplayer/ExampleUnitTest.kt b/app/mediaplayer/src/test/java/com/bobbyesp/mediaplayer/ExampleUnitTest.kt index dd5f3c5..f566943 100644 --- a/app/mediaplayer/src/test/java/com/bobbyesp/mediaplayer/ExampleUnitTest.kt +++ b/app/mediaplayer/src/test/java/com/bobbyesp/mediaplayer/ExampleUnitTest.kt @@ -13,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/com/bobbyesp/metadator/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/bobbyesp/metadator/ExampleInstrumentedTest.kt index f40b1f2..5f3cca6 100644 --- a/app/src/androidTest/java/com/bobbyesp/metadator/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/bobbyesp/metadator/ExampleInstrumentedTest.kt @@ -19,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.bobbyesp.metadator", appContext.packageName) } -} \ No newline at end of file +} diff --git a/app/src/foss/kotlin/FirebaseSetup.kt b/app/src/foss/kotlin/FirebaseSetup.kt index bd4e66b..dc4c3ca 100644 --- a/app/src/foss/kotlin/FirebaseSetup.kt +++ b/app/src/foss/kotlin/FirebaseSetup.kt @@ -1,14 +1,11 @@ import com.bobbyesp.metadator.App import com.bobbyesp.metadator.MainActivity -/** - * Initialize Firebase services. - * EMPTY Because this is part of the FOSS flavour of the app. - */ +/** Initialize Firebase services. EMPTY Because this is part of the FOSS flavour of the app. */ fun App.initializeFirebase() {} /** - * Setup Crashlytics collection to the app. - * EMPTY Because this is part of the FOSS flavour of the app. + * Setup Crashlytics collection to the app. EMPTY Because this is part of the FOSS flavour of the + * app. */ -fun MainActivity.setCrashlyticsCollection() {} \ No newline at end of file +fun MainActivity.setCrashlyticsCollection() {} diff --git a/app/src/main/java/com/bobbyesp/metadator/App.kt b/app/src/main/java/com/bobbyesp/metadator/App.kt index 17d0b7f..043d917 100644 --- a/app/src/main/java/com/bobbyesp/metadator/App.kt +++ b/app/src/main/java/com/bobbyesp/metadator/App.kt @@ -9,16 +9,17 @@ import com.bobbyesp.crashhandler.ReportInfo import com.bobbyesp.metadator.core.di.appCoroutinesScope import com.bobbyesp.metadator.core.di.appSystemManagers import com.bobbyesp.metadator.core.di.coreFunctionalitiesModule -import com.bobbyesp.metadator.mediastore.di.mediaStoreViewModelsModule -import com.bobbyesp.metadator.mediaplayer.di.mediaplayerViewModels -import com.bobbyesp.metadator.tageditor.di.tagEditorViewModelsModule import com.bobbyesp.metadator.features.spotify.di.spotifyMainModule import com.bobbyesp.metadator.features.spotify.di.spotifyServicesModule +import com.bobbyesp.metadator.mediaplayer.di.mediaplayerViewModels +import com.bobbyesp.metadator.mediastore.di.mediaStoreViewModelsModule +import com.bobbyesp.metadator.tageditor.di.tagEditorModule +import com.bobbyesp.metadator.tageditor.di.tagEditorViewModelsModule +import kotlin.properties.Delegates import mediaplayerInternalsModule import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.GlobalContext.startKoin -import kotlin.properties.Delegates class App : Application() { override fun onCreate() { @@ -28,34 +29,38 @@ class App : Application() { modules(appSystemManagers, appCoroutinesScope, coreFunctionalitiesModule) modules(mediaplayerInternalsModule) modules(mediaStoreViewModelsModule, tagEditorViewModelsModule, mediaplayerViewModels) + modules(tagEditorModule) modules(spotifyMainModule, spotifyServicesModule) } - packageInfo = packageManager.run { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) getPackageInfo( - packageName, PackageManager.PackageInfoFlags.of(0) - ) else - getPackageInfo(packageName, 0) - } - isPlayStoreBuild = BuildConfig.FLAVOR == "playstore" + packageInfo = + packageManager.run { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + else getPackageInfo(packageName, 0) + } + isPlayStoreBuild = (BuildConfig.FLAVOR == "playstore") super.onCreate() - if (!isPlayStoreBuild) setupCrashHandler( - reportInfo = ReportInfo( - androidVersion = true, - deviceInfo = true, - supportedABIs = true + if (!isPlayStoreBuild) + setupCrashHandler( + reportInfo = + ReportInfo(androidVersion = true, deviceInfo = true, supportedABIs = true), + reportUrl = CRASH_REPORT_URL, ) - ) } companion object { lateinit var packageInfo: PackageInfo var isPlayStoreBuild by Delegates.notNull() - val appVersion: String get() = packageInfo.versionName.toString() + val appVersion: String + get() = packageInfo.versionName.toString() const val APP_PACKAGE_NAME = "com.bobbyesp.metadator" const val PREFERENCES_NAME = "${APP_PACKAGE_NAME}_preferences" const val APP_FILE_PROVIDER = "$APP_PACKAGE_NAME.fileprovider" + + const val CRASH_REPORT_URL = + "https://github.com/BobbyESP/Metadator/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml&title=%5BApp%20crash%5D" } } diff --git a/app/src/main/java/com/bobbyesp/metadator/MainActivity.kt b/app/src/main/java/com/bobbyesp/metadator/MainActivity.kt index c272aa0..e50c474 100644 --- a/app/src/main/java/com/bobbyesp/metadator/MainActivity.kt +++ b/app/src/main/java/com/bobbyesp/metadator/MainActivity.kt @@ -1,23 +1,17 @@ package com.bobbyesp.metadator -import android.content.Intent import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController import coil.ImageLoader -import com.bobbyesp.mediaplayer.service.ConnectionHandler -import com.bobbyesp.mediaplayer.service.MediaplayerService import com.bobbyesp.metadator.core.data.local.preferences.AppPreferences import com.bobbyesp.metadator.core.data.local.preferences.PreferencesKey.COMPLETED_ONBOARDING import com.bobbyesp.metadator.core.data.local.preferences.UserPreferences.Companion.emptyUserPreferences @@ -25,7 +19,6 @@ import com.bobbyesp.metadator.core.presentation.common.AppLocalSettingsProvider import com.bobbyesp.metadator.core.presentation.common.LocalNavController import com.bobbyesp.metadator.core.presentation.common.Route import com.bobbyesp.metadator.core.presentation.theme.MetadatorTheme -import com.bobbyesp.metadator.mediaplayer.data.local.MediaplayerServiceConnection import com.dokar.sonner.Toaster import com.dokar.sonner.rememberToasterState import kotlinx.coroutines.Dispatchers @@ -38,10 +31,8 @@ import setCrashlyticsCollection @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) class MainActivity : ComponentActivity(), KoinComponent { - private var isMusicPlayerServiceStarted = false private var startDestination: Route? = null - private val connectionHandler: ConnectionHandler by inject() private val appPreferences: AppPreferences by inject() private val imageLoader: ImageLoader by inject() @@ -50,18 +41,18 @@ class MainActivity : ComponentActivity(), KoinComponent { val splashscreen = installSplashScreen() lifecycleScope.launch(Dispatchers.IO) { - val completedOnboarding = - async { appPreferences.getSetting(COMPLETED_ONBOARDING, false) } - startDestination = if (completedOnboarding.await()) { - Route.MetadatorNavigator - } else { - Route.OnboardingNavigator + val completedOnboarding = async { + appPreferences.getSetting(COMPLETED_ONBOARDING, false) } + startDestination = + if (completedOnboarding.await()) { + Route.MetadatorNavigator + } else { + Route.OnboardingNavigator + } } - splashscreen.setKeepOnScreenCondition { - startDestination == null - } + splashscreen.setKeepOnScreenCondition { startDestination == null } enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -75,30 +66,30 @@ class MainActivity : ComponentActivity(), KoinComponent { emptyUserPreferences() ) - CompositionLocalProvider( - LocalNavController provides navController, - ) { + CompositionLocalProvider(LocalNavController provides navController) { KoinContext { val windowSizeClass = calculateWindowSizeClass(this) AppLocalSettingsProvider( windowWidthSize = windowSizeClass.widthSizeClass, - playerConnectionHandler = connectionHandler, sonner = sonner, appPreferences = appPreferences, - imageLoader = imageLoader + imageLoader = imageLoader, ) { MetadatorTheme { Navigator( navController = navController, - startDestination = startDestination - ?: throw IllegalStateException("Start destination couldn't be determinate"), - preferences = userPreferences + startDestination = + startDestination + ?: throw IllegalStateException( + "Start destination couldn't be determinate" + ), + preferences = userPreferences, ) Toaster( state = sonner, richColors = true, - darkTheme = userPreferences.value.darkThemePreference.isDarkTheme() + darkTheme = userPreferences.value.darkThemePreference.isDarkTheme(), ) } } @@ -106,26 +97,4 @@ class MainActivity : ComponentActivity(), KoinComponent { } } } - - override fun onStart() { - super.onStart() - startMediaPlayerService() - } - - override fun onStop() { - super.onStop() - unbindService(serviceConnection) - isMusicPlayerServiceStarted = false - } - - private fun startMediaPlayerService() { - val intent = Intent(this, MediaplayerService::class.java) - if (!isMusicPlayerServiceStarted) { - isMusicPlayerServiceStarted = true - startService(intent) - bindService(intent, serviceConnection, BIND_AUTO_CREATE) - } - } - - private var serviceConnection = MediaplayerServiceConnection(connectionHandler) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/Navigation.kt b/app/src/main/java/com/bobbyesp/metadator/Navigation.kt index fef6245..7c778b6 100644 --- a/app/src/main/java/com/bobbyesp/metadator/Navigation.kt +++ b/app/src/main/java/com/bobbyesp/metadator/Navigation.kt @@ -2,7 +2,6 @@ package com.bobbyesp.metadator import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.State @@ -19,17 +18,13 @@ import com.bobbyesp.metadator.core.presentation.settingsRouting import com.bobbyesp.metadator.core.util.cleanNavigate import com.bobbyesp.metadator.core.util.navigateBack import com.bobbyesp.metadator.mediaplayer.mediaplayerRouting -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.MediaplayerViewModel import com.bobbyesp.metadator.mediastore.presentation.MediaStorePageViewModel import com.bobbyesp.metadator.mediastore.presentation.pages.home.HomePage import com.bobbyesp.metadator.onboarding.onboardingRouting import com.bobbyesp.metadator.tageditor.tagEditorRouting import com.bobbyesp.ui.motion.animatedComposable -import com.bobbyesp.ui.util.recomposeHighlighter import org.koin.androidx.compose.koinViewModel -@OptIn(ExperimentalMaterial3ExpressiveApi::class) - @Composable fun Navigator( navController: NavHostController, @@ -37,14 +32,11 @@ fun Navigator( preferences: State, ) { val mediaStoreViewModel = koinViewModel() - val mediaplayerViewModel = koinViewModel() val (_, setOnboardingCompleted) = rememberPreferenceState(COMPLETED_ONBOARDING) NavHost( - modifier = Modifier - .background(MaterialTheme.colorScheme.background) - .fillMaxSize(), + modifier = Modifier.background(MaterialTheme.colorScheme.background).fillMaxSize(), navController = navController, startDestination = startDestination, ) { @@ -53,32 +45,26 @@ fun Navigator( onCompletedOnboarding = { setOnboardingCompleted(true) navController.cleanNavigate(Route.MetadatorNavigator) - } + }, ) - navigation( - startDestination = Route.MetadatorNavigator.Home, - ) { + navigation(startDestination = Route.MetadatorNavigator.Home) { animatedComposable { - val songsState = - mediaStoreViewModel.songs.collectAsStateWithLifecycle() + val songsState = mediaStoreViewModel.songs.collectAsStateWithLifecycle() HomePage( songs = songsState, preferences = preferences, - onEvent = mediaStoreViewModel::onEvent + onEvent = mediaStoreViewModel::onEvent, ) } } mediaplayerRouting( - mediaplayerViewModel = mediaplayerViewModel, - onNavigateBack = { - navController.navigateBack() - } + // mediaplayerViewModel = mediaplayerViewModel, + onNavigateBack = { navController.navigateBack() } ) tagEditorRouting { navController.navigateBack() } settingsRouting { navController.navigateBack() } } } - diff --git a/app/src/main/java/com/bobbyesp/metadator/core/data/local/DarkThemePreference.kt b/app/src/main/java/com/bobbyesp/metadator/core/data/local/DarkThemePreference.kt index 71e2ae9..74715f2 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/data/local/DarkThemePreference.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/data/local/DarkThemePreference.kt @@ -11,20 +11,19 @@ import com.bobbyesp.utilities.R @Stable data class DarkThemePreference( val darkThemeValue: DarkThemeValue = DarkThemeValue.valueOf(DARK_THEME_VALUE.defaultValue), - val isHighContrastModeEnabled: Boolean = HIGH_CONTRAST.defaultValue + val isHighContrastModeEnabled: Boolean = HIGH_CONTRAST.defaultValue, ) { companion object { enum class DarkThemeValue { FOLLOW_SYSTEM, ON, - OFF + OFF, } } @Composable fun isDarkTheme(): Boolean { - return if (darkThemeValue == DarkThemeValue.FOLLOW_SYSTEM) - isSystemInDarkTheme() + return if (darkThemeValue == DarkThemeValue.FOLLOW_SYSTEM) isSystemInDarkTheme() else darkThemeValue == DarkThemeValue.ON } @@ -36,4 +35,4 @@ data class DarkThemePreference( else -> stringResource(R.string.off) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/AppPreferences.kt b/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/AppPreferences.kt index b84cfcd..86720e2 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/AppPreferences.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/AppPreferences.kt @@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences +import com.bobbyesp.coreutilities.theming.isDynamicColoringSupported import com.bobbyesp.metadator.core.data.local.DarkThemePreference import com.bobbyesp.metadator.core.data.local.DarkThemePreference.Companion.DarkThemeValue import com.bobbyesp.metadator.core.data.local.preferences.PreferencesKey.DARK_THEME_VALUE @@ -16,9 +17,8 @@ import com.bobbyesp.metadator.core.data.local.preferences.PreferencesKey.SONGS_L import com.bobbyesp.metadator.core.data.local.preferences.PreferencesKey.SONG_CARD_SIZE import com.bobbyesp.metadator.core.data.local.preferences.PreferencesKey.THEME_COLOR import com.bobbyesp.metadator.core.data.local.preferences.PreferencesKey.USE_DYNAMIC_COLORING -import com.bobbyesp.metadator.mediastore.domain.enums.LayoutType import com.bobbyesp.metadator.mediastore.domain.enums.CompactCardSize -import com.bobbyesp.metadator.core.presentation.theme.isDynamicColoringSupported +import com.bobbyesp.metadator.mediastore.domain.enums.LayoutType import com.materialkolor.PaletteStyle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -30,22 +30,23 @@ import kotlinx.io.IOException class AppPreferences( private val dataStore: DataStore, - scope: CoroutineScope //May be used in the future + scope: CoroutineScope, // May be used in the future ) : AppPreferencesController { override val userPreferencesFlow: Flow - get() = dataStore.data - .catch { exception -> - // dataStore.data throws an IOException when an error is encountered when reading data - if (exception is IOException) { - Log.e(TAG, "Error reading preferences.", exception) - emit(emptyPreferences()) - } else { - throw exception + get() = + dataStore.data + .catch { exception -> + // dataStore.data throws an IOException when an error is encountered when + // reading data + if (exception is IOException) { + Log.e(TAG, "Error reading preferences.", exception) + emit(emptyPreferences()) + } else { + throw exception + } } - }.map { preferences -> - mapUserPreferences(preferences) - } + .map { preferences -> mapUserPreferences(preferences) } override suspend fun getUserPreferences(): UserPreferences { val preferences = dataStore.data.firstOrNull() @@ -106,9 +107,7 @@ class AppPreferences( saveSetting(PALETTE_STYLE, paletteStyle.name) } - private fun mapUserPreferences( - preferences: Preferences - ): UserPreferences { + private fun mapUserPreferences(preferences: Preferences): UserPreferences { val desiredLayout: LayoutType = LayoutType.valueOf(preferences[SONGS_LAYOUT.key] ?: SONGS_LAYOUT.defaultValue) val reduceShadows: Boolean = preferences[REDUCE_SHADOWS.key] ?: REDUCE_SHADOWS.defaultValue @@ -132,14 +131,15 @@ class AppPreferences( darkThemePreference, useDynamicColoring, themeColor, - paletteStyle + paletteStyle, ) } private fun mapDarkThemePreferences(preferences: Preferences): DarkThemePreference { - val currentDarkThemeValue: DarkThemeValue = DarkThemeValue.valueOf( - preferences[DARK_THEME_VALUE.key] ?: DARK_THEME_VALUE.defaultValue - ) + val currentDarkThemeValue: DarkThemeValue = + DarkThemeValue.valueOf( + preferences[DARK_THEME_VALUE.key] ?: DARK_THEME_VALUE.defaultValue + ) val highContrast: Boolean = preferences[HIGH_CONTRAST.key] ?: HIGH_CONTRAST.defaultValue return DarkThemePreference(currentDarkThemeValue, highContrast) } @@ -153,21 +153,20 @@ class AppPreferences( is String -> preferences[key.key] = value is Boolean -> preferences[key.key] = value is Int -> preferences[key.key] = value - else -> throw IllegalArgumentException("Unsupported type: ${value!!::class.simpleName}") + else -> + throw IllegalArgumentException("Unsupported type: ${value!!::class.simpleName}") } } } override fun getSettingFlow(key: PreferencesKey, defaultValue: T?): Flow { - return dataStore.data - .map { preferences -> - preferences[key.key] ?: defaultValue ?: key.defaultValue - } + return dataStore.data.map { preferences -> + preferences[key.key] ?: defaultValue ?: key.defaultValue + } } override suspend fun getSetting(key: PreferencesKey, defaultValue: T?): T { val preferences = dataStore.data.firstOrNull() return preferences?.get(key.key) ?: defaultValue ?: key.defaultValue } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/AppPreferencesController.kt b/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/AppPreferencesController.kt index d87b6c7..62c3967 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/AppPreferencesController.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/AppPreferencesController.kt @@ -3,12 +3,16 @@ package com.bobbyesp.metadator.core.data.local.preferences import kotlinx.coroutines.flow.Flow interface AppPreferencesController { - val TAG get() = "AppPreferencesController" + val TAG + get() = "AppPreferencesController" val userPreferencesFlow: Flow + suspend fun getUserPreferences(): UserPreferences suspend fun saveSetting(key: PreferencesKey, value: T) + fun getSettingFlow(key: PreferencesKey, defaultValue: T?): Flow + suspend fun getSetting(key: PreferencesKey, defaultValue: T?): T -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/PreferencesKey.kt b/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/PreferencesKey.kt index 275bd18..4df8765 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/PreferencesKey.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/PreferencesKey.kt @@ -5,8 +5,8 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.bobbyesp.metadator.core.data.local.DarkThemePreference.Companion.DarkThemeValue -import com.bobbyesp.metadator.mediastore.domain.enums.LayoutType import com.bobbyesp.metadator.mediastore.domain.enums.CompactCardSize +import com.bobbyesp.metadator.mediastore.domain.enums.LayoutType import com.bobbyesp.utilities.ui.DEFAULT_SEED_COLOR import com.materialkolor.PaletteStyle @@ -29,10 +29,11 @@ sealed class PreferencesKey(val key: Preferences.Key, val defaultValue: T) PreferencesKey(stringPreferencesKey("song_card_size"), CompactCardSize.LARGE.name) // --> Theming - data object DARK_THEME_VALUE : PreferencesKey( - stringPreferencesKey("dark_theme_value"), - DarkThemeValue.FOLLOW_SYSTEM.name - ) + data object DARK_THEME_VALUE : + PreferencesKey( + stringPreferencesKey("dark_theme_value"), + DarkThemeValue.FOLLOW_SYSTEM.name, + ) data object HIGH_CONTRAST : PreferencesKey(booleanPreferencesKey("high_contrast"), false) @@ -45,4 +46,4 @@ sealed class PreferencesKey(val key: Preferences.Key, val defaultValue: T) data object PALETTE_STYLE : PreferencesKey(stringPreferencesKey("palette_style"), PaletteStyle.Vibrant.name) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/UserPreferences.kt b/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/UserPreferences.kt index 506f782..82d3d8b 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/UserPreferences.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/UserPreferences.kt @@ -9,8 +9,8 @@ import com.bobbyesp.metadator.core.data.local.preferences.PreferencesKey.SONGS_L import com.bobbyesp.metadator.core.data.local.preferences.PreferencesKey.SONG_CARD_SIZE import com.bobbyesp.metadator.core.data.local.preferences.PreferencesKey.THEME_COLOR import com.bobbyesp.metadator.core.data.local.preferences.PreferencesKey.USE_DYNAMIC_COLORING -import com.bobbyesp.metadator.mediastore.domain.enums.LayoutType import com.bobbyesp.metadator.mediastore.domain.enums.CompactCardSize +import com.bobbyesp.metadator.mediastore.domain.enums.LayoutType import com.materialkolor.PaletteStyle @Stable @@ -22,7 +22,7 @@ data class UserPreferences( val darkThemePreference: DarkThemePreference, val useDynamicColoring: Boolean, val themeColor: Int, - val paletteStyle: PaletteStyle + val paletteStyle: PaletteStyle, ) { companion object { fun emptyUserPreferences(): UserPreferences = @@ -34,7 +34,7 @@ data class UserPreferences( darkThemePreference = DarkThemePreference(), useDynamicColoring = USE_DYNAMIC_COLORING.defaultValue, themeColor = THEME_COLOR.defaultValue, - paletteStyle = PaletteStyle.valueOf(PALETTE_STYLE.defaultValue) + paletteStyle = PaletteStyle.valueOf(PALETTE_STYLE.defaultValue), ) } } diff --git a/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/datastore/DataStorePreferences.kt b/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/datastore/DataStorePreferences.kt index d0c62b7..e0170ed 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/datastore/DataStorePreferences.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/data/local/preferences/datastore/DataStorePreferences.kt @@ -19,34 +19,32 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -val Context.dataStore: DataStore by preferencesDataStore( - name = PREFERENCES_NAME, - corruptionHandler = ReplaceFileCorruptionHandler( - produceNewData = { emptyPreferences() } - ), - //migrations = emptyList(), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) -) +val Context.dataStore: DataStore by + preferencesDataStore( + name = PREFERENCES_NAME, + corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), + // migrations = emptyList(), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + ) @Composable fun rememberPreferenceState( key: PreferencesKey, - defaultValue: T = key.defaultValue + defaultValue: T = key.defaultValue, ): Pair, (T) -> Unit> { val appPreferences = LocalAppPreferencesController.current val coroutineScope = rememberCoroutineScope() - val preferenceFlow = - remember { appPreferences.getSettingFlow(key, defaultValue).distinctUntilChanged() } + val preferenceFlow = remember { + appPreferences.getSettingFlow(key, defaultValue).distinctUntilChanged() + } val valueState = preferenceFlow.collectAsStateWithLifecycle(initialValue = defaultValue) val updatePreference: (T) -> Unit = { newValue -> if (valueState.value != newValue) { - coroutineScope.launch(Dispatchers.IO) { - appPreferences.saveSetting(key, newValue) - } + coroutineScope.launch(Dispatchers.IO) { appPreferences.saveSetting(key, newValue) } } } return valueState to updatePreference -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/di/CoresModule.kt b/app/src/main/java/com/bobbyesp/metadator/core/di/CoresModule.kt index b563f27..e901101 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/di/CoresModule.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/di/CoresModule.kt @@ -15,34 +15,25 @@ import org.koin.core.qualifier.named import org.koin.dsl.module val appCoroutinesScope = module { - single( - qualifier = named("AppMainSupervisedScope") - ) { CoroutineScope(SupervisorJob()) } + single(qualifier = named("AppMainSupervisedScope")) { + CoroutineScope(SupervisorJob()) + } } val coreFunctionalitiesModule = module { - single> { - androidContext().dataStore - } + single> { androidContext().dataStore } single { - AppPreferences( - dataStore = get(), - scope = get(qualifier = named("AppMainSupervisedScope")) - ) + AppPreferences(dataStore = get(), scope = get(qualifier = named("AppMainSupervisedScope"))) } single { val context = androidContext() ImageLoader.Builder(context) - .memoryCache { - MemoryCache.Builder(context) - .maxSizePercent(0.4) - .build() - } + .memoryCache { MemoryCache.Builder(context).maxSizePercent(0.4).build() } .diskCache { DiskCache.Builder() .directory(context.cacheDir.resolve("image_cache")) - .maxSizeBytes(7 * 1024 * 1024) + .maxSizeBytes(64 * 1024 * 1024) .build() } .respectCacheHeaders(false) @@ -53,4 +44,4 @@ val coreFunctionalitiesModule = module { .dispatcher(Dispatchers.IO) .build() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/di/SystemManagersModule.kt b/app/src/main/java/com/bobbyesp/metadator/core/di/SystemManagersModule.kt index e240f86..578c328 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/di/SystemManagersModule.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/di/SystemManagersModule.kt @@ -9,6 +9,10 @@ import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val appSystemManagers = module { - single { androidApplication().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager } - single { androidContext().getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager } -} \ No newline at end of file + single { + androidApplication().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + } + single { + androidContext().getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager + } +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/domain/model/ParcelableSong.kt b/app/src/main/java/com/bobbyesp/metadator/core/domain/model/ParcelableSong.kt index d6cc238..dbfa3d4 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/domain/model/ParcelableSong.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/domain/model/ParcelableSong.kt @@ -3,6 +3,8 @@ package com.bobbyesp.metadator.core.domain.model import android.net.Uri import android.os.Parcelable import androidx.compose.runtime.Immutable +import androidx.core.net.toUri +import com.bobbyesp.mediaplayer.domain.model.MusicTrack import com.bobbyesp.utilities.mediastore.model.UriSerializer import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -15,5 +17,15 @@ data class ParcelableSong( val mainArtist: String, val localPath: String, @Serializable(with = UriSerializer::class) val artworkPath: Uri? = null, - val filename: String -) : Parcelable \ No newline at end of file + val filename: String, +) : Parcelable { + fun MusicTrack.toParcelableSong(): ParcelableSong { + return ParcelableSong( + name = title, + mainArtist = artist ?: "", + localPath = path, + artworkPath = artworkUri?.toUri(), + filename = title, + ) + } +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/ext/KClass.kt b/app/src/main/java/com/bobbyesp/metadator/core/ext/KClass.kt index f7695db..49affc4 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/ext/KClass.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/ext/KClass.kt @@ -2,4 +2,4 @@ package com.bobbyesp.metadator.core.ext import kotlin.reflect.KClass -fun KClass<*>.qualifiedName(): String = this.qualifiedName.toString() \ No newline at end of file +fun KClass<*>.qualifiedName(): String = this.qualifiedName.toString() diff --git a/app/src/main/java/com/bobbyesp/metadator/core/ext/List.kt b/app/src/main/java/com/bobbyesp/metadator/core/ext/List.kt index d64b4d5..3b4493a 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/ext/List.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/ext/List.kt @@ -3,21 +3,24 @@ package com.bobbyesp.metadator.core.ext import com.adamratzman.spotify.models.SimpleArtist fun List.formatArtists(useAmpersands: Boolean = false): String { - val artistNames = when (firstOrNull()) { - is String -> this as List - is SimpleArtist -> (this as List).mapNotNull { it.name } - else -> return "" - } + val artistNames = + when (firstOrNull()) { + is String -> this as List + is SimpleArtist -> (this as List).mapNotNull { it.name } + else -> return "" + } return if (useAmpersands) { when (artistNames.size) { 0 -> "" 1 -> artistNames.first() 2 -> artistNames.joinToString(" & ") - else -> artistNames.subList(0, artistNames.size - 1) - .joinToString(", ") + " & " + artistNames.last() + else -> + artistNames.subList(0, artistNames.size - 1).joinToString(", ") + + " & " + + artistNames.last() } } else { artistNames.joinToString(", ") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/ext/MediaItem.kt b/app/src/main/java/com/bobbyesp/metadator/core/ext/MediaItem.kt index bef2d51..159cecb 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/ext/MediaItem.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/ext/MediaItem.kt @@ -4,8 +4,7 @@ import androidx.media3.common.MediaItem import com.bobbyesp.utilities.mediastore.model.Song fun MediaItem.toSong(): Song { - val mediaMetadata = - this.mediaMetadata + val mediaMetadata = this.mediaMetadata return Song( id = mediaId.hashCode().toLong(), title = (mediaMetadata.displayTitle ?: "").toString(), @@ -14,6 +13,6 @@ fun MediaItem.toSong(): Song { artworkPath = mediaMetadata.artworkUri, duration = 0.0, path = this.localConfiguration?.uri.toString(), - fileName = (mediaMetadata.title ?: "").toString() + fileName = (mediaMetadata.title ?: "").toString(), ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/ext/Player.kt b/app/src/main/java/com/bobbyesp/metadator/core/ext/Player.kt index c578d22..7c52b25 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/ext/Player.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/ext/Player.kt @@ -19,7 +19,10 @@ fun Player.getQueueWindows(): List { var firstMediaItemIndex = currentMediaItemIndex var lastMediaItemIndex = currentMediaItemIndex val shuffleModeEnabled = shuffleModeEnabled - while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET) && queue.size < queueSize) { + while ( + (firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET) && + queue.size < queueSize + ) { if (lastMediaItemIndex != C.INDEX_UNSET) { lastMediaItemIndex = timeline.getNextWindowIndex(lastMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) @@ -28,11 +31,12 @@ fun Player.getQueueWindows(): List { } } if (firstMediaItemIndex != C.INDEX_UNSET && queue.size < queueSize) { - firstMediaItemIndex = timeline.getPreviousWindowIndex( - firstMediaItemIndex, - REPEAT_MODE_OFF, - shuffleModeEnabled - ) + firstMediaItemIndex = + timeline.getPreviousWindowIndex( + firstMediaItemIndex, + REPEAT_MODE_OFF, + shuffleModeEnabled, + ) if (firstMediaItemIndex != C.INDEX_UNSET) { queue.addFirst(timeline.getWindow(firstMediaItemIndex, Timeline.Window())) } diff --git a/app/src/main/java/com/bobbyesp/metadator/core/ext/ReleaseDate.kt b/app/src/main/java/com/bobbyesp/metadator/core/ext/ReleaseDate.kt index 2b96f81..1b70cda 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/ext/ReleaseDate.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/ext/ReleaseDate.kt @@ -20,4 +20,4 @@ fun ReleaseDate.format(precision: String?, separator: String = "/"): String { "Unknown" } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/ext/Song.kt b/app/src/main/java/com/bobbyesp/metadator/core/ext/Song.kt index 067afc8..0b7d90d 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/ext/Song.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/ext/Song.kt @@ -9,6 +9,6 @@ fun Song.toParcelableSong(): ParcelableSong { mainArtist = this.artist, localPath = this.path, artworkPath = this.artworkPath, - filename = this.fileName + filename = this.fileName, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/ext/String.kt b/app/src/main/java/com/bobbyesp/metadator/core/ext/String.kt index 6a36dee..381ef79 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/ext/String.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/ext/String.kt @@ -56,4 +56,4 @@ object TagLib { else -> Icons.Rounded.RunningWithErrors } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/SettingsRouting.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/SettingsRouting.kt index 6336b45..1db36b4 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/SettingsRouting.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/SettingsRouting.kt @@ -7,28 +7,16 @@ import com.bobbyesp.metadator.core.presentation.pages.settings.SettingsPage import com.bobbyesp.metadator.core.presentation.pages.settings.modules.GeneralSettingsPage import com.bobbyesp.ui.motion.animatedComposable -fun NavGraphBuilder.settingsRouting( - onNavigateBack: () -> Unit -) { - navigation( - startDestination = Route.SettingsNavigator.Settings, - ) { +fun NavGraphBuilder.settingsRouting(onNavigateBack: () -> Unit) { + navigation(startDestination = Route.SettingsNavigator.Settings) { animatedComposable { - SettingsPage( - onBackPressed = onNavigateBack - ) + SettingsPage(onBackPressed = onNavigateBack) } - animatedComposable { - GeneralSettingsPage() - } - - animatedComposable { + animatedComposable { GeneralSettingsPage() } - } + animatedComposable {} - animatedComposable { - - } + animatedComposable {} } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/CompositionLocals.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/CompositionLocals.kt index ced6fa8..ba6ee31 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/CompositionLocals.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/CompositionLocals.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import coil.ImageLoader -import com.bobbyesp.mediaplayer.service.ConnectionHandler import com.bobbyesp.metadator.core.data.local.DarkThemePreference import com.bobbyesp.metadator.core.data.local.preferences.AppPreferences import com.bobbyesp.metadator.core.data.local.preferences.UserPreferences.Companion.emptyUserPreferences @@ -35,24 +34,24 @@ val LocalAppPreferencesController = val LocalNavController = compositionLocalOf { error("No nav controller provided") } -val LocalWindowWidthState = - staticCompositionLocalOf { WindowWidthSizeClass.Compact } //This value probably will never change, that's why it is static +val LocalWindowWidthState = staticCompositionLocalOf { + WindowWidthSizeClass.Compact +} // This value probably will never change, that's why it is static val LocalSonner = compositionLocalOf { error("No sonner toaster state provided") } -val LocalMediaplayerConnection = - compositionLocalOf { error("No Media Player Service Connection handler has been provided") } @Composable fun AppLocalSettingsProvider( windowWidthSize: WindowWidthSizeClass, - playerConnectionHandler: ConnectionHandler, sonner: ToasterState = rememberToasterState(), appPreferences: AppPreferences, imageLoader: ImageLoader, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { val settingsFlow = - appPreferences.userPreferencesFlow.collectAsStateWithLifecycle(initialValue = emptyUserPreferences()) + appPreferences.userPreferencesFlow.collectAsStateWithLifecycle( + initialValue = emptyUserPreferences() + ) val seedColor = settingsFlow.value.themeColor val darkTheme = settingsFlow.value.darkThemePreference @@ -60,26 +59,31 @@ fun AppLocalSettingsProvider( val config = LocalConfiguration.current - val themeState = rememberDynamicMaterialThemeState( - seedColor = Color(seedColor), - isDark = darkTheme.isDarkTheme(), - style = themeStyle, - isAmoled = darkTheme.isHighContrastModeEnabled - ) + val themeState = + rememberDynamicMaterialThemeState( + seedColor = Color(seedColor), + isDark = darkTheme.isDarkTheme(), + style = themeStyle, + isAmoled = darkTheme.isHighContrastModeEnabled, + ) CompositionLocalProvider( - LocalDarkTheme provides darkTheme, //Tells the app what dark theme to use - //TODO: Modify to handle multiple colors (like based on images) - LocalSeedColor provides seedColor, //Tells the app what color to use as seed for the palette - LocalDynamicColoringSwitch provides settingsFlow.value.useDynamicColoring, //Tells the app if it should use dynamic colors or not (Android 12+ feature) - LocalDynamicThemeState provides themeState, //Provides the theme state to the app + LocalDarkTheme provides darkTheme, // Tells the app what dark theme to use + // TODO: Modify to handle multiple colors (like based on images) + LocalSeedColor provides + seedColor, // Tells the app what color to use as seed for the palette + LocalDynamicColoringSwitch provides + settingsFlow.value + .useDynamicColoring, // Tells the app if it should use dynamic colors or not + // (Android + // 12+ feature) + LocalDynamicThemeState provides themeState, // Provides the theme state to the app LocalAppPreferencesController provides appPreferences, LocalWindowWidthState provides windowWidthSize, LocalOrientation provides config.orientation, LocalSonner provides sonner, LocalCoilImageLoader provides imageLoader, - LocalMediaplayerConnection provides playerConnectionHandler, ) { - content() //The content of the app + content() // The content of the app } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/DestinationInfo.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/NavigatorInfo.kt similarity index 65% rename from app/src/main/java/com/bobbyesp/metadator/core/presentation/common/DestinationInfo.kt rename to app/src/main/java/com/bobbyesp/metadator/core/presentation/common/NavigatorInfo.kt index 0a0ffc7..88b803f 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/DestinationInfo.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/NavigatorInfo.kt @@ -9,21 +9,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import com.bobbyesp.metadator.R @Immutable -enum class DestinationInfo( - val icon: ImageVector, - @StringRes val title: Int, -) { - HOME( - icon = Icons.Rounded.Home, - title = R.string.home - ), - MEDIAPLAYER( - icon = Icons.Rounded.PlayArrow, - title = R.string.mediaplayer - ); +enum class NavigatorInfo(val icon: ImageVector, @StringRes val title: Int) { + HOME(icon = Icons.Rounded.Home, title = R.string.home), + MEDIAPLAYER(icon = Icons.Rounded.PlayArrow, title = R.string.mediaplayer); companion object { - fun fromRoute(route: Route): DestinationInfo? { + fun fromRoute(route: Route): NavigatorInfo? { return when (route) { is Route.MetadatorNavigator -> HOME is Route.MediaplayerNavigator -> MEDIAPLAYER @@ -31,4 +22,4 @@ enum class DestinationInfo( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/Route.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/Route.kt index 8a9c855..efd9ad5 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/Route.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/common/Route.kt @@ -8,11 +8,9 @@ sealed interface Route { @Serializable data object OnboardingNavigator : Route { - @Serializable - data object Welcome : Route + @Serializable data object Welcome : Route - @Serializable - data object Permissions : Route + @Serializable data object Permissions : Route } @Serializable @@ -20,42 +18,33 @@ sealed interface Route { @Serializable data object Home : Route { - @Serializable - data object VisualSettings : Route + @Serializable data object VisualSettings : Route } } @Serializable data object MediaplayerNavigator : Route { - @Serializable - data object Mediaplayer : Route + @Serializable data object Mediaplayer : Route } @Serializable data object UtilitiesNavigator : Route { - @Serializable - data class TagEditor(val selectedSong: ParcelableSong) : Route + @Serializable data class TagEditor(val selectedSong: ParcelableSong) : Route } @Serializable data object SettingsNavigator : Route { @Serializable data object Settings : Route { - @Serializable - data object General : Route + @Serializable data object General : Route - @Serializable - data object Appearance : Route + @Serializable data object Appearance : Route - @Serializable - data object About : Route + @Serializable data object About : Route } } } -val mainNavigators = listOf( - Route.MetadatorNavigator, - Route.MediaplayerNavigator -) +val mainNavigators = listOf(Route.MetadatorNavigator, Route.MediaplayerNavigator) -fun Any.qualifiedName(): String = this::class.qualifiedName.toString() \ No newline at end of file +fun Any.qualifiedName(): String = this::class.qualifiedName.toString() diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/AppDetails.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/AppDetails.kt index cade7af..d2dc46e 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/AppDetails.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/AppDetails.kt @@ -21,50 +21,47 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.bobbyesp.metadator.R -import com.bobbyesp.metadator.core.util.getAppVersionName import com.bobbyesp.metadator.core.presentation.theme.MetadatorLogoBackground import com.bobbyesp.metadator.core.presentation.theme.MetadatorLogoForeground +import com.bobbyesp.metadator.core.util.getAppVersionName @Composable -fun AppDetails( - modifier: Modifier = Modifier, - subtitle: String? = null -) { +fun AppDetails(modifier: Modifier = Modifier, subtitle: String? = null) { val context = LocalContext.current val isDarkMode = isSystemInDarkTheme() - val animatedColor by animateColorAsState( - targetValue = if(isDarkMode) MetadatorLogoForeground else MetadatorLogoBackground - ) + val animatedColor by + animateColorAsState( + targetValue = if (isDarkMode) MetadatorLogoForeground else MetadatorLogoBackground + ) Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Image( painter = painterResource(R.drawable.metadator_logo_foreground), colorFilter = ColorFilter.tint(animatedColor), contentDescription = null, - modifier = Modifier - .size(200.dp) - .graphicsLayer { + modifier = + Modifier.size(200.dp).graphicsLayer { scaleX = 2f scaleY = 2f - } + }, ) Text( text = stringResource(R.string.app_name).uppercase(), style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.SemiBold, - fontFamily = FontFamily.Monospace + fontFamily = FontFamily.Monospace, ) Text( text = subtitle ?: context.getAppVersionName(), color = MaterialTheme.colorScheme.outline, - fontFamily = FontFamily.Monospace + fontFamily = FontFamily.Monospace, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/image/AsyncImage.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/image/AsyncImage.kt index 51a20c2..6b25c2a 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/image/AsyncImage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/image/AsyncImage.kt @@ -39,11 +39,10 @@ fun AsyncImage( placeholder: ImageVector? = null, context: Context = LocalContext.current, imageLoader: ImageLoader? = LocalCoilImageLoader.current, - imageOptions: ImageOptions = ImageOptions( - contentDescription = null, contentScale = ContentScale.Crop - ), + imageOptions: ImageOptions = + ImageOptions(contentDescription = null, contentScale = ContentScale.Crop), requestListener: (() -> ImageRequest.Listener)? = null, - onSuccessData: (CoilImageState.Success) -> Unit = { _ -> } + onSuccessData: (CoilImageState.Success) -> Unit = { _ -> }, ) { val imageUrl by remember(imageModel) { mutableStateOf(imageModel) } @@ -62,20 +61,21 @@ fun AsyncImage( modifier = imageModifier.fillMaxSize(), icon = placeholder ?: Icons.Rounded.MusicNote, colorful = false, - contentDescription = "Song cover placeholder" + contentDescription = "Song cover placeholder", ) }, failure = { error -> - val icon = if (error.reason is FileNotFoundException) { - Icons.Rounded.MusicNote - } else { - Icons.Rounded.ErrorOutline - } + val icon = + if (error.reason is FileNotFoundException) { + Icons.Rounded.MusicNote + } else { + Icons.Rounded.ErrorOutline + } Placeholder( modifier = imageModifier.fillMaxSize(), icon = icon, colorful = false, - contentDescription = "Song cover failed to load" + contentDescription = "Song cover failed to load", ) }, imageLoader = { imageLoader ?: ImageLoader(context) }, @@ -89,11 +89,15 @@ fun loadBitmapFromUrl(url: String): Bitmap? { LaunchedEffect(url) { val imageLoader = ImageLoader(context) - val request = ImageRequest.Builder(context).data(url).target { drawable -> - if (drawable is BitmapDrawable) { - bitmap = drawable.bitmap - } - }.build() + val request = + ImageRequest.Builder(context) + .data(url) + .target { drawable -> + if (drawable is BitmapDrawable) { + bitmap = drawable.bitmap + } + } + .build() val result = (imageLoader.execute(request) as SuccessResult).drawable if (result is BitmapDrawable) { diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/text/ConditionedMarqueeText.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/text/ConditionedMarqueeText.kt index f39ccc9..3f62d71 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/text/ConditionedMarqueeText.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/components/text/ConditionedMarqueeText.kt @@ -42,7 +42,7 @@ fun ConditionedMarqueeText( sideGradient: MarqueeTextGradientOptions = MarqueeTextGradientOptions(), customEasing: Easing? = null, animationDuration: Float = 4000f, - delayBetweenAnimations: Long = 500L + delayBetweenAnimations: Long = 500L, ) { val (useMarqueeText) = rememberPreferenceState(MARQUEE_TEXT_ENABLED) @@ -68,7 +68,7 @@ fun ConditionedMarqueeText( sideGradient, customEasing, animationDuration, - delayBetweenAnimations + delayBetweenAnimations, ) } else { Text( @@ -87,7 +87,7 @@ fun ConditionedMarqueeText( overflow = overflow, softWrap = softWrap, onTextLayout = onTextLayout, - style = style + style = style, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/pages/settings/SettingsPage.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/pages/settings/SettingsPage.kt index 3ae70d6..70b84f8 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/pages/settings/SettingsPage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/pages/settings/SettingsPage.kt @@ -40,50 +40,46 @@ import com.bobbyesp.ui.components.topbar.ColumnWithCollapsibleTopBar @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -fun SettingsPage( - onBackPressed: () -> Unit -) { +fun SettingsPage(onBackPressed: () -> Unit) { val navController = LocalNavController.current var collapseFraction by remember { mutableFloatStateOf(0f) } - val mainSettingsGroup: List = listOf( - SettingsItem( - title = stringResource(id = R.string.general), - supportingText = stringResource(id = R.string.general_description), - icon = Icons.Rounded.Settings, - onClick = { - navController.navigate(Route.SettingsNavigator.Settings.General) - }), - SettingsItem( - title = stringResource(id = R.string.appearance), - supportingText = stringResource(id = R.string.appearance_description), - icon = Icons.Rounded.Brush, - onClick = { - navController.navigate(Route.SettingsNavigator.Settings.Appearance) - }), - ) + val mainSettingsGroup: List = + listOf( + SettingsItem( + title = stringResource(id = R.string.general), + supportingText = stringResource(id = R.string.general_description), + icon = Icons.Rounded.Settings, + onClick = { navController.navigate(Route.SettingsNavigator.Settings.General) }, + ), + SettingsItem( + title = stringResource(id = R.string.appearance), + supportingText = stringResource(id = R.string.appearance_description), + icon = Icons.Rounded.Brush, + onClick = { navController.navigate(Route.SettingsNavigator.Settings.Appearance) }, + ), + ) - val infoSettingsGroup: List = listOf( - SettingsItem( - title = stringResource(id = R.string.about), - supportingText = stringResource(id = R.string.about_description), - icon = Icons.Rounded.Info, - onClick = { - navController.navigate(Route.SettingsNavigator.Settings.About) - } + val infoSettingsGroup: List = + listOf( + SettingsItem( + title = stringResource(id = R.string.about), + supportingText = stringResource(id = R.string.about_description), + icon = Icons.Rounded.Info, + onClick = { navController.navigate(Route.SettingsNavigator.Settings.About) }, + ) ) - ) ColumnWithCollapsibleTopBar( topBarContent = { IconButton( onClick = onBackPressed, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(horizontal = 12.dp, vertical = 4.dp) + modifier = + Modifier.align(Alignment.BottomStart) + .padding(horizontal = 12.dp, vertical = 4.dp), ) { Icon( imageVector = Icons.Rounded.ArrowBackIosNew, - contentDescription = stringResource(id = com.bobbyesp.ui.R.string.back) + contentDescription = stringResource(id = com.bobbyesp.ui.R.string.back), ) } @@ -92,42 +88,30 @@ fun SettingsPage( style = MaterialTheme.typography.displaySmall, textAlign = TextAlign.Center, fontWeight = FontWeight.Medium, - modifier = Modifier - .align(Alignment.Center) - .padding(horizontal = 16.dp) - .graphicsLayer { + modifier = + Modifier.align(Alignment.Center).padding(horizontal = 16.dp).graphicsLayer { val scale = lerp(0.7f, 1f, collapseFraction) scaleX = scale scaleY = scale - } + }, ) }, - collapseFraction = { - collapseFraction = it - }, + collapseFraction = { collapseFraction = it }, contentPadding = PaddingValues(horizontal = 32.dp), contentHorizontalAlignment = Alignment.CenterHorizontally, contentVerticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxSize() - .safeDrawingPadding() + modifier = Modifier.fillMaxSize().safeDrawingPadding(), ) { - SettingsGroup( - modifier = Modifier, items = mainSettingsGroup - ) + SettingsGroup(modifier = Modifier, items = mainSettingsGroup) - SettingsGroup( - modifier = Modifier, items = infoSettingsGroup - ) + SettingsGroup(modifier = Modifier, items = infoSettingsGroup) Text( text = stringResource(id = R.string.made_with_love_by), - modifier = Modifier - .padding(horizontal = 12.dp) - .fillMaxWidth(), + modifier = Modifier.padding(horizontal = 12.dp).fillMaxWidth(), textAlign = TextAlign.Center, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/pages/settings/modules/GeneralSettingsPage.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/pages/settings/modules/GeneralSettingsPage.kt index b6b4aaf..814adeb 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/pages/settings/modules/GeneralSettingsPage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/pages/settings/modules/GeneralSettingsPage.kt @@ -28,37 +28,30 @@ fun GeneralSettingsPage() { val (reduceShadows, updateReduceShadows) = rememberPreferenceState(REDUCE_SHADOWS) val navController = LocalNavController.current - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( - state = rememberTopAppBarState(), - canScroll = { true } - ) + val scrollBehavior = + TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + state = rememberTopAppBarState(), + canScroll = { true }, + ) Scaffold( - modifier = Modifier - .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection), + modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { MediumTopAppBar( title = { Text( text = stringResource(id = R.string.general), - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleLarge, ) }, scrollBehavior = scrollBehavior, - navigationIcon = { - BackButton { - navController.popBackStack() - } - } + navigationIcon = { BackButton { navController.popBackStack() } }, ) - } + }, ) { paddingValues -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection), - contentPadding = paddingValues + modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = paddingValues, ) { item { PreferenceSwitch( @@ -66,9 +59,7 @@ fun GeneralSettingsPage() { description = stringResource(R.string.marquee_text_description), isChecked = useMarqueeText.value, thumbContent = null, - onClick = { - updateMarqueeText(!useMarqueeText.value) - } + onClick = { updateMarqueeText(!useMarqueeText.value) }, ) } item { @@ -77,11 +68,9 @@ fun GeneralSettingsPage() { description = stringResource(R.string.reduce_shadows_description), isChecked = reduceShadows.value, thumbContent = null, - onClick = { - updateReduceShadows(!reduceShadows.value) - } + onClick = { updateReduceShadows(!reduceShadows.value) }, ) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Shapes.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Shapes.kt index b1e6e1a..6a24f49 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Shapes.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Shapes.kt @@ -4,10 +4,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp -val AppShapes = Shapes( - extraSmall = RoundedCornerShape(2.dp), - small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(8.dp), - large = RoundedCornerShape(16.dp), - extraLarge = RoundedCornerShape(32.dp) -) \ No newline at end of file +val AppShapes = + Shapes( + extraSmall = RoundedCornerShape(2.dp), + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(32.dp), + ) diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Theme.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Theme.kt index 3258cbc..01bf500 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Theme.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Theme.kt @@ -1,6 +1,5 @@ package com.bobbyesp.metadator.core.presentation.theme -import android.os.Build import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle @@ -11,44 +10,40 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextDirection +import com.bobbyesp.coreutilities.theming.isDynamicColoringSupported import com.bobbyesp.metadator.core.presentation.common.LocalDynamicColoringSwitch import com.bobbyesp.metadator.core.presentation.common.LocalDynamicThemeState import com.materialkolor.DynamicMaterialTheme -fun isDynamicColoringSupported(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -} - val MetadatorLogoForeground = Color(0xFFFFFFF0) val MetadatorLogoBackground = Color(0xFF313638) @Composable -fun MetadatorTheme( - content: @Composable () -> Unit -) { +fun MetadatorTheme(content: @Composable () -> Unit) { val themeState = LocalDynamicThemeState.current val dynamicColoring = LocalDynamicColoringSwitch.current val context = LocalContext.current val canUseDynamicColor = dynamicColoring && isDynamicColoringSupported() - val dynamicColorScheme = if (canUseDynamicColor) { - if (themeState.isDark) { - dynamicDarkColorScheme(context).let { - if (themeState.isAmoled) it.copy( - surface = Color.Black, - background = Color.Black - ) else it + val dynamicColorScheme = + if (canUseDynamicColor) { + if (themeState.isDark) { + dynamicDarkColorScheme(context).let { + if (themeState.isAmoled) + it.copy(surface = Color.Black, background = Color.Black) + else it + } + } else { + dynamicLightColorScheme(context) } - } else { - dynamicLightColorScheme(context) - } - } else null + } else null ProvideTextStyle( - value = LocalTextStyle.current.copy( - lineBreak = LineBreak.Paragraph, - textDirection = TextDirection.Content - ) + value = + LocalTextStyle.current.copy( + lineBreak = LineBreak.Paragraph, + textDirection = TextDirection.Content, + ) ) { if (dynamicColorScheme != null) { MaterialTheme(colorScheme = dynamicColorScheme, shapes = AppShapes, content = content) @@ -57,8 +52,8 @@ fun MetadatorTheme( state = themeState, animate = true, shapes = AppShapes, - content = content + content = content, ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Type.kt b/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Type.kt index 7578170..0a415e6 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Type.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/presentation/theme/Type.kt @@ -7,28 +7,30 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/core/util/Debugging.kt b/app/src/main/java/com/bobbyesp/metadator/core/util/Debugging.kt index 2ddefe6..0f37589 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/util/Debugging.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/util/Debugging.kt @@ -2,7 +2,7 @@ package com.bobbyesp.metadator.core.util import com.bobbyesp.metadator.BuildConfig -//execute the code inside if it is a debug release +// execute the code inside if it is a debug release fun executeIfDebugging(debugOnlyOperation: () -> Unit) { if (BuildConfig.DEBUG) debugOnlyOperation() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/util/Navigation.kt b/app/src/main/java/com/bobbyesp/metadator/core/util/Navigation.kt index 1138d44..83c2846 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/util/Navigation.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/util/Navigation.kt @@ -6,11 +6,11 @@ import androidx.navigation.NavHostController /** * Determines whether the navigation controller can navigate back. * - * This property checks if the current back stack entry's lifecycle state is RESUMED. - * If the current entry is in the RESUMED state, it indicates that it's currently - * visible and interacting with the user, and therefore it's safe to navigate back from it. - * If the current entry is not in the RESUMED state (e.g., it's in CREATED, STARTED, or DESTROYED), - * navigating back might lead to unexpected behavior or UI inconsistencies. + * This property checks if the current back stack entry's lifecycle state is RESUMED. If the current + * entry is in the RESUMED state, it indicates that it's currently visible and interacting with the + * user, and therefore it's safe to navigate back from it. If the current entry is not in the + * RESUMED state (e.g., it's in CREATED, STARTED, or DESTROYED), navigating back might lead to + * unexpected behavior or UI inconsistencies. * * @return `true` if the navigation controller can go back, `false` otherwise. */ @@ -20,10 +20,10 @@ val NavHostController.canGoBack: Boolean /** * Navigates back to the previous destination in the navigation stack, if possible. * - * This function checks if the navigation controller can go back using [NavHostController.canGoBack]. - * If it can, it pops the current destination off the stack using [NavHostController.popBackStack], - * effectively navigating to the previous destination. If there's no previous destination to go back to, - * this function does nothing. + * This function checks if the navigation controller can go back using + * [NavHostController.canGoBack]. If it can, it pops the current destination off the stack using + * [NavHostController.popBackStack], effectively navigating to the previous destination. If there's + * no previous destination to go back to, this function does nothing. * * @receiver The [NavHostController] instance that manages the navigation stack. */ @@ -34,18 +34,18 @@ fun NavHostController.navigateBack() { } /** - * Extension function for NavHostController to navigate to a destination while cleaning up the back stack. + * Extension function for NavHostController to navigate to a destination while cleaning up the back + * stack. * * @param T The type of the destination, which must be a subclass of Any. * @param destination The destination to navigate to. */ -fun NavHostController.cleanNavigate(destination: T) = navigate(destination) { - // Pop up to the start destination of the graph, saving the state - popUpTo(graph.startDestinationId) { - saveState = true +fun NavHostController.cleanNavigate(destination: T) = + navigate(destination) { + // Pop up to the start destination of the graph, saving the state + popUpTo(graph.startDestinationId) { saveState = true } + // Launch the destination as a single top instance + launchSingleTop = true + // Restore the state if possible + restoreState = true } - // Launch the destination as a single top instance - launchSingleTop = true - // Restore the state if possible - restoreState = true -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/core/util/Permissions.kt b/app/src/main/java/com/bobbyesp/metadator/core/util/Permissions.kt index e63fdd1..1b9d5cc 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/util/Permissions.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/util/Permissions.kt @@ -5,17 +5,15 @@ import android.os.Build fun getNeededStoragePermissions(): Array { return when { - Build.VERSION.SDK_INT <= Build.VERSION_CODES.P -> arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) + Build.VERSION.SDK_INT <= Build.VERSION_CODES.P -> + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> arrayOf( - Manifest.permission.READ_MEDIA_AUDIO - ) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> + arrayOf(Manifest.permission.READ_MEDIA_AUDIO) - else -> arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE - ) + else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/core/util/getAppVersionName.kt b/app/src/main/java/com/bobbyesp/metadator/core/util/getAppVersionName.kt index e0b3b81..99ec30b 100644 --- a/app/src/main/java/com/bobbyesp/metadator/core/util/getAppVersionName.kt +++ b/app/src/main/java/com/bobbyesp/metadator/core/util/getAppVersionName.kt @@ -7,8 +7,10 @@ import com.bobbyesp.metadator.R fun Context.getAppVersionName(): String { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)).versionName + packageManager + .getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + .versionName } else { packageManager.getPackageInfo(packageName, 0).versionName } ?: this.getString(R.string.unknown) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/SpotifyServiceImpl.kt b/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/SpotifyServiceImpl.kt index 69b1323..9f82c8b 100644 --- a/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/SpotifyServiceImpl.kt +++ b/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/SpotifyServiceImpl.kt @@ -26,11 +26,12 @@ class SpotifyServiceImpl : SpotifyService, KoinComponent { * Retrieves the Spotify API instance. * * This function returns a [SpotifyAppApi] instance, allowing interaction with the Spotify API. - * It ensures that the API is built before returning it. If the API has not been built yet, it will call the [buildApi] function to initialize it. + * It ensures that the API is built before returning it. If the API has not been built yet, it + * will call the [buildApi] function to initialize it. * * @return A [SpotifyAppApi] instance, ready for use. - * @throws IllegalStateException If the connection to the Spotify API was not established. This can occur due to network issues or server outages. - * + * @throws IllegalStateException If the connection to the Spotify API was not established. This + * can occur due to network issues or server outages. */ override suspend fun getSpotifyApi(): SpotifyAppApi { if (api == null) { @@ -39,21 +40,19 @@ class SpotifyServiceImpl : SpotifyService, KoinComponent { return api ?: throw IllegalStateException( "The connection to the Spotify API was not established." + - " This may be due to a network error or a servers outage." + " This may be due to a network error or a servers outage." ) } /** * Retrieves the Spotify access token. * - * This function fetches the Spotify access token, ensuring it's available - * before returning it. If the token is null, it attempts to build/refresh - * the token via the `buildApi()` method. + * This function fetches the Spotify access token, ensuring it's available before returning it. + * If the token is null, it attempts to build/refresh the token via the `buildApi()` method. * * @return A [Token] object representing the Spotify access token. - * @throws IllegalStateException if the Spotify token is still null after - * attempting to build/refresh it. This indicates a failure in the token - * acquisition process. + * @throws IllegalStateException if the Spotify token is still null after attempting to + * build/refresh it. This indicates a failure in the token acquisition process. */ override suspend fun getSpotifyToken(): Token { if (token == null) { @@ -63,14 +62,15 @@ class SpotifyServiceImpl : SpotifyService, KoinComponent { } /** - * This method is responsible for building the Spotify API. - * It first checks if the application is in debug mode, and if so, logs the client ID and secret. - * Then, it attempts to build the Spotify API with the provided client ID and secret. - * If the API is successfully built, it retrieves the token from the API and stores it. + * This method is responsible for building the Spotify API. It first checks if the application + * is in debug mode, and if so, logs the client ID and secret. Then, it attempts to build the + * Spotify API with the provided client ID and secret. If the API is successfully built, it + * retrieves the token from the API and stores it. * - * If an exception occurs during the building of the API, it logs the error. - * If a BadRequestException occurs, it checks if the token should be refreshed and if the recursion depth is less than the maximum allowed. - * If both conditions are met, it logs the information, clears the API, and attempts to build the API again, incrementing the recursion depth. + * If an exception occurs during the building of the API, it logs the error. If a + * BadRequestException occurs, it checks if the token should be refreshed and if the recursion + * depth is less than the maximum allowed. If both conditions are met, it logs the information, + * clears the API, and attempts to build the API again, incrementing the recursion depth. * * @throws Exception if there is an error building the API. * @throws SpotifyException.BadRequestException if a bad request is made to the Spotify API. @@ -80,15 +80,16 @@ class SpotifyServiceImpl : SpotifyService, KoinComponent { executeIfDebugging { Log.d( "SpotifyApiRequests", - "Building API with client ID: $clientId and client secret: $clientSecret" + "Building API with client ID: $clientId and client secret: $clientSecret", ) } - api = spotifyAppApi(clientId, clientSecret).build().apply { - with(this.spotifyApiOptions) { - automaticRefresh = true - enableDebugMode = isDebug + api = + spotifyAppApi(clientId, clientSecret).build().apply { + with(this.spotifyApiOptions) { + automaticRefresh = true + enableDebugMode = isDebug + } } - } token = api?.token } catch (e: Exception) { executeIfDebugging { Log.e("SpotifyApiRequests", "Error building API", e) } @@ -98,12 +99,11 @@ class SpotifyServiceImpl : SpotifyService, KoinComponent { if (it.shouldRefresh() && recursionDepth < MAX_RECURSION_DEPTH) { Log.i( "SpotifyApiRequests", - "Token expired, refreshing token; recursion depth: $recursionDepth of $MAX_RECURSION_DEPTH" + "Token expired, refreshing token; recursion depth: $recursionDepth of $MAX_RECURSION_DEPTH", ) clearApi() buildApi() recursionDepth++ - return@let } } } @@ -117,4 +117,4 @@ class SpotifyServiceImpl : SpotifyService, KoinComponent { companion object { private const val MAX_RECURSION_DEPTH = 3 } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/repository/SearchRepositoryImpl.kt b/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/repository/SearchRepositoryImpl.kt index 1277d10..2c195e2 100644 --- a/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/repository/SearchRepositoryImpl.kt +++ b/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/repository/SearchRepositoryImpl.kt @@ -25,19 +25,21 @@ class SearchRepositoryImpl : SearchRepository, KoinComponent { private val searchService by inject() /** - * Search for tracks on Spotify. The search does support pagination but this implementation - * does not support it. For now, it will only return up to 50 results. + * Search for tracks on Spotify. The search does support pagination but this implementation does + * not support it. For now, it will only return up to 50 results. */ override suspend fun searchTracks(query: String): Result> { try { - val searchResult = searchService.search( - query, - searchTypes = arrayOf(SearchApi.SearchType.Track), - filters = emptyList() - ) + val searchResult = + searchService.search( + query, + searchTypes = arrayOf(SearchApi.SearchType.Track), + filters = emptyList(), + ) - searchResult.tracks?.let { return Result.success(it.items) } - ?: return Result.failure(NullPointerException("Search result is null")) + searchResult.tracks?.let { + return Result.success(it.items) + } ?: return Result.failure(NullPointerException("Search result is null")) } catch (th: Throwable) { return Result.failure(th) } @@ -70,4 +72,4 @@ class SearchRepositoryImpl : SearchRepository, KoinComponent { override suspend fun searchPaginatedArtists(query: String): Pager { TODO("Not yet implemented") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/search/SpotifySearchServiceImpl.kt b/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/search/SpotifySearchServiceImpl.kt index 056d324..db8c5e5 100644 --- a/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/search/SpotifySearchServiceImpl.kt +++ b/app/src/main/java/com/bobbyesp/metadator/features/spotify/data/remote/search/SpotifySearchServiceImpl.kt @@ -11,14 +11,13 @@ import com.bobbyesp.metadator.features.spotify.domain.services.SpotifyService import com.bobbyesp.metadator.features.spotify.domain.services.search.SpotifySearchService import org.koin.core.component.KoinComponent -class SpotifySearchServiceImpl( - private val spotifyService: SpotifyService -) : SpotifySearchService, KoinComponent { +class SpotifySearchServiceImpl(private val spotifyService: SpotifyService) : + SpotifySearchService, KoinComponent { override suspend fun search( query: String, vararg searchTypes: SearchApi.SearchType, - filters: List + filters: List, ): SpotifySearchResult { val api = spotifyService.getSpotifyApi() return api.search.search(query = query, searchTypes = searchTypes, filters = filters) @@ -26,21 +25,12 @@ class SpotifySearchServiceImpl( override suspend fun searchPaginatedTracks( query: String, - filters: List + filters: List, ): Pager { val api = spotifyService.getSpotifyApi() return Pager( - config = PagingConfig( - pageSize = 20, - enablePlaceholders = false, - initialLoadSize = 40, - ), - pagingSourceFactory = { - TracksPagingSource( - spotifyApi = api, - query = query, - ) - } + config = PagingConfig(pageSize = 20, enablePlaceholders = false, initialLoadSize = 40), + pagingSourceFactory = { TracksPagingSource(spotifyApi = api, query = query) }, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/features/spotify/di/SpotifyModule.kt b/app/src/main/java/com/bobbyesp/metadator/features/spotify/di/SpotifyModule.kt index 6e74e92..19e5a4e 100644 --- a/app/src/main/java/com/bobbyesp/metadator/features/spotify/di/SpotifyModule.kt +++ b/app/src/main/java/com/bobbyesp/metadator/features/spotify/di/SpotifyModule.kt @@ -16,4 +16,4 @@ val spotifyMainModule = module { val spotifyServicesModule = module { single { SpotifySearchServiceImpl(get()) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/pagination/TracksPagingSource.kt b/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/pagination/TracksPagingSource.kt index 562b57b..2f3283f 100644 --- a/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/pagination/TracksPagingSource.kt +++ b/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/pagination/TracksPagingSource.kt @@ -16,13 +16,14 @@ class TracksPagingSource( val offset = params.key ?: 0 return try { - val response = spotifyApi.search.searchTrack( - query = query, - limit = params.loadSize, - offset = offset, - market = null, - filters = filters, - ) + val response = + spotifyApi.search.searchTrack( + query = query, + limit = params.loadSize, + offset = offset, + market = null, + filters = filters, + ) if (response.isNotEmpty()) { val tracks = response.items @@ -30,7 +31,7 @@ class TracksPagingSource( LoadResult.Page( data = tracks, prevKey = if (offset > 0) offset - params.loadSize else null, - nextKey = if (tracks.isNotEmpty()) offset + params.loadSize else null + nextKey = if (tracks.isNotEmpty()) offset + params.loadSize else null, ) } else { LoadResult.Error(IllegalStateException("No tracks found")) @@ -43,4 +44,4 @@ class TracksPagingSource( override fun getRefreshKey(state: PagingState): Int? { return state.anchorPosition } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/repositories/SearchRepository.kt b/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/repositories/SearchRepository.kt index 133b69e..5b1eb75 100644 --- a/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/repositories/SearchRepository.kt +++ b/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/repositories/SearchRepository.kt @@ -8,11 +8,18 @@ import com.adamratzman.spotify.models.Track interface SearchRepository { suspend fun searchTracks(query: String): Result> + suspend fun searchAlbums(query: String): Result> + suspend fun searchPlaylists(query: String): Result> + suspend fun searchArtists(query: String): Result> + suspend fun searchPaginatedTracks(query: String): Pager + suspend fun searchPaginatedAlbums(query: String): Pager + suspend fun searchPaginatedPlaylists(query: String): Pager + suspend fun searchPaginatedArtists(query: String): Pager -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/services/SpotifyService.kt b/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/services/SpotifyService.kt index 48653a2..259f67c 100644 --- a/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/services/SpotifyService.kt +++ b/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/services/SpotifyService.kt @@ -5,5 +5,6 @@ import com.adamratzman.spotify.models.Token interface SpotifyService { suspend fun getSpotifyApi(): SpotifyAppApi + suspend fun getSpotifyToken(): Token -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/services/search/SpotifySearchService.kt b/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/services/search/SpotifySearchService.kt index d6eba6c..fd824cc 100644 --- a/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/services/search/SpotifySearchService.kt +++ b/app/src/main/java/com/bobbyesp/metadator/features/spotify/domain/services/search/SpotifySearchService.kt @@ -10,11 +10,8 @@ interface SpotifySearchService { suspend fun search( query: String, vararg searchTypes: SearchApi.SearchType, - filters: List + filters: List, ): SpotifySearchResult - suspend fun searchPaginatedTracks( - query: String, - filters: List, - ): Pager -} \ No newline at end of file + suspend fun searchPaginatedTracks(query: String, filters: List): Pager +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/MediaplayerRouting.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/MediaplayerRouting.kt index 0785f46..2af078b 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/MediaplayerRouting.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/MediaplayerRouting.kt @@ -3,19 +3,15 @@ package com.bobbyesp.metadator.mediaplayer import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.navigation import com.bobbyesp.metadator.core.presentation.common.Route -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.MediaplayerPage -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.MediaplayerViewModel import com.bobbyesp.ui.motion.animatedComposable fun NavGraphBuilder.mediaplayerRouting( - mediaplayerViewModel: MediaplayerViewModel, + // mediaplayerViewModel: MediaplayerViewModel, onNavigateBack: () -> Unit ) { navigation( - startDestination = Route.MediaplayerNavigator.Mediaplayer, + startDestination = Route.MediaplayerNavigator.Mediaplayer ) { - animatedComposable { - MediaplayerPage(mediaplayerViewModel) - } + animatedComposable {} } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/data/local/MediaplayerServiceConnection.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/data/local/MediaplayerServiceConnection.kt deleted file mode 100644 index 7e8acbe..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/data/local/MediaplayerServiceConnection.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.data.local - -import android.content.ComponentName -import android.content.ServiceConnection -import android.os.IBinder -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import com.bobbyesp.mediaplayer.service.ConnectionHandler -import com.bobbyesp.mediaplayer.service.MediaplayerService -import com.bobbyesp.utilities.Logging - -@OptIn(UnstableApi::class) -class MediaplayerServiceConnection( - private val connectionHandler: ConnectionHandler -) : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - Logging.i( - "The Music Service is connected. Updating the connection handler." - ) - val binder = service as MediaplayerService.MusicBinder - connectionHandler.connect(binder.service.mediaServiceHandler) - } - - override fun onServiceDisconnected(name: ComponentName?) { - Logging.i( - "The Music Service has been disconnected. Detaching the connection handler." - ) - connectionHandler.disconnect() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/di/MediaplayerViewModelsModule.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/di/MediaplayerViewModelsModule.kt index 3ef3551..2c7af02 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/di/MediaplayerViewModelsModule.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/di/MediaplayerViewModelsModule.kt @@ -1,9 +1,5 @@ package com.bobbyesp.metadator.mediaplayer.di -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.MediaplayerViewModel -import org.koin.core.module.dsl.viewModel import org.koin.dsl.module -val mediaplayerViewModels = module { - viewModel { MediaplayerViewModel(get(), get(), get(), get()) } -} \ No newline at end of file +val mediaplayerViewModels = module {} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/buttons/PlayPauseAnimatedButton.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/buttons/PlayPauseAnimatedButton.kt index d7d5bc8..cb6ea48 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/buttons/PlayPauseAnimatedButton.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/buttons/PlayPauseAnimatedButton.kt @@ -28,48 +28,42 @@ import com.bobbyesp.metadator.R fun PlayPauseAnimatedButton( modifier: Modifier = Modifier, isPlaying: Boolean, - onClick: () -> Unit + onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } val isPressed = interactionSource.collectIsPressedAsState() - val radius = if (isPlaying || isPressed.value) { - 40.dp - } else { - 24.dp - } - val cornerRadius = animateDpAsState(targetValue = radius, label = "Animated button shape") + val cornerRadius = + animateDpAsState( + targetValue = if (isPlaying || isPressed.value) 40.dp else 24.dp, + label = "Animated button shape", + ) Surface( tonalElevation = 10.dp, - modifier = modifier - .clip(RoundedCornerShape(cornerRadius.value)) + modifier = modifier.clip(RoundedCornerShape(cornerRadius.value)), ) { Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .size(72.dp) - .clip(RoundedCornerShape(cornerRadius.value)) - .clickable( - interactionSource = interactionSource, - indication = ripple(bounded = false), - ) { onClick() }, - contentAlignment = Alignment.Center + modifier = + Modifier.background(MaterialTheme.colorScheme.primaryContainer) + .size(72.dp) + .clip(RoundedCornerShape(cornerRadius.value)) + .clickable( + interactionSource = interactionSource, + indication = ripple(bounded = false), + onClick = onClick, + ), + contentAlignment = Alignment.Center, ) { - if (isPlaying) { - Icon( - imageVector = Icons.Rounded.Pause, - contentDescription = stringResource(id = R.string.pause), - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(32.dp) - ) - } else { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = stringResource(id = R.string.play), - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(32.dp) - ) - } + val icon = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow + val contentDescription = + stringResource(id = if (isPlaying) R.string.pause else R.string.play) + + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(32.dp), + ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/others/RepeatStateIcon.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/others/RepeatStateIcon.kt index 9a57b41..3d15b39 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/others/RepeatStateIcon.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/others/RepeatStateIcon.kt @@ -14,33 +14,18 @@ import androidx.media3.common.Player.REPEAT_MODE_ONE import com.bobbyesp.mediaplayer.R @Composable -fun RepeatStateIcon( - modifier: Modifier = Modifier, - repeatMode: Int -) { - when (repeatMode) { - REPEAT_MODE_OFF -> { - Icon( - imageVector = Icons.Rounded.Repeat, - contentDescription = stringResource(id = R.string.repeat_mode_off), - modifier = modifier.alpha(0.5f) - ) +fun RepeatStateIcon(modifier: Modifier = Modifier, repeatMode: Int) { + val (icon, description, alpha) = + when (repeatMode) { + REPEAT_MODE_OFF -> Triple(Icons.Rounded.Repeat, R.string.repeat_mode_off, 0.5f) + REPEAT_MODE_ONE -> Triple(Icons.Rounded.RepeatOne, R.string.repeat_mode_one, 1f) + REPEAT_MODE_ALL -> Triple(Icons.Rounded.Repeat, R.string.repeat_mode_all, 1f) + else -> Triple(Icons.Rounded.Repeat, R.string.repeat_mode_off, 0.5f) } - REPEAT_MODE_ONE -> { - Icon( - imageVector = Icons.Rounded.RepeatOne, - contentDescription = stringResource(id = R.string.repeat_mode_one), - modifier = modifier - ) - } - - REPEAT_MODE_ALL -> { - Icon( - imageVector = Icons.Rounded.Repeat, - contentDescription = stringResource(id = R.string.repeat_mode_all), - modifier = modifier - ) - } - } -} \ No newline at end of file + Icon( + imageVector = icon, + contentDescription = stringResource(id = description), + modifier = modifier.alpha(alpha), + ) +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/others/ShuffleStateIcon.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/others/ShuffleStateIcon.kt index 2ab416d..5697d5a 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/others/ShuffleStateIcon.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/components/others/ShuffleStateIcon.kt @@ -10,25 +10,12 @@ import androidx.compose.ui.res.stringResource import com.bobbyesp.mediaplayer.R @Composable -fun ShuffleStateIcon( - modifier: Modifier = Modifier, - isShuffleEnabled: Boolean -) { - when (isShuffleEnabled) { - true -> { - Icon( - imageVector = Icons.Rounded.ShuffleOn, - contentDescription = stringResource(id = R.string.action_shuffle_on), - modifier = modifier - ) - } +fun ShuffleStateIcon(modifier: Modifier = Modifier, isShuffleEnabled: Boolean) { + val icon = if (isShuffleEnabled) Icons.Rounded.ShuffleOn else Icons.Rounded.Shuffle + val description = + stringResource( + id = if (isShuffleEnabled) R.string.action_shuffle_on else R.string.action_shuffle_off + ) - false -> { - Icon( - imageVector = Icons.Rounded.Shuffle, - contentDescription = stringResource(id = R.string.action_shuffle_off), - modifier = modifier - ) - } - } -} \ No newline at end of file + Icon(imageVector = icon, contentDescription = description, modifier = modifier) +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/MediaplayerPage.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/MediaplayerPage.kt deleted file mode 100644 index cd054da..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/MediaplayerPage.kt +++ /dev/null @@ -1,203 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.add -import androidx.compose.foundation.layout.displayCutoutPadding -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bobbyesp.metadator.R -import com.bobbyesp.metadator.core.presentation.common.LocalNavController -import com.bobbyesp.metadator.core.util.navigateBack -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.CollapsedPlayerHeight -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.MediaplayerSheet -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.PlayerAnimationSpec -import com.bobbyesp.metadator.mediastore.presentation.components.card.songs.HorizontalSongCard -import com.bobbyesp.ui.components.bottomsheet.draggable.rememberDraggableBottomSheetState -import com.bobbyesp.ui.components.button.BackButton -import com.bobbyesp.ui.util.isDeviceInLandscape -import kotlinx.coroutines.launch -import my.nanihadesuka.compose.LazyColumnScrollbar -import my.nanihadesuka.compose.ScrollbarSelectionActionable -import my.nanihadesuka.compose.ScrollbarSettings - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MediaplayerPage( - viewModel: MediaplayerViewModel, -) { - val mediaStoreLazyColumnState = rememberLazyListState() - val isFirstItemVisible by remember { derivedStateOf { mediaStoreLazyColumnState.firstVisibleItemIndex == 0 } } - - val navController = LocalNavController.current - - val songs = viewModel.songsFlow.collectAsStateWithLifecycle(initialValue = emptyList()).value - - val scope = rememberCoroutineScope() - - val density = LocalDensity.current - val windowsInsets = WindowInsets.systemBars - - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) { - val bottomInset = with(density) { windowsInsets.getBottom(density).toDp() } - val mediaPlayerSheetState = rememberDraggableBottomSheetState( - dismissedBound = 0.dp, - collapsedBound = bottomInset + CollapsedPlayerHeight, - expandedBound = this.maxHeight, - animationSpec = PlayerAnimationSpec, - ) - - val targetBottom by remember { - derivedStateOf { - if (!mediaPlayerSheetState.isDismissed) { - CollapsedPlayerHeight + bottomInset - } else { - bottomInset - } - } - } - - val animatedBottom by animateDpAsState( - targetValue = targetBottom, label = "Animated bottom insets for player sheet" - ) - - val playerAwareWindowInsets by remember { - derivedStateOf { - windowsInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) - .add(WindowInsets(bottom = animatedBottom)) - } - } - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - CenterAlignedTopAppBar(navigationIcon = { - BackButton { - navController.navigateBack() - } - }, title = { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(id = R.string.mediaplayer).uppercase(), - fontWeight = FontWeight.SemiBold, - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.titleLarge.copy( - letterSpacing = 4.sp, - ), - ) - } - }) - }, - floatingActionButton = { - AnimatedVisibility( - visible = !isFirstItemVisible, - enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut() - ) { - FloatingActionButton(onClick = { - scope.launch { - mediaStoreLazyColumnState.animateScrollToItem(0) - } - }) { - Icon( - imageVector = Icons.Rounded.KeyboardDoubleArrowUp, - contentDescription = stringResource( - id = R.string.scroll_to_top - ) - ) - } - } - }, - contentWindowInsets = playerAwareWindowInsets, - ) { - Column( - modifier = Modifier - .fillMaxSize() - .then( - if(isDeviceInLandscape()) Modifier.displayCutoutPadding() else Modifier - ) - .padding(it) - ) { - LazyColumnScrollbar( - state = mediaStoreLazyColumnState, - settings = ScrollbarSettings( - thumbUnselectedColor = MaterialTheme.colorScheme.onSurfaceVariant, - thumbSelectedColor = MaterialTheme.colorScheme.primary, - selectionActionable = ScrollbarSelectionActionable.WhenVisible, - ), - ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - contentPadding = PaddingValues(horizontal = 8.dp), - state = mediaStoreLazyColumnState, - ) { - items( - count = songs.size, - key = { index -> songs[index].id }, - contentType = { index -> songs[index].id.toString() }) { index -> - val song = songs[index] - HorizontalSongCard( - song = song, modifier = Modifier.animateItem( - fadeInSpec = null, fadeOutSpec = null - ), onClick = { - viewModel.playOrderedQueue(song) - - if (mediaPlayerSheetState.isDismissed) { - mediaPlayerSheetState.collapseSoft() - } - }) - } - } - } - } - } - - MediaplayerSheet( - state = mediaPlayerSheetState, viewModel = viewModel - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/MediaplayerViewModel.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/MediaplayerViewModel.kt deleted file mode 100644 index 861e450..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/MediaplayerViewModel.kt +++ /dev/null @@ -1,247 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer - -import android.content.Context -import android.net.Uri -import androidx.annotation.OptIn -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.Player.REPEAT_MODE_OFF -import androidx.media3.common.util.UnstableApi -import androidx.media3.session.MediaLibraryService.MediaLibrarySession -import com.bobbyesp.mediaplayer.service.ConnectionHandler -import com.bobbyesp.mediaplayer.service.MediaServiceHandler -import com.bobbyesp.mediaplayer.service.MediaState -import com.bobbyesp.mediaplayer.service.PlayerEvent -import com.bobbyesp.mediaplayer.service.queue.SongsQueue -import com.bobbyesp.utilities.Time.formatDuration -import com.bobbyesp.utilities.mediastore.MediaStoreReceiver.Advanced.getSongs -import com.bobbyesp.utilities.mediastore.MediaStoreReceiver.Advanced.observeSongs -import com.bobbyesp.utilities.mediastore.model.Song -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.io.File - -@OptIn(UnstableApi::class) -class MediaplayerViewModel( - private val applicationContext: Context, - private val serviceHandler: MediaServiceHandler, - private val mediaSession: MediaLibrarySession, - val connectionHandler: ConnectionHandler -) : ViewModel() { - private val mutableMediaplayerPageState = MutableStateFlow(MediaplayerPageState()) - val pageViewState = mutableMediaplayerPageState.asStateFlow() - - val songsFlow = applicationContext.contentResolver.observeSongs() - - val songBeingPlayed = serviceHandler.currentMediaItem.asStateFlow() - - val isPlaying = serviceHandler.isPlaying - val isShuffleEnabled = serviceHandler.shuffleModeEnabled - val repeatMode = serviceHandler.repeatMode - - val canSkipNext = serviceHandler.canSkipNext - val canSkipPrevious = serviceHandler.canSkipPrevious - - data class MediaplayerPageState( - val uiState: PlayerState = PlayerState.Initial, - ) - - init { - viewModelScope.launch(Dispatchers.IO) { - serviceHandler.mediaState.collectLatest { mediaState -> - when (mediaState) { - is MediaState.Buffering -> { - if (mediaState.progress != C.TIME_UNSET) { - calculateProgressValues(mediaState.progress) - } - } - - is MediaState.Playing -> mutableMediaplayerPageState.update { - (it.uiState as? PlayerState.Ready)?.let { readyState -> - it.copy( - uiState = readyState.copy(isPlaying = mediaState.isPlaying) - ) - } ?: it - } - - is MediaState.Idle -> mutableMediaplayerPageState.update { it.copy(uiState = PlayerState.Initial) } - is MediaState.Progress -> { - if (mediaState.progress != C.TIME_UNSET) { - calculateProgressValues(mediaState.progress) - } - } - - is MediaState.Ready -> { - mutableMediaplayerPageState.update { - it.copy( - uiState = PlayerState.Ready( - duration = mediaState.duration, - ) - ) - } - } - } - } - } - } - - fun togglePlayPause() { - viewModelScope.launch { - serviceHandler.onPlayerEvent(PlayerEvent.PlayPause) - } - } - - fun toggleShuffle() { - viewModelScope.launch { - serviceHandler.onPlayerEvent(PlayerEvent.ToggleShuffle) - } - } - - fun toggleRepeat() { - viewModelScope.launch { - serviceHandler.onPlayerEvent(PlayerEvent.ToggleRepeat) - } - } - - fun playOrderedQueue(firstSong: Song) { - playQueue(firstSong) - viewModelScope.launch { - serviceHandler.onPlayerEvent(PlayerEvent.PlayPause) - } - } - - fun playShuffledQueue(firstSong: Song) { - playRandomQueue(firstSong) - viewModelScope.launch { - serviceHandler.onPlayerEvent(PlayerEvent.PlayPause) - } - } - - fun seekTo(progress: Float) { - val duration = (pageViewState.value.uiState as? PlayerState.Ready)?.duration ?: 0L - val seekPosition = (progress * duration).toLong() - viewModelScope.launch { - serviceHandler.seekTo(seekPosition) - } - } - - fun seekToPrevious() { - viewModelScope.launch { - serviceHandler.onPlayerEvent(PlayerEvent.Previous) - } - } - - fun seekToNext() { - viewModelScope.launch { - serviceHandler.onPlayerEvent(PlayerEvent.Next) - } - } - - private fun playRandomQueue(firstSong: Song) { - viewModelScope.launch { - val copiedList = applicationContext.contentResolver.getSongs().toMutableList() - - copiedList.shuffle() - - // Move the firstSong to the front of the list - copiedList.remove(firstSong) - copiedList.add(0, firstSong) - - loadQueueSongs(copiedList) - } - } - - private fun playQueue(firstSong: Song) { - viewModelScope.launch { - val copiedList = applicationContext.contentResolver.getSongs().toMutableList() - - copiedList.remove(firstSong) - copiedList.add(0, firstSong) - - loadQueueSongs(copiedList) - } - } - - private fun loadQueueSongs(songs: List) { - val mediaItems = songs.map { song -> - MediaItem.Builder() - .setCustomCacheKey(song.id.toString()) - .setUri(Uri.fromFile(File(song.path))) - .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(song.title) - .setArtist(song.artist) - .setAlbumTitle(song.album) - .setArtworkUri(song.artworkPath) - .build() - ).build() - } - - viewModelScope.launch { - val queue = SongsQueue(title = null, items = mediaItems) - serviceHandler.playQueue(queue) - } - } - - fun dismissPlayer() { - mediaSession.player.stop() - mediaSession.player.clearMediaItems() - connectionHandler.disconnect() - } - - private fun calculateProgressValues(currentProgress: Long) { - if (currentProgress <= 0) { - mutableMediaplayerPageState.update { - (it.uiState as? PlayerState.Ready)?.let { readyState -> - it.copy( - uiState = readyState.copy( - progress = 0f, - progressString = "00:00", - isPlaying = false - ) - ) - } ?: it - } - return - } - - (pageViewState.value.uiState as? PlayerState.Ready)?.let { readyState -> - val progress = currentProgress.toFloat() / readyState.duration - val progressString = formatDuration(currentProgress) - mutableMediaplayerPageState.update { - it.copy( - uiState = readyState.copy( - progress = progress, - progressString = progressString - ) - ) - } - } ?: calculateProgressValues(0L) - } - - override fun onCleared() { - viewModelScope.launch { - serviceHandler.killPlayer() - } - super.onCleared() - } - - sealed interface PlayerState { - data object Initial : PlayerState - data class Ready( - val progress: Float = 0f, - val progressString: String = "00:00", - val duration: Long = 0L, - val isPlaying: Boolean = false, - val isShuffleEnabled: Boolean = false, - val repeatMode: Int = REPEAT_MODE_OFF - ) : PlayerState - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/MediaplayerConstants.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/MediaplayerConstants.kt deleted file mode 100644 index 1aacc92..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/MediaplayerConstants.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player - -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.SizeTransform -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.EaseInOutSine -import androidx.compose.animation.core.tween -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.bobbyesp.ui.motion.materialSharedAxisXIn -import com.bobbyesp.ui.motion.materialSharedAxisXOut - -val CollapsedPlayerHeight = 84.dp -val SeekToButtonSize = 48.dp -val PlayerCommandsButtonSize = 48.dp - -val PlayerAnimationSpec: AnimationSpec = tween( - durationMillis = 750, - delayMillis = 0, - easing = EaseInOutSine -) - -val AnimatedTextContentTransformation = ContentTransform( - materialSharedAxisXIn(initialOffsetX = { it / 10 }), - materialSharedAxisXOut(targetOffsetX = { -it / 10 }), - sizeTransform = SizeTransform(clip = false) -) \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/MediaplayerSheet.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/MediaplayerSheet.kt deleted file mode 100644 index ba2cd23..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/MediaplayerSheet.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bobbyesp.mediaplayer.service.ConnectionState -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.MediaplayerViewModel -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.views.MediaplayerCollapsedContent -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.views.MediaplayerExpandedContent -import com.bobbyesp.ui.components.bottomsheet.draggable.DraggableBottomSheet -import com.bobbyesp.ui.components.bottomsheet.draggable.DraggableBottomSheetState -import kotlinx.coroutines.launch - -@Composable -fun MediaplayerSheet( - modifier: Modifier = Modifier, state: DraggableBottomSheetState, viewModel: MediaplayerViewModel -) { - val playingSong = - viewModel.songBeingPlayed.collectAsStateWithLifecycle().value?.mediaMetadata ?: return - val connectionState = - viewModel.connectionHandler.connectionState.collectAsStateWithLifecycle().value - - LaunchedEffect(connectionState, Unit) { - if (connectionState is ConnectionState.Connected && state.isDismissed) { - launch { - state.collapseSoft() - } - } - } - - DraggableBottomSheet(modifier = modifier, state = state, collapsedContent = { - MediaplayerCollapsedContent( - viewModel = viewModel, nowPlaying = playingSong - ) - }, backgroundColor = MaterialTheme.colorScheme.surfaceContainerHigh, onDismiss = { - viewModel.dismissPlayer() - }) { - MediaplayerExpandedContent( - viewModel = viewModel, - sheetState = state, - ) - } -} - - diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/MediaplayerSheetView.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/MediaplayerSheetView.kt deleted file mode 100644 index b657877..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/MediaplayerSheetView.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player - -enum class MediaplayerSheetView { - FULL_PLAYER, - QUEUE -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/PlayerControls.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/PlayerControls.kt deleted file mode 100644 index 6a3b0d8..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/PlayerControls.kt +++ /dev/null @@ -1,237 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.EaseInOutSine -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.rememberTransition -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.SkipNext -import androidx.compose.material.icons.rounded.SkipPrevious -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.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bobbyesp.metadator.R -import com.bobbyesp.metadator.mediaplayer.presentation.components.buttons.PlayPauseAnimatedButton -import com.bobbyesp.metadator.mediaplayer.presentation.components.others.RepeatStateIcon -import com.bobbyesp.metadator.mediaplayer.presentation.components.others.ShuffleStateIcon -import com.bobbyesp.metadator.core.presentation.components.text.ConditionedMarqueeText -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.MediaplayerViewModel -import com.bobbyesp.ui.components.text.MarqueeTextGradientOptions -import com.bobbyesp.utilities.Time -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PlayerControls( - modifier: Modifier = Modifier, - viewModel: MediaplayerViewModel, -) { - val scope = rememberCoroutineScope() - - val viewState = viewModel.pageViewState.collectAsStateWithLifecycle().value - val playerState = viewState.uiState - - val readyState = playerState as? MediaplayerViewModel.PlayerState.Ready - - val progress = readyState?.progress ?: 0f - - val playingSong = viewModel.songBeingPlayed.collectAsStateWithLifecycle().value?.mediaMetadata - - var sliderPosition by remember { - mutableStateOf(null) - } - - val duration by remember(readyState?.duration) { - derivedStateOf { - mutableLongStateOf(readyState?.duration ?: 0L) - } - } - - var temporalProgressString by remember { - mutableStateOf(null) - } - - val isPlaying = viewModel.isPlaying.collectAsStateWithLifecycle().value - val isShuffleEnabled = viewModel.isShuffleEnabled.collectAsStateWithLifecycle().value - val repeatMode = viewModel.repeatMode.collectAsStateWithLifecycle().value - - val transitionState = remember { MutableTransitionState(playingSong) } - - LaunchedEffect(playingSong) { - transitionState.targetState = playingSong - } - - val transition = rememberTransition(transitionState = transitionState) - - - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - transition.AnimatedContent( - modifier = Modifier - .padding(horizontal = 24.dp) - .align(Alignment.Start), - transitionSpec = { AnimatedTextContentTransformation }) { - Column { - Text( - text = it?.title.toString(), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Medium) - ) - ConditionedMarqueeText( - text = it?.artist.toString(), - style = MaterialTheme.typography.bodyLarge, - customEasing = EaseInOutSine, - sideGradient = MarqueeTextGradientOptions( - color = MaterialTheme.colorScheme.surfaceContainer, left = false - ) - ) - } - - } - Column( - modifier = Modifier.padding(horizontal = 18.dp) - ) { - val interactionSource = remember { - MutableInteractionSource() - } - - val songDuration by remember(readyState?.duration) { - derivedStateOf { - Time.formatDuration(readyState?.duration ?: 0L) - } - } - - val colors = SliderDefaults.colors() - - Spacer(modifier = Modifier.height(16.dp)) - - Slider( - modifier = Modifier.height(20.dp), - value = sliderPosition ?: progress, - onValueChange = { - sliderPosition = it - temporalProgressString = Time.formatDuration((it * duration.longValue).toLong()) - }, - onValueChangeFinished = { - viewModel.seekTo(sliderPosition ?: return@Slider) - scope.launch { - delay(350) - sliderPosition = null - temporalProgressString = null - } - }, - colors = colors, - track = { sliderState -> - SliderDefaults.Track( - sliderState = sliderState, - drawStopIndicator = null, - thumbTrackGapSize = 4.dp, - modifier = Modifier.height(8.dp) - ) - }, - thumb = { - SliderDefaults.Thumb( - interactionSource = interactionSource, - thumbSize = DpSize(width = 4.dp, height = 20.dp) - ) - }, - interactionSource = interactionSource - ) - Row(modifier = Modifier.padding(horizontal = 2.dp)) { - Text( - text = temporalProgressString ?: readyState?.progressString ?: "00:00", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = songDuration, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - modifier = Modifier.size(PlayerCommandsButtonSize), - onClick = viewModel::toggleShuffle - ) { - ShuffleStateIcon( - modifier = Modifier, - isShuffleEnabled = isShuffleEnabled - ) - } - IconButton( - modifier = Modifier.size(SeekToButtonSize), - onClick = { viewModel.seekToPrevious() }) { - Icon( - modifier = Modifier.fillMaxSize(), - imageVector = Icons.Rounded.SkipPrevious, - contentDescription = stringResource(id = R.string.seek_to_previous) - ) - } - PlayPauseAnimatedButton(isPlaying = isPlaying) { - viewModel.togglePlayPause() - } - IconButton( - modifier = Modifier.size(SeekToButtonSize), - onClick = { viewModel.seekToNext() }) { - Icon( - modifier = Modifier.fillMaxSize(), - imageVector = Icons.Rounded.SkipNext, - contentDescription = stringResource(id = R.string.seek_to_previous) - ) - } - - IconButton( - modifier = Modifier.size(PlayerCommandsButtonSize), - onClick = viewModel::toggleRepeat - ) { - RepeatStateIcon( - modifier = Modifier, - repeatMode = repeatMode - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/PlayerOptions.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/PlayerOptions.kt deleted file mode 100644 index 7c77ad7..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/PlayerOptions.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.QueueMusic -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.bobbyesp.metadator.R - -@Composable -fun PlayerOptions( - modifier: Modifier = Modifier, - onOpenQueue: () -> Unit = {} -) { - Box( - modifier = modifier - .clip( - RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) - ) - .background(MaterialTheme.colorScheme.surfaceContainerHigh), - contentAlignment = Alignment.Center - ) { - Row( - modifier = Modifier - .navigationBarsPadding() - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - IconButton(onClick = onOpenQueue) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.QueueMusic, - contentDescription = stringResource(id = R.string.music_queue) - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/MediaplayerCollapsedContent.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/MediaplayerCollapsedContent.kt deleted file mode 100644 index fca11cd..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/MediaplayerCollapsedContent.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.views - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.MediaplayerViewModel -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.CollapsedPlayerHeight -import com.bobbyesp.metadator.core.presentation.theme.MetadatorTheme - -@Composable -fun MediaplayerCollapsedContent( - nowPlaying: MediaMetadata, - modifier: Modifier = Modifier, - viewModel: MediaplayerViewModel, -) { - val viewState = viewModel.pageViewState.collectAsStateWithLifecycle().value - val playerState = viewState.uiState - - val progress = (playerState as? MediaplayerViewModel.PlayerState.Ready)?.progress ?: 0f - val isPlaying = viewModel.isPlaying.collectAsStateWithLifecycle().value - - Box( - modifier = modifier - .fillMaxWidth() - .height(CollapsedPlayerHeight) - .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)), - contentAlignment = Alignment.Center - ) { - MiniplayerContent( - modifier = Modifier - .padding(horizontal = 12.dp) - .padding(bottom = 6.dp), - playingSong = nowPlaying, - songProgress = progress, - isPlaying = isPlaying, - ) { - viewModel.togglePlayPause() - } - } -} - -@Preview -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CollapsedContentPrev() { - MetadatorTheme { - val metadata = MediaItem.Builder().setUri("path").setMediaMetadata( - MediaMetadata.Builder().setTitle("Bones").setArtist("Imagine Dragons") - .setAlbumTitle("Mercury - Acts 1 & 2").setArtworkUri(null).build() - ).build() - MiniplayerContent( - playingSong = metadata.mediaMetadata - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/MediaplayerExpandedContent.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/MediaplayerExpandedContent.kt deleted file mode 100644 index 963592a..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/MediaplayerExpandedContent.kt +++ /dev/null @@ -1,234 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.views - -import android.content.res.Configuration -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -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.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBackIosNew -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bobbyesp.metadator.R -import com.bobbyesp.metadator.core.presentation.components.image.AsyncImage -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.MediaplayerViewModel -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.MediaplayerSheetView -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.PlayerControls -import com.bobbyesp.ui.components.bottomsheet.draggable.DraggableBottomSheetState -import com.bobbyesp.ui.motion.MotionConstants -import com.bobbyesp.ui.motion.tweenEnter -import com.bobbyesp.ui.motion.tweenExit -import kotlinx.coroutines.launch - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun MediaplayerExpandedContent( - modifier: Modifier = Modifier, - viewModel: MediaplayerViewModel, - sheetState: DraggableBottomSheetState -) { - var view by remember { - mutableStateOf(MediaplayerSheetView.FULL_PLAYER) - } - val scope = rememberCoroutineScope() - val playingSong = viewModel.songBeingPlayed.collectAsStateWithLifecycle().value?.mediaMetadata - - val config = LocalConfiguration.current - - BackHandler { - sheetState.collapseSoft() - } - - SharedTransitionLayout( - modifier = Modifier.fillMaxSize() - ) { - Surface( - modifier = modifier - .fillMaxSize() - .padding( - WindowInsets.systemBars - .only(WindowInsetsSides.Horizontal) - .asPaddingValues() - ), - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - when (config.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> { - Row( - modifier = Modifier.fillMaxSize() - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .weight(1f), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - imageModel = playingSong?.artworkUri, - modifier = Modifier - .fillMaxHeight(0.9f) - .aspectRatio(1f) - .clip(MaterialTheme.shapes.small) - ) - Column( - modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(start = 12.dp), - ) { - IconButton(onClick = { - scope.launch { - sheetState.collapseSoft() - } - }) { - Icon( - imageVector = Icons.Rounded.ArrowBackIosNew, - contentDescription = stringResource(id = R.string.close), - modifier = Modifier.rotate(-90f) - ) - } - IconButton(onClick = { - - }) { - Icon( - imageVector = Icons.Rounded.MoreVert, - contentDescription = stringResource( - id = R.string.more - ) - ) - } - } - } - Box( - modifier = Modifier - .fillMaxHeight() - .weight(1f), - contentAlignment = Alignment.Center, - ) { - PlayerControls( - modifier = Modifier.fillMaxWidth(), viewModel = viewModel - ) - } - } - } - - else -> { - AnimatedContent(targetState = view, label = "", transitionSpec = { - fadeIn( - tweenEnter(delayMillis = MotionConstants.DURATION_EXIT_SHORT) - ) togetherWith fadeOut( - tweenExit(durationMillis = MotionConstants.DURATION_EXIT_SHORT) - ) - }) { - when (it) { - MediaplayerSheetView.FULL_PLAYER -> { - Column( - modifier = Modifier.statusBarsPadding() - ) { - Row( - modifier = Modifier - .padding(horizontal = 12.dp) - .padding(top = 12.dp, bottom = 12.dp) - ) { - IconButton(onClick = { - scope.launch { - sheetState.collapseSoft() - } - }) { - Icon( - imageVector = Icons.Rounded.ArrowBackIosNew, - contentDescription = stringResource(id = R.string.close), - modifier = Modifier.rotate(-90f) - ) - - } - Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = { - - }) { - Icon( - imageVector = Icons.Rounded.MoreVert, - contentDescription = stringResource(id = R.string.more) - ) - } - } - Column( - modifier = Modifier, - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - AsyncImage( - imageModel = playingSong?.artworkUri, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .padding(horizontal = 24.dp, vertical = 16.dp) - .clip(MaterialTheme.shapes.small) - ) - PlayerControls( - modifier = Modifier.fillMaxWidth(), - viewModel = viewModel - ) - } - Spacer(modifier = Modifier.weight(1f)) -// PlayerOptions( -// modifier = Modifier.fillMaxWidth(), -// onOpenQueue = { -// view = MediaplayerSheetView.QUEUE -// } -// ) - } - - } - - MediaplayerSheetView.QUEUE -> { - PlayerQueue( - imageModifier = Modifier, - nowPlaying = playingSong, - queue = emptyList(), onBackPressed = { - view = MediaplayerSheetView.FULL_PLAYER - } - ) - } - } - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/MiniplayerContent.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/MiniplayerContent.kt deleted file mode 100644 index 621aded..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/MiniplayerContent.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.views - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.rememberTransition -import androidx.compose.foundation.layout.Arrangement -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.material.icons.Icons -import androidx.compose.material.icons.rounded.Pause -import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.media3.common.MediaMetadata -import com.bobbyesp.metadator.R -import com.bobbyesp.metadator.core.presentation.components.image.AsyncImage -import com.bobbyesp.metadator.core.presentation.components.text.ConditionedMarqueeText -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.AnimatedTextContentTransformation -import com.bobbyesp.ui.components.button.DynamicButton - -@Composable -fun MiniplayerContent( - modifier: Modifier = Modifier, - playingSong: MediaMetadata, - isPlaying: Boolean = false, - songProgress: Float = 0f, - onPlayPause: () -> Unit = {} -) { - val transitionState = remember { MutableTransitionState(playingSong) } - - LaunchedEffect(playingSong) { - transitionState.targetState = playingSong - } - - val transition = rememberTransition(transitionState = transitionState) - - val songCardArtworkUri = remember(transitionState.isIdle) { - transitionState.currentState.artworkUri - } - - val animatedSongProgress by animateFloatAsState( - targetValue = songProgress, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - label = "Animated song progress" - ) - - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - AsyncImage( - modifier = Modifier - .size(52.dp) - .clip(MaterialTheme.shapes.extraSmall), - imageModel = songCardArtworkUri - ) - Column( - horizontalAlignment = Alignment.Start, - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 6.dp) - .weight(1f) - ) { - transition.AnimatedContent(transitionSpec = { AnimatedTextContentTransformation }) { - Column { - ConditionedMarqueeText( - text = it.title.toString(), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 16.sp - ) - - ConditionedMarqueeText( - text = it.artist.toString(), - style = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ), - fontSize = 12.sp - ) - } - - } - } - - DynamicButton( - modifier = Modifier - .size(42.dp) - .padding(4.dp), icon = { - Icon( - imageVector = Icons.Rounded.Pause, - contentDescription = stringResource( - id = R.string.pause - ), - ) - }, icon2 = { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = stringResource( - id = R.string.play - ), - ) - }, isIcon1 = isPlaying - ) { - onPlayPause() - } - } - LinearProgressIndicator( - progress = { - animatedSongProgress - }, - modifier = Modifier.fillMaxWidth(), - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/PlayerQueue.kt b/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/PlayerQueue.kt deleted file mode 100644 index 5ea96fe..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/mediaplayer/presentation/pages/mediaplayer/player/views/PlayerQueue.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.views - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import androidx.media3.common.MediaMetadata -import com.bobbyesp.metadator.core.presentation.components.image.AsyncImage - -@Composable -fun PlayerQueue( - modifier: Modifier = Modifier, - imageModifier: Modifier, - nowPlaying: MediaMetadata?, - queue: List, - onPlay: (MediaMetadata) -> Unit = {}, - onBackPressed: () -> Unit = {} -) { - BackHandler { - onBackPressed() - } - - Column { - Box( - modifier = modifier - .fillMaxWidth() - .padding(24.dp) - ) { - AsyncImage( - imageModel = nowPlaying?.artworkUri, - modifier = imageModifier - .clip(MaterialTheme.shapes.small) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/mediastore/di/MediaStoreViewModelsModule.kt b/app/src/main/java/com/bobbyesp/metadator/mediastore/di/MediaStoreViewModelsModule.kt index 715aa69..3e24755 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediastore/di/MediaStoreViewModelsModule.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediastore/di/MediaStoreViewModelsModule.kt @@ -4,6 +4,4 @@ import com.bobbyesp.metadator.mediastore.presentation.MediaStorePageViewModel import org.koin.core.module.dsl.viewModel import org.koin.dsl.module -val mediaStoreViewModelsModule = module { - viewModel { MediaStorePageViewModel(get()) } -} \ No newline at end of file +val mediaStoreViewModelsModule = module { viewModel { MediaStorePageViewModel(get()) } } diff --git a/app/src/main/java/com/bobbyesp/metadator/mediastore/domain/enums/CompactCardSize.kt b/app/src/main/java/com/bobbyesp/metadator/mediastore/domain/enums/CompactCardSize.kt index 0307a6a..1911b84 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediastore/domain/enums/CompactCardSize.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediastore/domain/enums/CompactCardSize.kt @@ -18,11 +18,12 @@ enum class CompactCardSize(val value: Dp) { CompactCardSize.entries.first { it.ordinal == this } @Composable - fun CompactCardSize.toShape(): CornerBasedShape = when (this) { - SMALL -> MaterialTheme.shapes.small - MEDIUM -> MaterialTheme.shapes.medium - LARGE -> MaterialTheme.shapes.large - EXTRA_LARGE -> MaterialTheme.shapes.extraLarge - } + fun CompactCardSize.toShape(): CornerBasedShape = + when (this) { + SMALL -> MaterialTheme.shapes.small + MEDIUM -> MaterialTheme.shapes.medium + LARGE -> MaterialTheme.shapes.large + EXTRA_LARGE -> MaterialTheme.shapes.extraLarge + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediastore/domain/enums/LayoutType.kt b/app/src/main/java/com/bobbyesp/metadator/mediastore/domain/enums/LayoutType.kt index 4f010cf..371e618 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediastore/domain/enums/LayoutType.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediastore/domain/enums/LayoutType.kt @@ -12,4 +12,4 @@ enum class LayoutType(val icon: ImageVector) { companion object { fun Int.toListType(): LayoutType = LayoutType.entries.first { it.ordinal == this } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/MediaStorePage.kt b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/MediaStorePage.kt index fd7bce3..042fff5 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/MediaStorePage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/MediaStorePage.kt @@ -18,9 +18,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bobbyesp.metadator.R +import com.bobbyesp.metadator.mediastore.domain.enums.CompactCardSize import com.bobbyesp.metadator.mediastore.domain.enums.LayoutType import com.bobbyesp.metadator.mediastore.presentation.components.card.songs.HorizontalSongCard -import com.bobbyesp.metadator.mediastore.domain.enums.CompactCardSize import com.bobbyesp.metadator.mediastore.presentation.components.card.songs.compact.CompactSongCard import com.bobbyesp.metadator.mediastore.presentation.components.others.status.EmptyMediaStoreWarning import com.bobbyesp.ui.common.pages.ErrorPage @@ -41,7 +41,7 @@ fun MediaStorePage( desiredLayout: LayoutType, compactCardSize: CompactCardSize, onReloadMediaStore: () -> Unit, - onItemClicked: (Song) -> Unit + onItemClicked: (Song) -> Unit, ) { val songsList = songs.value @@ -49,60 +49,68 @@ fun MediaStorePage( modifier = modifier.fillMaxSize(), targetState = desiredLayout, label = "List item transition", - animationSpec = tween(200) + animationSpec = tween(200), ) { type -> when (songsList) { - is ResourceState.Loading -> LoadingPage(text = stringResource(R.string.loading_mediastore)) + is ResourceState.Loading -> + LoadingPage(text = stringResource(R.string.loading_mediastore)) - is ResourceState.Error -> ErrorPage( - modifier = Modifier.fillMaxSize(), - throwable = Exception(songsList.message ?: stringResource(R.string.unknown)) - ) { onReloadMediaStore() } + is ResourceState.Error -> + ErrorPage( + modifier = Modifier.fillMaxSize(), + throwable = Exception(songsList.message ?: stringResource(R.string.unknown)), + ) { + onReloadMediaStore() + } is ResourceState.Success -> { - val dataSongsList = songsList.data ?: throw IllegalStateException(stringResource(R.string.data_null)) + val dataSongsList = + songsList.data + ?: throw IllegalStateException(stringResource(R.string.data_null)) if (dataSongsList.isEmpty()) { - EmptyMediaStoreWarning( - modifier = Modifier.fillMaxSize() - ) + EmptyMediaStoreWarning(modifier = Modifier.fillMaxSize()) } else { when (type) { LayoutType.Grid -> { LazyVerticalGridScrollbar( - state = lazyGridState, settings = ScrollbarSettings( - thumbUnselectedColor = MaterialTheme.colorScheme.onSurfaceVariant, - thumbSelectedColor = MaterialTheme.colorScheme.primary, - selectionActionable = ScrollbarSelectionActionable.WhenVisible, - ) + state = lazyGridState, + settings = + ScrollbarSettings( + thumbUnselectedColor = + MaterialTheme.colorScheme.onSurfaceVariant, + thumbSelectedColor = MaterialTheme.colorScheme.primary, + selectionActionable = + ScrollbarSelectionActionable.WhenVisible, + ), ) { LazyVerticalGrid( columns = GridCells.Adaptive(125.dp), verticalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(8.dp), - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - state = lazyGridState + modifier = + Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.background), + state = lazyGridState, ) { items( count = dataSongsList.size, key = { index -> dataSongsList[index].id }, - contentType = { _ -> "songItem" }) { index -> + contentType = { _ -> "songItem" }, + ) { index -> val song = dataSongsList[index] CompactSongCard( - modifier = Modifier - .animateItem( - fadeInSpec = null, fadeOutSpec = null + modifier = + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, ), size = compactCardSize, name = song.title, artists = song.artist, artworkUri = song.artworkPath, - onClick = { - onItemClicked(song) - } + onClick = { onItemClicked(song) }, ) } } @@ -111,31 +119,37 @@ fun MediaStorePage( LayoutType.List -> { LazyColumnScrollbar( - state = lazyListState, settings = ScrollbarSettings( - thumbUnselectedColor = MaterialTheme.colorScheme.onSurfaceVariant, - thumbSelectedColor = MaterialTheme.colorScheme.primary, - selectionActionable = ScrollbarSelectionActionable.WhenVisible, - ) + state = lazyListState, + settings = + ScrollbarSettings( + thumbUnselectedColor = + MaterialTheme.colorScheme.onSurfaceVariant, + thumbSelectedColor = MaterialTheme.colorScheme.primary, + selectionActionable = + ScrollbarSelectionActionable.WhenVisible, + ), ) { LazyColumn( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + modifier = + Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.background), state = lazyListState, ) { items( count = dataSongsList.size, key = { index -> dataSongsList[index].id }, - contentType = { _ -> "songHorizontalItem" }) { index -> + contentType = { _ -> "songHorizontalItem" }, + ) { index -> val song = dataSongsList[index] HorizontalSongCard( song = song, - modifier = Modifier.animateItem( - fadeInSpec = null, fadeOutSpec = null - ), - onClick = { - onItemClicked(song) - }) + modifier = + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ), + onClick = { onItemClicked(song) }, + ) } } } @@ -145,4 +159,4 @@ fun MediaStorePage( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/MediaStorePageViewModel.kt b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/MediaStorePageViewModel.kt index b1135c6..c9eac83 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/MediaStorePageViewModel.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/MediaStorePageViewModel.kt @@ -1,27 +1,29 @@ package com.bobbyesp.metadator.mediastore.presentation -import android.content.Context +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.bobbyesp.utilities.mediastore.MediaStoreReceiver.Advanced.observeSongs +import com.bobbyesp.mediaplayer.domain.model.MusicTrack +import com.bobbyesp.mediaplayer.domain.repository.MusicLibraryRepository import com.bobbyesp.utilities.mediastore.model.Song import com.bobbyesp.utilities.states.ResourceState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class MediaStorePageViewModel( - context: Context -) : ViewModel() { +class MediaStorePageViewModel(musicLibraryRepository: MusicLibraryRepository) : ViewModel() { private val _songs: MutableStateFlow>> = MutableStateFlow(ResourceState.Loading()) val songs = _songs.asStateFlow() private val mediaStoreSongsFlow = - context.contentResolver.observeSongs() + musicLibraryRepository.observeMusicLibrary(null, null).map { musicTracks -> + musicTracks.map { it.toSong() } + } private fun songsCollection() { viewModelScope.launch(Dispatchers.IO) { @@ -45,9 +47,23 @@ class MediaStorePageViewModel( } companion object { + fun MusicTrack.toSong(): Song { + return Song( + id = id, + title = title, + artist = artist ?: "", + album = album ?: "", + artworkPath = (artworkUri ?: "").toUri(), + duration = duration?.toDouble() ?: 0.0, + path = path, + fileName = this.path.substringAfterLast("/"), + ) + } + interface Events { data object StartObservingMediaStore : Events + data object ReloadMediaStore : Events } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/HorizontalSongCard.kt b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/HorizontalSongCard.kt index 151e944..92bb16a 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/HorizontalSongCard.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/HorizontalSongCard.kt @@ -25,43 +25,31 @@ import com.bobbyesp.utilities.Time import com.bobbyesp.utilities.mediastore.model.Song @Composable -fun HorizontalSongCard( - modifier: Modifier = Modifier, - song: Song, - onClick: () -> Unit -) { - Surface( - modifier = modifier, - onClick = onClick - ) { +fun HorizontalSongCard(modifier: Modifier = Modifier, song: Song, onClick: () -> Unit) { + Surface(modifier = modifier, onClick = onClick) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - AsyncImage( - modifier = Modifier - .size(64.dp) - .padding(4.dp), - imageModel = song.artworkPath - ) + AsyncImage(modifier = Modifier.size(64.dp).padding(4.dp), imageModel = song.artworkPath) Column( - horizontalAlignment = Alignment.Start, modifier = Modifier - .padding(vertical = 8.dp, horizontal = 6.dp) - .weight(1f) + horizontalAlignment = Alignment.Start, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 6.dp).weight(1f), ) { ConditionedMarqueeText( text = song.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, - fontSize = 15.sp + fontSize = 15.sp, ) ConditionedMarqueeText( text = song.artist, - style = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ), - fontSize = 12.sp + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ), + fontSize = 12.sp, ) } @@ -69,19 +57,18 @@ fun HorizontalSongCard( text = Time.formatDuration(song.duration.toLong()), style = MaterialTheme.typography.bodySmall, fontSize = 12.sp, - modifier = Modifier - .padding(8.dp) - .background( - MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.1f), - MaterialTheme.shapes.small - ) - .padding(6.dp) + modifier = + Modifier.padding(8.dp) + .background( + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.1f), + MaterialTheme.shapes.small, + ) + .padding(6.dp), ) } } } - @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -89,15 +76,18 @@ private fun HorizontalSongCardPreview() { MetadatorTheme { HorizontalSongCard( modifier = Modifier.fillMaxWidth(), - song = Song( - id = 1, - title = "Bones", - artist = "Imagine Dragons", - album = "Mercury - Acts 1 & 2", - artworkPath = null, - duration = 100.0, - path = "path", - fileName = "Bones" - ), onClick = {}) + song = + Song( + id = 1, + title = "Bones", + artist = "Imagine Dragons", + album = "Mercury - Acts 1 & 2", + artworkPath = null, + duration = 100.0, + path = "path", + fileName = "Bones", + ), + onClick = {}, + ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/VerticalSongCard.kt b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/VerticalSongCard.kt index d418879..e79b6e7 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/VerticalSongCard.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/VerticalSongCard.kt @@ -21,60 +21,51 @@ import com.bobbyesp.metadator.core.presentation.theme.MetadatorTheme import com.bobbyesp.utilities.mediastore.model.Song @Composable -fun VerticalSongCard( - modifier: Modifier = Modifier, - song: Song, - onClick: () -> Unit -) { - Surface( - modifier = modifier - .clip(MaterialTheme.shapes.small), - onClick = onClick - ) { +fun VerticalSongCard(modifier: Modifier = Modifier, song: Song, onClick: () -> Unit) { + Surface(modifier = modifier.clip(MaterialTheme.shapes.small), onClick = onClick) { Column { AsyncImage( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f), - imageModel = song.artworkPath + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + imageModel = song.artworkPath, ) - Column( - horizontalAlignment = Alignment.Start, modifier = Modifier.padding(8.dp) - ) { + Column(horizontalAlignment = Alignment.Start, modifier = Modifier.padding(8.dp)) { ConditionedMarqueeText( text = song.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, - fontSize = 15.sp + fontSize = 15.sp, ) ConditionedMarqueeText( text = song.artist, - style = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ), - fontSize = 12.sp + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ), + fontSize = 12.sp, ) } } } } - @Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun LocalSongCardPreview() { MetadatorTheme { VerticalSongCard( - song = Song( - id = 1, - title = "Bones", - artist = "Imagine Dragons", - album = "Mercury - Acts 1 & 2", - artworkPath = null, - duration = 100.0, - path = "path", - fileName = "Bones" - ), onClick = {}) + song = + Song( + id = 1, + title = "Bones", + artist = "Imagine Dragons", + album = "Mercury - Acts 1 & 2", + artworkPath = null, + duration = 100.0, + path = "path", + fileName = "Bones", + ), + onClick = {}, + ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/compact/CompactSongCard.kt b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/compact/CompactSongCard.kt index 20e953b..bbbd269 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/compact/CompactSongCard.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/compact/CompactSongCard.kt @@ -25,10 +25,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.bobbyesp.metadator.core.data.local.preferences.PreferencesKey.REDUCE_SHADOWS import com.bobbyesp.metadator.core.data.local.preferences.datastore.rememberPreferenceState -import com.bobbyesp.metadator.mediastore.domain.enums.CompactCardSize.Companion.toShape import com.bobbyesp.metadator.core.presentation.components.image.AsyncImage import com.bobbyesp.metadator.core.presentation.components.text.ConditionedMarqueeText import com.bobbyesp.metadator.mediastore.domain.enums.CompactCardSize +import com.bobbyesp.metadator.mediastore.domain.enums.CompactCardSize.Companion.toShape @Composable fun CompactSongCard( @@ -38,22 +38,14 @@ fun CompactSongCard( artworkUri: Uri? = null, listIndex: Int? = null, size: CompactCardSize = CompactCardSize.LARGE, - onClick: () -> Unit + onClick: () -> Unit, ) { val (reduceShadows, _) = rememberPreferenceState(REDUCE_SHADOWS) val cardSize by remember(size) { mutableStateOf(size.value) } val formalizedShape = size.toShape() - Box( - modifier = modifier - .clip(formalizedShape) - .size(cardSize) - .clickable(onClick = onClick) - ) { - AsyncImage( - modifier = Modifier.fillMaxSize(), - imageModel = artworkUri, - ) + Box(modifier = modifier.clip(formalizedShape).size(cardSize).clickable(onClick = onClick)) { + AsyncImage(modifier = Modifier.fillMaxSize(), imageModel = artworkUri) listIndex?.let { Text( @@ -61,31 +53,31 @@ fun CompactSongCard( style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.8f), fontWeight = FontWeight.Bold, - modifier = Modifier - .padding(8.dp) - .align(Alignment.TopEnd) + modifier = Modifier.padding(8.dp).align(Alignment.TopEnd), ) } Column( - modifier = Modifier - .background( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, MaterialTheme.colorScheme.scrim), - startY = 0f, endY = 500f - ), - alpha = if (reduceShadows.value) 0f else 0.6f - ) - .fillMaxSize() - .padding(horizontal = 8.dp, vertical = 6.dp), + modifier = + Modifier.background( + brush = + Brush.verticalGradient( + colors = listOf(Color.Transparent, MaterialTheme.colorScheme.scrim), + startY = 0f, + endY = 500f, + ), + alpha = if (reduceShadows.value) 0f else 0.6f, + ) + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 6.dp), verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.Start + horizontalAlignment = Alignment.Start, ) { ConditionedMarqueeText( text = name, style = MaterialTheme.typography.titleSmall, color = Color.White, overflow = TextOverflow.Ellipsis, - maxLines = 1 + maxLines = 1, ) if (artists.isNotEmpty()) { @@ -94,9 +86,9 @@ fun CompactSongCard( style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.6f), overflow = TextOverflow.Ellipsis, - maxLines = 1 + maxLines = 1, ) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/spotify/SpotifyHorizontalSongCard.kt b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/spotify/SpotifyHorizontalSongCard.kt index 3b53640..60539a5 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/spotify/SpotifyHorizontalSongCard.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/card/songs/spotify/SpotifyHorizontalSongCard.kt @@ -39,51 +39,43 @@ fun SpotifyHorizontalSongCard( onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, ) { - val albumArtPath by remember(track) { - mutableStateOf(track.album.images?.getOrNull(0)?.url) - } + val albumArtPath by remember(track) { mutableStateOf(track.album.images?.getOrNull(0)?.url) } Surface( - modifier = modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.extraSmall) - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ), - color = surfaceColor + modifier = + modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.extraSmall) + .combinedClickable(onClick = onClick, onLongClick = onLongClick), + color = surfaceColor, ) { Row( modifier = innerModifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { if (listIndex != null) { Text( text = "${listIndex + 1}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, - modifier = Modifier - .padding(8.dp) - .padding(end = 4.dp) + modifier = Modifier.padding(8.dp).padding(end = 4.dp), ) } Box(contentAlignment = Alignment.CenterStart) { AsyncImage( modifier = imageModifier.size(64.dp), imageModel = albumArtPath, - shape = MaterialTheme.shapes.extraSmall + shape = MaterialTheme.shapes.extraSmall, ) } Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.Center, ) { Column( horizontalAlignment = Alignment.Start, - modifier = Modifier - .padding(8.dp) - .weight(1f) + modifier = Modifier.padding(8.dp).weight(1f), ) { ConditionedMarqueeText( text = track.name, @@ -92,12 +84,13 @@ fun SpotifyHorizontalSongCard( ) ConditionedMarqueeText( text = track.artists.formatArtists(), - style = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ), ) } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/others/status/EmptyMediaStoreWarning.kt b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/others/status/EmptyMediaStoreWarning.kt index 7707217..7d42d81 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/others/status/EmptyMediaStoreWarning.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/components/others/status/EmptyMediaStoreWarning.kt @@ -27,35 +27,28 @@ import com.bobbyesp.metadator.R import com.bobbyesp.metadator.core.presentation.theme.MetadatorTheme @Composable -fun EmptyMediaStoreWarning( - modifier: Modifier = Modifier -) { - Box( - modifier = modifier, - contentAlignment = Alignment.Center - ) { +fun EmptyMediaStoreWarning(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( modifier = Modifier.size(72.dp), imageVector = Icons.TwoTone.LibraryMusic, - contentDescription = stringResource(id = R.string.empty_media_store) + contentDescription = stringResource(id = R.string.empty_media_store), ) HorizontalDivider(modifier = Modifier.fillMaxWidth(0.7f)) Text( text = stringResource(id = R.string.empty_media_store), style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) Text( text = stringResource(id = R.string.empty_media_store_desc), style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } @@ -67,9 +60,7 @@ fun EmptyMediaStoreWarning( private fun EmptyMediaStorePrev() { MetadatorTheme { EmptyMediaStoreWarning( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/pages/home/HomePage.kt b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/pages/home/HomePage.kt index a4b7ebf..eda3ebe 100644 --- a/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/pages/home/HomePage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/mediastore/presentation/pages/home/HomePage.kt @@ -63,10 +63,10 @@ import com.bobbyesp.ui.components.dropdown.DropdownItemContainer import com.bobbyesp.ui.components.text.AutoResizableText import com.bobbyesp.utilities.mediastore.model.Song import com.bobbyesp.utilities.states.ResourceState -import com.bobbyesp.utilities.ui.permission.PermissionNotGrantedDialog -import com.bobbyesp.utilities.ui.permission.PermissionRequestHandler -import com.bobbyesp.utilities.ui.permission.PermissionType.Companion.toPermissionType -import com.bobbyesp.utilities.ui.rememberForeverLazyGridState +import com.bobbyesp.utilities.ui.layouts.lazygrid.rememberForeverLazyGridState +import com.bobbyesp.utilities.ui.permissions.PermissionNotGrantedDialog +import com.bobbyesp.utilities.ui.permissions.PermissionRequestHandler +import com.bobbyesp.utilities.ui.permissions.PermissionType.Companion.toPermissionType import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -80,14 +80,16 @@ fun HomePage( modifier: Modifier = Modifier, songs: State>>, preferences: State, - onEvent: (MediaStorePageViewModel.Companion.Events) -> Unit = {} + onEvent: (MediaStorePageViewModel.Companion.Events) -> Unit = {}, ) { val context = LocalActivity.current - val readAudioFiles = when { - Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU -> Manifest.permission.READ_EXTERNAL_STORAGE + val readAudioFiles = + when { + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU -> + Manifest.permission.READ_EXTERNAL_STORAGE - else -> Manifest.permission.READ_MEDIA_AUDIO - } + else -> Manifest.permission.READ_MEDIA_AUDIO + } val storagePermissionState = rememberPermissionState(permission = readAudioFiles) @@ -100,9 +102,7 @@ fun HomePage( val navController = LocalNavController.current val scope = rememberCoroutineScope() - var moreOptionsVisible by remember { - mutableStateOf(false) - } + var moreOptionsVisible by remember { mutableStateOf(false) } val mediaStoreLazyGridState = rememberForeverLazyGridState(key = "lazyGrid") val mediaStoreLazyColumnState = rememberLazyListState() @@ -113,14 +113,10 @@ fun HomePage( val songCardSize = preferences.value.songCardSize val gridIsFirstItemVisible by remember { - derivedStateOf { - mediaStoreLazyGridState.firstVisibleItemIndex == 0 - } + derivedStateOf { mediaStoreLazyGridState.firstVisibleItemIndex == 0 } } val listIsFirstItemVisible by remember { - derivedStateOf { - mediaStoreLazyColumnState.firstVisibleItemIndex == 0 - } + derivedStateOf { mediaStoreLazyColumnState.firstVisibleItemIndex == 0 } } Scaffold( @@ -128,95 +124,76 @@ fun HomePage( topBar = { TopAppBar( title = { - Column( - horizontalAlignment = Alignment.Start, - ) { + Column(horizontalAlignment = Alignment.Start) { Text( text = stringResource(id = R.string.app_name).uppercase(), fontWeight = FontWeight.SemiBold, fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.titleLarge.copy( - letterSpacing = 4.sp, - ), + style = MaterialTheme.typography.titleLarge.copy(letterSpacing = 4.sp), ) AutoResizableText( text = stringResource(id = R.string.app_desc).uppercase(), fontWeight = FontWeight.Normal, fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodySmall.copy( - letterSpacing = 2.sp, - ), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + style = MaterialTheme.typography.bodySmall.copy(letterSpacing = 2.sp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), ) } - }, actions = { + }, + actions = { IconButton( onClick = { - scope.launch { - moreOptionsVisible = false - } + scope.launch { moreOptionsVisible = false } navController.navigate(Route.MediaplayerNavigator) } ) { Icon( imageVector = Icons.Rounded.PlayArrow, - contentDescription = stringResource( - id = R.string.mediaplayer - ) + contentDescription = stringResource(id = R.string.mediaplayer), ) } IconButton( - onClick = { navController.navigate(Route.SettingsNavigator.Settings) }) { + onClick = { navController.navigate(Route.SettingsNavigator.Settings) } + ) { Icon( imageVector = Icons.Rounded.Settings, - contentDescription = stringResource( - id = R.string.settings - ) + contentDescription = stringResource(id = R.string.settings), ) } - IconButton( - onClick = { - moreOptionsVisible = !moreOptionsVisible - }) { + IconButton(onClick = { moreOptionsVisible = !moreOptionsVisible }) { Icon( imageVector = Icons.Rounded.MoreVert, - contentDescription = stringResource( - id = R.string.open_more_options - ) + contentDescription = stringResource(id = R.string.open_more_options), ) } AnimatedDropdownMenu( - expanded = moreOptionsVisible, onDismissRequest = { - moreOptionsVisible = false - }) { + expanded = moreOptionsVisible, + onDismissRequest = { moreOptionsVisible = false }, + ) { DropdownMenuContent( desiredLayout = configuredLayout, - onLayoutChanged = { - setConfiguredLayout(it.name) - } + onLayoutChanged = { setConfiguredLayout(it.name) }, ) } - - }) - }, floatingActionButton = { + }, + ) + }, + floatingActionButton = { when (configuredLayout) { LayoutType.Grid -> { AnimatedVisibility( visible = !gridIsFirstItemVisible, enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut() + exit = fadeOut() + scaleOut(), ) { FloatingActionButton( onClick = { - scope.launch { - mediaStoreLazyGridState.animateScrollToItem(0) - } - }) { + scope.launch { mediaStoreLazyGridState.animateScrollToItem(0) } + } + ) { Icon( imageVector = Icons.Rounded.KeyboardDoubleArrowUp, - contentDescription = stringResource( - id = R.string.scroll_to_top - ) + contentDescription = stringResource(id = R.string.scroll_to_top), ) } } @@ -226,36 +203,31 @@ fun HomePage( AnimatedVisibility( visible = !listIsFirstItemVisible, enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut() + exit = fadeOut() + scaleOut(), ) { - FloatingActionButton(onClick = { - scope.launch { - mediaStoreLazyColumnState.animateScrollToItem(0) + FloatingActionButton( + onClick = { + scope.launch { mediaStoreLazyColumnState.animateScrollToItem(0) } } - }) { + ) { Icon( imageVector = Icons.Rounded.KeyboardDoubleArrowUp, - contentDescription = stringResource( - id = R.string.scroll_to_top - ) + contentDescription = stringResource(id = R.string.scroll_to_top), ) } } } } - }) { paddingValues -> + }, + ) { paddingValues -> PermissionRequestHandler( permissionState = storagePermissionState, deniedContent = { shouldShowRationale -> PermissionNotGrantedDialog( neededPermissions = persistentListOf(readAudioFiles.toPermissionType()), - onGrantRequest = { - storagePermissionState.launchPermissionRequest() - }, - onDismissRequest = { - context?.finish() - }, - shouldShowRationale = shouldShowRationale + onGrantRequest = { storagePermissionState.launchPermissionRequest() }, + onDismissRequest = { context?.finish() }, + shouldShowRationale = shouldShowRationale, ) }, content = { @@ -273,28 +245,27 @@ fun HomePage( navController.navigate( Route.UtilitiesNavigator.TagEditor(song.toParcelableSong()) ) - }) - }) + }, + ) + }, + ) } } @Composable -private fun DropdownMenuContent( - desiredLayout: LayoutType, - onLayoutChanged: (LayoutType) -> Unit, -) { +private fun DropdownMenuContent(desiredLayout: LayoutType, onLayoutChanged: (LayoutType) -> Unit) { val availableLayoutType = LayoutType.entries.toImmutableList() Column( modifier = Modifier, verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(id = R.string.layout_type), modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.labelMedium + style = MaterialTheme.typography.labelMedium, ) DropdownItemContainer( modifier = Modifier, @@ -303,22 +274,21 @@ private fun DropdownMenuContent( availableLayoutType.forEachIndexed { index, listType -> SegmentedButton( selected = desiredLayout.ordinal == listType.ordinal, - onClick = { - onLayoutChanged(listType) - }, - shape = SegmentedButtonDefaults.itemShape( - index = index, count = availableLayoutType.size - ), + onClick = { onLayoutChanged(listType) }, + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = availableLayoutType.size, + ), ) { Icon( imageVector = listType.icon, - contentDescription = stringResource(id = R.string.list_type) + contentDescription = stringResource(id = R.string.list_type), ) } } } - } + }, ) } } - diff --git a/app/src/main/java/com/bobbyesp/metadator/onboarding/OnboardingRouting.kt b/app/src/main/java/com/bobbyesp/metadator/onboarding/OnboardingRouting.kt index 134bcfd..e925557 100644 --- a/app/src/main/java/com/bobbyesp/metadator/onboarding/OnboardingRouting.kt +++ b/app/src/main/java/com/bobbyesp/metadator/onboarding/OnboardingRouting.kt @@ -10,33 +10,30 @@ import com.bobbyesp.metadator.core.util.getNeededStoragePermissions import com.bobbyesp.metadator.onboarding.presentation.pages.OnboardingPermissionsPage import com.bobbyesp.metadator.onboarding.presentation.pages.OnboardingWelcomePage import com.bobbyesp.ui.motion.animatedComposable -import com.bobbyesp.utilities.ui.permission.PermissionType.Companion.toPermissionType +import com.bobbyesp.utilities.ui.permissions.PermissionType.Companion.toPermissionType fun NavGraphBuilder.onboardingRouting( onNavigate: (Route) -> Unit, - onCompletedOnboarding: () -> Unit + onCompletedOnboarding: () -> Unit, ) { navigation( - startDestination = Route.OnboardingNavigator.Welcome::class, + startDestination = Route.OnboardingNavigator.Welcome::class ) { animatedComposable { OnboardingWelcomePage( - onGetStarted = { - onNavigate(Route.OnboardingNavigator.Permissions) - } + onGetStarted = { onNavigate(Route.OnboardingNavigator.Permissions) } ) } animatedComposable { - - val neededPermissions by remember { mutableStateOf(getNeededStoragePermissions().map { it.toPermissionType() }) } + val neededPermissions by remember { + mutableStateOf(getNeededStoragePermissions().map { it.toPermissionType() }) + } OnboardingPermissionsPage( neededPermissions = neededPermissions, - onNextClick = { - onCompletedOnboarding() - } + onNextClick = { onCompletedOnboarding() }, ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/onboarding/domain/PermissionItem.kt b/app/src/main/java/com/bobbyesp/metadator/onboarding/domain/PermissionItem.kt index fed097b..a8a04ef 100644 --- a/app/src/main/java/com/bobbyesp/metadator/onboarding/domain/PermissionItem.kt +++ b/app/src/main/java/com/bobbyesp/metadator/onboarding/domain/PermissionItem.kt @@ -2,12 +2,12 @@ package com.bobbyesp.metadator.onboarding.domain import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.vector.ImageVector -import com.bobbyesp.utilities.ui.permission.PermissionType +import com.bobbyesp.utilities.ui.permissions.PermissionType @Stable data class PermissionItem( val permission: PermissionType, val icon: ImageVector, val isGranted: Boolean, - val onClick: () -> Unit -) \ No newline at end of file + val onClick: () -> Unit, +) diff --git a/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/components/OnboardingScreenHeader.kt b/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/components/OnboardingScreenHeader.kt index d697019..9ea52a5 100644 --- a/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/components/OnboardingScreenHeader.kt +++ b/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/components/OnboardingScreenHeader.kt @@ -29,33 +29,33 @@ fun OnboardingScreenHeader( modifier: Modifier = Modifier, title: String, description: String? = null, - icon: ImageVector + icon: ImageVector, ) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), ) { Icon( imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSecondary, - modifier = Modifier - .size(150.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondary) - .padding(32.dp) + modifier = + Modifier.size(150.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondary) + .padding(32.dp), ) Column( modifier = Modifier, horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = title.uppercase(), style = MaterialTheme.typography.headlineLarge, fontFamily = FontFamily.Monospace, - letterSpacing = 5.sp + letterSpacing = 5.sp, ) description?.let { Text( @@ -63,12 +63,11 @@ fun OnboardingScreenHeader( style = MaterialTheme.typography.bodyMediumEmphasized, fontFamily = FontFamily.Monospace, letterSpacing = 1.sp, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } } - } @Preview @@ -76,14 +75,13 @@ fun OnboardingScreenHeader( private fun Preview() { MaterialTheme { OnboardingScreenHeader( - modifier = Modifier - .background(MaterialTheme.colorScheme.background) - .padding(16.dp), + modifier = Modifier.background(MaterialTheme.colorScheme.background).padding(16.dp), title = "Permissions", - description = "This is a very large text just to see how this reacts." + " " + + description = + "This is a very large text just to see how this reacts." + + " " + "Permissions are needed for the app to work properly", - icon = Icons.Rounded.Security + icon = Icons.Rounded.Security, ) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/pages/OnboardingPermissionsPage.kt b/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/pages/OnboardingPermissionsPage.kt index d031895..56337be 100644 --- a/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/pages/OnboardingPermissionsPage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/pages/OnboardingPermissionsPage.kt @@ -42,13 +42,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEachIndexed import com.bobbyesp.metadator.R -import com.bobbyesp.metadator.mediaplayer.presentation.pages.mediaplayer.player.AnimatedTextContentTransformation import com.bobbyesp.metadator.onboarding.domain.PermissionItem import com.bobbyesp.metadator.onboarding.presentation.components.OnboardingScreenHeader import com.bobbyesp.ui.components.others.AdditionalInformation +import com.bobbyesp.ui.util.AnimatedTextContentTransformation import com.bobbyesp.ui.util.isDeviceInLandscape -import com.bobbyesp.utilities.ui.permission.PermissionType -import com.bobbyesp.utilities.ui.permission.PermissionType.Companion.toPermissionType +import com.bobbyesp.utilities.ui.permissions.PermissionType +import com.bobbyesp.utilities.ui.permissions.PermissionType.Companion.toPermissionType import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -60,32 +60,28 @@ fun OnboardingPermissionsPage( onNextClick: () -> Unit = {}, ) { - val shouldShowRationale = neededPermissions.any { - !rememberPermissionState(it.permission).status.isGranted - } + val shouldShowRationale = + neededPermissions.any { !rememberPermissionState(it.permission).status.isGranted } - val allPermissionsGranted = neededPermissions.all { - rememberPermissionState(it.permission).status.isGranted - } + val allPermissionsGranted = + neededPermissions.all { rememberPermissionState(it.permission).status.isGranted } - val permissions = neededPermissions.map { - val storagePermissionState = rememberPermissionState(it.permission) - - PermissionItem( - permission = it.permission.toPermissionType(), - icon = it.toPermissionIcon(), - isGranted = storagePermissionState.status.isGranted, - onClick = { storagePermissionState.launchPermissionRequest() } - ) - } + val permissions = + neededPermissions.map { + val storagePermissionState = rememberPermissionState(it.permission) + PermissionItem( + permission = it.permission.toPermissionType(), + icon = it.toPermissionIcon(), + isGranted = storagePermissionState.status.isGranted, + onClick = { storagePermissionState.launchPermissionRequest() }, + ) + } Scaffold( modifier = Modifier.fillMaxSize(), floatingActionButton = { - val transitionState = remember { - MutableTransitionState(allPermissionsGranted) - } + val transitionState = remember { MutableTransitionState(allPermissionsGranted) } LaunchedEffect(allPermissionsGranted) { transitionState.targetState = allPermissionsGranted @@ -93,90 +89,81 @@ fun OnboardingPermissionsPage( val transition = rememberTransition(transitionState = transitionState) - Button( - modifier = Modifier - .padding(16.dp), + modifier = Modifier.padding(16.dp), onClick = { - //if they are all granted, go to the next page, otherwise request the permissions + // if they are all granted, go to the next page, otherwise request the + // permissions if (allPermissionsGranted) { onNextClick() } else { - permissions.fastForEach { - it.onClick() - } + permissions.fastForEach { it.onClick() } } }, ) { - transition.AnimatedContent( - transitionSpec = { AnimatedTextContentTransformation } - ) { + transition.AnimatedContent(transitionSpec = { AnimatedTextContentTransformation }) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { val textId = if (it) R.string.finish else com.bobbyesp.utilities.R.string.grant val icon = - if (it) Icons.AutoMirrored.Rounded.KeyboardArrowRight else Icons.Rounded.Check + if (it) Icons.AutoMirrored.Rounded.KeyboardArrowRight + else Icons.Rounded.Check Text(text = stringResource(id = textId)) Icon( modifier = Modifier, imageVector = icon, - contentDescription = stringResource(id = textId) + contentDescription = stringResource(id = textId), ) } } } - } + }, ) { paddingValues -> if (isDeviceInLandscape()) { val scrollState = rememberScrollState() Row( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), + modifier = Modifier.fillMaxSize().padding(paddingValues), horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { OnboardingScreenHeader( modifier = Modifier.padding(16.dp).weight(0.4f), title = stringResource(R.string.permissions), description = stringResource(R.string.permissions_description), - icon = Icons.Rounded.Security + icon = Icons.Rounded.Security, ) PermissionsScreenContent( modifier = Modifier.weight(0.6f), permissions = permissions, shouldShowRationale = shouldShowRationale, - scrollState = scrollState + scrollState = scrollState, ) - } } else { Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 16.dp), + modifier = + Modifier.fillMaxSize().padding(paddingValues).padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), ) { OnboardingScreenHeader( modifier = Modifier.padding(16.dp), title = stringResource(R.string.permissions), description = stringResource(R.string.permissions_description), - icon = Icons.Rounded.Security + icon = Icons.Rounded.Security, ) PermissionsScreenContent( modifier = Modifier, permissions = permissions, shouldShowRationale = shouldShowRationale, - scrollState = rememberScrollState() + scrollState = rememberScrollState(), ) } } @@ -188,41 +175,33 @@ fun PermissionsScreenContent( modifier: Modifier = Modifier, permissions: List, shouldShowRationale: Boolean, - scrollState: ScrollState + scrollState: ScrollState, ) { - Column( - modifier = modifier - .padding(horizontal = 16.dp) - .verticalScroll(scrollState) - ) { + Column(modifier = modifier.padding(horizontal = 16.dp).verticalScroll(scrollState)) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.spacedBy(4.dp), ) { PermissionItemsGroup( modifier = Modifier.padding(vertical = 16.dp), - permissionItems = permissions + permissionItems = permissions, ) } - AnimatedContent( - targetState = shouldShowRationale, - modifier = Modifier - ) { visible -> + AnimatedContent(targetState = shouldShowRationale, modifier = Modifier) { visible -> if (visible) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), ) { HorizontalDivider() AdditionalInformation( modifier = Modifier, text = stringResource(R.string.permissions_additional_information), - fontFamily = FontFamily.Monospace + fontFamily = FontFamily.Monospace, ) } - } } } @@ -231,63 +210,59 @@ fun PermissionsScreenContent( @Preview @Composable private fun OnboardingPermissionsPagePreview() { - val neededPermissions = listOf( - PermissionType.READ_EXTERNAL_STORAGE, - PermissionType.READ_MEDIA_AUDIO - ) - MaterialTheme { - OnboardingPermissionsPage( - neededPermissions = neededPermissions - ) - } + val neededPermissions = + listOf(PermissionType.READ_EXTERNAL_STORAGE, PermissionType.READ_MEDIA_AUDIO) + MaterialTheme { OnboardingPermissionsPage(neededPermissions = neededPermissions) } } @OptIn(ExperimentalFoundationApi::class) @Composable -private fun PermissionItemButton( - permissionItem: PermissionItem, - modifier: Modifier = Modifier, -) { - val animatedBackgroundColor by animateColorAsState( - targetValue = if (!permissionItem.isGranted) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary - ) +private fun PermissionItemButton(permissionItem: PermissionItem, modifier: Modifier = Modifier) { + val animatedBackgroundColor by + animateColorAsState( + targetValue = + if (!permissionItem.isGranted) MaterialTheme.colorScheme.tertiary + else MaterialTheme.colorScheme.primary + ) - val animatedTextColor by animateColorAsState( - targetValue = if (!permissionItem.isGranted) MaterialTheme.colorScheme.onTertiary else MaterialTheme.colorScheme.onPrimary - ) + val animatedTextColor by + animateColorAsState( + targetValue = + if (!permissionItem.isGranted) MaterialTheme.colorScheme.onTertiary + else MaterialTheme.colorScheme.onPrimary + ) Row( - modifier = modifier - .background(color = MaterialTheme.colorScheme.surfaceContainer) - .combinedClickable( - onClick = permissionItem.onClick - ) - .padding(horizontal = 24.dp, vertical = 16.dp), + modifier = + modifier + .background(color = MaterialTheme.colorScheme.surfaceContainer) + .combinedClickable(onClick = permissionItem.onClick) + .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Icon( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(animatedBackgroundColor) - .padding(8.dp), + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .background(animatedBackgroundColor) + .padding(8.dp), imageVector = permissionItem.icon, tint = animatedTextColor, - contentDescription = null + contentDescription = null, ) Column { Text( text = permissionItem.permission.toPermissionString(), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Text( text = permissionItem.permission.toPermissionDescription(), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -298,41 +273,38 @@ private fun PermissionItemsGroup( permissionItems: List, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { permissionItems.fastForEachIndexed { index, item -> PermissionItemButton( permissionItem = item, - modifier = Modifier - .fillMaxWidth() - .clip( - when { - permissionItems.size == 1 -> { - MaterialTheme.shapes.extraLarge - } + modifier = + Modifier.fillMaxWidth() + .clip( + when { + permissionItems.size == 1 -> { + MaterialTheme.shapes.extraLarge + } - index == 0 -> { - MaterialTheme.shapes.extraLarge.copy( - bottomStart = MaterialTheme.shapes.medium.bottomStart, - bottomEnd = MaterialTheme.shapes.medium.bottomEnd - ) - } + index == 0 -> { + MaterialTheme.shapes.extraLarge.copy( + bottomStart = MaterialTheme.shapes.medium.bottomStart, + bottomEnd = MaterialTheme.shapes.medium.bottomEnd, + ) + } - index == permissionItems.lastIndex -> { - MaterialTheme.shapes.extraLarge.copy( - topStart = MaterialTheme.shapes.medium.topStart, - topEnd = MaterialTheme.shapes.medium.topEnd - ) - } + index == permissionItems.lastIndex -> { + MaterialTheme.shapes.extraLarge.copy( + topStart = MaterialTheme.shapes.medium.topStart, + topEnd = MaterialTheme.shapes.medium.topEnd, + ) + } - else -> { - MaterialTheme.shapes.medium + else -> { + MaterialTheme.shapes.medium + } } - } - ) + ), ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/pages/OnboardingWelcomePage.kt b/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/pages/OnboardingWelcomePage.kt index ce1564c..b09236c 100644 --- a/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/pages/OnboardingWelcomePage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/onboarding/presentation/pages/OnboardingWelcomePage.kt @@ -24,35 +24,22 @@ import com.bobbyesp.metadator.R import com.bobbyesp.metadator.core.presentation.components.AppDetails @Composable -fun OnboardingWelcomePage( - onGetStarted: () -> Unit, - subtitle: String? = null -) { +fun OnboardingWelcomePage(onGetStarted: () -> Unit, subtitle: String? = null) { Scaffold( modifier = Modifier.fillMaxSize(), floatingActionButton = { - Button( - modifier = Modifier - .padding(16.dp), - onClick = onGetStarted, - ) { + Button(modifier = Modifier.padding(16.dp), onClick = onGetStarted) { Text(text = stringResource(id = R.string.get_started)) Icon( modifier = Modifier, imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = stringResource(id = R.string.get_started) + contentDescription = stringResource(id = R.string.get_started), ) } - } + }, ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - AppDetails( - modifier = Modifier.align(Alignment.Center), subtitle = subtitle - ) + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + AppDetails(modifier = Modifier.align(Alignment.Center), subtitle = subtitle) } } } @@ -64,9 +51,6 @@ private fun OnboardingPreview() { MaterialTheme( colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() ) { - OnboardingWelcomePage( - subtitle = "1.0.0-beta09", - onGetStarted = {} - ) + OnboardingWelcomePage(subtitle = "1.0.0-beta09", onGetStarted = {}) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/TagEditorRouting.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/TagEditorRouting.kt index 34d7578..4a2cc3d 100644 --- a/app/src/main/java/com/bobbyesp/metadator/tageditor/TagEditorRouting.kt +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/TagEditorRouting.kt @@ -13,36 +13,39 @@ import com.bobbyesp.metadator.core.domain.model.ParcelableSong import com.bobbyesp.metadator.core.presentation.common.Route import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.MetadataEditorPage import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.MetadataEditorViewModel -import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.MetadataBottomSheetViewModel import com.bobbyesp.ui.motion.slideInVerticallyComposable import com.bobbyesp.utilities.navigation.parcelableType +import kotlin.reflect.typeOf import kotlinx.coroutines.flow.collectLatest import org.koin.androidx.compose.koinViewModel -import kotlin.reflect.typeOf -fun NavGraphBuilder.tagEditorRouting( - onNavigateBack: () -> Unit -) { +fun NavGraphBuilder.tagEditorRouting(onNavigateBack: () -> Unit) { navigation( - startDestination = Route.UtilitiesNavigator.TagEditor::class, + startDestination = Route.UtilitiesNavigator.TagEditor::class ) { slideInVerticallyComposable( - typeMap = mapOf(typeOf() to parcelableType()), + typeMap = mapOf(typeOf() to parcelableType()) ) { + val viewModel = koinViewModel() + val song = it.toRoute() - val viewModel = koinViewModel() - val bsViewModel = koinViewModel() + LaunchedEffect(song) { + viewModel.onEvent( + MetadataEditorViewModel.Event.LoadMetadata(song.selectedSong.localPath) + ) + } val state = viewModel.state.collectAsStateWithLifecycle() - val bsState = bsViewModel.viewStateFlow.collectAsStateWithLifecycle() val securityErrorHandler = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartIntentSenderForResult() ) { result -> if (result.resultCode == Activity.RESULT_OK) { - viewModel.savePropertyMap( - audioPath = song.selectedSong.localPath + viewModel.onEvent( + MetadataEditorViewModel.Event.SaveProperties( + song.selectedSong.localPath + ) ) onNavigateBack() } @@ -52,9 +55,7 @@ fun NavGraphBuilder.tagEditorRouting( viewModel.eventFlow.collectLatest { event -> when (event) { is MetadataEditorViewModel.UiEvent.RequestPermission -> { - val intent = - IntentSenderRequest.Builder(event.intent) - .build() + val intent = IntentSenderRequest.Builder(event.intent).build() securityErrorHandler.launch(intent) } @@ -65,30 +66,11 @@ fun NavGraphBuilder.tagEditorRouting( } } - LaunchedEffect(true) { - bsViewModel.outerEventsFlow.collectLatest { event -> - when (event) { - is MetadataBottomSheetViewModel.OuterEvent.SaveMetadata -> { - event.modifiedFields.forEach { field -> - viewModel.onEvent( - MetadataEditorViewModel.Event.UpdateProperty( - field.key, - field.value - ) - ) - } - } - } - } - } - MetadataEditorPage( - state = state, - bsViewState = bsState, + pageState = state.value, receivedAudio = song.selectedSong, - onBsEvent = bsViewModel::onEvent, - onEvent = viewModel::onEvent + onEvent = viewModel::onEvent, ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/data/local/UriToPictureConverter.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/data/local/UriToPictureConverter.kt new file mode 100644 index 0000000..be41cee --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/data/local/UriToPictureConverter.kt @@ -0,0 +1,74 @@ +package com.bobbyesp.metadator.tageditor.data.local + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.bobbyesp.metadator.tageditor.model.PictureType +import com.kyant.taglib.Picture +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class UriToPictureConverter(private val context: Context) { + /** + * Converts a list of URIs into Picture objects. + * + * @param uris List of URIs to be converted. + * @param pictureType Type of image according to ID3v2 specification. + * @param descriptionFormat Format string for custom descriptions (use %d for index). + * @return List of Picture objects. + */ + suspend fun convert( + uris: List, + pictureType: PictureType = PictureType.FRONT_COVER, + descriptionFormat: String = "Audio image %d - Metadator", + ): List = + withContext(Dispatchers.IO) { + uris.mapIndexedNotNull { index, uri -> + try { + val byteArray = + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: run { + Log.e(TAG, "Failed to read image data: $uri") + return@mapIndexedNotNull null + } + + val mimeType = + context.contentResolver.getType(uri) + ?: run { + Log.e(TAG, "Could not determine MIME type: $uri") + return@mapIndexedNotNull null + } + + Picture( + data = byteArray, + mimeType = mimeType, + description = descriptionFormat.format(index + 1), + pictureType = pictureType.id3Type, + ) + } catch (e: Exception) { + Log.e(TAG, "Error processing image: $uri", e) + null + } + } + } + + /** + * Converts a single URI into a Picture object. + * + * @param uri The URI to be converted. + * @param pictureType Image type as per ID3v2 specification. + * @param description Description of the image. + * @return A Picture object, or null if the conversion fails. + */ + suspend fun convertSingle( + uri: Uri, + pictureType: PictureType = PictureType.FRONT_COVER, + description: String = "Audio image - Metadator", + ): Picture? { + return convert(listOf(uri), pictureType, description).firstOrNull() + } + + companion object { + private const val TAG = "UriToPictureConverter" + } +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/data/local/repository/AudioMetadataRepositoryImpl.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/data/local/repository/AudioMetadataRepositoryImpl.kt new file mode 100644 index 0000000..92c8d63 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/data/local/repository/AudioMetadataRepositoryImpl.kt @@ -0,0 +1,63 @@ +package com.bobbyesp.metadator.tageditor.data.local.repository + +import com.bobbyesp.metadator.tageditor.model.repository.AudioMetadataRepository +import com.bobbyesp.utilities.mediastore.model.FileDescriptorMode +import com.bobbyesp.utilities.mediastore.model.repository.MediaStoreUseCase +import com.kyant.taglib.AudioProperties +import com.kyant.taglib.AudioPropertiesReadStyle +import com.kyant.taglib.Metadata +import com.kyant.taglib.Picture +import com.kyant.taglib.PropertyMap +import com.kyant.taglib.TagLib +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AudioMetadataRepositoryImpl(private val mediaStoreUseCase: MediaStoreUseCase) : + AudioMetadataRepository { + + /** Retrieves a detached file descriptor from the given path and mode. */ + private fun getDetachedFileDescriptor(path: String, mode: FileDescriptorMode): Int? { + return mediaStoreUseCase.getFileDescriptorFromPath(path, mode)?.use { fd -> + fd.dup()?.detachFd() + } + } + + override suspend fun getMetadata(path: String): Result = + withContext(Dispatchers.IO) { + runCatching { + getDetachedFileDescriptor(path, FileDescriptorMode.READ)?.let { + TagLib.getMetadata(it) + } ?: throw IllegalStateException("Failed to retrieve metadata") + } + } + + override suspend fun getAudioProperties( + path: String, + style: AudioPropertiesReadStyle, + ): Result = + withContext(Dispatchers.IO) { + runCatching { + getDetachedFileDescriptor(path, FileDescriptorMode.READ)?.let { + TagLib.getAudioProperties(it, style) + } ?: throw IllegalStateException("Failed to retrieve audio properties") + } + } + + override suspend fun writePropertyMap(path: String, propertyMap: PropertyMap): Result = + withContext(Dispatchers.IO) { + runCatching { + getDetachedFileDescriptor(path, FileDescriptorMode.WRITE)?.let { + TagLib.savePropertyMap(it, propertyMap) + } ?: throw IllegalStateException("Failed to write property map") + } + } + + override suspend fun writePictures(path: String, pictures: List): Result = + withContext(Dispatchers.IO) { + runCatching { + getDetachedFileDescriptor(path, FileDescriptorMode.WRITE)?.let { + TagLib.savePictures(it, pictures.toTypedArray()) + } ?: throw IllegalStateException("Failed to write pictures") + } + } +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/di/TagEditorModule.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/di/TagEditorModule.kt new file mode 100644 index 0000000..4fad71d --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/di/TagEditorModule.kt @@ -0,0 +1,17 @@ +package com.bobbyesp.metadator.tageditor.di + +import com.bobbyesp.metadator.tageditor.data.local.UriToPictureConverter +import com.bobbyesp.metadator.tageditor.data.local.repository.AudioMetadataRepositoryImpl +import com.bobbyesp.metadator.tageditor.model.repository.AudioMetadataRepository +import com.bobbyesp.utilities.mediastore.data.local.repository.MediaStoreUseCaseImpl +import com.bobbyesp.utilities.mediastore.model.repository.MediaStoreUseCase +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val tagEditorModule = module { + single { MediaStoreUseCaseImpl(context = androidContext()) } + + single { UriToPictureConverter(androidContext()) } + + single { AudioMetadataRepositoryImpl(get()) } +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/di/TagEditorViewModelsModule.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/di/TagEditorViewModelsModule.kt index f200832..91f1a74 100644 --- a/app/src/main/java/com/bobbyesp/metadator/tageditor/di/TagEditorViewModelsModule.kt +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/di/TagEditorViewModelsModule.kt @@ -1,21 +1,9 @@ package com.bobbyesp.metadator.tageditor.di import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.MetadataEditorViewModel -import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.MetadataBottomSheetViewModel -import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val tagEditorViewModelsModule = module { - viewModel { - MetadataEditorViewModel( - context = androidContext(), - stateHandle = get() - ) - } - viewModel { - MetadataBottomSheetViewModel( - searchService = get() - ) - } -} \ No newline at end of file + viewModel { MetadataEditorViewModel(get(), get(), get()) } +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/domain/AudioEditableMetadata.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/domain/AudioEditableMetadata.kt new file mode 100644 index 0000000..32fa1f8 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/domain/AudioEditableMetadata.kt @@ -0,0 +1,44 @@ +package com.bobbyesp.metadator.tageditor.domain + +import kotlinx.serialization.Serializable + +@Serializable +data class AudioEditableMetadata( + val title: String = "", + val artist: String = "", + val album: String = "", + val trackNumber: Int = 0, + val discNumber: Int = 0, + val date: String = "", + val genre: String = "", + val comment: String = "", + val lyrics: String = "", +) { + companion object { + fun fromMap(map: Map): AudioEditableMetadata { + return AudioEditableMetadata( + title = map["TITLE"] ?: "", + artist = map["ARTIST"] ?: "", + album = map["ALBUM"] ?: "", + trackNumber = map["TRACKNUMBER"]?.toIntOrNull() ?: 0, + discNumber = map["DISCNUMBER"]?.toIntOrNull() ?: 0, + date = map["DATE"] ?: "", + genre = map["GENRE"] ?: "", + comment = map["COMMENT"] ?: "", + lyrics = map["LYRICS"] ?: "" + ) + } + } + + fun toMap(): Map = mapOf( + "TITLE" to title, + "ARTIST" to artist, + "ALBUM" to album, + "TRACKNUMBER" to trackNumber.toString(), + "DISCNUMBER" to discNumber.toString(), + "DATE" to date, + "GENRE" to genre, + "COMMENT" to comment, + "LYRICS" to lyrics, + ) +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/model/PictureType.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/model/PictureType.kt new file mode 100644 index 0000000..3bab36e --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/model/PictureType.kt @@ -0,0 +1,12 @@ +package com.bobbyesp.metadator.tageditor.model + +enum class PictureType(val id3Type: String) { + OTHER("Other"), + FRONT_COVER("Front Cover"), + BACK_COVER("Back Cover"), + ARTIST("Artist"), + BAND("Band"), + COMPOSER("Composer"), + DURING_RECORDING("During Recording"), + ILLUSTRATION("Illustration"), +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/model/repository/AudioMetadataRepository.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/model/repository/AudioMetadataRepository.kt new file mode 100644 index 0000000..226a04c --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/model/repository/AudioMetadataRepository.kt @@ -0,0 +1,20 @@ +package com.bobbyesp.metadator.tageditor.model.repository + +import com.kyant.taglib.AudioProperties +import com.kyant.taglib.AudioPropertiesReadStyle +import com.kyant.taglib.Metadata +import com.kyant.taglib.Picture +import com.kyant.taglib.PropertyMap + +interface AudioMetadataRepository { + suspend fun getMetadata(path: String): Result + + suspend fun getAudioProperties( + path: String, + style: AudioPropertiesReadStyle, + ): Result + + suspend fun writePropertyMap(path: String, propertyMap: PropertyMap): Result + + suspend fun writePictures(path: String, pictures: List): Result +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/text/PreConfiguredOutlinedTextField.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/components/textfield/MetadataOutlinedTextField.kt similarity index 50% rename from app/ui/src/main/java/com/bobbyesp/ui/components/text/PreConfiguredOutlinedTextField.kt rename to app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/components/textfield/MetadataOutlinedTextField.kt index bacc6f3..3bb8f88 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/text/PreConfiguredOutlinedTextField.kt +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/components/textfield/MetadataOutlinedTextField.kt @@ -1,4 +1,4 @@ -package com.bobbyesp.ui.components.text +package com.bobbyesp.metadator.tageditor.presentation.components.textfield import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -9,23 +9,34 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Undo +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import com.bobbyesp.ui.R -import com.bobbyesp.ui.util.rememberVolatileSaveable +import com.materialkolor.DynamicMaterialTheme +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun PreConfiguredOutlinedTextField( +fun MetadataOutlinedTextField( modifier: Modifier = Modifier, value: String?, label: String = "", + isModified: Boolean = false, enabled: Boolean = true, readOnly: Boolean = false, keyboardOptions: KeyboardOptions = KeyboardOptions(), @@ -33,17 +44,19 @@ fun PreConfiguredOutlinedTextField( singleLine: Boolean = false, maxLines: Int = 2, minLines: Int = 1, - returnModifiedValue: (String) -> Unit = {} + onValueChange: (String) -> Unit = {}, ) { - val (text, setText) = rememberVolatileSaveable(value ?: "") - val originalValue = remember { value ?: "" } + val originalValue by remember { mutableStateOf(value ?: "") } + var text by rememberSaveable { mutableStateOf(originalValue) } + + val fieldModified = text != originalValue || isModified OutlinedTextField( modifier = modifier, value = text, onValueChange = { newValue -> - setText(newValue) - returnModifiedValue(newValue) + text = newValue + onValueChange(newValue) }, label = { Text(text = label, maxLines = 1, overflow = TextOverflow.Ellipsis) }, enabled = enabled, @@ -53,22 +66,42 @@ fun PreConfiguredOutlinedTextField( singleLine = singleLine, maxLines = maxLines, minLines = minLines, + shape = MaterialTheme.shapes.large, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = + if (fieldModified) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outline, + unfocusedBorderColor = + if (fieldModified) MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline, + ), trailingIcon = { AnimatedVisibility( - visible = text != originalValue, + visible = fieldModified, enter = fadeIn() + slideInHorizontally(), - exit = fadeOut() + slideOutHorizontally() + exit = fadeOut() + slideOutHorizontally(), ) { - IconButton(onClick = { - setText(originalValue) - returnModifiedValue(originalValue) - }) { + IconButton( + onClick = { + text = originalValue + onValueChange(originalValue) + } + ) { Icon( imageVector = Icons.AutoMirrored.Rounded.Undo, - contentDescription = stringResource(id = R.string.undo) + contentDescription = stringResource(id = R.string.undo), ) } } - } + }, ) -} \ No newline at end of file +} + +@Preview +@Composable +private fun PreConfiguredOutlineTextFieldPreview() { + DynamicMaterialTheme(seedColor = Color(0xFF4565FF)) { + MetadataOutlinedTextField(value = "Hello, World!", label = "Label") + } +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/MediaStoreInfoDialog.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/MediaStoreInfoDialog.kt deleted file mode 100644 index 6e6c68b..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/MediaStoreInfoDialog.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.bobbyesp.metadator.tageditor.presentation.pages.tageditor - -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.bobbyesp.metadator.R - -@Composable -fun MediaStoreInfoDialog( - modifier: Modifier = Modifier, - onDismissRequest: () -> Unit, -) { - AlertDialog( - modifier = modifier, - onDismissRequest = onDismissRequest, - text = { - Column { - } - }, - confirmButton = {}, - dismissButton = { - TextButton( - onClick = onDismissRequest - ) { - Text(stringResource(id = R.string.dismiss)) - } - } - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/MetadataEditorPage.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/MetadataEditorPage.kt index 58590f2..3f456a4 100644 --- a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/MetadataEditorPage.kt +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/MetadataEditorPage.kt @@ -9,74 +9,62 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.ScrollState 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.aspectRatio -import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Downloading import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Lyrics -import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetValue +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bobbyesp.metadator.R +import com.bobbyesp.metadator.core.domain.model.ParcelableSong import com.bobbyesp.metadator.core.presentation.common.LocalNavController -import com.bobbyesp.metadator.core.presentation.common.LocalOrientation import com.bobbyesp.metadator.core.presentation.common.LocalSonner -import com.bobbyesp.metadator.core.domain.model.ParcelableSong import com.bobbyesp.metadator.core.presentation.components.image.AsyncImage -import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.MetadataBottomSheetViewModel -import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.SpMetadataBottomSheetContent +import com.bobbyesp.metadator.tageditor.presentation.components.textfield.MetadataOutlinedTextField +import com.bobbyesp.metadator.tageditor.presentation.state.MetadataEditorUiState +import com.bobbyesp.metadator.tageditor.presentation.state.FieldState import com.bobbyesp.ui.common.pages.ErrorPage import com.bobbyesp.ui.common.pages.LoadingPage import com.bobbyesp.ui.components.button.CloseButton import com.bobbyesp.ui.components.others.MetadataTag import com.bobbyesp.ui.components.text.LargeCategoryTitle -import com.bobbyesp.ui.components.text.PreConfiguredOutlinedTextField import com.bobbyesp.utilities.ext.fromMillisToMinutes import com.bobbyesp.utilities.ext.isNeitherNullNorBlank import com.bobbyesp.utilities.states.ResourceState @@ -89,101 +77,90 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun MetadataEditorPage( - state: State, - bsViewState: State, + pageState: MetadataEditorViewModel.PageViewState, receivedAudio: ParcelableSong, - onBsEvent: (MetadataBottomSheetViewModel.Event) -> Unit, - onEvent: (MetadataEditorViewModel.Event) -> Unit + onEvent: (MetadataEditorViewModel.Event) -> Unit, ) { val navController = LocalNavController.current val sonner = LocalSonner.current val context = LocalContext.current val scope = rememberCoroutineScope() - val pageState = state.value LaunchedEffect(receivedAudio) { onEvent(MetadataEditorViewModel.Event.LoadMetadata(receivedAudio.localPath)) } var newArtworkAddress by rememberSaveable { mutableStateOf(null) } + var showInstallSongSyncDialog by remember { mutableStateOf(false) } - val singleImagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickVisualMedia(), - onResult = { uri -> newArtworkAddress = uri } - ) - - val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState( - initialValue = SheetValue.Hidden, - skipHiddenState = false + val singleImagePickerLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> newArtworkAddress = uri }, ) - ) - var showInstallSongSyncDialog by remember { - mutableStateOf(false) - } - - val lyricsActivityLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { result -> - when (result.resultCode) { - Activity.RESULT_OK -> { - val receivedLyrics = result.data?.getStringExtra("lyrics") - if (receivedLyrics.isNeitherNullNorBlank()) { - pageState.mutablePropertiesMap["LYRICS"] = receivedLyrics!! + val lyricsActivityLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + when (result.resultCode) { + Activity.RESULT_OK -> { + val receivedLyrics = result.data?.getStringExtra("lyrics") + if (receivedLyrics.isNeitherNullNorBlank()) { + onEvent( + MetadataEditorViewModel.Event.UpdateProperty("LYRICS", receivedLyrics!!) + ) + scope.launch { + sonner.show( + message = context.getString(R.string.lyrics_received), + type = ToastType.Success, + ) + } + } else { + scope.launch { + sonner.show( + message = context.getString(R.string.empty_lyrics_received), + type = ToastType.Error, + ) + } + } + } + Activity.RESULT_CANCELED -> scope.launch { sonner.show( - message = context.getString(R.string.lyrics_received), - type = ToastType.Success + message = context.getString(R.string.lyrics_retrieve_cancelled), + type = ToastType.Info, ) } - } else { + else -> scope.launch { sonner.show( - message = context.getString(R.string.empty_lyrics_received), - type = ToastType.Error + message = context.getString(R.string.something_unexpected_occurred), + type = ToastType.Error, ) } - } - } - - Activity.RESULT_CANCELED -> { - scope.launch { - sonner.show( - message = context.getString(R.string.lyrics_retrieve_cancelled), - type = ToastType.Info - ) - } - } - - else -> { - scope.launch { - sonner.show( - message = context.getString(R.string.something_unexpected_occurred), - type = ToastType.Error - ) - } } } - } - val lyricsRetrieveIntent = Intent("android.intent.action.SEND").apply { - putExtra("songName", pageState.mutablePropertiesMap["TITLE"]) - putExtra("artistName", pageState.mutablePropertiesMap["ARTIST"]) - type = "text/plain" - setPackage("pl.lambada.songsync") - } + val lyricsRetrieveIntent = + Intent("android.intent.action.SEND").apply { +// putExtra("songName", pageState.uiState.fields["TITLE"]) +// putExtra("artistName", pageState.properties["ARTIST"]) + type = "text/plain" + setPackage("pl.lambada.songsync") + } fun launchLyricsRetrieveIntent() { try { lyricsActivityLauncher.launch(lyricsRetrieveIntent) } catch (e: Exception) { - when (e) { - is ActivityNotFoundException -> showInstallSongSyncDialog = true - else -> scope.launch { + if (e is ActivityNotFoundException) { + showInstallSongSyncDialog = true + } else { + scope.launch { sonner.show( message = context.getString(R.string.something_unexpected_occurred), - type = ToastType.Error + type = ToastType.Error, ) } } @@ -191,434 +168,298 @@ fun MetadataEditorPage( } if (showInstallSongSyncDialog) { - SongSyncNeededDialog( - onDismissRequest = { showInstallSongSyncDialog = false } - ) + SongSyncNeededDialog(onDismissRequest = { showInstallSongSyncDialog = false }) } - BottomSheetScaffold( + Scaffold( topBar = { - TopAppBar( - title = { - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = stringResource(id = R.string.viewing_metadata), - style = MaterialTheme.typography.bodyLarge, - fontSize = 20.sp, - fontWeight = FontWeight.Bold + EditorTopBar( + onClose = { navController.popBackStack() }, + onSave = { + onEvent( + MetadataEditorViewModel.Event.SaveAll( + receivedAudio.localPath, + listOf(newArtworkAddress ?: receivedAudio.artworkPath ?: Uri.EMPTY), ) - } + ) }, - navigationIcon = { CloseButton { navController.popBackStack() } }, - actions = { - IconButton( - onClick = { - if (scaffoldState.bottomSheetState.isVisible) { - onBsEvent(MetadataBottomSheetViewModel.Event.Search(receivedAudio.name + " " + receivedAudio.mainArtist)) - } else { - scope.launch { scaffoldState.bottomSheetState.partialExpand() } - } - } - ) { - Icon( - imageVector = Icons.Rounded.Downloading, - contentDescription = stringResource(id = R.string.retrieve_song_info) - ) - } - TextButton( - onClick = { - onEvent( - MetadataEditorViewModel.Event.SaveAll( - receivedAudio.localPath, - listOf( - newArtworkAddress ?: receivedAudio.artworkPath ?: Uri.EMPTY - ) - ) - ) - } - ) { - Text(text = stringResource(id = R.string.save)) - } + ) + } + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + EditorContent( + pageState = pageState, + artworkUri = newArtworkAddress ?: receivedAudio.artworkPath, + onEditArtwork = { + singleImagePickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) }, - scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + onRetrieveLyrics = { launchLyricsRetrieveIntent() }, + onEvent = onEvent, + ) + } + } +} + +/** Composable para la TopAppBar del editor */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditorTopBar(onClose: () -> Unit, onSave: () -> Unit) { + TopAppBar( + title = { + Text( + text = stringResource(id = R.string.viewing_metadata), + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp), ) }, - modifier = Modifier.fillMaxSize(), - scaffoldState = scaffoldState, - sheetPeekHeight = 148.dp, - sheetShadowElevation = 8.dp, - sheetContent = { - SpMetadataBottomSheetContent( - name = receivedAudio.name, - artist = receivedAudio.mainArtist, - sheetState = scaffoldState.bottomSheetState, - bsViewState = bsViewState, - onEvent = onBsEvent, - ) { - scope.launch { scaffoldState.bottomSheetState.hide() } - } + navigationIcon = { CloseButton(onClick = onClose) }, + actions = { + TextButton(onClick = onSave) { Text(text = stringResource(id = R.string.save)) } }, - ) { innerPadding -> - val animatedBottomPadding by animateDpAsState( - targetValue = if (scaffoldState.bottomSheetState.isVisible) innerPadding.calculateBottomPadding() + 6.dp else 0.dp, - label = "animatedBottomPadding" - ) + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + ) +} - Crossfade( - targetState = pageState.pageState, - animationSpec = tween(175), - label = "Fade between pages (ID3MetadataEditorPage)", - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() - ) { state -> - when (state) { - is ScreenState.Error -> ErrorPage( - modifier = Modifier.fillMaxSize(), - throwable = state.exception - ) { - onEvent(MetadataEditorViewModel.Event.LoadMetadata(receivedAudio.localPath)) +@Composable +private fun EditorContent( + pageState: MetadataEditorViewModel.PageViewState, + artworkUri: Uri?, + onEditArtwork: () -> Unit, + onRetrieveLyrics: () -> Unit, + onEvent: (MetadataEditorViewModel.Event) -> Unit, +) { + val scrollState = rememberScrollState() + val configuration = LocalConfiguration.current + + Crossfade(targetState = pageState.pageState, animationSpec = tween(175)) { state -> + when (state) { + is ScreenState.Error -> + ErrorPage(modifier = Modifier.fillMaxSize(), throwable = state.exception) { + // Acción de reintento, según convenga } - - ScreenState.Loading -> LoadingPage( + ScreenState.Loading -> + LoadingPage( modifier = Modifier.fillMaxSize(), - text = stringResource(id = R.string.loading_metadata) + text = stringResource(id = R.string.loading_metadata), ) + is ScreenState.Success -> { + if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + PortraitContent( + pageState = pageState, + artworkUri = artworkUri, + onEditArtwork = onEditArtwork, + onRetrieveLyrics = onRetrieveLyrics, + scrollState = scrollState, + onEvent = onEvent, + ) + } else { + LandscapeContent( + pageState = pageState, + artworkUri = artworkUri, + onEditArtwork = onEditArtwork, + onRetrieveLyrics = onRetrieveLyrics, + scrollState = scrollState, + onEvent = onEvent, + ) + } + } + } + } +} - is ScreenState.Success -> { - val scrollState = rememberScrollState() - val orientation = LocalOrientation.current - val artworkUri = newArtworkAddress ?: receivedAudio.artworkPath - - when (orientation) { - Configuration.ORIENTATION_PORTRAIT -> { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(horizontal = 16.dp) - ) { - Box( - modifier = Modifier - .size(250.dp) - .padding(8.dp) - .aspectRatio(1f) - .align(Alignment.CenterHorizontally), - ) { - AsyncImage( - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.small) - .align(Alignment.Center), - imageModel = artworkUri, - ) - Box( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - contentAlignment = Alignment.BottomEnd - ) { - IconButton( - colors = IconButtonDefaults.iconButtonColors( - containerColor = Color.Black.copy(alpha = 0.5f) - ), - onClick = { - singleImagePickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - } - ) { - Icon( - imageVector = Icons.Rounded.Edit, - tint = Color.White.harmonize(Color.Black.copy(alpha = 0.5f)), - contentDescription = stringResource(id = R.string.edit_image) - ) - } - } - } - - if (pageState.audioProperties is ResourceState.Success && pageState.audioProperties.data != null) { - AudioProperties( - modifier = Modifier, - audioProperties = pageState.audioProperties.data!! - ) - } - - if (pageState.metadata is ResourceState.Success) { - SongProperties( - mutablePropertiesMap = pageState.mutablePropertiesMap, - retrieveLyrics = { launchLyricsRetrieveIntent() } - ) - Spacer(modifier = Modifier.height(animatedBottomPadding)) - } - } - } +/** Contenido en orientación vertical */ +@Composable +private fun PortraitContent( + pageState: MetadataEditorViewModel.PageViewState, + artworkUri: Uri?, + onEditArtwork: () -> Unit, + onRetrieveLyrics: () -> Unit, + scrollState: ScrollState, + onEvent: (MetadataEditorViewModel.Event) -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(horizontal = 16.dp) + ) { + ArtworkSection( + artworkUri = artworkUri, + onEditArtwork = onEditArtwork, + modifier = Modifier.size(250.dp).padding(8.dp).align(Alignment.CenterHorizontally), + ) + if ( + pageState.audioProperties is ResourceState.Success && + pageState.audioProperties.data != null + ) { + AudioPropertiesSection(audioProperties = pageState.audioProperties.data!!) + } + if (pageState.metadata is ResourceState.Success) { + // Dynamic fields based on UI state + DynamicFieldsSection( + uiState = pageState.uiState, + onUpdateProperty = { key, value -> + onEvent(MetadataEditorViewModel.Event.UpdateProperty(key, value)) + } + ) + } + } +} - Configuration.ORIENTATION_LANDSCAPE -> { - Row( - modifier = Modifier.fillMaxSize().displayCutoutPadding(), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Box( - modifier = Modifier - .padding(8.dp) - .aspectRatio(1f) - .align(Alignment.CenterVertically), - ) { - AsyncImage( - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.small) - .align(Alignment.Center), - imageModel = artworkUri, - ) - Box( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - contentAlignment = Alignment.BottomEnd - ) { - IconButton( - colors = IconButtonDefaults.iconButtonColors( - containerColor = Color.Black.copy(alpha = 0.5f) - ), - onClick = { - singleImagePickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - } - ) { - Icon( - imageVector = Icons.Rounded.Edit, - tint = Color.White.harmonize(Color.Black.copy(alpha = 0.5f)), - contentDescription = stringResource(id = R.string.edit_image) - ) - } - } - } - Column( - modifier = Modifier - .weight(0.5f) - .verticalScroll(scrollState) - ) { - if (pageState.audioProperties is ResourceState.Success && pageState.audioProperties.data != null) { - AudioProperties( - modifier = Modifier, - audioProperties = pageState.audioProperties.data!! - ) - } - if (pageState.metadata is ResourceState.Success) { - SongProperties( - mutablePropertiesMap = pageState.mutablePropertiesMap, - retrieveLyrics = { launchLyricsRetrieveIntent() } - ) - } - } - } - } +/** Contenido en orientación horizontal */ +@Composable +private fun LandscapeContent( + pageState: MetadataEditorViewModel.PageViewState, + artworkUri: Uri?, + onEditArtwork: () -> Unit, + onRetrieveLyrics: () -> Unit, + scrollState: ScrollState, + onEvent: (MetadataEditorViewModel.Event) -> Unit, +) { + Row( + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + ArtworkSection( + artworkUri = artworkUri, + onEditArtwork = onEditArtwork, + modifier = Modifier.padding(8.dp).aspectRatio(1f).align(Alignment.CenterVertically), + ) + Column(modifier = Modifier.weight(0.5f).verticalScroll(scrollState)) { + if ( + pageState.audioProperties is ResourceState.Success && + pageState.audioProperties.data != null + ) { + AudioPropertiesSection(audioProperties = pageState.audioProperties.data!!) + } + if (pageState.metadata is ResourceState.Success) { + // Dynamic fields based on UI state + DynamicFieldsSection( + uiState = pageState.uiState, + onUpdateProperty = { key, value -> + onEvent(MetadataEditorViewModel.Event.UpdateProperty(key, value)) } - } + ) + } + } + } +} + +/** Sección para mostrar y editar la imagen de portada */ +@Composable +private fun ArtworkSection( + artworkUri: Uri?, + onEditArtwork: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + AsyncImage( + modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.small), + imageModel = artworkUri, + ) + Box( + modifier = Modifier.fillMaxSize().padding(8.dp), + contentAlignment = Alignment.BottomEnd, + ) { + IconButton( + colors = + IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f) + ), + onClick = onEditArtwork, + ) { + Icon( + imageVector = Icons.Rounded.Edit, + tint = Color.White.harmonize(Color.Black.copy(alpha = 0.5f)), + contentDescription = stringResource(id = R.string.edit_image), + ) } } } } @Composable -private fun AudioProperties(modifier: Modifier = Modifier, audioProperties: AudioProperties) { +private fun AudioPropertiesSection(audioProperties: AudioProperties) { LargeCategoryTitle( modifier = Modifier.padding(vertical = 6.dp), - text = stringResource(id = R.string.audio_details) + text = stringResource(id = R.string.audio_details), ) - Column( - modifier = modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - ) { + Row(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { MetadataTag( modifier = Modifier.weight(0.5f), typeOfMetadata = stringResource(id = R.string.bitrate), - metadata = audioProperties.bitrate.toString() + " kbps" + metadata = "${audioProperties.bitrate} kbps", ) MetadataTag( modifier = Modifier.weight(0.5f), typeOfMetadata = stringResource(id = R.string.sample_rate), - metadata = audioProperties.sampleRate.toString() + " Hz" + metadata = "${audioProperties.sampleRate} Hz", ) } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - ) { + Row(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { MetadataTag( modifier = Modifier.weight(0.5f), typeOfMetadata = stringResource(id = R.string.channels), - metadata = audioProperties.channels.toString() + metadata = audioProperties.channels.toString(), ) MetadataTag( modifier = Modifier.weight(0.5f), typeOfMetadata = stringResource(id = R.string.duration), - metadata = audioProperties.length.fromMillisToMinutes() + metadata = audioProperties.length.fromMillisToMinutes(), ) } } } +/** Encabezado de sección */ @Composable -private fun SongProperties( - mutablePropertiesMap: SnapshotStateMap, - retrieveLyrics: () -> Unit -) { - LargeCategoryTitle( - modifier = Modifier.padding(vertical = 6.dp), - text = stringResource(id = R.string.general_tags) - ) - - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["TITLE"], - label = stringResource(id = R.string.title), - modifier = Modifier.fillMaxWidth() - ) { title -> mutablePropertiesMap["TITLE"] = title } - - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["ARTIST"], - label = stringResource(id = R.string.artist), - modifier = Modifier.fillMaxWidth() - ) { artists -> mutablePropertiesMap["ARTIST"] = artists } - - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["ALBUM"], - label = stringResource(id = R.string.album), - modifier = Modifier.fillMaxWidth() - ) { album -> mutablePropertiesMap["ALBUM"] = album } - - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["ALBUMARTIST"], - label = stringResource(id = R.string.album_artist), - modifier = Modifier.fillMaxWidth() - ) { albumArtist -> mutablePropertiesMap["ALBUMARTIST"] = albumArtist } - - Column(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["TRACKNUMBER"], - label = stringResource(id = R.string.track_number), - modifier = Modifier.weight(0.5f) - ) { trackNumber -> mutablePropertiesMap["TRACKNUMBER"] = trackNumber } - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["DISCNUMBER"], - label = stringResource(id = R.string.disc_number), - modifier = Modifier.weight(0.5f) - ) { discNumber -> mutablePropertiesMap["DISCNUMBER"] = discNumber } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["DATE"], - label = stringResource(id = R.string.date), - modifier = Modifier.weight(0.5f) - ) { date -> mutablePropertiesMap["DATE"] = date } - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["GENRE"], - label = stringResource(id = R.string.genre), - modifier = Modifier.weight(0.5f) - ) { genre -> mutablePropertiesMap["GENRE"] = genre } - } - } +private fun SectionHeader(title: String) { + LargeCategoryTitle(modifier = Modifier.padding(vertical = 12.dp), text = title) +} - LargeCategoryTitle( - modifier = Modifier.padding(vertical = 6.dp), - text = stringResource(id = R.string.credits) +/** Campo editable de metadata */ +@Composable +private fun MetadataField( + key: String, + properties: Map, + modifiedKeys: Set, + label: String, + onUpdateProperty: (String, String) -> Unit, + modifier: Modifier = Modifier, + maxLines: Int = 1, +) { + val value = properties[key] ?: "" + val isModified = key in modifiedKeys + + MetadataOutlinedTextField( + value = value, + label = label, + isModified = isModified, + modifier = modifier, + maxLines = maxLines, + onValueChange = { onUpdateProperty(key, it) }, ) +} - Column(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["COMPOSER"], - label = stringResource(id = R.string.composer), - modifier = Modifier.weight(0.5f) - ) { composer -> mutablePropertiesMap["COMPOSER"] = composer } - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["LYRICIST"], - label = stringResource(id = R.string.lyricist), - modifier = Modifier.weight(0.5f) - ) { lyricist -> mutablePropertiesMap["LYRICIST"] = lyricist } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["CONDUCTOR"], - label = stringResource(id = R.string.conductor), - modifier = Modifier.weight(0.5f) - ) { conductor -> mutablePropertiesMap["CONDUCTOR"] = conductor } - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["REMIXER"], - label = stringResource(id = R.string.remixer), - modifier = Modifier.weight(0.5f) - ) { remixer -> mutablePropertiesMap["REMIXER"] = remixer } - } - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["PERFORMER"], - label = stringResource(id = R.string.performer), - modifier = Modifier.fillMaxWidth() - ) { performer -> mutablePropertiesMap["PERFORMER"] = performer } - - LargeCategoryTitle( - modifier = Modifier.padding(vertical = 6.dp), - text = stringResource(id = R.string.others) - ) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["COMMENT"], - label = stringResource(id = R.string.comment), +// Dynamic rendering of fields from MetadataEditorUiState +@Composable +private fun DynamicFieldsSection( + uiState: MetadataEditorUiState, + onUpdateProperty: (String, String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + uiState.fields.forEach { field: FieldState<*> -> + MetadataOutlinedTextField( + value = field.current.toString(), + label = stringResource(id = field.labelRes), + isModified = field.isModified, modifier = Modifier.fillMaxWidth(), - maxLines = 3 - ) { comment -> mutablePropertiesMap["COMMENT"] = comment } - Column(modifier = Modifier.fillMaxWidth()) { - TextButton( - modifier = Modifier.align(Alignment.End), - onClick = retrieveLyrics - ) { - Row( - modifier = Modifier, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.Lyrics, - contentDescription = stringResource(id = R.string.retrieve_lyrics) - ) - - Text(text = stringResource(id = R.string.retrieve_lyrics)) - } - } - PreConfiguredOutlinedTextField( - value = mutablePropertiesMap["LYRICS"], - label = stringResource(id = R.string.lyrics), - modifier = Modifier.fillMaxWidth(), - maxLines = 20 - ) { lyrics -> mutablePropertiesMap["LYRICS"] = lyrics } - } + maxLines = if (field is com.bobbyesp.metadator.tageditor.presentation.state.StringFieldState && field.key == "COMMENT") 3 else 1, + onValueChange = { onUpdateProperty(field.key, it) } + ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/MetadataEditorViewModel.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/MetadataEditorViewModel.kt index f2745d1..52d7e7e 100644 --- a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/MetadataEditorViewModel.kt +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/MetadataEditorViewModel.kt @@ -2,31 +2,26 @@ package com.bobbyesp.metadator.tageditor.presentation.pages.tageditor import android.app.PendingIntent import android.app.RecoverableSecurityException -import android.content.Context import android.net.Uri import android.os.Build -import android.os.ParcelFileDescriptor import android.util.Log -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.bobbyesp.metadator.core.util.executeIfDebugging +import com.bobbyesp.metadator.tageditor.data.local.UriToPictureConverter +import com.bobbyesp.metadator.tageditor.domain.AudioEditableMetadata +import com.bobbyesp.metadator.tageditor.model.repository.AudioMetadataRepository +import com.bobbyesp.metadator.tageditor.presentation.state.MetadataEditorUiState import com.bobbyesp.utilities.ext.toModifiableMap import com.bobbyesp.utilities.mediastore.AudioFileMetadata.Companion.toAudioFileMetadata import com.bobbyesp.utilities.mediastore.AudioFileMetadata.Companion.toPropertyMap -import com.bobbyesp.utilities.mediastore.MediaStoreReceiver import com.bobbyesp.utilities.states.ResourceState import com.bobbyesp.utilities.states.ScreenState import com.kyant.taglib.AudioProperties import com.kyant.taglib.AudioPropertiesReadStyle import com.kyant.taglib.Metadata -import com.kyant.taglib.Picture -import com.kyant.taglib.PropertyMap -import com.kyant.taglib.TagLib -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -35,25 +30,14 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.coroutines.CoroutineContext class MetadataEditorViewModel( - private val context: Context, - private val stateHandle: SavedStateHandle + private val repository: AudioMetadataRepository, + private val uriConverter: UriToPictureConverter, + private val stateHandle: SavedStateHandle, ) : ViewModel() { - private val mutableState = MutableStateFlow(PageViewState()) - val state = mutableState.onStart { - stateHandle.get("path")?.let { - onEvent(Event.LoadMetadata(it)) - } - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - PageViewState() - ) - private val _eventFlow = MutableSharedFlow() + private val _eventFlow = MutableSharedFlow(extraBufferCapacity = 1) val eventFlow = _eventFlow.asSharedFlow() private var latestLoadedSongPath: String? = null @@ -62,236 +46,115 @@ class MetadataEditorViewModel( val metadata: ResourceState = ResourceState.Loading(), val audioProperties: ResourceState = ResourceState.Loading(), val pageState: ScreenState = ScreenState.Loading, - val mutablePropertiesMap: SnapshotStateMap = mutableStateMapOf() + val uiState: MetadataEditorUiState = MetadataEditorUiState(), ) + private val _state = MutableStateFlow(PageViewState()) + val state = + _state + .onStart { stateHandle.get("path")?.let { onEvent(Event.LoadMetadata(it)) } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PageViewState()) + override fun onCleared() { super.onCleared() updateState(ScreenState.Loading) - mutableState.update { - it.copy( - metadata = ResourceState.Loading(), - audioProperties = ResourceState.Loading() - ) + _state.update { + it.copy(metadata = ResourceState.Loading(), audioProperties = ResourceState.Loading()) } } - private suspend fun loadTrackMetadata(path: String, scope: CoroutineScope) { - updateState(ScreenState.Loading) - mutableState.value.mutablePropertiesMap.clear() - + private suspend fun loadTrackMetadata(path: String) { + _state.update { it.copy(pageState = ScreenState.Loading) } try { stateHandle["path"] = path - MediaStoreReceiver.getFileDescriptorFromPath(context, path, mode = "r")?.use { songFd -> - val metadataDeferred = scope.async { - loadAudioMetadata( - songFd = songFd, - coroutineContext = scope.coroutineContext - ) - } - - val audioPropertiesDeferred = scope.async { - loadAudioProperties( - songFd = songFd, - readStyle = AudioPropertiesReadStyle.Average, - coroutineContext = scope.coroutineContext - ) - } - - val metadata = metadataDeferred.await() - val audioProperties = audioPropertiesDeferred.await() - - updateAudioInformation(metadata, audioProperties) - } - updateState(ScreenState.Success(null)) - mutableState.value.metadata.data?.propertyMap?.toModifiableMap()?.forEach { - mutableState.value.mutablePropertiesMap[it.key] = it.value ?: "" - } - } catch (error: Exception) { - Log.e( - "MetadataEditorVM", "Error while trying to load the audio file: ${error.message}" - ) - when (error) { - is NullAudioFileDescriptorException -> { - if (error.isAudioProperties) { - mutableState.update { - it.copy( - audioProperties = ResourceState.Error( - errorMessage = error.message ?: error.stackTraceToString() - ) - ) - } - } else { - mutableState.update { - it.copy( - metadata = ResourceState.Error( - errorMessage = error.message ?: error.stackTraceToString() - ) - ) - } - } - } - - else -> updateState(ScreenState.Error(error)) - } - } - } - - private suspend fun loadAudioMetadata( - songFd: ParcelFileDescriptor, - coroutineContext: CoroutineContext = Dispatchers.IO - ): Metadata? { - val fd = songFd.dup()?.detachFd() ?: throw NullAudioFileDescriptorException( - isAudioProperties = false - ) - - return withContext(coroutineContext) { - TagLib.getMetadata(fd = fd) - } - } - - - private suspend fun loadAudioProperties( - songFd: ParcelFileDescriptor, - readStyle: AudioPropertiesReadStyle = AudioPropertiesReadStyle.Average, - coroutineContext: CoroutineContext = Dispatchers.IO - ): AudioProperties? { - val fd = songFd.dup()?.detachFd() ?: throw NullAudioFileDescriptorException( - isAudioProperties = true - ) - - return withContext(coroutineContext) { - TagLib.getAudioProperties( - fd = fd, readStyle = readStyle - ) - } - } - - private fun updateMapProperty(key: String, value: String) { - mutableState.value.mutablePropertiesMap[key] = value - } - - fun savePropertyMap( - context: Context = this.context, - newPropertiesMap: PropertyMap = mutableState.value.mutablePropertiesMap.toAudioFileMetadata() - .toPropertyMap(), - audioPath: String, - intentPassthrough: (PendingIntent) -> Unit = {} - ): Boolean { - return try { - val fd = MediaStoreReceiver.getFileDescriptorFromPath(context, audioPath, mode = "w") - ?: throw NullAudioFileDescriptorException(isAudioProperties = false) - - fd.dup().detachFd().let { - TagLib.savePropertyMap( - it, propertyMap = newPropertiesMap + val meta = repository.getMetadata(path).getOrThrow() + val audioProps = + repository.getAudioProperties(path, AudioPropertiesReadStyle.Average).getOrThrow() + + // Converts PropertyMap → AudioFileMetadata → AudioEditableMetadata + val flatMap = + meta.propertyMap.toModifiableMap().mapValues { it.value ?: "" } + val fileMeta = flatMap.toAudioFileMetadata() + val editable = + AudioEditableMetadata( + title = fileMeta.title.orEmpty(), + artist = fileMeta.artist.orEmpty(), + album = fileMeta.album.orEmpty(), + trackNumber = fileMeta.trackNumber?.toIntOrNull() ?: 0, + discNumber = fileMeta.discNumber?.toIntOrNull() ?: 0, + date = fileMeta.date.orEmpty(), + genre = fileMeta.genre.orEmpty(), + comment = fileMeta.comment.orEmpty(), + lyrics = fileMeta.lyrics.orEmpty(), ) - } - true - } catch (securityException: SecurityException) { - handleSecurityException(securityException, intentPassthrough) - false - } catch (e: Exception) { - mutableState.update { + _state.update { it.copy( - pageState = ScreenState.Error(e) + metadata = ResourceState.Success(meta), + audioProperties = ResourceState.Success(audioProps), + pageState = ScreenState.Success(null), + uiState = MetadataEditorUiState().apply { loadFrom(editable) }, ) } - false + } catch (e: Exception) { + Log.e("MetadataEditorVM", "Error loading: ${e.message}") + _state.update { it.copy(pageState = ScreenState.Error(e)) } } } - private fun savePictures( - context: Context = this.context, - imagesUri: List = emptyList(), + private suspend fun savePropertyMap( audioPath: String, - intentPassthrough: (PendingIntent) -> Unit - ): Boolean { - return try { - val fd = MediaStoreReceiver.getFileDescriptorFromPath(context, audioPath, mode = "w") - ?: throw NullAudioFileDescriptorException(isAudioProperties = false) - - val mutablePicturesList = mutableListOf() - fd.dup().detachFd().let { - imagesUri.forEachIndexed { index, imageUri -> - val byteArray = context.contentResolver.openInputStream(imageUri)?.readBytes() - ?: return@forEachIndexed - val mimeType = - context.contentResolver.getType(imageUri) ?: return@forEachIndexed - val picture = Picture( - data = byteArray, - mimeType = mimeType, - description = "Audio image $index - Metadator", - pictureType = "Front cover" - ) - - mutablePicturesList.add(picture) + intentPassthrough: (PendingIntent) -> Unit = {}, + ) { + try { + // Convert the actual properties to a PropertyMap + val flatMap = _state.value.uiState.fields.associate { it.key to it.current.toString() } + val propertyMap = flatMap.toAudioFileMetadata().toPropertyMap() + + repository.writePropertyMap(audioPath, propertyMap) + .onSuccess { + // Clear modified flags after saving properties + _state.update { it.copy(uiState = it.uiState.clearModified()) } + } + .onFailure { error -> + if (error is SecurityException) { + handleSecurityException(error, intentPassthrough) + } else { + _state.update { it.copy(pageState = ScreenState.Error(error)) } + } } - - TagLib.savePictures( - it, pictures = mutablePicturesList.toTypedArray() - ) - } - true - } catch (securityException: SecurityException) { - handleSecurityException(securityException, intentPassthrough) - false } catch (e: Exception) { - mutableState.update { - it.copy( - pageState = ScreenState.Error(e) - ) - } - false + _state.update { it.copy(pageState = ScreenState.Error(e)) } } } - private fun savePictures( - context: Context = this.context, - imagesUri: List = emptyList(), - fileDescriptorId: Int = -1 - ): Boolean { - - val mutablePicturesList = mutableListOf() - - imagesUri.forEachIndexed { index, uri -> - val byteArray = context.contentResolver.openInputStream(uri)?.readBytes() - ?: throw IllegalStateException("Image byte array is null") - val mimeType = context.contentResolver.getType(uri) - ?: throw IllegalStateException("Image mime type is null") - - val picture = Picture( - data = byteArray, - mimeType = mimeType, - description = "Audio image $index - Metadator", - pictureType = "Front cover" - ) - - mutablePicturesList.add(picture) - } - - runCatching { - TagLib.savePictures( - fileDescriptorId, pictures = mutablePicturesList.toTypedArray() - ) - }.onFailure { - return false - }.onSuccess { - return true + private suspend fun savePictures( + imagesUri: List, + audioPath: String, + intentPassthrough: (PendingIntent) -> Unit, + ) { + try { + val pictures = uriConverter.convert(imagesUri) + repository.writePictures(audioPath, pictures).onFailure { error -> + if (error is SecurityException) { + handleSecurityException(error, intentPassthrough) + } else { + _state.update { it.copy(pageState = ScreenState.Error(error)) } + } + } + } catch (e: Exception) { + _state.update { it.copy(pageState = ScreenState.Error(e)) } } - - return true } private fun handleSecurityException( - securityException: SecurityException, intentPassthrough: (PendingIntent) -> Unit + securityException: SecurityException, + intentPassthrough: (PendingIntent) -> Unit, ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val recoverableSecurityException = - securityException as? RecoverableSecurityException ?: throw RuntimeException( - securityException.message, securityException - ) + securityException as? RecoverableSecurityException + ?: throw RuntimeException(securityException.message, securityException) intentPassthrough(recoverableSecurityException.userAction.actionIntent) } else { @@ -299,28 +162,12 @@ class MetadataEditorViewModel( } } - - private fun updateAudioInformation(metadata: Metadata?, audioProperties: AudioProperties?) { - mutableState.update { - it.copy( - metadata = ResourceState.Success(metadata), - audioProperties = ResourceState.Success(audioProperties) - ) - } - } - private fun updateState(state: ScreenState) { - mutableState.update { - it.copy( - pageState = state - ) - } + _state.update { it.copy(pageState = state) } } private fun emitUiEvent(event: UiEvent) { - viewModelScope.launch { - _eventFlow.emit(event) - } + viewModelScope.launch { _eventFlow.emit(event) } } fun onEvent(event: Event) { @@ -328,94 +175,113 @@ class MetadataEditorViewModel( is Event.LoadMetadata -> { viewModelScope.launch { if (latestLoadedSongPath != event.path) { - loadTrackMetadata(event.path, this) + loadTrackMetadata(event.path) latestLoadedSongPath = event.path } } } is Event.SaveProperties -> { - val succeeded = savePropertyMap( - audioPath = event.path, - intentPassthrough = { - emitUiEvent(UiEvent.RequestPermission(it)) - } - ) + viewModelScope.launch(Dispatchers.IO) { + val succeeded = + try { + savePropertyMap( + audioPath = event.path, + intentPassthrough = { emitUiEvent(UiEvent.RequestPermission(it)) }, + ) + true + } catch (e: Exception) { + executeIfDebugging { + Log.e( + "MetadataEditorVM", + "Error while trying to save the properties: ${e.message}", + ) + } + emitUiEvent(UiEvent.SaveFailed) + false + } - if (succeeded) { - emitUiEvent(UiEvent.SaveSuccess(properties = true)) - } else { - emitUiEvent(UiEvent.SaveFailed) + if (succeeded) { + emitUiEvent(UiEvent.SaveSuccess(properties = true)) + } else { + emitUiEvent(UiEvent.SaveFailed) + } } } is Event.SavePictures -> { - val succeeded = savePictures( - imagesUri = event.imagesUri, - audioPath = event.path, - intentPassthrough = { - emitUiEvent(UiEvent.RequestPermission(it)) - }) - - if (succeeded) { - emitUiEvent(UiEvent.SaveSuccess(pictures = true)) - } else { - emitUiEvent(UiEvent.SaveFailed) - } - } - - is Event.SaveAll -> { - val propertiesSaved = savePropertyMap( - audioPath = event.path, - intentPassthrough = { - emitUiEvent(UiEvent.RequestPermission(it)) - } - ) + viewModelScope.launch(Dispatchers.IO) { + val succeeded = + try { + savePictures( + audioPath = event.path, + imagesUri = event.imagesUri, + intentPassthrough = { emitUiEvent(UiEvent.RequestPermission(it)) }, + ) + true + } catch (e: Exception) { + executeIfDebugging { + Log.e( + "MetadataEditorVM", + "Error while trying to save the pictures: ${e.message}", + ) + } + emitUiEvent(UiEvent.SaveFailed) + false + } - val succeededPictures = savePictures( - audioPath = event.path, - imagesUri = event.imagesUri, - intentPassthrough = { - emitUiEvent(UiEvent.RequestPermission(it)) + if (succeeded) { + emitUiEvent(UiEvent.SaveSuccess(pictures = true)) + } else { + emitUiEvent(UiEvent.SaveFailed) } - ) - - if (propertiesSaved || succeededPictures) { - emitUiEvent( - UiEvent.SaveSuccess( - pictures = succeededPictures, properties = propertiesSaved - ) - ) - } else { - emitUiEvent(UiEvent.SaveFailed) } } is Event.UpdateProperty -> { - updateMapProperty(event.key, event.value) + // Update a single field in the UI state + _state.update { it.copy(uiState = it.uiState.updateField(event.key, event.value)) } + } + + is Event.SaveAll -> { + viewModelScope.launch(Dispatchers.IO) { + val flatMap = state.value.uiState.fields.associate { it.key to it.current.toString() } + val propertyMap = flatMap.toAudioFileMetadata().toPropertyMap() + try { + repository.writePropertyMap(event.path, propertyMap) + val pictures = uriConverter.convert(event.imagesUri) + repository.writePictures(event.path, pictures) + // Clear all modified fields after full save + _state.update { it.copy(uiState = it.uiState.clearModified()) } + emitUiEvent(UiEvent.SaveSuccess(pictures = true, properties = true)) + } catch (e: SecurityException) { + handleSecurityException(e) { emitUiEvent(UiEvent.RequestPermission(it)) } + } catch (e: Exception) { + emitUiEvent(UiEvent.SaveFailed) + } + } } } } - interface Event { data class LoadMetadata(val path: String) : Event + data class SaveAll(val path: String, val imagesUri: List) : Event + data class SaveProperties(val path: String) : Event + data class SavePictures(val path: String, val imagesUri: List) : Event + data class UpdateProperty(val key: String, val value: String) : Event } interface UiEvent { data class RequestPermission(val intent: PendingIntent) : UiEvent + data class SaveSuccess(val pictures: Boolean? = null, val properties: Boolean? = null) : UiEvent data object SaveFailed : UiEvent } - - companion object { - class NullAudioFileDescriptorException(val isAudioProperties: Boolean) : - Exception("Audio file descriptor is null") - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/SongSyncNeededDialog.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/SongSyncNeededDialog.kt index 808619f..1831912 100644 --- a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/SongSyncNeededDialog.kt +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/SongSyncNeededDialog.kt @@ -18,29 +18,26 @@ import com.bobbyesp.utilities.R @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SongSyncNeededDialog( - modifier: Modifier = Modifier, - onDismissRequest: () -> Unit, -) { +fun SongSyncNeededDialog(modifier: Modifier = Modifier, onDismissRequest: () -> Unit) { val uriLauncher = LocalUriHandler.current AlertDialog( icon = { - Icon( - imageVector = Icons.Filled.Lyrics, - contentDescription = "SongSync app needed" - ) - }, modifier = modifier, onDismissRequest = onDismissRequest, - title = { - Text(text = stringResource(id = R.string.song_sync_needed)) - }, text = { + Icon(imageVector = Icons.Filled.Lyrics, contentDescription = "SongSync app icon") + }, + modifier = modifier, + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(id = R.string.song_sync_needed)) }, + text = { Text( - text = buildAnnotatedString { - append(stringResource(id = R.string.song_sync_needed_desc)) - append(" \n") - append(stringResource(id = R.string.song_sync_not_installed)) - }, + text = + buildAnnotatedString { + append(stringResource(id = R.string.song_sync_needed_desc)) + append(" \n") + append(stringResource(id = R.string.song_sync_not_installed)) + } ) - }, confirmButton = { + }, + confirmButton = { TextButton( onClick = { uriLauncher.openUri("https://github.com/Lambada10/SongSync/releases/latest") @@ -48,18 +45,15 @@ fun SongSyncNeededDialog( ) { Text(stringResource(id = R.string.download)) } - }, dismissButton = { - TextButton( - onClick = onDismissRequest - ) { - Text(stringResource(id = R.string.dismiss)) - } - } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { Text(stringResource(id = R.string.dismiss)) } + }, ) } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SongSyncNeededDialogPreview() { - SongSyncNeededDialog(onDismissRequest = { }) -} \ No newline at end of file + SongSyncNeededDialog(onDismissRequest = {}) +} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/MetadataBottomSheetViewModel.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/MetadataBottomSheetViewModel.kt deleted file mode 100644 index e4e015a..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/MetadataBottomSheetViewModel.kt +++ /dev/null @@ -1,135 +0,0 @@ -package com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.PagingData -import androidx.paging.cachedIn -import com.adamratzman.spotify.models.Track -import com.bobbyesp.metadator.features.spotify.domain.services.search.SpotifySearchService -import com.bobbyesp.utilities.states.ResourceState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent - -class MetadataBottomSheetViewModel( - private val searchService: SpotifySearchService -) : KoinComponent, ViewModel() { - private val mutableViewStateFlow = MutableStateFlow(ViewState()) - val viewStateFlow = mutableViewStateFlow.asStateFlow() - - private val _outerEventsFlow = MutableSharedFlow() - val outerEventsFlow = _outerEventsFlow.asSharedFlow() - - data class ViewState( - val stage: BottomSheetStage = BottomSheetStage.SEARCH, - val searchedTracks: ResourceState>> = ResourceState.Loading(null), - val selectedTrack: Track? = null, - val lastQuery: String = "", - ) - - private fun searchTracks(query: String) { - updateQuery(query) - viewModelScope.launch(Dispatchers.IO) { - getTracksPaginatedData(query) - } - } - - private suspend fun getTracksPaginatedData(query: String) { - try { - val tracksPager = searchService.searchPaginatedTracks( - query = query, - filters = emptyList() - ).flow.cachedIn(viewModelScope) - - mutableViewStateFlow.update { - it.copy( - searchedTracks = ResourceState.Success(tracksPager) - ) - } - } catch (th: Throwable) { - mutableViewStateFlow.update { - it.copy( - searchedTracks = ResourceState.Error( - errorMessage = th.message ?: th.stackTrace.toString() - ) - ) - } - } - } - - private fun chooseTrack(track: Track?) { - mutableViewStateFlow.update { - it.copy( - selectedTrack = track - ) - } - updateStage(BottomSheetStage.TRACK_DETAILS) - } - - private fun updateStage(stage: BottomSheetStage) { - mutableViewStateFlow.update { - it.copy( - stage = stage - ) - } - } - - private fun saveMetadata(modifiedFields: Map) { - viewModelScope.launch { - _outerEventsFlow.emit(OuterEvent.SaveMetadata(modifiedFields)) - } - } - - private fun updateQuery(query: String) { - mutableViewStateFlow.update { - it.copy( - lastQuery = query - ) - } - } - - fun onEvent(event: Event) { - when (event) { - is Event.Search -> { - searchTracks(event.query) - } - - is Event.ChangeState -> { - updateStage(event.state) - } - - is Event.SelectTrack -> { - chooseTrack(event.track) - if (event.track == null) updateStage(BottomSheetStage.SEARCH) - } - - is Event.UpdateMetadataFields -> { - saveMetadata(event.properties) - } - } - } - - interface OuterEvent { - data class SaveMetadata(val modifiedFields: Map) : OuterEvent - } - - interface Event { - data class Search(val query: String) : Event - data class ChangeState(val state: BottomSheetStage) : Event - data class SelectTrack(val track: Track?) : Event - data class UpdateMetadataFields(val properties: Map) : Event - } - - companion object { - enum class BottomSheetStage { - SEARCH, - TRACK_DETAILS - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/SpMetadataBottomSheetContent.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/SpMetadataBottomSheetContent.kt deleted file mode 100644 index 9ea4e2d..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/SpMetadataBottomSheetContent.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.ui.Modifier -import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.MetadataBottomSheetViewModel.Companion.BottomSheetStage -import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.stages.NoSongInformationProvided -import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.stages.SpMetadataBsDetails -import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.stages.SpMetadataBsSearch -import com.bobbyesp.ui.motion.MotionConstants.DURATION_EXIT_SHORT -import com.bobbyesp.ui.motion.tweenEnter -import com.bobbyesp.ui.motion.tweenExit - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SpMetadataBottomSheetContent( - name: String, - artist: String, - sheetState: SheetState, - bsViewState: State, - onEvent: (MetadataBottomSheetViewModel.Event) -> Unit, - onCloseSheet: () -> Unit -) { - val lazyListState = rememberLazyListState() - - fun search(query: String) { - onEvent(MetadataBottomSheetViewModel.Event.ChangeState(BottomSheetStage.SEARCH)) - onEvent(MetadataBottomSheetViewModel.Event.Search(query)) - } - - if (name.isEmpty() && artist.isEmpty()) { - NoSongInformationProvided { providedName, providedArtist -> - val query = "$providedName $providedArtist" - search(query) - } - } - - LaunchedEffect(sheetState.isVisible, name, artist) { - val query = "$name $artist" - if (sheetState.isVisible && bsViewState.value.lastQuery != query) { - search(query) - } - } - - AnimatedContent( - targetState = bsViewState.value.stage, - label = "Transition between bs states", - transitionSpec = { - fadeIn( - tweenEnter(delayMillis = DURATION_EXIT_SHORT) - ) togetherWith fadeOut( - tweenExit(durationMillis = DURATION_EXIT_SHORT) - ) - }) { actualStage -> - when (actualStage) { - BottomSheetStage.SEARCH -> { - SpMetadataBsSearch( - name = name, - artist = artist, - listState = lazyListState, - pageViewState = bsViewState, - onChooseTrack = { track -> - onEvent(MetadataBottomSheetViewModel.Event.SelectTrack(track)) - } - ) - } - - BottomSheetStage.TRACK_DETAILS -> { - SpMetadataBsDetails( - modifier = Modifier.fillMaxSize(), - onEvent = onEvent, - pageViewState = bsViewState, - onCloseSheet = onCloseSheet - ) - } - } - } -} diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/stages/NoSongInformationProvided.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/stages/NoSongInformationProvided.kt deleted file mode 100644 index 810bf10..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/stages/NoSongInformationProvided.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.stages - -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.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.MoodBad -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -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.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.bobbyesp.metadator.R -import com.bobbyesp.ui.components.text.PreConfiguredOutlinedTextField - -@Composable -fun NoSongInformationProvided( - onRetrySearch: (String, String) -> Unit -) { - val (name, setName) = remember { mutableStateOf("") } - val (artist, setArtist) = remember { mutableStateOf("") } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) - ) { - Icon( - imageVector = Icons.Rounded.MoodBad, - contentDescription = stringResource(id = R.string.no_song_information_provided), - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurface - ) - Column( - modifier = Modifier.fillMaxWidth(0.9f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(id = R.string.no_song_information_provided), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold - ) - Row( - modifier = Modifier.fillMaxWidth(), - ) { - PreConfiguredOutlinedTextField( - value = name, - label = stringResource(id = R.string.title), - modifier = Modifier.weight(0.5f) - ) { title -> - setName(title) - } - Spacer(modifier = Modifier.width(8.dp)) - PreConfiguredOutlinedTextField( - value = artist, - label = stringResource(id = R.string.artist), - modifier = Modifier.weight(0.5f) - ) { artist -> - setArtist(artist) - } - } - } - Button( - modifier = Modifier.fillMaxWidth(0.7f), - onClick = { onRetrySearch(name, artist) } - ) { - Text( - text = stringResource(id = R.string.retry_search), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/stages/SpMetadataBsDetails.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/stages/SpMetadataBsDetails.kt deleted file mode 100644 index 819a337..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/stages/SpMetadataBsDetails.kt +++ /dev/null @@ -1,306 +0,0 @@ -package com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.stages - -import android.content.Context -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.BorderStroke -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.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.adamratzman.spotify.models.Track -import com.bobbyesp.metadator.R -import com.bobbyesp.metadator.core.ext.TagLib.toImageVector -import com.bobbyesp.metadator.core.ext.TagLib.toLocalizedName -import com.bobbyesp.metadator.core.ext.format -import com.bobbyesp.metadator.core.ext.formatArtists -import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.MetadataBottomSheetViewModel -import com.bobbyesp.metadator.core.presentation.components.image.AsyncImage -import com.bobbyesp.metadator.core.presentation.components.text.ConditionedMarqueeText -import com.bobbyesp.ui.components.button.BackButton -import com.bobbyesp.ui.components.others.SelectableSurface -import com.bobbyesp.ui.util.rememberVolatileSaveable - -@Composable -fun SpMetadataBsDetails( - modifier: Modifier = Modifier, - pageViewState: State, - onEvent: (MetadataBottomSheetViewModel.Event) -> Unit, - onCloseSheet: () -> Unit, -) { - BackHandler { - onEvent(MetadataBottomSheetViewModel.Event.SelectTrack(null)) - } - - val chosenMetadata = rememberSaveable(key = "chosenMetadata") { - mutableMapOf() - } - - val lazyGirdState = rememberLazyGridState() - - Column( - modifier = modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), - verticalAlignment = Alignment.CenterVertically - ) { - BackButton( - onClick = { - onEvent(MetadataBottomSheetViewModel.Event.SelectTrack(null)) - } - ) - Text( - text = stringResource(id = R.string.spotify_metadata), - style = MaterialTheme.typography.titleSmall.copy( - fontSize = 20.sp, - fontWeight = FontWeight.Bold - ), - overflow = TextOverflow.Ellipsis, - modifier = Modifier - ) - Spacer(modifier = Modifier.weight(1f)) - TextButton( - onClick = { - onEvent(MetadataBottomSheetViewModel.Event.UpdateMetadataFields(chosenMetadata.toMap())) - onCloseSheet() - } - ) { - Text(text = stringResource(id = R.string.save)) - } - } - pageViewState.value.selectedTrack?.let { track -> - val context = LocalContext.current - val metadataMap = createMetadataMap(context, track) - - TrackInfo( - modifier = Modifier.padding(vertical = 6.dp, horizontal = 8.dp), - track = track - ) - - LazyVerticalGrid( - state = lazyGirdState, - columns = GridCells.Adaptive(200.dp), - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(8.dp) - ) { - val metadataList = metadataMap.toList() - val isTheListOdd = metadataList.size % 2 != 0 - - items(metadataList.size) { index -> - // Skip the last item if the size of metadataMap is odd - if (isTheListOdd && index == metadataList.size - 1) { - return@items - } - - val (field, retrievedValue) = metadataList[index] - SelectableMetadataField( - modifier = Modifier - .padding(4.dp) - .heightIn(min = 100.dp), - title = field, - value = retrievedValue, - onSelectMetadata = { title, value -> chosenMetadata[title] = value }, - onDeleteMetadata = { title -> chosenMetadata.remove(title) } - ) - } - - if (isTheListOdd) { - val lastElement = metadataList.last() - val (field, retrievedValue) = lastElement - item(span = { GridItemSpan(maxLineSpan) }) { - SelectableMetadataField( - modifier = Modifier - .padding(4.dp) - .heightIn(min = 100.dp), - title = field, - value = retrievedValue, - onSelectMetadata = { title, value -> chosenMetadata[title] = value }, - onDeleteMetadata = { title -> chosenMetadata.remove(title) } - ) - } - } - } - } - } -} - -@Composable -fun createMetadataMap(context: Context, track: Track) = rememberSaveable { - mutableMapOf( - "TITLE" to track.name, - "ARTIST" to track.artists.formatArtists(), - "ALBUM" to track.album.name, - "ALBUMARTIST" to track.album.artists.formatArtists(), - "TRACKNUMBER" to track.trackNumber.toString(), - "DISCNUMBER" to track.discNumber.toString(), - "DATE" to (track.album.releaseDate?.format(track.album.releaseDatePrecisionString) - ?: context.getString(R.string.unknown)), - ) -} - -@Composable -private fun TrackInfo( - modifier: Modifier = Modifier, - track: Track, -) { - val albumArtPath = track.album.images?.getOrNull(0)?.url - - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - AsyncImage( - modifier = Modifier.size(64.dp), - imageModel = albumArtPath, - shape = MaterialTheme.shapes.extraSmall - ) - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Column( - horizontalAlignment = Alignment.Start, - modifier = Modifier - .padding(8.dp) - .weight(1f) - ) { - ConditionedMarqueeText( - text = track.name, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - ) - Text( - text = track.artists.formatArtists(), - style = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - } -} - -@Composable -private fun SelectableMetadataField( - modifier: Modifier = Modifier, - title: String, - value: String, - useIcon: Boolean = true, - onSelectMetadata: (title: String, value: String) -> Unit, - onDeleteMetadata: (title: String) -> Unit -) { - var isSelected by rememberVolatileSaveable(initialValue = false) - SelectableSurface( - modifier = modifier, - isSelected = isSelected, - tonalElevation = 2.dp, - borderStroke = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - onSelected = { - isSelected = !isSelected - if (isSelected) onSelectMetadata(title, value) else onDeleteMetadata(title) - }, - shape = MaterialTheme.shapes.medium, - ) { - Box { - Column( - modifier = Modifier - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.Top), - horizontalAlignment = Alignment.Start - ) { - Row( - modifier = Modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start) - ) { - if (useIcon) { - Icon(imageVector = title.toImageVector(), contentDescription = null) - } - Text( - text = title.toLocalizedName(), - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Bold - ) - ) - } - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - RadioButton( - selected = isSelected, - onClick = { - isSelected = !isSelected - if (isSelected) onSelectMetadata(title, value) else onDeleteMetadata(title) - }, - modifier = Modifier.align(Alignment.BottomEnd) - ) - } - } -} - -@Preview -@Composable -fun SelectableMetadataFieldNoIconPreview() { - SelectableMetadataField( - modifier = Modifier.size(200.dp), - title = "TITLE", - value = "Title", - onSelectMetadata = { _, _ -> }, - onDeleteMetadata = { }, - useIcon = false - ) -} - -@Preview -@Composable -fun SelectableMetadataFieldWithIconPreview() { - SelectableMetadataField( - modifier = Modifier.size(200.dp), - title = "TITLE", - value = "Title", - onSelectMetadata = { _, _ -> }, - onDeleteMetadata = { }, - useIcon = true - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/stages/SpMetadataBsSearch.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/stages/SpMetadataBsSearch.kt deleted file mode 100644 index d2a7688..0000000 --- a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/pages/tageditor/spotify/stages/SpMetadataBsSearch.kt +++ /dev/null @@ -1,195 +0,0 @@ -package com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.stages - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.paging.compose.collectAsLazyPagingItems -import androidx.paging.compose.itemContentType -import androidx.paging.compose.itemKey -import com.adamratzman.spotify.models.Track -import com.bobbyesp.metadator.R -import com.bobbyesp.metadator.mediastore.presentation.components.card.songs.spotify.SpotifyHorizontalSongCard -import com.bobbyesp.metadator.tageditor.presentation.pages.tageditor.spotify.MetadataBottomSheetViewModel -import com.bobbyesp.ui.components.button.VerticalButtonWithIconAndText -import com.bobbyesp.ui.components.state.LoadingState -import com.bobbyesp.utilities.states.ResourceState -import com.bobbyesp.utilities.ui.pagingStateHandler - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun SpMetadataBsSearch( - name: String, - artist: String, - listState: LazyListState = rememberLazyListState(), - pageViewState: State, - onChooseTrack: (Track) -> Unit -) { - val paginatedTracksState = pageViewState.value.searchedTracks - val paginatedTracks = paginatedTracksState.data?.collectAsLazyPagingItems() - - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize() - ) { - if (name.isNotEmpty() && artist.isNotEmpty()) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - brush = Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.surfaceContainerLow, Color.Transparent - ), - startY = 50f - ) - ) - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { // TODO: Be able to change the query - Row( - modifier = Modifier - .fillMaxWidth() - .padding(4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.showing_results_for).uppercase(), - style = MaterialTheme.typography.labelLarge.copy( - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), - letterSpacing = 2.sp - ) - ) - Text( - text = name, style = MaterialTheme.typography.headlineSmall - ) - Text( - text = buildAnnotatedString { - append(stringResource(R.string.by)) - append(" ") - withStyle(MaterialTheme.typography.titleMedium.toSpanStyle()) { - append(artist) - } - }, - ) - } - Row( - modifier = Modifier - .fillMaxHeight() - .weight(1f), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - VerticalButtonWithIconAndText( - icon = Icons.Rounded.Edit, - text = stringResource(R.string.edit_query), - modifier = Modifier.weight(1f), - onClick = { /* TODO: Edit query */ }, - enabled = true, - shape = RoundedCornerShape(8.dp) - ) - } - } - } - } - } - - when (paginatedTracksState) { - is ResourceState.Loading -> { - item { - LoadingState(stringResource(id = R.string.retrieving_spotify_token)) - } - } - - is ResourceState.Success -> { - items( - count = paginatedTracks!!.itemCount, - key = paginatedTracks.itemKey(), - contentType = paginatedTracks.itemContentType() - ) { index -> - val item = paginatedTracks[index] ?: return@items - SpotifyHorizontalSongCard( - innerModifier = Modifier.padding(8.dp), - surfaceColor = Color.Transparent, - track = item, - onClick = { - onChooseTrack(item) - } - ) - } - - pagingStateHandler(paginatedTracks, itemCount = 1) { - LoadingState(stringResource(id = R.string.loading)) - } - } - - is ResourceState.Error -> { - item { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - border = BorderStroke( - 1.dp, - MaterialTheme.colorScheme.error.copy(alpha = 0.5f) - ), - contentColor = MaterialTheme.colorScheme.error, - shape = MaterialTheme.shapes.medium - ) { - Column( - modifier = Modifier.padding(4.dp), - verticalArrangement = Arrangement.spacedBy( - 8.dp, - Alignment.CenterVertically - ), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - modifier = Modifier, - text = paginatedTracksState.message - ?: stringResource(id = com.bobbyesp.ui.R.string.unknown_error_title), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Normal - ) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/FieldState.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/FieldState.kt new file mode 100644 index 0000000..8e24c57 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/FieldState.kt @@ -0,0 +1,21 @@ +package com.bobbyesp.metadator.tageditor.presentation.state + +sealed class FieldState( + val key: String, + val labelRes: Int, + val original: T, + var current: T +) { + val isModified: Boolean + get() = original != current + + abstract val errorMessageRes: Int? + + private var _error: Int? = null + val errorMessage: Int? + get() = _error + + fun validate(validator: (T) -> Int?): FieldState = apply { + _error = validator(current) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/IntFieldState.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/IntFieldState.kt new file mode 100644 index 0000000..54b224d --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/IntFieldState.kt @@ -0,0 +1,9 @@ +package com.bobbyesp.metadator.tageditor.presentation.state + +class IntFieldState( + key: String, + labelRes: Int, + original: Int +) : FieldState(key, labelRes, original, original) { + override val errorMessageRes: Int? = null +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/MetadataEditorUiState.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/MetadataEditorUiState.kt new file mode 100644 index 0000000..b6d2225 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/MetadataEditorUiState.kt @@ -0,0 +1,52 @@ +package com.bobbyesp.metadator.tageditor.presentation.state + +import com.bobbyesp.metadator.R +import com.bobbyesp.metadator.tageditor.domain.AudioEditableMetadata + +data class MetadataEditorUiState( + val fields: List> = emptyList() +) { + + fun loadFrom(editable: AudioEditableMetadata): MetadataEditorUiState = + copy( + fields = listOf( + StringFieldState("TITLE", R.string.title, editable.title), + StringFieldState("ARTIST", R.string.artist, editable.artist), + StringFieldState("ALBUM", R.string.album, editable.album), + IntFieldState("TRACKNUMBER", R.string.track_number, editable.trackNumber), + IntFieldState("DISCNUMBER", R.string.disc_number, editable.discNumber), + StringFieldState("DATE", R.string.date, editable.date), + StringFieldState("GENRE", R.string.genre, editable.genre), + StringFieldState("COMMENT", R.string.comment, editable.comment), + StringFieldState("LYRICS", R.string.lyrics, editable.lyrics), + ) + ) + + fun toDomain(): AudioEditableMetadata { + val map = fields.associate { it.key to it.current.toString() } + return AudioEditableMetadata.fromMap(map) + } + + val modifiedKeys: Set + get() = fields.filter { it.isModified }.map { it.key }.toSet() + + /** Update a single field's current value based on its key */ + fun updateField(key: String, value: String): MetadataEditorUiState = + copy(fields = fields.map { field -> + if (field.key == key) { + when (field) { + is StringFieldState -> StringFieldState(field.key, field.labelRes, field.current).apply { current = value } + is IntFieldState -> IntFieldState(field.key, field.labelRes, field.original).apply { current = value.toIntOrNull() ?: original } + } + } else field + }) + + /** Reset the original values to the current ones, clearing modification state */ + fun clearModified(): MetadataEditorUiState = + copy(fields = fields.map { field -> + when (field) { + is StringFieldState -> StringFieldState(field.key, field.labelRes, field.current) + is IntFieldState -> IntFieldState(field.key, field.labelRes, field.current) + } + }) +} \ No newline at end of file diff --git a/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/StringFieldState.kt b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/StringFieldState.kt new file mode 100644 index 0000000..e4e06e6 --- /dev/null +++ b/app/src/main/java/com/bobbyesp/metadator/tageditor/presentation/state/StringFieldState.kt @@ -0,0 +1,9 @@ +package com.bobbyesp.metadator.tageditor.presentation.state + +class StringFieldState( + key: String, + labelRes: Int, + original: String +) : FieldState(key, labelRes, original, original) { + override val errorMessageRes: Int? = null +} \ No newline at end of file diff --git a/app/src/playstore/kotlin/FirebaseSetup.kt b/app/src/playstore/kotlin/FirebaseSetup.kt index cae1b43..5781f05 100644 --- a/app/src/playstore/kotlin/FirebaseSetup.kt +++ b/app/src/playstore/kotlin/FirebaseSetup.kt @@ -10,9 +10,9 @@ fun App.initializeFirebase() { /** * Extension function for MainActivity to enable Crashlytics collection. * - * This function sets the Crashlytics collection to be enabled, allowing Firebase Crashlytics - * to collect crash reports for the application. + * This function sets the Crashlytics collection to be enabled, allowing Firebase Crashlytics to + * collect crash reports for the application. */ fun setCrashlyticsCollection() { Firebase.crashlytics.setCrashlyticsCollectionEnabled(true) -} \ No newline at end of file +} diff --git a/app/src/test/java/com/bobbyesp/metadator/ExampleUnitTest.kt b/app/src/test/java/com/bobbyesp/metadator/ExampleUnitTest.kt index 2bb4768..cb6e609 100644 --- a/app/src/test/java/com/bobbyesp/metadator/ExampleUnitTest.kt +++ b/app/src/test/java/com/bobbyesp/metadator/ExampleUnitTest.kt @@ -13,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/app/ui/build.gradle.kts b/app/ui/build.gradle.kts index 8ce3205..22e47fe 100644 --- a/app/ui/build.gradle.kts +++ b/app/ui/build.gradle.kts @@ -2,18 +2,17 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.android.kotlin) alias(libs.plugins.compose.compiler) + alias(libs.plugins.ktfmt.gradle) } android { namespace = "com.bobbyesp.ui" - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 24 - vectorDrawables { - useSupportLibrary = true - } + vectorDrawables { useSupportLibrary = true } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") @@ -24,44 +23,39 @@ android { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) - packaging { - resources.excludes.add("META-INF/*.kotlin_module") - } + packaging { resources.excludes.add("META-INF/*.kotlin_module") } } } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } - kotlinOptions { - jvmTarget = "21" - } - buildFeatures { - compose = true - } - composeCompiler { - reportsDestination = layout.buildDirectory.dir("compose_compiler") - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } + buildFeatures { compose = true } + composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } } -dependencies { +ktfmt { + // Google style - 2 space indentation & automatically adds/removes trailing commas + // googleStyle() + + // KotlinLang style - 4 space indentation - From + // https://kotlinlang.org/docs/coding-conventions.html + kotlinLangStyle() +} +dependencies { implementation(libs.core.ktx) implementation(libs.bundles.compose) implementation(libs.compose.tooling.preview) implementation(libs.materialKolor) implementation(libs.scrollbar) - //Compose testing and tooling libraries + // Compose testing and tooling libraries androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.compose.test.junit4) debugImplementation(libs.compose.tooling) debugImplementation(libs.compose.test.manifest) -} \ No newline at end of file +} diff --git a/app/ui/src/androidTest/java/com/bobbyesp/ui/ExampleInstrumentedTest.kt b/app/ui/src/androidTest/java/com/bobbyesp/ui/ExampleInstrumentedTest.kt index 4e44cea..0729e04 100644 --- a/app/ui/src/androidTest/java/com/bobbyesp/ui/ExampleInstrumentedTest.kt +++ b/app/ui/src/androidTest/java/com/bobbyesp/ui/ExampleInstrumentedTest.kt @@ -19,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.bobbyesp.ui.test", appContext.packageName) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/common/pages/ErrorPage.kt b/app/ui/src/main/java/com/bobbyesp/ui/common/pages/ErrorPage.kt index 5253fdb..a589dfd 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/common/pages/ErrorPage.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/common/pages/ErrorPage.kt @@ -66,52 +66,46 @@ import com.bobbyesp.ui.motion.MotionConstants.DURATION_EXIT_SHORT @OptIn(ExperimentalSharedTransitionApi::class) @Composable -fun ErrorPage( - modifier: Modifier = Modifier, throwable: Throwable, onRetry: () -> Unit -) { +fun ErrorPage(modifier: Modifier = Modifier, throwable: Throwable, onRetry: () -> Unit) { var showFullscreenError by remember { mutableStateOf(false) } - SharedTransitionLayout( - modifier = modifier.background(MaterialTheme.colorScheme.background), - ) { + SharedTransitionLayout(modifier = modifier.background(MaterialTheme.colorScheme.background)) { AnimatedContent( transitionSpec = { fadeIn( tween( durationMillis = DURATION_ENTER, delayMillis = DURATION_EXIT_SHORT, - easing = EmphasizedDecelerateEasing - ) - ) togetherWith fadeOut( - tween( - durationMillis = DURATION_EXIT_SHORT, easing = EmphasizedAccelerateEasing + easing = EmphasizedDecelerateEasing, ) - ) using SizeTransform { _, _ -> - tween(durationMillis = DURATION, easing = EmphasizedEasing) - } - }, targetState = showFullscreenError, label = "Error Page animated content transition" + ) togetherWith + fadeOut( + tween( + durationMillis = DURATION_EXIT_SHORT, + easing = EmphasizedAccelerateEasing, + ) + ) using + SizeTransform { _, _ -> + tween(durationMillis = DURATION, easing = EmphasizedEasing) + } + }, + targetState = showFullscreenError, + label = "Error Page animated content transition", ) { wantsFullscreen -> - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if (wantsFullscreen) { ExpandedErrorPage( modifier = modifier, throwable = throwable, animatedVisibilityScope = this@AnimatedContent, - onMinimize = { - showFullscreenError = false - } + onMinimize = { showFullscreenError = false }, ) } else { MinimizedErrorPage( modifier = modifier, throwable = throwable, animatedVisibilityScope = this@AnimatedContent, - onCardClicked = { - showFullscreenError = true - }, - onRetry = onRetry + onCardClicked = { showFullscreenError = true }, + onRetry = onRetry, ) } } @@ -129,57 +123,58 @@ private fun SharedTransitionScope.MinimizedErrorPage( onRetry: () -> Unit, ) { Column( - modifier = modifier - .padding(8.dp), + modifier = modifier.padding(8.dp), verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( modifier = Modifier.size(48.dp), imageVector = Icons.Rounded.WarningAmber, contentDescription = stringResource(id = R.string.error), - tint = MaterialTheme.colorScheme.error + tint = MaterialTheme.colorScheme.error, ) Text( text = stringResource(id = R.string.unknown_error_title), - style = MaterialTheme.typography.titleLarge.copy( - color = MaterialTheme.colorScheme.onBackground - ), + style = + MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.onBackground + ), fontWeight = FontWeight.SemiBold, ) PrimaryStacktraceCard( - modifier = Modifier - .sharedBounds( - boundsTransform = DefaultBoundsTransform, - enter = fadeIn( - tween( - durationMillis = DURATION_ENTER, - delayMillis = DURATION_EXIT_SHORT, - easing = EmphasizedDecelerateEasing - ) - ), - exit = fadeOut( - tween( - durationMillis = DURATION_EXIT_SHORT, - easing = EmphasizedAccelerateEasing - ) - ), - sharedContentState = rememberSharedContentState(key = "stacktraceCardBounds"), - animatedVisibilityScope = animatedVisibilityScope, - placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize, - ) - .padding(horizontal = 12.dp, vertical = 8.dp) - .fillMaxWidth(), - errorType = throwable::class.simpleName - ?: stringResource(id = R.string.unknown_error_title), - methodFailed = throwable.localizedMessage - ?: stringResource(id = R.string.unknown_error_title), + modifier = + Modifier.sharedBounds( + boundsTransform = DefaultBoundsTransform, + enter = + fadeIn( + tween( + durationMillis = DURATION_ENTER, + delayMillis = DURATION_EXIT_SHORT, + easing = EmphasizedDecelerateEasing, + ) + ), + exit = + fadeOut( + tween( + durationMillis = DURATION_EXIT_SHORT, + easing = EmphasizedAccelerateEasing, + ) + ), + sharedContentState = + rememberSharedContentState(key = "stacktraceCardBounds"), + animatedVisibilityScope = animatedVisibilityScope, + placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize, + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + .fillMaxWidth(), + errorType = + throwable::class.simpleName ?: stringResource(id = R.string.unknown_error_title), + methodFailed = + throwable.localizedMessage ?: stringResource(id = R.string.unknown_error_title), line = throwable.stackTrace.firstOrNull()?.lineNumber ?: 0, - onClick = onCardClicked + onClick = onCardClicked, ) - Button(onClick = onRetry) { - Text(text = stringResource(id = R.string.retry)) - } + Button(onClick = onRetry) { Text(text = stringResource(id = R.string.retry)) } } } @@ -191,86 +186,72 @@ private fun SharedTransitionScope.ExpandedErrorPage( throwable: Throwable, onMinimize: () -> Unit, ) { - BackHandler { - onMinimize() - } + BackHandler { onMinimize() } Column( - modifier = modifier - .sharedBounds( - boundsTransform = DefaultBoundsTransform, - enter = fadeIn( - tween( - durationMillis = DURATION_ENTER, - delayMillis = DURATION_EXIT_SHORT, - easing = EmphasizedDecelerateEasing - ) - ), - exit = fadeOut( - tween( - durationMillis = DURATION_EXIT_SHORT, easing = EmphasizedAccelerateEasing - ) - ), - sharedContentState = rememberSharedContentState(key = "stacktraceCardBounds"), - animatedVisibilityScope = animatedVisibilityScope, - placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize, - ) - .fillMaxSize(), + modifier = + modifier + .sharedBounds( + boundsTransform = DefaultBoundsTransform, + enter = + fadeIn( + tween( + durationMillis = DURATION_ENTER, + delayMillis = DURATION_EXIT_SHORT, + easing = EmphasizedDecelerateEasing, + ) + ), + exit = + fadeOut( + tween( + durationMillis = DURATION_EXIT_SHORT, + easing = EmphasizedAccelerateEasing, + ) + ), + sharedContentState = rememberSharedContentState(key = "stacktraceCardBounds"), + animatedVisibilityScope = animatedVisibilityScope, + placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize, + ) + .fillMaxSize() ) { Row( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - .systemBarsPadding() - .fillMaxWidth() - .padding(4.dp), + modifier = + Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + .systemBarsPadding() + .fillMaxWidth() + .padding(4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { - BackButton( - onClick = { - onMinimize() - } - ) + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurface + ) { + BackButton(onClick = { onMinimize() }) Text( modifier = Modifier, text = stringResource(id = R.string.unknown_error_title), style = MaterialTheme.typography.titleMedium, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } } - StackTraceViewer( - modifier = Modifier - .fillMaxWidth(), - throwable = throwable - ) + StackTraceViewer(modifier = Modifier.fillMaxWidth(), throwable = throwable) } } @Composable -fun StackTraceViewer( - modifier: Modifier = Modifier, - throwable: Throwable -) { +fun StackTraceViewer(modifier: Modifier = Modifier, throwable: Throwable) { Column( - modifier = modifier - .verticalScroll(rememberScrollState()) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) + modifier = modifier.verticalScroll(rememberScrollState()).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { throwable.stackTrace.forEachIndexed { index, element -> - Row( - modifier = Modifier.fillMaxWidth() - ) { + Row(modifier = Modifier.fillMaxWidth()) { Text( text = "${index + 1}", style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .width(32.dp) - .padding(6.dp) - .alpha(0.72f) + modifier = Modifier.width(32.dp).padding(6.dp).alpha(0.72f), ) Spacer(modifier = Modifier.width(8.dp)) Text( @@ -278,7 +259,7 @@ fun StackTraceViewer( style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, - overflow = TextOverflow.Clip + overflow = TextOverflow.Clip, ) } HorizontalDivider() @@ -286,64 +267,60 @@ fun StackTraceViewer( } } - @Composable private fun PrimaryStacktraceCard( modifier: Modifier = Modifier, errorType: String, methodFailed: String, line: Int, - onClick: () -> Unit = {} + onClick: () -> Unit = {}, ) { Surface( modifier = modifier, onClick = onClick, shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surface, - tonalElevation = 8.dp + tonalElevation = 8.dp, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + modifier = Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.TwoTone.BugReport, contentDescription = stringResource(id = R.string.error), - modifier = Modifier - .size(48.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.primaryContainer) - .padding(4.dp), - tint = MaterialTheme.colorScheme.primary + modifier = + Modifier.size(48.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(4.dp), + tint = MaterialTheme.colorScheme.primary, ) - Column( - modifier = Modifier.fillMaxWidth(), - ) { + Column(modifier = Modifier.fillMaxWidth()) { Text( modifier = Modifier, text = errorType.uppercase(), - style = MaterialTheme.typography.bodyLarge.copy( - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f), - letterSpacing = 1.sp - ), + style = + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f), + letterSpacing = 1.sp, + ), fontWeight = FontWeight.SemiBold, ) Text( text = methodFailed, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, - overflow = TextOverflow.Ellipsis - + overflow = TextOverflow.Ellipsis, ) + Text( text = stringResource(R.string.line, line), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Normal, fontFamily = FontFamily.Monospace, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } } @@ -357,7 +334,8 @@ private fun ErrorPagePrev() { ErrorPage( modifier = Modifier.background(MaterialTheme.colorScheme.background), throwable = Exception("An error occurred"), - onRetry = {}) + onRetry = {}, + ) } } @@ -368,7 +346,8 @@ private fun ErrorPagePrevWhite() { ErrorPage( modifier = Modifier.background(MaterialTheme.colorScheme.background), throwable = Exception("An error occurred"), - onRetry = {}) + onRetry = {}, + ) } } @@ -376,9 +355,7 @@ private fun ErrorPagePrevWhite() { @Composable private fun PrimaryStacktraceCardPrev() { MaterialTheme { - PrimaryStacktraceCard( - errorType = "Error", methodFailed = "Method failed", line = 1 - ) + PrimaryStacktraceCard(errorType = "Error", methodFailed = "Method failed", line = 1) } } @@ -386,8 +363,6 @@ private fun PrimaryStacktraceCardPrev() { @Composable private fun PrimaryStacktraceCardPrevDark() { MaterialTheme(colorScheme = darkColorScheme()) { - PrimaryStacktraceCard( - errorType = "Error", methodFailed = "Method failed", line = 1 - ) + PrimaryStacktraceCard(errorType = "Error", methodFailed = "Method failed", line = 1) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/common/pages/IdlePage.kt b/app/ui/src/main/java/com/bobbyesp/ui/common/pages/IdlePage.kt index aa4184f..85e6746 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/common/pages/IdlePage.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/common/pages/IdlePage.kt @@ -9,10 +9,7 @@ import androidx.compose.ui.Modifier @Composable fun IdlePage() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(text = "Idle Page") } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/common/pages/LoadingPage.kt b/app/ui/src/main/java/com/bobbyesp/ui/common/pages/LoadingPage.kt index 110b819..d62e362 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/common/pages/LoadingPage.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/common/pages/LoadingPage.kt @@ -16,29 +16,22 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @Composable -fun LoadingPage( - modifier: Modifier = Modifier, - text: String -) { +fun LoadingPage(modifier: Modifier = Modifier, text: String) { Box( - modifier = modifier - .fillMaxSize() - .safeDrawingPadding(), - contentAlignment = Alignment.Center + modifier = modifier.fillMaxSize().safeDrawingPadding(), + contentAlignment = Alignment.Center, ) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = text, style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold - ) - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(0.7f) + fontWeight = FontWeight.SemiBold, ) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth(0.7f)) } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/animatable/rememberAnimatable.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/animatable/rememberAnimatable.kt index eef89c3..ab04c2e 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/animatable/rememberAnimatable.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/animatable/rememberAnimatable.kt @@ -10,11 +10,9 @@ import com.bobbyesp.ui.util.AnimatableSaver @Composable fun rememberAnimatable( initialValue: Float, - visibilityThreshold: Float = Spring.DefaultDisplacementThreshold + visibilityThreshold: Float = Spring.DefaultDisplacementThreshold, ): Animatable { - return rememberSaveable( - saver = AnimatableSaver - ) { + return rememberSaveable(saver = AnimatableSaver) { Animatable(initialValue, visibilityThreshold) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheet.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheet.kt index 68183a4..21f2de3 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheet.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheet.kt @@ -38,10 +38,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch -/** - * Bottom Sheet - * Improved version from [ViMusic](https://github.com/vfsfitvnm/ViMusic) - */ +/** Bottom Sheet Improved version from [ViMusic](https://github.com/vfsfitvnm/ViMusic) */ @Composable fun DraggableBottomSheet( state: DraggableBottomSheetState, @@ -52,40 +49,39 @@ fun DraggableBottomSheet( content: @Composable BoxScope.() -> Unit, ) { Box( - modifier = modifier - .fillMaxSize() - .offset { - val y = (state.expandedBound - state.value) - .roundToPx() - .coerceAtLeast(0) - IntOffset(x = 0, y = y) - } - .pointerInput(state) { - val velocityTracker = VelocityTracker() + modifier = + modifier + .fillMaxSize() + .offset { + val y = (state.expandedBound - state.value).roundToPx().coerceAtLeast(0) + IntOffset(x = 0, y = y) + } + .pointerInput(state) { + val velocityTracker = VelocityTracker() - detectVerticalDragGestures( - onVerticalDrag = { change, dragAmount -> - velocityTracker.addPointerInputChange(change) - state.dispatchRawDelta(dragAmount) - }, - onDragCancel = { - velocityTracker.resetTracking() - state.snapTo(state.collapsedBound) - }, - onDragEnd = { - val velocity = -velocityTracker.calculateVelocity().y - velocityTracker.resetTracking() - state.performFling(velocity, onDismiss) - } - ) - } - .clip( - RoundedCornerShape( - topStart = if (!state.isExpanded) 8.dp else 0.dp, - topEnd = if (!state.isExpanded) 8.dp else 0.dp + detectVerticalDragGestures( + onVerticalDrag = { change, dragAmount -> + velocityTracker.addPointerInputChange(change) + state.dispatchRawDelta(dragAmount) + }, + onDragCancel = { + velocityTracker.resetTracking() + state.snapTo(state.collapsedBound) + }, + onDragEnd = { + val velocity = -velocityTracker.calculateVelocity().y + velocityTracker.resetTracking() + state.performFling(velocity, onDismiss) + }, + ) + } + .clip( + RoundedCornerShape( + topStart = if (!state.isExpanded) 8.dp else 0.dp, + topEnd = if (!state.isExpanded) 8.dp else 0.dp, + ) ) - ) - .background(backgroundColor) + .background(backgroundColor) ) { if (!state.isCollapsed && !state.isDismissed) { BackHandler(onBack = state::collapseSoft) @@ -93,29 +89,26 @@ fun DraggableBottomSheet( if (!state.isCollapsed) { BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { + modifier = + Modifier.fillMaxSize().graphicsLayer { alpha = ((state.progress - 0.25f) * 4).coerceIn(0f, 1f) }, - content = content + content = content, ) } if (!state.isExpanded && (onDismiss == null || !state.isDismissed)) { Box( - modifier = Modifier - .graphicsLayer { - alpha = 1f - (state.progress * 4).coerceAtMost(1f) - } - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = state::expandSoft - ) - .fillMaxWidth() - .height(state.collapsedBound), - content = collapsedContent + modifier = + Modifier.graphicsLayer { alpha = 1f - (state.progress * 4).coerceAtMost(1f) } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = state::expandSoft, + ) + .fillMaxWidth() + .height(state.collapsedBound), + content = collapsedContent, ) } } @@ -127,40 +120,37 @@ fun rememberDraggableBottomSheetState( expandedBound: Dp, collapsedBound: Dp = dismissedBound, initialAnchor: DraggableBottomSheetAnchor = DraggableBottomSheetAnchor.DISMISSED, - animationSpec: AnimationSpec + animationSpec: AnimationSpec, ): DraggableBottomSheetState { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() - var previousAnchor by rememberSaveable(key = "previousAnchorDraggableBs") { - mutableStateOf(initialAnchor) - } - val animatable = remember { - Animatable(0.dp, Dp.VectorConverter) - } + var previousAnchor by + rememberSaveable(key = "previousAnchorDraggableBs") { mutableStateOf(initialAnchor) } + val animatable = remember { Animatable(0.dp, Dp.VectorConverter) } return remember(dismissedBound, expandedBound, collapsedBound, coroutineScope) { - val initialValue = when (previousAnchor) { - DraggableBottomSheetAnchor.EXPANDED -> expandedBound - DraggableBottomSheetAnchor.COLLAPSED -> collapsedBound - DraggableBottomSheetAnchor.DISMISSED -> dismissedBound - } + val initialValue = + when (previousAnchor) { + DraggableBottomSheetAnchor.EXPANDED -> expandedBound + DraggableBottomSheetAnchor.COLLAPSED -> collapsedBound + DraggableBottomSheetAnchor.DISMISSED -> dismissedBound + } animatable.updateBounds(dismissedBound.coerceAtMost(expandedBound), expandedBound) - coroutineScope.launch { - animatable.animateTo(initialValue, animationSpec) - } + coroutineScope.launch { animatable.animateTo(initialValue, animationSpec) } DraggableBottomSheetState( - draggableState = DraggableState { delta -> - coroutineScope.launch { - animatable.snapTo(animatable.value - with(density) { delta.toDp() }) - } - }, + draggableState = + DraggableState { delta -> + coroutineScope.launch { + animatable.snapTo(animatable.value - with(density) { delta.toDp() }) + } + }, onAnchorChanged = { previousAnchor = it }, coroutineScope = coroutineScope, animatable = animatable, - collapsedBound = collapsedBound + collapsedBound = collapsedBound, ) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheetAnchor.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheetAnchor.kt index f0599fa..5eac3fa 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheetAnchor.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheetAnchor.kt @@ -3,5 +3,5 @@ package com.bobbyesp.ui.components.bottomsheet.draggable enum class DraggableBottomSheetAnchor { DISMISSED, COLLAPSED, - EXPANDED -} \ No newline at end of file + EXPANDED, +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheetState.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheetState.kt index 261f660..60f7349 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheetState.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/bottomsheet/draggable/DraggableBottomSheetState.kt @@ -34,34 +34,26 @@ class DraggableBottomSheetState( val value by animatable.asState() - val isDismissed by derivedStateOf { - value == animatable.lowerBound!! - } + val isDismissed by derivedStateOf { value == animatable.lowerBound!! } - val isCollapsed by derivedStateOf { - value == collapsedBound - } + val isCollapsed by derivedStateOf { value == collapsedBound } - val isExpanded by derivedStateOf { - value == animatable.upperBound - } + val isExpanded by derivedStateOf { value == animatable.upperBound } val progress by derivedStateOf { - 1f - (animatable.upperBound!! - animatable.value) / (animatable.upperBound!! - collapsedBound) + 1f - + (animatable.upperBound!! - animatable.value) / + (animatable.upperBound!! - collapsedBound) } fun collapse(animationSpec: AnimationSpec) { onAnchorChanged(DraggableBottomSheetAnchor.COLLAPSED) - coroutineScope.launch { - animatable.animateTo(collapsedBound, animationSpec) - } + coroutineScope.launch { animatable.animateTo(collapsedBound, animationSpec) } } fun expand(animationSpec: AnimationSpec) { onAnchorChanged(DraggableBottomSheetAnchor.EXPANDED) - coroutineScope.launch { - animatable.animateTo(animatable.upperBound!!, animationSpec) - } + coroutineScope.launch { animatable.animateTo(animatable.upperBound!!, animationSpec) } } private fun collapse() { @@ -82,15 +74,11 @@ class DraggableBottomSheetState( fun dismiss() { onAnchorChanged(DraggableBottomSheetAnchor.DISMISSED) - coroutineScope.launch { - animatable.animateTo(animatable.lowerBound!!) - } + coroutineScope.launch { animatable.animateTo(animatable.lowerBound!!) } } fun snapTo(value: Dp) { - coroutineScope.launch { - animatable.snapTo(value) - } + coroutineScope.launch { animatable.snapTo(value) } } fun performFling(velocity: Float, onDismiss: (() -> Unit)?) { @@ -127,53 +115,61 @@ class DraggableBottomSheetState( } val preUpPostDownNestedScrollConnection - get() = object : NestedScrollConnection { - var isTopReached = false + get() = + object : NestedScrollConnection { + var isTopReached = false - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (isExpanded && available.y < 0) { - isTopReached = false - } + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (isExpanded && available.y < 0) { + isTopReached = false + } - return if (isTopReached && available.y < 0 && source == NestedScrollSource.Companion.UserInput) { - dispatchRawDelta(available.y) - available - } else { - Offset.Companion.Zero + return if ( + isTopReached && + available.y < 0 && + source == NestedScrollSource.Companion.UserInput + ) { + dispatchRawDelta(available.y) + available + } else { + Offset.Companion.Zero + } } - } - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource, - ): Offset { - if (!isTopReached) { - isTopReached = consumed.y == 0f && available.y > 0 - } + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (!isTopReached) { + isTopReached = consumed.y == 0f && available.y > 0 + } - return if (isTopReached && source == NestedScrollSource.Companion.UserInput) { - dispatchRawDelta(available.y) - available - } else { - Offset.Companion.Zero + return if (isTopReached && source == NestedScrollSource.Companion.UserInput) { + dispatchRawDelta(available.y) + available + } else { + Offset.Companion.Zero + } } - } - override suspend fun onPreFling(available: Velocity): Velocity { - return if (isTopReached) { - val velocity = -available.y - performFling(velocity, null) + override suspend fun onPreFling(available: Velocity): Velocity { + return if (isTopReached) { + val velocity = -available.y + performFling(velocity, null) - available - } else { - Velocity.Companion.Zero + available + } else { + Velocity.Companion.Zero + } } - } - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - isTopReached = false - return Velocity.Companion.Zero + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity, + ): Velocity { + isTopReached = false + return Velocity.Companion.Zero + } } - } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/button/BackButton.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/button/BackButton.kt index ac2d788..d297246 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/button/BackButton.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/button/BackButton.kt @@ -24,10 +24,7 @@ fun BackButton(onClick: () -> Unit) { @Composable fun CloseButton(onClick: () -> Unit) { IconButton(modifier = Modifier, onClick = onClick) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.back), - ) + Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.back)) } } @@ -37,7 +34,7 @@ fun DynamicButton( icon: @Composable () -> Unit, icon2: @Composable () -> Unit, isIcon1: Boolean, - onClick: () -> Unit + onClick: () -> Unit, ) { FilledTonalIconButton(modifier = modifier, onClick = onClick) { if (isIcon1) { @@ -46,4 +43,4 @@ fun DynamicButton( icon2() } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/button/FilledButtons.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/button/FilledButtons.kt index 35adec1..710c7ba 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/button/FilledButtons.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/button/FilledButtons.kt @@ -19,23 +19,20 @@ fun FilledButtonWithIcon( icon: ImageVector, enabled: Boolean = true, text: String, - contentDescription: String? = null + contentDescription: String? = null, ) { Button( modifier = modifier, onClick = onClick, enabled = enabled, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, ) { Icon( modifier = Modifier.size(18.dp), imageVector = icon, - contentDescription = contentDescription - ) - Text( - modifier = Modifier.padding(start = 6.dp), - text = text + contentDescription = contentDescription, ) + Text(modifier = Modifier.padding(start = 6.dp), text = text) } } @@ -45,21 +42,18 @@ fun FilledTonalButtonWithIcon( onClick: () -> Unit, icon: ImageVector, text: String, - contentDescription: String? = null + contentDescription: String? = null, ) { FilledTonalButton( modifier = modifier, onClick = onClick, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, ) { Icon( modifier = Modifier.size(18.dp), imageVector = icon, - contentDescription = contentDescription - ) - Text( - modifier = Modifier.padding(start = 8.dp), - text = text + contentDescription = contentDescription, ) + Text(modifier = Modifier.padding(start = 8.dp), text = text) } } diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/button/OutlinedButtons.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/button/OutlinedButtons.kt index 67a9c0f..2f99b56 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/button/OutlinedButtons.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/button/OutlinedButtons.kt @@ -20,22 +20,19 @@ fun OutlinedButtonWithIcon( icon: ImageVector, text: String, contentColor: Color = MaterialTheme.colorScheme.primary, - contentDescription: String? = null + contentDescription: String? = null, ) { OutlinedButton( modifier = modifier, onClick = onClick, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - colors = ButtonDefaults.outlinedButtonColors(contentColor = contentColor) + colors = ButtonDefaults.outlinedButtonColors(contentColor = contentColor), ) { Icon( modifier = Modifier.size(18.dp), imageVector = icon, - contentDescription = contentDescription - ) - Text( - modifier = Modifier.padding(start = 8.dp), - text = text + contentDescription = contentDescription, ) + Text(modifier = Modifier.padding(start = 8.dp), text = text) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/button/SquaredButton.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/button/SquaredButton.kt index b5616ce..5a3986b 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/button/SquaredButton.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/button/SquaredButton.kt @@ -37,44 +37,38 @@ fun VerticalButtonWithIconAndText( border: Boolean = false, backgroundColor: Color = MaterialTheme.colorScheme.secondaryContainer, shape: CornerBasedShape = MaterialTheme.shapes.small, - onClick: () -> Unit = {} + onClick: () -> Unit = {}, ) { - val animatedAlpha by animateFloatAsState( - targetValue = if (enabled) 1f else 0.4f - ) + val animatedAlpha by animateFloatAsState(targetValue = if (enabled) 1f else 0.4f) Surface( - modifier = modifier - .semantics { role = Role.Button } - .alpha(animatedAlpha), + modifier = modifier.semantics { role = Role.Button }.alpha(animatedAlpha), onClick = onClick, enabled = enabled, shape = shape, border = if (border) BorderStroke(1.dp, MaterialTheme.colorScheme.outline) else null, - color = backgroundColor + color = backgroundColor, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), - modifier = Modifier - .padding(8.dp) - .defaultMinSize( - minWidth = ButtonDefaults.MinWidth, - minHeight = ButtonDefaults.MinHeight - ) - .fillMaxWidth() + modifier = + Modifier.padding(8.dp) + .defaultMinSize( + minWidth = ButtonDefaults.MinWidth, + minHeight = ButtonDefaults.MinHeight, + ) + .fillMaxWidth(), ) { Icon( imageVector = icon, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface + tint = MaterialTheme.colorScheme.onSurface, ) Text( text = text, - style = MaterialTheme.typography.bodySmall.copy( - fontWeight = FontWeight.Medium, - ), + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), color = MaterialTheme.colorScheme.onSurface, overflow = TextOverflow.Ellipsis, ) @@ -91,42 +85,35 @@ fun HorizontalButtonWithIconAndText( border: Boolean = false, backgroundColor: Color = MaterialTheme.colorScheme.secondaryContainer, shape: CornerBasedShape = MaterialTheme.shapes.small, - onClick: () -> Unit = {} + onClick: () -> Unit = {}, ) { - val animatedAlpha by animateFloatAsState( - targetValue = if (enabled) 1f else 0.4f - ) + val animatedAlpha by animateFloatAsState(targetValue = if (enabled) 1f else 0.4f) Surface( - modifier = modifier - .semantics { role = Role.Button } - .alpha(animatedAlpha), + modifier = modifier.semantics { role = Role.Button }.alpha(animatedAlpha), onClick = onClick, enabled = enabled, shape = shape, border = if (border) BorderStroke(1.dp, MaterialTheme.colorScheme.outline) else null, - color = backgroundColor + color = backgroundColor, ) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(8.dp) + modifier = Modifier.padding(8.dp), ) { Icon( imageVector = icon, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface + tint = MaterialTheme.colorScheme.onSurface, ) Text( text = text, - style = MaterialTheme.typography.bodySmall.copy( - fontWeight = FontWeight.Medium, - ), + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), color = MaterialTheme.colorScheme.onSurface, overflow = TextOverflow.Ellipsis, ) } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/button/navigation/HorizontalFloatingAppBarItem.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/button/navigation/HorizontalFloatingAppBarItem.kt index 335494e..4813732 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/button/navigation/HorizontalFloatingAppBarItem.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/button/navigation/HorizontalFloatingAppBarItem.kt @@ -43,20 +43,20 @@ fun HorizontalFloatingAppBarItem( badge: (@Composable () -> Unit)? = null, shape: Shape = CircleShape, colors: NavigationDrawerItemColors = NavigationDrawerItemDefaults.colors(), - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - val animatedSurfaceColor by animateColorAsState( - targetValue = colors.containerColor(selected).value, - label = "Animated Surface Color" - ) + val animatedSurfaceColor by + animateColorAsState( + targetValue = colors.containerColor(selected).value, + label = "Animated Surface Color", + ) Surface( onClick = onClick, - modifier = modifier - .semantics { role = Role.Companion.Tab }, + modifier = modifier.semantics { role = Role.Companion.Tab }, shape = shape, color = animatedSurfaceColor, - interactionSource = interactionSource + interactionSource = interactionSource, ) { Row( modifier = Modifier.Companion.padding(8.dp), @@ -64,30 +64,21 @@ fun HorizontalFloatingAppBarItem( ) { icon?.let { Box(contentAlignment = Alignment.Companion.Center) { - ProvideColor(colors.iconColor(selected).value) { - icon() - } + ProvideColor(colors.iconColor(selected).value) { icon() } if (badge != null) { Box( - modifier = Modifier.Companion - .align(Alignment.Companion.BottomEnd) - .padding(bottom = 4.dp, end = 4.dp) + modifier = + Modifier.Companion.align(Alignment.Companion.BottomEnd) + .padding(bottom = 4.dp, end = 4.dp) ) { - ProvideColor(colors.badgeColor(selected).value) { - badge() - } + ProvideColor(colors.badgeColor(selected).value) { badge() } } } } } - AnimatedVisibility( - visible = expanded && !selected, - modifier = Modifier.Companion - ) { - ProvideColor(colors.textColor(selected).value) { - label() - } + AnimatedVisibility(visible = expanded && !selected, modifier = Modifier.Companion) { + ProvideColor(colors.textColor(selected).value) { label() } } } } @@ -103,17 +94,18 @@ private fun ProvideColor(color: Color, content: @Composable () -> Unit) { private fun HorizontalFloatingAppBarItemPreview() { var expanded by remember { mutableStateOf(true) } var selected by remember { mutableStateOf(true) } - HorizontalFloatingAppBarItem(label = { - Text( - modifier = Modifier.Companion.padding(horizontal = 12.dp), - text = "Home", - fontWeight = FontWeight.Companion.Bold - ) - }, selected = selected, expanded = expanded, onClick = { - selected = !selected - }, icon = { - Icon( - imageVector = Icons.Rounded.Home, contentDescription = "Home" - ) - }, badge = null) -} \ No newline at end of file + HorizontalFloatingAppBarItem( + label = { + Text( + modifier = Modifier.Companion.padding(horizontal = 12.dp), + text = "Home", + fontWeight = FontWeight.Companion.Bold, + ) + }, + selected = selected, + expanded = expanded, + onClick = { selected = !selected }, + icon = { Icon(imageVector = Icons.Rounded.Home, contentDescription = "Home") }, + badge = null, + ) +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/card/ExpandableElevatedCard.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/card/ExpandableElevatedCard.kt index a46b6be..518e674 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/card/ExpandableElevatedCard.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/card/ExpandableElevatedCard.kt @@ -46,53 +46,43 @@ fun ExpandableElevatedCard( ElevatedCard( modifier = modifier, onClick = { expanded = !expanded }, - shape = MaterialTheme.shapes.small + shape = MaterialTheme.shapes.small, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.weight(0.1f), imageVector = icon, - contentDescription = "Device information" + contentDescription = "Device information", ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(6.dp) - .weight(1f), - ) { + Column(modifier = Modifier.fillMaxWidth().padding(6.dp).weight(1f)) { Text( text = title, style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Text( text = subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.62f), - fontWeight = FontWeight.Normal + fontWeight = FontWeight.Normal, ) } FilledTonalIconButton( - modifier = Modifier - .padding() - .size(24.dp), - onClick = { expanded = !expanded }) { + modifier = Modifier.padding().size(24.dp), + onClick = { expanded = !expanded }, + ) { Icon( Icons.Outlined.ExpandLess, null, tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.rotate(animatedDegree.value) + modifier = Modifier.rotate(animatedDegree.value), ) } } - AnimatedVisibility(visible = expanded) { - content() - } + AnimatedVisibility(visible = expanded) { content() } } } @@ -103,9 +93,7 @@ private fun ExpandableElevatedCardPreview() { ExpandableElevatedCard( title = "Title", subtitle = "Subtitle", - content = { - Text(text = "Content") - }, - icon = Icons.Outlined.PermDeviceInformation + content = { Text(text = "Content") }, + icon = Icons.Outlined.PermDeviceInformation, ) -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/card/OnboardingCard.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/card/OnboardingCard.kt index 5215779..da03d7d 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/card/OnboardingCard.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/card/OnboardingCard.kt @@ -19,28 +19,24 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @Composable -fun OnboardingCard( - icon: @Composable () -> Unit, - title: String, - text: String -) { +fun OnboardingCard(icon: @Composable () -> Unit, title: String, text: String) { Column( - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(16.dp) + modifier = + Modifier.fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp) ) { Surface( tonalElevation = 16.dp, shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f) - .compositeOver(MaterialTheme.colorScheme.surfaceVariant), + color = + MaterialTheme.colorScheme.primary + .copy(alpha = 0.35f) + .compositeOver(MaterialTheme.colorScheme.surfaceVariant), contentColor = MaterialTheme.colorScheme.primary, ) { - Box(Modifier.padding(12.dp)) { - icon() - } + Box(Modifier.padding(12.dp)) { icon() } } Spacer(modifier = Modifier.height(8.dp)) @@ -49,7 +45,7 @@ fun OnboardingCard( text = title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(2.dp)) @@ -57,7 +53,7 @@ fun OnboardingCard( Text( text = text, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -68,18 +64,16 @@ private fun OnboardingCardPreview() { OnboardingCard( icon = { Surface( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), + modifier = Modifier.fillMaxWidth().height(48.dp), shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f) - .compositeOver(MaterialTheme.colorScheme.surfaceVariant), + color = + MaterialTheme.colorScheme.primary + .copy(alpha = 0.35f) + .compositeOver(MaterialTheme.colorScheme.surfaceVariant), contentColor = MaterialTheme.colorScheme.primary, - ) { - - } + ) {} }, title = "Title", - text = "Text" + text = "Text", ) -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/card/UtilityCard.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/card/UtilityCard.kt index 37998b3..afa41b1 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/card/UtilityCard.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/card/UtilityCard.kt @@ -34,46 +34,47 @@ fun AppUtilityCard( cardSize: Dp = 200.dp, utilityName: String, icon: ImageVector, - onClick: () -> Unit + onClick: () -> Unit, ) { val xOffset = cardSize / 2.3f val yOffset = cardSize / 5 OutlinedCard( - modifier = modifier - .aspectRatio(1.0f) - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)), + modifier = + modifier + .aspectRatio(1.0f) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)), onClick = onClick, shape = MaterialTheme.shapes.small, ) { - Box( - modifier = Modifier - ) { + Box(modifier = Modifier) { Icon( imageVector = icon, contentDescription = "Utility Icon", - modifier = Modifier - .fillMaxSize(0.8f) - .offset(xOffset, yOffset), - tint = MaterialTheme.colorScheme.onSurface + modifier = Modifier.fillMaxSize(0.8f).offset(xOffset, yOffset), + tint = MaterialTheme.colorScheme.onSurface, ) Box( - modifier = Modifier - .fillMaxSize() - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - MaterialTheme.colorScheme.surface, - ), startY = -200f - ) - ), contentAlignment = Alignment.BottomEnd + modifier = + Modifier.fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + MaterialTheme.colorScheme.surface, + ), + startY = -200f, + ) + ), + contentAlignment = Alignment.BottomEnd, ) { MarqueeText( modifier = Modifier.padding(12.dp), text = utilityName, fontWeight = FontWeight.Bold, - fontSize = MaterialTheme.typography.headlineSmall.fontSize + fontSize = MaterialTheme.typography.headlineSmall.fontSize, ) } } @@ -88,6 +89,6 @@ fun AppUtilityCardPreview() { modifier = Modifier.size(200.dp), onClick = {}, icon = Icons.AutoMirrored.Outlined.AirplaneTicket, - utilityName = "Flights" + utilityName = "Flights", ) } diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/card/WarningCard.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/card/WarningCard.kt index 657b6c1..e007528 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/card/WarningCard.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/card/WarningCard.kt @@ -21,45 +21,34 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @Composable -fun WarningCard( - modifier: Modifier = Modifier, - title: String, - warningText: String, -) { +fun WarningCard(modifier: Modifier = Modifier, title: String, warningText: String) { OutlinedCard( modifier = modifier, shape = MaterialTheme.shapes.small, - colors = CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ) + colors = + CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Icon( modifier = Modifier.weight(0.175f), imageVector = Icons.Default.WarningAmber, tint = MaterialTheme.colorScheme.onPrimaryContainer, - contentDescription = "Warning icon" + contentDescription = "Warning icon", ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(6.dp) - .weight(1f), - ) { + Column(modifier = Modifier.fillMaxWidth().padding(6.dp).weight(1f)) { Text( text = title, style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Text( text = warningText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.62f), fontWeight = FontWeight.Normal, - textAlign = TextAlign.Justify + textAlign = TextAlign.Justify, ) } } @@ -70,7 +59,5 @@ fun WarningCard( @Preview @Composable fun WarningCardPreview() { - WarningCard( - title = "Warning", warningText = "This is a warning" - ) -} \ No newline at end of file + WarningCard(title = "Warning", warningText = "This is a warning") +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/chip/ButtonChips.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/chip/ButtonChips.kt index de5ef34..6dead96 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/chip/ButtonChips.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/chip/ButtonChips.kt @@ -39,14 +39,16 @@ fun ButtonChip( colors = AssistChipDefaults.elevatedAssistChipColors(leadingIconContentColor = iconColor), enabled = enabled, leadingIcon = { - if (icon != null) Icon( - imageVector = icon, null, modifier = Modifier.size(AssistChipDefaults.IconSize) - ) - } + if (icon != null) + Icon( + imageVector = icon, + null, + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, ) } - @Composable fun FlatButtonChip( modifier: Modifier = Modifier, @@ -54,24 +56,27 @@ fun FlatButtonChip( label: String, iconColor: Color = MaterialTheme.colorScheme.primary, labelColor: Color = MaterialTheme.colorScheme.onSurface, - onClick: () -> Unit + onClick: () -> Unit, ) { AssistChip( modifier = modifier.padding(horizontal = 4.dp), - colors = AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f), - labelColor = labelColor, - leadingIconContentColor = iconColor - ), + colors = + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f), + labelColor = labelColor, + leadingIconContentColor = iconColor, + ), border = null, onClick = onClick, leadingIcon = { Icon( imageVector = icon, - contentDescription = null, Modifier.size(AssistChipDefaults.IconSize) + contentDescription = null, + Modifier.size(AssistChipDefaults.IconSize), ) }, - label = { Text(text = label) }) + label = { Text(text = label) }, + ) } @Composable @@ -81,18 +86,21 @@ fun OutlinedButtonChip( label: String, iconModifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.primary, - onClick: () -> Unit + onClick: () -> Unit, ) { AssistChip( - modifier = modifier, onClick = onClick, leadingIcon = { + modifier = modifier, + onClick = onClick, + leadingIcon = { Icon( imageVector = icon, contentDescription = null, modifier = iconModifier.size(AssistChipDefaults.IconSize), - tint = tint + tint = tint, ) - }, label = { Text(text = label) }) - + }, + label = { Text(text = label) }, + ) } @Composable @@ -102,28 +110,30 @@ fun OutlinedButtonChip( label: String, iconModifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.primary, - onClick: () -> Unit + onClick: () -> Unit, ) { AssistChip( - modifier = modifier, onClick = onClick, + modifier = modifier, + onClick = onClick, leadingIcon = { if (icon != null) { Icon( painter = painterResource(id = icon.hashCode()), contentDescription = null, modifier = iconModifier.size(AssistChipDefaults.IconSize), - tint = tint + tint = tint, ) } else { Icon( imageVector = Icons.Default.Public, contentDescription = null, modifier = iconModifier.size(AssistChipDefaults.IconSize), - tint = tint + tint = tint, ) } - }, label = { Text(text = label) }) - + }, + label = { Text(text = label) }, + ) } @Composable @@ -134,32 +144,33 @@ fun OutlinedButtonChipWithIndex( iconModifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.primary, index: Int?, - onClick: () -> Unit + onClick: () -> Unit, ) { AssistChip( - modifier = modifier, onClick = onClick, + modifier = modifier, + onClick = onClick, leadingIcon = { - Box( - modifier = Modifier, - ) { + Box(modifier = Modifier) { Icon( imageVector = icon, contentDescription = null, - modifier = iconModifier - .size(AssistChipDefaults.IconSize) - .align(Alignment.Center), - tint = tint + modifier = + iconModifier.size(AssistChipDefaults.IconSize).align(Alignment.Center), + tint = tint, ) if (index != null) { Text( text = (index + 1).toString(), - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)) - .align(Alignment.BottomEnd) - .offset(x = 2.dp, y = 3.dp) + modifier = + Modifier.background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + ) + .align(Alignment.BottomEnd) + .offset(x = 2.dp, y = 3.dp), ) } } - }, label = { Text(text = label) } + }, + label = { Text(text = label) }, ) -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/chip/SingleChoiceChip.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/chip/SingleChoiceChip.kt index af73220..55cbdea 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/chip/SingleChoiceChip.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/chip/SingleChoiceChip.kt @@ -21,25 +21,23 @@ fun SingleChoiceChip( selected: Boolean, onClick: () -> Unit, label: String, - leadingIcon: ImageVector = Icons.Outlined.Check + leadingIcon: ImageVector = Icons.Outlined.Check, ) { FilterChip( modifier = modifier.padding(horizontal = 4.dp), selected = selected, onClick = onClick, - label = { - Text(text = label) - }, + label = { Text(text = label) }, leadingIcon = { Row { AnimatedVisibility(visible = selected, modifier = Modifier) { Icon( imageVector = leadingIcon, contentDescription = null, - modifier = Modifier.size(FilterChipDefaults.IconSize) + modifier = Modifier.size(FilterChipDefaults.IconSize), ) } } }, ) -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/AnimatedDropdownMenu.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/AnimatedDropdownMenu.kt index b41e785..121d46e 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/AnimatedDropdownMenu.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/AnimatedDropdownMenu.kt @@ -16,12 +16,14 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties /** - * Material Design dropdown menu. + * Material Design dropdown menu. * * Menus display a list of choices on a temporary surface. They appear when users interact with a * button, action, or other control. * - * ![Dropdown menu image](https://developer.android.com/images/reference/androidx/compose/material3/menu.png) + * ![Dropdown menu + * image](https://developer.android.com/images/reference/androidx/compose/material3/menu.png) * * A [DropdownMenu] behaves similarly to a [Popup], and will use the position of the parent layout * to position itself on screen. Commonly a [DropdownMenu] will be placed in a [Box] with a sibling @@ -29,12 +31,12 @@ import androidx.compose.ui.window.PopupProperties * space in a layout, as the menu is displayed in a separate window, on top of other content. * * The [content] of a [DropdownMenu] will typically be [DropdownMenuItem]s, as well as custom - * content. Using [DropdownMenuItem]s will result in a menu that matches the Material - * specification for menus. Also note that the [content] is placed inside a scrollable [Column], - * so using a [LazyColumn] as the root layout inside [content] is unsupported. + * content. Using [DropdownMenuItem]s will result in a menu that matches the Material specification + * for menus. Also note that the [content] is placed inside a scrollable [Column], so using a + * [LazyColumn] as the root layout inside [content] is unsupported. * - * [onDismissRequest] will be called when the menu should close - for example when there is a - * tap outside the menu, or when the back key is pressed. + * [onDismissRequest] will be called when the menu should close - for example when there is a tap + * outside the menu, or when the back key is pressed. * * [DropdownMenu] changes its positioning depending on the available space, always trying to be * fully visible. Depending on layout direction, first it will try to align its start to the start @@ -45,13 +47,12 @@ import androidx.compose.ui.window.PopupProperties * An [offset] can be provided to adjust the positioning of the menu for cases when the layout * bounds of its parent do not coincide with its visual bounds. * - * * @param expanded whether the menu is expanded or not * @param onDismissRequest called when the user requests to dismiss the menu, such as by tapping - * outside the menu's bounds + * outside the menu's bounds * @param modifier [Modifier] to be applied to the menu's content * @param offset [DpOffset] from the original position of the menu. The offset respects the - * [LayoutDirection], so the offset's x position will be added in LTR and subtracted in RTL. + * [LayoutDirection], so the offset's x position will be added in LTR and subtracted in RTL. * @param scrollState a [ScrollState] to used by the menu's content for items vertical scrolling * @param properties [PopupProperties] for further customization of this popup's behavior * @param content the content of this dropdown menu, typically a [DropdownMenuItem] @@ -64,30 +65,26 @@ fun AnimatedDropdownMenu( offset: DpOffset = DpOffset(0.dp, 0.dp), scrollState: ScrollState = rememberScrollState(), properties: PopupProperties = PopupProperties(focusable = true), - content: @Composable ColumnScope.() -> Unit + content: @Composable ColumnScope.() -> Unit, ) { val expandedState = remember { MutableTransitionState(false) } expandedState.targetState = expanded if (expandedState.currentState || expandedState.targetState || !expandedState.isIdle) { val density = LocalDensity.current - val popupPositionProvider = remember(offset, density) { - DropdownMenuPositionProvider( - offset, - density - ) - } + val popupPositionProvider = + remember(offset, density) { DropdownMenuPositionProvider(offset, density) } Popup( onDismissRequest = onDismissRequest, popupPositionProvider = popupPositionProvider, - properties = properties + properties = properties, ) { DropdownMenuContent( expandedState = expandedState, scrollState = scrollState, modifier = modifier, - content = content + content = content, ) } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/DropdownItemContainer.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/DropdownItemContainer.kt index a17950f..b76042d 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/DropdownItemContainer.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/DropdownItemContainer.kt @@ -14,20 +14,21 @@ import androidx.compose.ui.unit.dp fun DropdownItemContainer( modifier: Modifier = Modifier, contentPadding: androidx.compose.ui.unit.Dp = 0.dp, - content: @Composable RowScope.() -> Unit + content: @Composable RowScope.() -> Unit, ) { Row( - modifier = modifier - .fillMaxWidth() - // Preferred min and max width used during the intrinsic measurement. - .sizeIn( - minWidth = DropdownMenuItemDefaultMinWidth, - maxWidth = DropdownMenuItemDefaultMaxWidth, - minHeight = ListItemContainerHeight - ) - .padding(contentPadding) - .padding(horizontal = DropdownMenuItemHorizontalPadding), - verticalAlignment = Alignment.CenterVertically + modifier = + modifier + .fillMaxWidth() + // Preferred min and max width used during the intrinsic measurement. + .sizeIn( + minWidth = DropdownMenuItemDefaultMinWidth, + maxWidth = DropdownMenuItemDefaultMaxWidth, + minHeight = ListItemContainerHeight, + ) + .padding(contentPadding) + .padding(horizontal = DropdownMenuItemHorizontalPadding), + verticalAlignment = Alignment.CenterVertically, ) { content() } @@ -36,4 +37,4 @@ fun DropdownItemContainer( private val DropdownMenuItemHorizontalPadding = 12.dp private val DropdownMenuItemDefaultMinWidth = 112.dp private val DropdownMenuItemDefaultMaxWidth = 280.dp -private val ListItemContainerHeight = 48.0.dp \ No newline at end of file +private val ListItemContainerHeight = 48.0.dp diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/DropdownMenuImplementation.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/DropdownMenuImplementation.kt index 1b26808..e2925cb 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/DropdownMenuImplementation.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/DropdownMenuImplementation.kt @@ -58,11 +58,7 @@ internal object MenuPosition { */ @Stable fun interface Vertical { - fun position( - anchorBounds: IntRect, - windowSize: IntSize, - menuHeight: Int, - ): Int + fun position(anchorBounds: IntRect, windowSize: IntSize, menuHeight: Int): Int } /** @@ -88,11 +84,12 @@ internal object MenuPosition { * The given [offset] is [LayoutDirection]-aware. It will be added to the resulting x position * for [LayoutDirection.Ltr] and subtracted for [LayoutDirection.Rtl]. */ - fun startToAnchorStart(offset: Int = 0): Horizontal = AnchorAlignmentOffsetPosition.Horizontal( - menuAlignment = Alignment.Start, - anchorAlignment = Alignment.Start, - offset = offset, - ) + fun startToAnchorStart(offset: Int = 0): Horizontal = + AnchorAlignmentOffsetPosition.Horizontal( + menuAlignment = Alignment.Start, + anchorAlignment = Alignment.Start, + offset = offset, + ) /** * Returns a [MenuPosition.Horizontal] which aligns the end of the menu to the end of the @@ -101,11 +98,12 @@ internal object MenuPosition { * The given [offset] is [LayoutDirection]-aware. It will be added to the resulting x position * for [LayoutDirection.Ltr] and subtracted for [LayoutDirection.Rtl]. */ - fun endToAnchorEnd(offset: Int = 0): Horizontal = AnchorAlignmentOffsetPosition.Horizontal( - menuAlignment = Alignment.End, - anchorAlignment = Alignment.End, - offset = offset, - ) + fun endToAnchorEnd(offset: Int = 0): Horizontal = + AnchorAlignmentOffsetPosition.Horizontal( + menuAlignment = Alignment.End, + anchorAlignment = Alignment.End, + offset = offset, + ) /** * Returns a [MenuPosition.Horizontal] which aligns the left of the menu to the left of the @@ -114,10 +112,11 @@ internal object MenuPosition { * The resulting x position will be coerced so that the menu remains within the area inside the * given [margin] from the left and right edges of the window. */ - fun leftToWindowLeft(margin: Int = 0): Horizontal = WindowAlignmentMarginPosition.Horizontal( - alignment = AbsoluteAlignment.Left, - margin = margin, - ) + fun leftToWindowLeft(margin: Int = 0): Horizontal = + WindowAlignmentMarginPosition.Horizontal( + alignment = AbsoluteAlignment.Left, + margin = margin, + ) /** * Returns a [MenuPosition.Horizontal] which aligns the right of the menu to the right of the @@ -126,52 +125,53 @@ internal object MenuPosition { * The resulting x position will be coerced so that the menu remains within the area inside the * given [margin] from the left and right edges of the window. */ - fun rightToWindowRight(margin: Int = 0): Horizontal = WindowAlignmentMarginPosition.Horizontal( - alignment = AbsoluteAlignment.Right, - margin = margin, - ) + fun rightToWindowRight(margin: Int = 0): Horizontal = + WindowAlignmentMarginPosition.Horizontal( + alignment = AbsoluteAlignment.Right, + margin = margin, + ) /** * Returns a [MenuPosition.Vertical] which aligns the top of the menu to the bottom of the * anchor. */ - fun topToAnchorBottom(offset: Int = 0): Vertical = AnchorAlignmentOffsetPosition.Vertical( - menuAlignment = Alignment.Top, - anchorAlignment = Alignment.Bottom, - offset = offset, - ) + fun topToAnchorBottom(offset: Int = 0): Vertical = + AnchorAlignmentOffsetPosition.Vertical( + menuAlignment = Alignment.Top, + anchorAlignment = Alignment.Bottom, + offset = offset, + ) /** * Returns a [MenuPosition.Vertical] which aligns the bottom of the menu to the top of the * anchor. */ - fun bottomToAnchorTop(offset: Int = 0): Vertical = AnchorAlignmentOffsetPosition.Vertical( - menuAlignment = Alignment.Bottom, - anchorAlignment = Alignment.Top, - offset = offset, - ) + fun bottomToAnchorTop(offset: Int = 0): Vertical = + AnchorAlignmentOffsetPosition.Vertical( + menuAlignment = Alignment.Bottom, + anchorAlignment = Alignment.Top, + offset = offset, + ) /** * Returns a [MenuPosition.Vertical] which aligns the center of the menu to the top of the * anchor. */ - fun centerToAnchorTop(offset: Int = 0): Vertical = AnchorAlignmentOffsetPosition.Vertical( - menuAlignment = Alignment.CenterVertically, - anchorAlignment = Alignment.Top, - offset = offset, - ) + fun centerToAnchorTop(offset: Int = 0): Vertical = + AnchorAlignmentOffsetPosition.Vertical( + menuAlignment = Alignment.CenterVertically, + anchorAlignment = Alignment.Top, + offset = offset, + ) /** - * Returns a [MenuPosition.Vertical] which aligns the top of the menu to the top of the - * window. + * Returns a [MenuPosition.Vertical] which aligns the top of the menu to the top of the window. * * The resulting y position will be coerced so that the menu remains within the area inside the * given [margin] from the top and bottom edges of the window. */ - fun topToWindowTop(margin: Int = 0): Vertical = WindowAlignmentMarginPosition.Vertical( - alignment = Alignment.Top, - margin = margin, - ) + fun topToWindowTop(margin: Int = 0): Vertical = + WindowAlignmentMarginPosition.Vertical(alignment = Alignment.Top, margin = margin) /** * Returns a [MenuPosition.Vertical] which aligns the bottom of the menu to the bottom of the @@ -180,10 +180,8 @@ internal object MenuPosition { * The resulting y position will be coerced so that the menu remains within the area inside the * given [margin] from the top and bottom edges of the window. */ - fun bottomToWindowBottom(margin: Int = 0): Vertical = WindowAlignmentMarginPosition.Vertical( - alignment = Alignment.Bottom, - margin = margin, - ) + fun bottomToWindowBottom(margin: Int = 0): Vertical = + WindowAlignmentMarginPosition.Vertical(alignment = Alignment.Bottom, margin = margin) } @Immutable @@ -207,16 +205,14 @@ internal object AnchorAlignmentOffsetPosition { menuWidth: Int, layoutDirection: LayoutDirection, ): Int { - val anchorAlignmentOffset = anchorAlignment.align( - size = 0, - space = anchorBounds.width, - layoutDirection = layoutDirection, - ) - val menuAlignmentOffset = -menuAlignment.align( - size = 0, - space = menuWidth, - layoutDirection, - ) + val anchorAlignmentOffset = + anchorAlignment.align( + size = 0, + space = anchorBounds.width, + layoutDirection = layoutDirection, + ) + val menuAlignmentOffset = + -menuAlignment.align(size = 0, space = menuWidth, layoutDirection) val resolvedOffset = if (layoutDirection == LayoutDirection.Ltr) offset else -offset return anchorBounds.left + anchorAlignmentOffset + menuAlignmentOffset + resolvedOffset } @@ -232,19 +228,9 @@ internal object AnchorAlignmentOffsetPosition { private val anchorAlignment: Alignment.Vertical, private val offset: Int, ) : MenuPosition.Vertical { - override fun position( - anchorBounds: IntRect, - windowSize: IntSize, - menuHeight: Int, - ): Int { - val anchorAlignmentOffset = anchorAlignment.align( - size = 0, - space = anchorBounds.height, - ) - val menuAlignmentOffset = -menuAlignment.align( - size = 0, - space = menuHeight, - ) + override fun position(anchorBounds: IntRect, windowSize: IntSize, menuHeight: Int): Int { + val anchorAlignmentOffset = anchorAlignment.align(size = 0, space = anchorBounds.height) + val menuAlignmentOffset = -menuAlignment.align(size = 0, space = menuHeight) return anchorBounds.top + anchorAlignmentOffset + menuAlignmentOffset + offset } } @@ -253,18 +239,16 @@ internal object AnchorAlignmentOffsetPosition { @Immutable internal object WindowAlignmentMarginPosition { /** - * A [MenuPosition.Horizontal] which horizontally aligns the menu within the window according - * to the given [alignment]. + * A [MenuPosition.Horizontal] which horizontally aligns the menu within the window according to + * the given [alignment]. * * The resulting x position will be coerced so that the menu remains within the area inside the * given [margin] from the left and right edges of the window. If this is not possible, i.e., * the menu is too wide, then it is centered horizontally instead. */ @Immutable - data class Horizontal( - private val alignment: Alignment.Horizontal, - private val margin: Int, - ) : MenuPosition.Horizontal { + data class Horizontal(private val alignment: Alignment.Horizontal, private val margin: Int) : + MenuPosition.Horizontal { override fun position( anchorBounds: IntRect, windowSize: IntSize, @@ -278,57 +262,47 @@ internal object WindowAlignmentMarginPosition { layoutDirection = layoutDirection, ) } - val x = alignment.align( - size = menuWidth, - space = windowSize.width, - layoutDirection = layoutDirection, - ) + val x = + alignment.align( + size = menuWidth, + space = windowSize.width, + layoutDirection = layoutDirection, + ) return x.coerceIn(margin, windowSize.width - margin - menuWidth) } } /** - * A [MenuPosition.Vertical] which vertically aligns the menu within the window according to - * the given [alignment]. + * A [MenuPosition.Vertical] which vertically aligns the menu within the window according to the + * given [alignment]. * * The resulting y position will be coerced so that the menu remains within the area inside the * given [margin] from the top and bottom edges of the window. If this is not possible, i.e., * the menu is too tall, then it is centered vertically instead. */ @Immutable - data class Vertical( - private val alignment: Alignment.Vertical, - private val margin: Int, - ) : MenuPosition.Vertical { - override fun position( - anchorBounds: IntRect, - windowSize: IntSize, - menuHeight: Int, - ): Int { + data class Vertical(private val alignment: Alignment.Vertical, private val margin: Int) : + MenuPosition.Vertical { + override fun position(anchorBounds: IntRect, windowSize: IntSize, menuHeight: Int): Int { if (menuHeight >= windowSize.height - 2 * margin) { return Alignment.CenterVertically.align( size = menuHeight, space = windowSize.height, ) } - val y = alignment.align( - size = menuHeight, - space = windowSize.height, - ) + val y = alignment.align(size = menuHeight, space = windowSize.height) return y.coerceIn(margin, windowSize.height - margin - menuHeight) } } } -/** - * Calculates the position of a Material [DropdownMenu]. - */ +/** Calculates the position of a Material [DropdownMenu]. */ @Immutable internal data class DropdownMenuPositionProvider( val contentOffset: DpOffset, val density: Density, val verticalMargin: Int = with(density) { MenuVerticalMargin.roundToPx() }, - val onPositionCalculated: (anchorBounds: IntRect, menuBounds: IntRect) -> Unit = { _, _ -> } + val onPositionCalculated: (anchorBounds: IntRect, menuBounds: IntRect) -> Unit = { _, _ -> }, ) : PopupPositionProvider { // Horizontal position private val startToAnchorStart: Horizontal @@ -363,55 +337,66 @@ internal data class DropdownMenuPositionProvider( anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, - popupContentSize: IntSize + popupContentSize: IntSize, ): IntOffset { - val xCandidates = listOf( - startToAnchorStart, endToAnchorEnd, if (anchorBounds.center.x < windowSize.width / 2) { - leftToWindowLeft - } else { - rightToWindowRight - } - ).fastMap { - it.position( - anchorBounds = anchorBounds, - windowSize = windowSize, - menuWidth = popupContentSize.width, - layoutDirection = layoutDirection - ) - } - val x = xCandidates.fastFirstOrNull { - it >= 0 && it + popupContentSize.width <= windowSize.width - } ?: xCandidates.last() + val xCandidates = + listOf( + startToAnchorStart, + endToAnchorEnd, + if (anchorBounds.center.x < windowSize.width / 2) { + leftToWindowLeft + } else { + rightToWindowRight + }, + ) + .fastMap { + it.position( + anchorBounds = anchorBounds, + windowSize = windowSize, + menuWidth = popupContentSize.width, + layoutDirection = layoutDirection, + ) + } + val x = + xCandidates.fastFirstOrNull { + it >= 0 && it + popupContentSize.width <= windowSize.width + } ?: xCandidates.last() - val yCandidates = listOf( - topToAnchorBottom, - bottomToAnchorTop, - centerToAnchorTop, - if (anchorBounds.center.y < windowSize.height / 2) { - topToWindowTop - } else { - bottomToWindowBottom - } - ).fastMap { - it.position( - anchorBounds = anchorBounds, - windowSize = windowSize, - menuHeight = popupContentSize.height - ) - } - val y = yCandidates.fastFirstOrNull { - it >= verticalMargin && it + popupContentSize.height <= windowSize.height - verticalMargin - } ?: yCandidates.last() + val yCandidates = + listOf( + topToAnchorBottom, + bottomToAnchorTop, + centerToAnchorTop, + if (anchorBounds.center.y < windowSize.height / 2) { + topToWindowTop + } else { + bottomToWindowBottom + }, + ) + .fastMap { + it.position( + anchorBounds = anchorBounds, + windowSize = windowSize, + menuHeight = popupContentSize.height, + ) + } + val y = + yCandidates.fastFirstOrNull { + it >= verticalMargin && + it + popupContentSize.height <= windowSize.height - verticalMargin + } ?: yCandidates.last() val menuOffset = IntOffset(x, y) - onPositionCalculated(/* anchorBounds = */anchorBounds,/* menuBounds = */ - IntRect(offset = menuOffset, size = popupContentSize) + onPositionCalculated( + /* anchorBounds = */ anchorBounds, + /* menuBounds = */ IntRect(offset = menuOffset, size = popupContentSize), ) return menuOffset } } -// The shadow disappears when the surface is fading out, delay the animation to make it less noticeable +// The shadow disappears when the surface is fading out, delay the animation to make it less +// noticeable private const val FadeOutDuration = 80 @Composable @@ -419,19 +404,22 @@ fun DropdownMenuContent( expandedState: MutableTransitionState, scrollState: ScrollState, modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit + content: @Composable ColumnScope.() -> Unit, ) { AnimatedVisibility( visibleState = expandedState, label = "Dropdown menu animation", enter = EnterTransition.None, - exit = fadeOut( - animationSpec = tween( - delayMillis = DURATION_EXIT - FadeOutDuration, - durationMillis = FadeOutDuration, - easing = LinearEasing - ) - ), modifier = modifier + exit = + fadeOut( + animationSpec = + tween( + delayMillis = DURATION_EXIT - FadeOutDuration, + durationMillis = FadeOutDuration, + easing = LinearEasing, + ) + ), + modifier = modifier, ) { Surface( modifier = Modifier, @@ -441,51 +429,71 @@ fun DropdownMenuContent( shadowElevation = ElevationTokens.Level1.dp, ) { AnimatedVisibility( - visibleState = expandedState, label = "", enter = fadeIn( - animationSpec = tween( - durationMillis = DURATION_ENTER, easing = EmphasizedDecelerate - ) - ) + expandVertically( - animationSpec = tween( - durationMillis = DURATION_ENTER, easing = EmphasizedDecelerate - ), - expandFrom = Alignment.Top, - ) + slideInVertically( - animationSpec = tween( - durationMillis = DURATION_ENTER, easing = EmphasizedDecelerate - ), - initialOffsetY = { -it / 10 }, - ), exit = fadeOut( - animationSpec = tween( - // Why ??? - durationMillis = DURATION_EXIT - 20, - easing = EmphasizedAccelerate - ) - ) + shrinkVertically( - animationSpec = tween( - durationMillis = DURATION_EXIT, easing = EmphasizedAccelerate - ), - shrinkTowards = Alignment.Top, - ) + slideOutVertically( - animationSpec = tween( - durationMillis = DURATION_EXIT, easing = EmphasizedAccelerate - ), targetOffsetY = { -it / 10 }), modifier = Modifier + visibleState = expandedState, + label = "", + enter = + fadeIn( + animationSpec = + tween(durationMillis = DURATION_ENTER, easing = EmphasizedDecelerate) + ) + + expandVertically( + animationSpec = + tween( + durationMillis = DURATION_ENTER, + easing = EmphasizedDecelerate, + ), + expandFrom = Alignment.Top, + ) + + slideInVertically( + animationSpec = + tween( + durationMillis = DURATION_ENTER, + easing = EmphasizedDecelerate, + ), + initialOffsetY = { -it / 10 }, + ), + exit = + fadeOut( + animationSpec = + tween( + // Why ??? + durationMillis = DURATION_EXIT - 20, + easing = EmphasizedAccelerate, + ) + ) + + shrinkVertically( + animationSpec = + tween( + durationMillis = DURATION_EXIT, + easing = EmphasizedAccelerate, + ), + shrinkTowards = Alignment.Top, + ) + + slideOutVertically( + animationSpec = + tween( + durationMillis = DURATION_EXIT, + easing = EmphasizedAccelerate, + ), + targetOffsetY = { -it / 10 }, + ), + modifier = Modifier, ) { Column( - modifier = Modifier - .padding( - vertical = DropdownMenuVerticalPadding, - horizontal = DropdownMenuVerticalPadding / 2 - ) - .width(IntrinsicSize.Max) - .verticalScroll(scrollState), content = content + modifier = + Modifier.padding( + vertical = DropdownMenuVerticalPadding, + horizontal = DropdownMenuVerticalPadding / 2, + ) + .width(IntrinsicSize.Max) + .verticalScroll(scrollState), + content = content, ) } } } } - // Size defaults. internal val MenuVerticalMargin = 48.dp -internal val DropdownMenuVerticalPadding = 16.dp \ No newline at end of file +internal val DropdownMenuVerticalPadding = 16.dp diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/M3ElevationTokens.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/M3ElevationTokens.kt index 7f54bb4..9c8c05a 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/M3ElevationTokens.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/dropdown/M3ElevationTokens.kt @@ -12,4 +12,4 @@ internal object ElevationTokens { const val Level3 = 6 const val Level4 = 8 const val Level5 = 12 -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/image/ProfilePictureGenerator.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/image/ProfilePictureGenerator.kt index c6efb14..ac4b044 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/image/ProfilePictureGenerator.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/image/ProfilePictureGenerator.kt @@ -27,7 +27,7 @@ fun ProfilePicture( name: String, shape: CornerBasedShape = CircleShape, surfaceColor: Color = MaterialTheme.colorScheme.primary, - onClick: () -> Unit = {} + onClick: () -> Unit = {}, ) { val firstLetter = name.first().toString() Surface( @@ -58,4 +58,4 @@ private fun ProfilePicturePreview() { modifier = Modifier, size = 40, ) -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/others/AdditionalInformation.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/others/AdditionalInformation.kt index 4e5d8a1..e12042d 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/others/AdditionalInformation.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/others/AdditionalInformation.kt @@ -18,24 +18,15 @@ import androidx.compose.ui.unit.dp import com.bobbyesp.ui.R @Composable -fun AdditionalInformation( - modifier: Modifier = Modifier, - text: AnnotatedString -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { +fun AdditionalInformation(modifier: Modifier = Modifier, text: AnnotatedString) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { Icon( imageVector = Icons.Rounded.Info, tint = MaterialTheme.colorScheme.onSurfaceVariant, contentDescription = stringResource(id = R.string.additional_information), ) - Text( - text = text, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text(text = text, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -43,12 +34,9 @@ fun AdditionalInformation( fun AdditionalInformation( modifier: Modifier = Modifier, text: String, - fontFamily: FontFamily = FontFamily.Default + fontFamily: FontFamily = FontFamily.Default, ) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { Icon( modifier = Modifier.size(32.dp), imageVector = Icons.Rounded.Info, @@ -60,7 +48,7 @@ fun AdditionalInformation( text = text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = fontFamily + fontFamily = fontFamily, ) } } @@ -70,8 +58,9 @@ fun AdditionalInformation( private fun Preview() { MaterialTheme { AdditionalInformation( - text = "This is a preview text preview text preview text preview text preview text preview text " + + text = + "This is a preview text preview text preview text preview text preview text preview text " + "preview text preview text" ) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/others/LoadingPlaceholder.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/others/LoadingPlaceholder.kt index 61a85c9..b6a5200 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/others/LoadingPlaceholder.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/others/LoadingPlaceholder.kt @@ -10,11 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable -fun LoadingPlaceholder( - modifier: Modifier = Modifier, - progress: Float? = null, - colorful: Boolean, -) { +fun LoadingPlaceholder(modifier: Modifier = Modifier, progress: Float? = null, colorful: Boolean) { val color = if (colorful) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface @@ -22,27 +18,18 @@ fun LoadingPlaceholder( if (colorful) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface val elevation = if (colorful) 0.dp else 8.dp - Surface( - tonalElevation = elevation, - color = color, - modifier = modifier - ) { + Surface(tonalElevation = elevation, color = color, modifier = modifier) { if (progress == null) { CircularProgressIndicator( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), + modifier = Modifier.fillMaxSize().padding(8.dp), color = onColor, ) } else { CircularProgressIndicator( progress = { progress }, - modifier = Modifier - .fillMaxSize() - .padding(8.dp), + modifier = Modifier.fillMaxSize().padding(8.dp), color = onColor, ) } - } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/others/MetadataTag.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/others/MetadataTag.kt index afd6798..d17c360 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/others/MetadataTag.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/others/MetadataTag.kt @@ -25,22 +25,23 @@ fun MetadataTag( val clipboardManager = LocalClipboardManager.current Column( - modifier = modifier.clickable { - clipboardManager.setText(AnnotatedString(metadata)) - Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() - } + modifier = + modifier.clickable { + clipboardManager.setText(AnnotatedString(metadata)) + Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + } ) { Text( text = typeOfMetadata, modifier = Modifier.alpha(alpha = 0.8f), style = MaterialTheme.typography.labelSmall, - textAlign = TextAlign.Start + textAlign = TextAlign.Start, ) Text( modifier = Modifier, text = metadata, style = MaterialTheme.typography.titleLarge.copy(fontSize = 16.sp), - textAlign = TextAlign.Start + textAlign = TextAlign.Start, ) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/others/Placeholder.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/others/Placeholder.kt index 45c2d33..1be18dc 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/others/Placeholder.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/others/Placeholder.kt @@ -26,22 +26,23 @@ fun Placeholder( modifier: Modifier = Modifier, icon: ImageVector?, colorful: Boolean, - contentDescription: String? = null + contentDescription: String? = null, ) { Surface( tonalElevation = if (colorful) 0.dp else 8.dp, - color = if (colorful) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface, - modifier = modifier + color = + if (colorful) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface, + modifier = modifier, ) { icon?.let { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Icon( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), + modifier = Modifier.fillMaxSize().padding(8.dp), imageVector = icon, contentDescription = contentDescription, - tint = if (colorful) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + tint = + if (colorful) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface, ) } } @@ -54,12 +55,10 @@ fun Placeholder( private fun PlaceholderPreview() { MaterialTheme { Placeholder( - modifier = Modifier - .width(200.dp) - .aspectRatio(1f), + modifier = Modifier.width(200.dp).aspectRatio(1f), icon = Icons.Rounded.Lyrics, colorful = true, - contentDescription = "Song cover" + contentDescription = "Song cover", ) } } @@ -70,12 +69,10 @@ private fun PlaceholderPreview() { private fun PlaceholderPreviewNonColourful() { MaterialTheme { Placeholder( - modifier = Modifier - .width(200.dp) - .aspectRatio(1f), + modifier = Modifier.width(200.dp).aspectRatio(1f), icon = Icons.Default.Lyrics, colorful = false, - contentDescription = "Song cover" + contentDescription = "Song cover", ) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/others/SelectableSurface.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/others/SelectableSurface.kt index 5f2478b..33e5329 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/others/SelectableSurface.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/others/SelectableSurface.kt @@ -34,18 +34,17 @@ fun SelectableSurface( shape: Shape = MaterialTheme.shapes.small, isSelected: Boolean = false, onSelected: () -> Unit = {}, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { - val animatedColor by animateColorAsState( - targetValue = selectColorHandler(isSelected), label = "Animated transition for color change" - ) + val animatedColor by + animateColorAsState( + targetValue = selectColorHandler(isSelected), + label = "Animated transition for color change", + ) Surface( - modifier = modifier.selectable( - selected = isSelected, - onClick = onSelected, - role = Role.Button - ), + modifier = + modifier.selectable(selected = isSelected, onClick = onSelected, role = Role.Button), color = animatedColor, border = borderStroke, shape = shape, @@ -56,12 +55,9 @@ fun SelectableSurface( } @Composable -private fun selectColorHandler( - isSelected: Boolean -): Color { - return if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.harmonizeWithPrimary( - MaterialTheme.colorScheme.onSecondary - ) +private fun selectColorHandler(isSelected: Boolean): Color { + return if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.harmonizeWithPrimary(MaterialTheme.colorScheme.onSecondary) } @Preview @@ -69,21 +65,19 @@ private fun selectColorHandler( fun SelectableSurfacePreview() { MaterialTheme { var selected by remember { mutableStateOf(false) } - SelectableSurface( - isSelected = selected, - onSelected = { selected = !selected } - ) { + SelectableSurface(isSelected = selected, onSelected = { selected = !selected }) { Column( - modifier = Modifier - .size(200.dp) - .padding(12.dp), + modifier = Modifier.size(200.dp).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.Start + horizontalAlignment = Alignment.Start, ) { Text(text = "SelectableSurface", fontWeight = FontWeight.Bold) - Text(text = "This is just a test to see how this should work. Lol this is a very long text don't you think? I think so.") + Text( + text = + "This is just a test to see how this should work. Lol this is a very long text don't you think? I think so." + ) } } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/PreferencesItems.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/PreferencesItems.kt index a247626..13c22fc 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/PreferencesItems.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/PreferencesItems.kt @@ -67,7 +67,6 @@ private val PreferenceTitleVariant: TextStyle private val PreferenceTitle @Composable get() = MaterialTheme.typography.titleMedium - @OptIn(ExperimentalFoundationApi::class) @Composable fun PreferenceItem( @@ -83,18 +82,17 @@ fun PreferenceItem( onClick: () -> Unit = {}, ) { Surface( - modifier = Modifier.combinedClickable( - onClick = onClick, - onClickLabel = onClickLabel, - enabled = enabled, - onLongClickLabel = onLongClickLabel, - onLongClick = onLongClick - ) + modifier = + Modifier.combinedClickable( + onClick = onClick, + onClickLabel = onClickLabel, + enabled = enabled, + onLongClickLabel = onLongClickLabel, + onLongClick = onLongClick, + ) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal.dp, vertical.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal.dp, vertical.dp), verticalAlignment = Alignment.CenterVertically, ) { leadingIcon?.invoke() @@ -104,10 +102,8 @@ fun PreferenceItem( Icon( imageVector = icon, contentDescription = null, - modifier = Modifier - .padding(start = 8.dp, end = 16.dp) - .size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled) + modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled), ) } @@ -115,39 +111,37 @@ fun PreferenceItem( Icon( painter = icon, contentDescription = null, - modifier = Modifier - .padding(start = 8.dp, end = 16.dp) - .size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled) + modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled), ) } } Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = if (icon == null && leadingIcon == null) 8.dp else 0.dp) - .padding(end = 8.dp) + modifier = + Modifier.weight(1f) + .padding( + horizontal = if (icon == null && leadingIcon == null) 8.dp else 0.dp + ) + .padding(end = 8.dp) ) { PreferenceItemTitle(text = title, enabled = enabled) - if (!description.isNullOrEmpty()) PreferenceItemDescription( - text = description, enabled = enabled - ) + if (!description.isNullOrEmpty()) + PreferenceItemDescription(text = description, enabled = enabled) } trailingIcon?.let { VerticalDivider( - modifier = Modifier - .height(32.dp) - .padding(horizontal = 8.dp) - .align(Alignment.CenterVertically), + modifier = + Modifier.height(32.dp) + .padding(horizontal = 8.dp) + .align(Alignment.CenterVertically), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), - thickness = 1.dp + thickness = 1.dp, ) trailingIcon.invoke() } } } - } @Composable @@ -159,10 +153,14 @@ private fun PreferenceItemPreview() { PreferenceSubtitle(text = "Preview") PreferenceItem(title = "title", description = "description") PreferenceItem( - title = "title", description = "description", icon = Icons.Outlined.Update + title = "title", + description = "description", + icon = Icons.Outlined.Update, ) PreferenceItemVariant( - title = "title", description = "description", icon = Icons.Outlined.Update + title = "title", + description = "description", + icon = Icons.Outlined.Update, ) } } @@ -183,35 +181,32 @@ fun PreferenceItemVariant( onClick: () -> Unit = {}, ) { Surface( - modifier = Modifier.combinedClickable( - enabled = enabled, - onClick = onClick, - onClickLabel = onClickLabel, - onLongClick = onLongClick, - onLongClickLabel = onLongClickLabel - ) + modifier = + Modifier.combinedClickable( + enabled = enabled, + onClick = onClick, + onClickLabel = onClickLabel, + onLongClick = onLongClick, + onLongClickLabel = onLongClickLabel, + ) ) { Row( - modifier = modifier - .fillMaxWidth() - .padding(12.dp, 16.dp), + modifier = modifier.fillMaxWidth().padding(12.dp, 16.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier - .padding(start = 8.dp, end = 16.dp) - .size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled) + modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled), ) } Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = if (icon == null) 12.dp else 0.dp) - .padding(end = 8.dp) + modifier = + Modifier.weight(1f) + .padding(horizontal = if (icon == null) 12.dp else 0.dp) + .padding(end = 8.dp) ) { PreferenceItemTitle(text = title, enabled = enabled) if (description != null) { @@ -220,7 +215,6 @@ fun PreferenceItemVariant( } } } - } @Composable @@ -229,38 +223,26 @@ fun PreferenceSingleChoiceItem( text: String, selected: Boolean, contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 16.dp), - onClick: () -> Unit + onClick: () -> Unit, ) { - Surface( - modifier = Modifier.selectable( - selected = selected, onClick = onClick - ) - ) { + Surface(modifier = Modifier.selectable(selected = selected, onClick = onClick)) { Row( - modifier = modifier - .fillMaxWidth() - .padding(contentPadding), + modifier = modifier.fillMaxWidth().padding(contentPadding), verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = Modifier - .weight(1f) - .padding(start = 8.dp) - ) { + Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { Text( text = text, maxLines = 1, style = PreferenceTitleVariant, color = MaterialTheme.colorScheme.onSurface, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } RadioButton( selected = selected, onClick = onClick, - modifier = Modifier - .padding() - .clearAndSetSemantics { }, + modifier = Modifier.padding().clearAndSetSemantics {}, ) } } @@ -274,7 +256,7 @@ internal fun PreferenceItemTitle( style: TextStyle = PreferenceTitle, enabled: Boolean, color: Color = MaterialTheme.colorScheme.onBackground, - overflow: TextOverflow = TextOverflow.Ellipsis + overflow: TextOverflow = TextOverflow.Ellipsis, ) { Text( modifier = modifier, @@ -282,7 +264,7 @@ internal fun PreferenceItemTitle( maxLines = maxLines, style = style, color = color.applyAlpha(enabled), - overflow = overflow + overflow = overflow, ) } @@ -294,7 +276,7 @@ internal fun PreferenceItemDescription( style: TextStyle = MaterialTheme.typography.bodyMedium, enabled: Boolean, color: Color = MaterialTheme.colorScheme.onSurfaceVariant, - overflow: TextOverflow = TextOverflow.Ellipsis + overflow: TextOverflow = TextOverflow.Ellipsis, ) { Text( modifier = modifier, @@ -302,7 +284,7 @@ internal fun PreferenceItemDescription( maxLines = maxLines, style = style, color = color.applyAlpha(enabled), - overflow = overflow + overflow = overflow, ) } @@ -315,7 +297,7 @@ private fun PreferenceSwitchPreview() { title = "PreferenceSwitch", description = "Supporting text", icon = Icons.Outlined.ToggleOn, - isChecked = b + isChecked = b, ) { b = !b } @@ -336,19 +318,20 @@ private fun PreferenceSwitchWithDividerPreview() { fun rememberThumbContent( isChecked: Boolean, checkedIcon: ImageVector = Icons.Outlined.Check, -): (@Composable () -> Unit)? = remember(isChecked, checkedIcon) { - if (isChecked) { - { - Icon( - imageVector = checkedIcon, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize), - ) +): (@Composable () -> Unit)? = + remember(isChecked, checkedIcon) { + if (isChecked) { + { + Icon( + imageVector = checkedIcon, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + } else { + null } - } else { - null } -} @Composable fun PreferenceSwitchVariant( @@ -362,40 +345,34 @@ fun PreferenceSwitchVariant( ) { val interactionSource = remember { MutableInteractionSource() } Surface( - modifier = Modifier.toggleable( - value = isChecked, - enabled = enabled, - onValueChange = { onClick() }, - indication = LocalIndication.current, - interactionSource = interactionSource - ) + modifier = + Modifier.toggleable( + value = isChecked, + enabled = enabled, + onValueChange = { onClick() }, + indication = LocalIndication.current, + interactionSource = interactionSource, + ) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal.dp, vertical.dp) - .padding(start = if (icon == null) 12.dp else 0.dp), + modifier = + Modifier.fillMaxWidth() + .padding(horizontal.dp, vertical.dp) + .padding(start = if (icon == null) 12.dp else 0.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier - .padding(start = 8.dp, end = 16.dp) - .size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled) + modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled), ) } - Column( - modifier = Modifier.weight(1f) - ) { - PreferenceItemTitle( - text = title, enabled = enabled, style = PreferenceTitleVariant - ) - if (!description.isNullOrEmpty()) PreferenceItemDescription( - text = description, enabled = enabled - ) + Column(modifier = Modifier.weight(1f)) { + PreferenceItemTitle(text = title, enabled = enabled, style = PreferenceTitleVariant) + if (!description.isNullOrEmpty()) + PreferenceItemDescription(text = description, enabled = enabled) } Switch( checked = isChecked, @@ -403,7 +380,7 @@ fun PreferenceSwitchVariant( interactionSource = interactionSource, modifier = Modifier.padding(start = 20.dp, end = 6.dp), enabled = enabled, - thumbContent = thumbContent + thumbContent = thumbContent, ) } } @@ -422,40 +399,34 @@ fun PreferenceSwitch( val interactionSource = remember { MutableInteractionSource() } Surface( - modifier = Modifier.toggleable( - value = isChecked, - enabled = enabled, - onValueChange = { onClick() }, - indication = LocalIndication.current, - interactionSource = interactionSource - ) + modifier = + Modifier.toggleable( + value = isChecked, + enabled = enabled, + onValueChange = { onClick() }, + indication = LocalIndication.current, + interactionSource = interactionSource, + ) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal.dp, vertical.dp) - .padding(start = if (icon == null) 12.dp else 0.dp), + modifier = + Modifier.fillMaxWidth() + .padding(horizontal.dp, vertical.dp) + .padding(start = if (icon == null) 12.dp else 0.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier - .padding(start = 8.dp, end = 16.dp) - .size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled) + modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled), ) } - Column( - modifier = Modifier.weight(1f) - ) { - PreferenceItemTitle( - text = title, enabled = enabled - ) - if (!description.isNullOrEmpty()) PreferenceItemDescription( - text = description, enabled = enabled - ) + Column(modifier = Modifier.weight(1f)) { + PreferenceItemTitle(text = title, enabled = enabled) + if (!description.isNullOrEmpty()) + PreferenceItemDescription(text = description, enabled = enabled) } Switch( checked = isChecked, @@ -463,16 +434,13 @@ fun PreferenceSwitch( interactionSource = interactionSource, modifier = Modifier.padding(start = 20.dp, end = 6.dp), enabled = enabled, - colors = SwitchDefaults.colors( - uncheckedBorderColor = Color.Transparent, - ), - thumbContent = thumbContent + colors = SwitchDefaults.colors(uncheckedBorderColor = Color.Transparent), + thumbContent = thumbContent, ) } } } - @Composable fun PreferenceSwitchWithDivider( title: String, @@ -483,62 +451,53 @@ fun PreferenceSwitchWithDivider( isChecked: Boolean = true, thumbContent: (@Composable () -> Unit)? = rememberThumbContent(isChecked = isChecked), onClick: (() -> Unit) = {}, - onChecked: () -> Unit = {} + onChecked: () -> Unit = {}, ) { Surface( - modifier = Modifier.clickable( - enabled = enabled, - onClick = onClick, - onClickLabel = stringResource(id = R.string.open_settings) - ) + modifier = + Modifier.clickable( + enabled = enabled, + onClick = onClick, + onClickLabel = stringResource(id = R.string.open_settings), + ) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal.dp, vertical.dp) - .height(IntrinsicSize.Min), + modifier = + Modifier.fillMaxWidth() + .padding(horizontal.dp, vertical.dp) + .height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier - .padding(start = 8.dp, end = 16.dp) - .size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled) + modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.applyAlpha(enabled), ) } - Column( - modifier = Modifier.weight(1f) - ) { + Column(modifier = Modifier.weight(1f)) { PreferenceItemTitle(text = title, enabled = enabled) - if (!description.isNullOrEmpty()) PreferenceItemDescription( - text = description, enabled = enabled - ) + if (!description.isNullOrEmpty()) + PreferenceItemDescription(text = description, enabled = enabled) } VerticalDivider( - modifier = Modifier - .height(32.dp) - .padding(horizontal = 8.dp) - .width(1f.dp) - .align(Alignment.CenterVertically), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + modifier = + Modifier.height(32.dp) + .padding(horizontal = 8.dp) + .width(1f.dp) + .align(Alignment.CenterVertically), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), ) Switch( checked = isChecked, onCheckedChange = { onChecked() }, - modifier = Modifier - .padding(horizontal = 6.dp) - .semantics { - contentDescription = title - }, + modifier = + Modifier.padding(horizontal = 6.dp).semantics { contentDescription = title }, enabled = isSwitchEnabled, - colors = SwitchDefaults.colors( - uncheckedBorderColor = Color.Transparent, - ), - thumbContent = thumbContent + colors = SwitchDefaults.colors(uncheckedBorderColor = Color.Transparent), + thumbContent = thumbContent, ) } } @@ -553,44 +512,47 @@ fun PreferencesCautionCard( ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp) - .clip(MaterialTheme.shapes.extraLarge) - .background(MaterialTheme.colorScheme.harmonizeWithPrimary(MaterialTheme.colorScheme.errorContainer)) - .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 16.dp), + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp) + .clip(MaterialTheme.shapes.extraLarge) + .background( + MaterialTheme.colorScheme.harmonizeWithPrimary( + MaterialTheme.colorScheme.errorContainer + ) + ) + .clickable { onClick() } + .padding(horizontal = 12.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier - .padding(start = 8.dp, end = 16.dp) - .size(24.dp), - tint = MaterialTheme.colorScheme.harmonizeWithPrimary(MaterialTheme.colorScheme.error) - + modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), + tint = + MaterialTheme.colorScheme.harmonizeWithPrimary(MaterialTheme.colorScheme.error), ) } Column( - modifier = Modifier - .weight(1f) - .padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp) + modifier = + Modifier.weight(1f).padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp) ) { with(MaterialTheme) { Text( text = title, maxLines = 1, style = PreferenceTitleVariant, - color = colorScheme.harmonizeWithPrimary(colorScheme.onErrorContainer) - ) - if (description != null) Text( - text = description, color = colorScheme.harmonizeWithPrimary(colorScheme.onErrorContainer), - maxLines = 2, overflow = TextOverflow.Ellipsis, - style = typography.bodyMedium, ) + if (description != null) + Text( + text = description, + color = colorScheme.harmonizeWithPrimary(colorScheme.onErrorContainer), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = typography.bodyMedium, + ) } } } @@ -606,40 +568,42 @@ fun PreferencesHintCard( onClick: () -> Unit = {}, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - .clip(MaterialTheme.shapes.extraLarge) - .background(containerColor) - .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 16.dp), + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .clip(MaterialTheme.shapes.extraLarge) + .background(containerColor) + .clickable { onClick() } + .padding(horizontal = 12.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier - .padding(start = 8.dp, end = 16.dp) - .size(24.dp), - tint = contentColor + modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), + tint = contentColor, ) } Column( - modifier = Modifier - .weight(1f) - .padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp) + modifier = + Modifier.weight(1f).padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp) ) { with(MaterialTheme) { Text( - text = title, maxLines = 1, style = PreferenceTitleVariant, color = contentColor - ) - if (description != null) Text( - text = description, + text = title, + maxLines = 1, + style = PreferenceTitleVariant, color = contentColor, - maxLines = 2, overflow = TextOverflow.Ellipsis, - style = typography.bodyMedium, ) + if (description != null) + Text( + text = description, + color = contentColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = typography.bodyMedium, + ) } } } @@ -655,7 +619,7 @@ private fun PreferenceSwitchWithContainerPreview() { title = "Title ".repeat(2), isChecked = isChecked, onClick = { isChecked = !isChecked }, - icon = null + icon = null, ) } } @@ -671,42 +635,37 @@ fun PreferenceSwitchWithContainer( val interactionSource = remember { MutableInteractionSource() } Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - .clip(MaterialTheme.shapes.extraLarge) - .background( - MaterialTheme.colorScheme.primaryContainer - ) - .toggleable( - value = isChecked, - onValueChange = { onClick() }, - interactionSource = interactionSource, - indication = LocalIndication.current - ) - .padding(horizontal = 16.dp, vertical = 20.dp), + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .clip(MaterialTheme.shapes.extraLarge) + .background(MaterialTheme.colorScheme.primaryContainer) + .toggleable( + value = isChecked, + onValueChange = { onClick() }, + interactionSource = interactionSource, + indication = LocalIndication.current, + ) + .padding(horizontal = 16.dp, vertical = 20.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier - .padding(start = 8.dp, end = 16.dp) - .size(24.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer + modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, ) } Column( - modifier = Modifier - .weight(1f) - .padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp) + modifier = + Modifier.weight(1f).padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp) ) { Text( text = title, maxLines = 2, style = PreferenceTitleVariant, - color = MaterialTheme.colorScheme.onPrimaryContainer + color = MaterialTheme.colorScheme.onPrimaryContainer, ) } Switch( @@ -714,9 +673,7 @@ fun PreferenceSwitchWithContainer( interactionSource = interactionSource, onCheckedChange = null, modifier = Modifier.padding(start = 12.dp, end = 6.dp), - colors = SwitchDefaults.colors( - uncheckedBorderColor = Color.Transparent, - ), + colors = SwitchDefaults.colors(uncheckedBorderColor = Color.Transparent), thumbContent = thumbContent, ) } @@ -731,28 +688,23 @@ fun CreditItem( ) { Surface(modifier = Modifier.clickable { onClick() }) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = 10.dp) - ) { + Column(modifier = Modifier.weight(1f).padding(horizontal = 10.dp)) { with(MaterialTheme) { Text( text = title, maxLines = 1, style = typography.titleMedium, - color = colorScheme.onSurface.applyAlpha(enabled) + color = colorScheme.onSurface.applyAlpha(enabled), ) license?.let { Text( text = it, color = colorScheme.onSurfaceVariant.applyAlpha(enabled), - maxLines = 2, overflow = TextOverflow.Ellipsis, + maxLines = 2, + overflow = TextOverflow.Ellipsis, style = typography.bodyMedium, ) } @@ -765,11 +717,7 @@ fun CreditItem( @Composable fun PreferenceSubtitle( modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues( - start = 16.dp, - top = 20.dp, - bottom = 8.dp, - ), + contentPadding: PaddingValues = PaddingValues(start = 16.dp, top = 20.dp, bottom = 8.dp), text: String, color: Color = MaterialTheme.colorScheme.primary, ) { @@ -777,7 +725,7 @@ fun PreferenceSubtitle( text = text, modifier = modifier.padding(contentPadding), color = color, - style = MaterialTheme.typography.labelLarge + style = MaterialTheme.typography.labelLarge, ) } @@ -786,22 +734,19 @@ fun PreferenceInfo( modifier: Modifier = Modifier, text: String, icon: ImageVector = Icons.Outlined.Info, - applyPaddings: Boolean = true + applyPaddings: Boolean = true, ) { Column( - modifier = modifier - .fillMaxWidth() - .run { - if (applyPaddings) padding(horizontal = 16.dp, vertical = 16.dp) - else this - }) { - Icon( - modifier = Modifier.padding(), imageVector = icon, contentDescription = null - ) + modifier = + modifier.fillMaxWidth().run { + if (applyPaddings) padding(horizontal = 16.dp, vertical = 16.dp) else this + } + ) { + Icon(modifier = Modifier.padding(), imageVector = icon, contentDescription = null) Text( modifier = Modifier.padding(top = 16.dp), text = text, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingOption.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingOption.kt index 12454a2..f111bf0 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingOption.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingOption.kt @@ -1,7 +1,3 @@ - package com.bobbyesp.ui.components.preferences -sealed class SettingOption( - val title: String, - val onSelection: () -> Unit, -) +sealed class SettingOption(val title: String, val onSelection: () -> Unit) diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingOptionsRow.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingOptionsRow.kt index b33d559..7267476 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingOptionsRow.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingOptionsRow.kt @@ -21,36 +21,28 @@ fun SettingOptionsRow( title: String, options: List, modifier: Modifier = Modifier, - optionContent: @Composable (T) -> Unit + optionContent: @Composable (T) -> Unit, ) { Column( - modifier = modifier - .clip(ShapeDefaults.ExtraLarge) - .background(color = MaterialTheme.colorScheme.surfaceContainer) - .padding(vertical = 16.dp) + modifier = + modifier + .clip(ShapeDefaults.ExtraLarge) + .background(color = MaterialTheme.colorScheme.surfaceContainer) + .padding(vertical = 16.dp) ) { Text( text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(start = 20.dp) + modifier = Modifier.padding(start = 20.dp), ) Spacer(modifier = Modifier.height(8.dp)) LazyRow { - item { - Spacer(modifier = Modifier.width(8.dp)) - } - items( - items = options, - key = { it.title } - ) { option -> - optionContent(option) - } - item { - Spacer(modifier = Modifier.width(8.dp)) - } + item { Spacer(modifier = Modifier.width(8.dp)) } + items(items = options, key = { it.title }) { option -> optionContent(option) } + item { Spacer(modifier = Modifier.width(8.dp)) } } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSegmentOption.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSegmentOption.kt index debac6f..caa0db8 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSegmentOption.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSegmentOption.kt @@ -5,5 +5,5 @@ import androidx.compose.ui.graphics.vector.ImageVector data class SettingSegmentOption( val icon: ImageVector, val contentDescription: String, - val onClick: () -> Unit -) \ No newline at end of file + val onClick: () -> Unit, +) diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSegmentOptions.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSegmentOptions.kt index 0ca1857..fcec54c 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSegmentOptions.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSegmentOptions.kt @@ -39,85 +39,77 @@ fun SettingSegmentOptions( icon: ImageVector, options: List, selectedOptionIndex: Int, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Row( - modifier = modifier - .clip(ShapeDefaults.Large), - verticalAlignment = Alignment.CenterVertically + modifier = modifier.clip(ShapeDefaults.Large), + verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = icon, tint = MaterialTheme.colorScheme.onSurface, - contentDescription = null + contentDescription = null, ) - Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp) - ) { + Column(modifier = Modifier.weight(1f).padding(horizontal = 16.dp)) { Text( text = title, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Text( text = supportingText, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Box( - modifier = modifier - .width(IntrinsicSize.Min) - .height(IntrinsicSize.Min) - .clip(ShapeDefaults.ExtraLarge) - .background(color = MaterialTheme.colorScheme.surfaceContainerHighest) + modifier = + modifier + .width(IntrinsicSize.Min) + .height(IntrinsicSize.Min) + .clip(ShapeDefaults.ExtraLarge) + .background(color = MaterialTheme.colorScheme.surfaceContainerHighest) ) { - var midPoint by remember { - mutableStateOf(0.dp) - } + var midPoint by remember { mutableStateOf(0.dp) } val density = LocalDensity.current - val capsuleOffset by animateDpAsState( - targetValue = if (selectedOptionIndex == 0) 0.dp else midPoint, - label = "capsule-offset-animation" - ) + val capsuleOffset by + animateDpAsState( + targetValue = if (selectedOptionIndex == 0) 0.dp else midPoint, + label = "capsule-offset-animation", + ) Box( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(.5f) - .offset(x = capsuleOffset) - .clip(ShapeDefaults.ExtraLarge) - .background(color = MaterialTheme.colorScheme.primary) + modifier = + Modifier.fillMaxHeight() + .fillMaxWidth(.5f) + .offset(x = capsuleOffset) + .clip(ShapeDefaults.ExtraLarge) + .background(color = MaterialTheme.colorScheme.primary) ) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .onGloballyPositioned { + modifier = + Modifier.onGloballyPositioned { midPoint = with(density) { (it.size.width / 2).toDp() } - } + }, ) { options.fastForEachIndexed { index, option -> Icon( imageVector = option.icon, contentDescription = option.contentDescription, - tint = if (selectedOptionIndex == index) { - MaterialTheme.colorScheme.onPrimary - } else MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .padding(8.dp) - .clip(CircleShape) - .clickable { - option.onClick() - } + tint = + if (selectedOptionIndex == index) { + MaterialTheme.colorScheme.onPrimary + } else MaterialTheme.colorScheme.onSurface, + modifier = + Modifier.padding(8.dp).clip(CircleShape).clickable { option.onClick() }, ) } } } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSlider.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSlider.kt index 1d308b2..b66435b 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSlider.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSlider.kt @@ -1,4 +1,3 @@ - package com.bobbyesp.ui.components.preferences import androidx.annotation.IntRange @@ -24,25 +23,20 @@ fun SettingSlider( onValueChangeFinished: () -> Unit, valueToShow: String? = null, @IntRange steps: Int = 0, - valueRange: ClosedFloatingPointRange = 0f..1f + valueRange: ClosedFloatingPointRange = 0f..1f, ) { - Column( - modifier = modifier - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { + Column(modifier = modifier) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( text = title, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Text( text = "${valueToShow ?: value.toInt()}", style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } @@ -54,7 +48,7 @@ fun SettingSlider( onValueChangeFinished = onValueChangeFinished, steps = steps, valueRange = valueRange, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSwitch.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSwitch.kt index f629825..53c291a 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSwitch.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingSwitch.kt @@ -27,49 +27,42 @@ fun SettingSwitch( icon: ImageVector, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Row( - modifier = modifier - .clip(ShapeDefaults.Large) - .clickable( + modifier = + modifier.clip(ShapeDefaults.Large).clickable( interactionSource = remember { MutableInteractionSource() }, - indication = null + indication = null, ) { onCheckedChange(!isChecked) }, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = icon, tint = MaterialTheme.colorScheme.onSurface, - contentDescription = null + contentDescription = null, ) - Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp) - ) { + Column(modifier = Modifier.weight(1f).padding(horizontal = 16.dp)) { Text( text = title, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Text( text = supportingText, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Switch( checked = isChecked, onCheckedChange = onCheckedChange, - colors = SwitchDefaults.colors( - uncheckedBorderColor = Color.Transparent, - ) + colors = SwitchDefaults.colors(uncheckedBorderColor = Color.Transparent), ) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingsItem.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingsItem.kt index 5cbf0da..b87de0d 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingsItem.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/preferences/SettingsItem.kt @@ -31,98 +31,88 @@ data class SettingsItem( @OptIn(ExperimentalFoundationApi::class) @Composable -fun SettingsItem( - item: SettingsItem, - modifier: Modifier = Modifier -) { +fun SettingsItem(item: SettingsItem, modifier: Modifier = Modifier) { Row( - modifier = modifier - .background(color = MaterialTheme.colorScheme.surfaceContainer) - .combinedClickable( - onClick = item.onClick - ) - .padding(horizontal = 24.dp, vertical = 16.dp), //maybe delete this padding + modifier = + modifier + .background(color = MaterialTheme.colorScheme.surfaceContainer) + .combinedClickable(onClick = item.onClick) + .padding(horizontal = 24.dp, vertical = 16.dp), // maybe delete this padding verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Icon( imageVector = item.icon, tint = MaterialTheme.colorScheme.onSurface, - contentDescription = null + contentDescription = null, ) Column { Text( text = item.title, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Text( text = item.supportingText, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } - @Composable -fun SettingsGroup( - items: List, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { +fun SettingsGroup(items: List, modifier: Modifier = Modifier) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { items.fastForEachIndexed { index, item -> SettingsItem( item = item, - modifier = Modifier - .fillMaxWidth() - .clip( - when { - items.size == 1 -> { - MaterialTheme.shapes.extraLarge - } + modifier = + Modifier.fillMaxWidth() + .clip( + when { + items.size == 1 -> { + MaterialTheme.shapes.extraLarge + } - index == 0 -> { - MaterialTheme.shapes.extraLarge.copy( - bottomStart = MaterialTheme.shapes.medium.bottomStart, - bottomEnd = MaterialTheme.shapes.medium.bottomEnd - ) - } + index == 0 -> { + MaterialTheme.shapes.extraLarge.copy( + bottomStart = MaterialTheme.shapes.medium.bottomStart, + bottomEnd = MaterialTheme.shapes.medium.bottomEnd, + ) + } - index == items.lastIndex -> { - MaterialTheme.shapes.extraLarge.copy( - topStart = MaterialTheme.shapes.medium.topStart, - topEnd = MaterialTheme.shapes.medium.topEnd - ) - } + index == items.lastIndex -> { + MaterialTheme.shapes.extraLarge.copy( + topStart = MaterialTheme.shapes.medium.topStart, + topEnd = MaterialTheme.shapes.medium.topEnd, + ) + } - else -> { - MaterialTheme.shapes.medium + else -> { + MaterialTheme.shapes.medium + } } - } - ) + ), ) } } } -//create previews +// create previews @Preview @Composable fun SettingsItemPreview() { SettingsItem( - item = SettingsItem( - title = "Title", - supportingText = "Supporting Text", - icon = Icons.Rounded.Settings, - onClick = {} - ) + item = + SettingsItem( + title = "Title", + supportingText = "Supporting Text", + icon = Icons.Rounded.Settings, + onClick = {}, + ) ) } @@ -130,49 +120,50 @@ fun SettingsItemPreview() { @Composable fun SettingsGroupPreview() { SettingsGroup( - items = listOf( - SettingsItem( - title = "Title", - supportingText = "Supporting Text", - icon = Icons.Rounded.Settings, - onClick = {} - ), - SettingsItem( - title = "Title", - supportingText = "Supporting Text", - icon = Icons.Rounded.Settings, - onClick = {} - ), - SettingsItem( - title = "Title", - supportingText = "Supporting Text", - icon = Icons.Rounded.Settings, - onClick = {} - ), - SettingsItem( - title = "Title", - supportingText = "Supporting Text", - icon = Icons.Rounded.Settings, - onClick = {} - ), - SettingsItem( - title = "Title", - supportingText = "Supporting Text", - icon = Icons.Rounded.Settings, - onClick = {} - ), - SettingsItem( - title = "Title", - supportingText = "Supporting Text", - icon = Icons.Rounded.Settings, - onClick = {} - ), - SettingsItem( - title = "Title", - supportingText = "Supporting Text", - icon = Icons.Rounded.Settings, - onClick = {} - ), - ) + items = + listOf( + SettingsItem( + title = "Title", + supportingText = "Supporting Text", + icon = Icons.Rounded.Settings, + onClick = {}, + ), + SettingsItem( + title = "Title", + supportingText = "Supporting Text", + icon = Icons.Rounded.Settings, + onClick = {}, + ), + SettingsItem( + title = "Title", + supportingText = "Supporting Text", + icon = Icons.Rounded.Settings, + onClick = {}, + ), + SettingsItem( + title = "Title", + supportingText = "Supporting Text", + icon = Icons.Rounded.Settings, + onClick = {}, + ), + SettingsItem( + title = "Title", + supportingText = "Supporting Text", + icon = Icons.Rounded.Settings, + onClick = {}, + ), + SettingsItem( + title = "Title", + supportingText = "Supporting Text", + icon = Icons.Rounded.Settings, + onClick = {}, + ), + SettingsItem( + title = "Title", + supportingText = "Supporting Text", + icon = Icons.Rounded.Settings, + onClick = {}, + ), + ) ) -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullState.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullState.kt index ffc4166..63a454c 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullState.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullState.kt @@ -22,33 +22,24 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable -fun rememberPullState( - config: PullStateConfig = PullStateConfig() -): PullState { +fun rememberPullState(config: PullStateConfig = PullStateConfig()): PullState { val density = LocalDensity.current val scope = rememberCoroutineScope() val insetTop = WindowInsets.statusBars.getTop(density) return remember(insetTop, config, density, scope) { - PullState( - insetTop, - config, - density, - scope - ) + PullState(insetTop, config, density, scope) } } -data class PullStateConfig( - val heightRefreshing: Dp = 60.dp, - val heightMax: Dp = 80.dp, -) { +data class PullStateConfig(val heightRefreshing: Dp = 60.dp, val heightMax: Dp = 80.dp) { init { require(heightMax >= heightRefreshing) } } -class PullState internal constructor( +class PullState +internal constructor( val maxInsetTop: Int, val config: PullStateConfig, private val density: Density, @@ -58,16 +49,20 @@ class PullState internal constructor( private val heightMax = with(density) { config.heightMax.toPx() } private val _offsetY = Animatable(0f) - val offsetY: Float get() = _offsetY.value + val offsetY: Float + get() = _offsetY.value // 1f -> Refresh triggered on release - val progressRefreshTrigger: Float get() = (offsetY / heightRefreshing).coerceIn(0f, 1f) + val progressRefreshTrigger: Float + get() = (offsetY / heightRefreshing).coerceIn(0f, 1f) // 1f -> Max drag amount reached - val progressHeightMax: Float get() = (offsetY / heightMax).coerceIn(0f, 1f) + val progressHeightMax: Float + get() = (offsetY / heightMax).coerceIn(0f, 1f) // Use this for your content's top padding. Only relevant when app is drawing behind status bar - val insetTop: Dp get() = with(density) { (maxInsetTop - maxInsetTop * progressRefreshTrigger).toDp() } + val insetTop: Dp + get() = with(density) { (maxInsetTop - maxInsetTop * progressRefreshTrigger).toDp() } // User drag in progress var isDragging by mutableStateOf(false) @@ -101,93 +96,94 @@ class PullState internal constructor( } } - val scrollConnection = object : NestedScrollConnection { - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - when { - !isEnabled -> return Offset.Zero - available.y > 0 && source == NestedScrollSource.UserInput -> { - // 1. User is dragging - // 2. Scrollable container reached the top (OR max drag reached and neither scroll container nor P2R are interested. Poor available Offset...) - // 3. There is still drag available that the scrollable container did not consume - // -> Start drag. Because next frame offsetY will be > 0f, onPreScroll will take over from here - isDragging = true - scope.launch { - _offsetY.snapTo((offsetY + available.y).coerceIn(0f, heightMax)) + val scrollConnection = + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + when { + !isEnabled -> return Offset.Zero + available.y > 0 && source == NestedScrollSource.UserInput -> { + // 1. User is dragging + // 2. Scrollable container reached the top (OR max drag reached and neither + // scroll + // container nor P2R are interested. Poor available Offset...) + // 3. There is still drag available that the scrollable container did not + // consume + // -> Start drag. Because next frame offsetY will be > 0f, onPreScroll will + // take over + // from here + isDragging = true + scope.launch { + _offsetY.snapTo((offsetY + available.y).coerceIn(0f, heightMax)) + } } } - } - return Offset.Zero - } - - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - when { - !isEnabled -> return Offset.Zero - offsetY > 0 && source == NestedScrollSource.UserInput -> { - // Consumes the drag as long as the indicator is visible - isDragging = true - val newOffset = offsetY + available.y - - // Surplus drag amount is not consumed - val remaining = when { - newOffset > heightMax -> newOffset - heightMax - newOffset < 0f -> newOffset - else -> 0f - } + return Offset.Zero + } - scope.launch { - _offsetY.snapTo(newOffset.coerceIn(0f, heightMax)) + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + when { + !isEnabled -> return Offset.Zero + offsetY > 0 && source == NestedScrollSource.UserInput -> { + // Consumes the drag as long as the indicator is visible + isDragging = true + val newOffset = offsetY + available.y + + // Surplus drag amount is not consumed + val remaining = + when { + newOffset > heightMax -> newOffset - heightMax + newOffset < 0f -> newOffset + else -> 0f + } + + scope.launch { _offsetY.snapTo(newOffset.coerceIn(0f, heightMax)) } + + return Offset(0f, (available.y - remaining)) } - - return Offset(0f, (available.y - remaining)) } + + return Offset.Zero } - return Offset.Zero - } + override suspend fun onPreFling(available: Velocity): Velocity { + if (!isEnabled) return Velocity.Zero - override suspend fun onPreFling(available: Velocity): Velocity { - if (!isEnabled) return Velocity.Zero + isDragging = false - isDragging = false + when { + // When refreshing and a drag stops, either settle to 0f or heightRefreshing, + isRefreshing -> { + val target = + when { + heightRefreshing - offsetY < heightRefreshing / 2 -> + heightRefreshing + else -> 0f + } - when { - // When refreshing and a drag stops, either settle to 0f or heightRefreshing, - isRefreshing -> { - val target = when { - heightRefreshing - offsetY < heightRefreshing / 2 -> heightRefreshing - else -> 0f - } + scope.launch { settle(target) } - scope.launch { - settle(target) + // Consume the velocity as long as the indicator is visible + return if (offsetY == 0f) Velocity.Zero else available } - // Consume the velocity as long as the indicator is visible - return if (offsetY == 0f) Velocity.Zero else available - } - - // Trigger refresh - offsetY >= heightRefreshing -> { - isRefreshing = true - scope.launch { - settle(heightRefreshing) + // Trigger refresh + offsetY >= heightRefreshing -> { + isRefreshing = true + scope.launch { settle(heightRefreshing) } } - } - // Drag cancelled, go back to 0f - else -> { - scope.launch { - settle(0f) + // Drag cancelled, go back to 0f + else -> { + scope.launch { settle(0f) } } } - } - return Velocity.Zero + return Velocity.Zero + } } - } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullToRefreshIndicator.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullToRefreshIndicator.kt index d69b048..dc89ea2 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullToRefreshIndicator.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullToRefreshIndicator.kt @@ -29,14 +29,13 @@ import androidx.compose.ui.unit.dp import com.bobbyesp.ui.R @Composable -fun Indicator( - pullState: PullState -) { +fun Indicator(pullState: PullState) { val hapticFeedback = LocalHapticFeedback.current val scale = remember { Animatable(1f) } - // Pop the indicator once shortly when reaching refresh trigger offset. Also trigger some haptic feedback + // Pop the indicator once shortly when reaching refresh trigger offset. Also trigger some haptic + // feedback LaunchedEffect(pullState.progressRefreshTrigger >= 1f) { if (pullState.progressRefreshTrigger >= 1f && !pullState.isRefreshing) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) @@ -46,51 +45,48 @@ fun Indicator( } Box( - modifier = Modifier - .statusBarsPadding() - .heightIn( - 24.dp, - pullState.config.heightMax * pullState.progressHeightMax - pullState.insetTop - ) - .fillMaxWidth(), - contentAlignment = Alignment.Center + modifier = + Modifier.statusBarsPadding() + .heightIn( + 24.dp, + pullState.config.heightMax * pullState.progressHeightMax - pullState.insetTop, + ) + .fillMaxWidth(), + contentAlignment = Alignment.Center, ) { Row( - modifier = Modifier - .scale(scale.value), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.scale(scale.value), + verticalAlignment = Alignment.CenterVertically, ) { if (pullState.isRefreshing) { - CircularProgressIndicator( - modifier = Modifier - .size(16.dp), - strokeWidth = 2.dp, - ) + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) } else if (pullState.isReloadFinished) { Icon( imageVector = Icons.Rounded.Check, contentDescription = stringResource(R.string.successfully_refreshed), - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) } else { CircularProgressIndicator( progress = { pullState.progressRefreshTrigger }, - modifier = Modifier - .size(16.dp), + modifier = Modifier.size(16.dp), strokeWidth = 2.dp, ) } Spacer(modifier = Modifier.width(8.dp)) Text( modifier = Modifier, - text = when { - pullState.isRefreshing -> stringResource(id = R.string.refreshing) - pullState.isReloadFinished -> stringResource(id = R.string.successfully_refreshed) - pullState.progressRefreshTrigger >= 1f -> stringResource(id = R.string.release_to_refresh) - else -> stringResource(id = R.string.pull_to_refresh) - }, + text = + when { + pullState.isRefreshing -> stringResource(id = R.string.refreshing) + pullState.isReloadFinished -> + stringResource(id = R.string.successfully_refreshed) + pullState.progressRefreshTrigger >= 1f -> + stringResource(id = R.string.release_to_refresh) + else -> stringResource(id = R.string.pull_to_refresh) + }, style = MaterialTheme.typography.labelLarge, ) } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullToRefreshLayout.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullToRefreshLayout.kt index b4d42db..600fdc7 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullToRefreshLayout.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/pulltorefresh/PullToRefreshLayout.kt @@ -2,6 +2,7 @@ package com.bobbyesp.ui.components.pulltorefresh import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,9 +22,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -/** - * Made by Konstantin Klassen (https://twitter.com/Snokbert) - */ +/** Made by Konstantin Klassen (https://twitter.com/Snokbert) */ @OptIn(ExperimentalFoundationApi::class) @Composable fun PullToRefreshLayout( @@ -32,44 +31,51 @@ fun PullToRefreshLayout( content: @Composable () -> Unit, ) { CompositionLocalProvider( - LocalOverscrollConfiguration provides null // Disable overscroll otherwise it consumes the drag before we get the chance + LocalOverscrollFactory provides + null // Disable overscroll otherwise it consumes the drag before we get the chance ) { Box( - modifier = modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .background( - brush = Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.background, - Color.Transparent - ), - startY = -10f, - endY = pullState.progressRefreshTrigger * 120 + modifier = + modifier + .background(MaterialTheme.colorScheme.primaryContainer) + .background( + brush = + Brush.verticalGradient( + colors = + listOf(MaterialTheme.colorScheme.background, Color.Transparent), + startY = -10f, + endY = pullState.progressRefreshTrigger * 120, + ) ) - ) - .nestedScroll(pullState.scrollConnection), + .nestedScroll(pullState.scrollConnection) ) { Indicator(pullState = pullState) Column { - // This invisible spacer height + current top inset is always equals max top inset to keep scroll speed constant - Spacer(modifier = Modifier.height(LocalDensity.current.run { pullState.maxInsetTop.toDp() } - pullState.insetTop)) + // This invisible spacer height + current top inset is always equals max top inset + // to + // keep scroll speed constant + Spacer( + modifier = + Modifier.height( + LocalDensity.current.run { pullState.maxInsetTop.toDp() } - + pullState.insetTop + ) + ) Surface( - modifier = Modifier - .offset { - IntOffset(0, pullState.offsetY.toInt()) - }, + modifier = Modifier.offset { IntOffset(0, pullState.offsetY.toInt()) }, color = Color.Transparent, - shape = RoundedCornerShape( - topStart = 16.dp * pullState.progressRefreshTrigger, - topEnd = 16.dp * pullState.progressRefreshTrigger, - bottomStart = 0.dp, - bottomEnd = 0.dp - ) + shape = + RoundedCornerShape( + topStart = 16.dp * pullState.progressRefreshTrigger, + topEnd = 16.dp * pullState.progressRefreshTrigger, + bottomStart = 0.dp, + bottomEnd = 0.dp, + ), ) { content() } } } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/state/LoadingState.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/state/LoadingState.kt index 2e0646e..a5ebb85 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/state/LoadingState.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/state/LoadingState.kt @@ -17,21 +17,16 @@ import androidx.compose.ui.unit.dp fun LoadingState(text: String, modifier: Modifier = Modifier) { Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy( - 8.dp, - Alignment.CenterVertically - ), - horizontalAlignment = Alignment.CenterHorizontally + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = text, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold, - modifier = Modifier.fillMaxWidth() - ) - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(0.8f) + modifier = Modifier.fillMaxWidth(), ) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth(0.8f)) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/tags/RoundedTag.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/tags/RoundedTag.kt index 7970a00..7e094bd 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/tags/RoundedTag.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/tags/RoundedTag.kt @@ -18,12 +18,12 @@ import androidx.compose.ui.unit.dp fun RoundedTag( modifier: Modifier = Modifier, text: String, - shape: Shape = MaterialTheme.shapes.medium + shape: Shape = MaterialTheme.shapes.medium, ) { Surface( modifier = modifier, color = MaterialTheme.colorScheme.secondaryContainer, - shape = shape + shape = shape, ) { Row( modifier = Modifier, @@ -36,8 +36,8 @@ fun RoundedTag( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), ) } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/text/AnimatedCounter.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/text/AnimatedCounter.kt index a6ed105..657abfe 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/text/AnimatedCounter.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/text/AnimatedCounter.kt @@ -20,37 +20,31 @@ import androidx.compose.ui.text.TextStyle fun AnimatedCounter( count: Int, modifier: Modifier = Modifier, - style: TextStyle = MaterialTheme.typography.bodySmall + style: TextStyle = MaterialTheme.typography.bodySmall, ) { - var oldCount by remember { - mutableIntStateOf(count) - } - SideEffect { - oldCount = count - } + var oldCount by remember { mutableIntStateOf(count) } + SideEffect { oldCount = count } Row(modifier = modifier) { val countString = count.toString() val oldCountString = oldCount.toString() countString.indices.forEach { i -> val oldChar = oldCountString.getOrNull(i) val newChar = countString[i] - val char = if (oldChar == newChar) { - oldCountString[i] - } else { - countString[i] - } + val char = + if (oldChar == newChar) { + oldCountString[i] + } else { + countString[i] + } AnimatedContent( targetState = char, transitionSpec = { slideInVertically { it } togetherWith slideOutVertically { -it } - }, label = "Animated Counter" + }, + label = "Animated Counter", ) { character -> - Text( - text = character.toString(), - style = style, - softWrap = false - ) + Text(text = character.toString(), style = style, softWrap = false) } } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/text/AutoResizableText.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/text/AutoResizableText.kt index b757b3c..0a10cc7 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/text/AutoResizableText.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/text/AutoResizableText.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.isUnspecified -//Gotten from the Philip Slacker's YouTube channel +// Gotten from the Philip Slacker's YouTube channel @Composable fun AutoResizableText( modifier: Modifier = Modifier, @@ -29,12 +29,8 @@ fun AutoResizableText( color: Color = textStyle.color, maxLines: Int = 1, ) { - var resizedTextStyle by remember { - mutableStateOf(textStyle) - } - var shouldDraw by remember { - mutableStateOf(false) - } + var resizedTextStyle by remember { mutableStateOf(textStyle) } + var shouldDraw by remember { mutableStateOf(false) } val defaultFontSize = MaterialTheme.typography.bodySmall.fontSize @@ -42,31 +38,29 @@ fun AutoResizableText( text = text, color = color, maxLines = maxLines, - modifier = modifier.drawWithContent { - if (shouldDraw) { - drawContent() - } - }, + modifier = + modifier.drawWithContent { + if (shouldDraw) { + drawContent() + } + }, softWrap = false, style = resizedTextStyle, onTextLayout = { result -> if (result.didOverflowWidth) { if (textStyle.fontSize.isUnspecified) { - resizedTextStyle = resizedTextStyle.copy( - fontSize = defaultFontSize - ) + resizedTextStyle = resizedTextStyle.copy(fontSize = defaultFontSize) } - resizedTextStyle = resizedTextStyle.copy( - fontSize = resizedTextStyle.fontSize * 0.95 - ) + resizedTextStyle = + resizedTextStyle.copy(fontSize = resizedTextStyle.fontSize * 0.95) } else { shouldDraw = true } - } + }, ) } -//auto resizable text but with all the text parameters +// auto resizable text but with all the text parameters @Composable fun AutoResizableText( modifier: Modifier = Modifier, @@ -83,12 +77,8 @@ fun AutoResizableText( style: TextStyle = LocalTextStyle.current.plus(TextStyle()), color: Color = style.color, ) { - var resizedTextStyle by remember { - mutableStateOf(style) - } - var shouldDraw by remember { - mutableStateOf(false) - } + var resizedTextStyle by remember { mutableStateOf(style) } + var shouldDraw by remember { mutableStateOf(false) } val defaultFontSize = MaterialTheme.typography.bodySmall.fontSize @@ -104,27 +94,24 @@ fun AutoResizableText( textDecoration = textDecoration, textAlign = textAlign, lineHeight = lineHeight, - - modifier = modifier.drawWithContent { - if (shouldDraw) { - drawContent() - } - }, + modifier = + modifier.drawWithContent { + if (shouldDraw) { + drawContent() + } + }, softWrap = false, style = resizedTextStyle, onTextLayout = { result -> if (result.didOverflowWidth) { if (style.fontSize.isUnspecified) { - resizedTextStyle = resizedTextStyle.copy( - fontSize = defaultFontSize - ) + resizedTextStyle = resizedTextStyle.copy(fontSize = defaultFontSize) } - resizedTextStyle = resizedTextStyle.copy( - fontSize = resizedTextStyle.fontSize * 0.95 - ) + resizedTextStyle = + resizedTextStyle.copy(fontSize = resizedTextStyle.fontSize * 0.95) } else { shouldDraw = true } - } + }, ) -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/text/CategoryTitles.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/text/CategoryTitles.kt index 70822d9..3fc28b4 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/text/CategoryTitles.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/text/CategoryTitles.kt @@ -18,12 +18,10 @@ fun ExtraLargeCategoryTitle( ) { Text( text = text, - modifier = modifier - .fillMaxWidth() - .padding(start = 4.dp, top = 16.dp, bottom = 8.dp), + modifier = modifier.fillMaxWidth().padding(start = 4.dp, top = 16.dp, bottom = 8.dp), color = color, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) } @@ -35,11 +33,9 @@ fun LargeCategoryTitle( ) { Text( text = text, - modifier = modifier - .fillMaxWidth() - .padding(start = 4.dp, top = 16.dp, bottom = 8.dp), + modifier = modifier.fillMaxWidth().padding(start = 4.dp, top = 16.dp, bottom = 8.dp), color = color, - style = MaterialTheme.typography.labelLarge + style = MaterialTheme.typography.labelLarge, ) } @@ -51,11 +47,9 @@ fun MediumCategoryTitle( ) { Text( text = text, - modifier = modifier - .fillMaxWidth() - .padding(start = 4.dp, top = 16.dp, bottom = 8.dp), + modifier = modifier.fillMaxWidth().padding(start = 4.dp, top = 16.dp, bottom = 8.dp), color = color, - style = MaterialTheme.typography.labelMedium + style = MaterialTheme.typography.labelMedium, ) } @@ -67,10 +61,8 @@ fun SmallCategoryTitle( ) { Text( text = text, - modifier = modifier - .fillMaxWidth() - .padding(start = 4.dp, top = 16.dp, bottom = 8.dp), + modifier = modifier.fillMaxWidth().padding(start = 4.dp, top = 16.dp, bottom = 8.dp), color = color, - style = MaterialTheme.typography.labelSmall + style = MaterialTheme.typography.labelSmall, ) -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/text/DotWithText.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/text/DotWithText.kt index d5f577c..6340507 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/text/DotWithText.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/text/DotWithText.kt @@ -19,15 +19,9 @@ import androidx.compose.ui.unit.dp fun DotWithText(text: String) { Row(verticalAlignment = Alignment.CenterVertically) { Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - ) - Text( - text = text, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp) + modifier = + Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary) ) + Text(text = text, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp)) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/text/ExpandableText.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/text/ExpandableText.kt index abfdebc..0604fc8 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/text/ExpandableText.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/text/ExpandableText.kt @@ -42,9 +42,9 @@ fun ExpandableText( var canBeExpanded by remember { mutableStateOf(false) } Text( - modifier = if (canBeExpanded) modifier - .clickable { expanded = !expanded } - .animateContentSize() else modifier, + modifier = + if (canBeExpanded) modifier.clickable { expanded = !expanded }.animateContentSize() + else modifier, text = text, color = color, fontSize = fontSize, @@ -63,6 +63,6 @@ fun ExpandableText( if (!canBeExpanded) { canBeExpanded = it.hasVisualOverflow } - } + }, ) } diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/text/MarqueeText.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/text/MarqueeText.kt index 5090199..9cfa266 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/text/MarqueeText.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/text/MarqueeText.kt @@ -41,10 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable -fun SubtextOverline( - text: String, - modifier: Modifier = Modifier -) { +fun SubtextOverline(text: String, modifier: Modifier = Modifier) { Text( text, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), @@ -53,7 +50,7 @@ fun SubtextOverline( lineHeight = 18.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = modifier + modifier = modifier, ) } @@ -104,7 +101,7 @@ fun MarqueeText( sideGradient: MarqueeTextGradientOptions = MarqueeTextGradientOptions(), customEasing: Easing? = null, animationDuration: Float = 4000f, - delayBetweenAnimations: Long = 500L + delayBetweenAnimations: Long = 500L, ) { // State to track text layout information var textLayoutInfo by remember { mutableStateOf(null) } @@ -113,26 +110,27 @@ fun MarqueeText( var offset by remember(text) { mutableIntStateOf(0) } // Composable to create the text with consistent styling - val createText = @Composable { localModifier: Modifier -> - Text( - text = text, - textAlign = textAlign, - modifier = localModifier, - color = color, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - lineHeight = lineHeight, - overflow = overflow, - softWrap = softWrap, - maxLines = maxLines, - onTextLayout = onTextLayout, - style = style, - ) - } + val createText = + @Composable { localModifier: Modifier -> + Text( + text = text, + textAlign = textAlign, + modifier = localModifier, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + onTextLayout = onTextLayout, + style = style, + ) + } LaunchedEffect(textLayoutInfo) { val layoutInfo = textLayoutInfo ?: return@LaunchedEffect @@ -146,30 +144,31 @@ fun MarqueeText( animate( initialValue = 0f, targetValue = -layoutInfo.textWidth.toFloat(), - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = duration, - delayMillis = 1000, - easing = customEasing ?: LinearEasing + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = duration, + delayMillis = 1000, + easing = customEasing ?: LinearEasing, + ), + repeatMode = RepeatMode.Restart, ), - repeatMode = RepeatMode.Restart - ) ) { value, _ -> offset = value.toInt() } } // SubcomposeLayout for flexible text rendering - SubcomposeLayout( - modifier = modifier.clipToBounds() - ) { constraints -> + SubcomposeLayout(modifier = modifier.clipToBounds()) { constraints -> // Allow infinite width for text measurement val infiniteWidthConstraints = constraints.copy(maxWidth = Int.MAX_VALUE) // Measure main text - val mainText = subcompose(MarqueeLayers.MainText) { - createText(textModifier) - }.first().measure(infiniteWidthConstraints) + val mainText = + subcompose(MarqueeLayers.MainText) { createText(textModifier) } + .first() + .measure(infiniteWidthConstraints) // Initialize placeholders var gradient: Placeable? = null @@ -183,76 +182,79 @@ fun MarqueeText( } else { // Calculate spacing and layout info val spacing = constraints.maxWidth * 2 / 3 - textLayoutInfo = TextLayoutInfo( - textWidth = mainText.width + spacing, - containerWidth = constraints.maxWidth - ) + textLayoutInfo = + TextLayoutInfo( + textWidth = mainText.width + spacing, + containerWidth = constraints.maxWidth, + ) // Prepare secondary text placement val secondTextOffset = mainText.width + offset + spacing val secondTextSpace = constraints.maxWidth - secondTextOffset if (secondTextSpace > 0) { - secondPlaceableWithOffset = subcompose(MarqueeLayers.SecondaryText) { - createText(textModifier) - }.first().measure(infiniteWidthConstraints) to secondTextOffset + secondPlaceableWithOffset = + subcompose(MarqueeLayers.SecondaryText) { createText(textModifier) } + .first() + .measure(infiniteWidthConstraints) to secondTextOffset } // Create gradient edges - gradient = subcompose(MarqueeLayers.EdgesGradient) { - Row { - if (sideGradient.left) { - GradientEdge( - startColor = sideGradient.color, - endColor = Color.Transparent - ) + gradient = + subcompose(MarqueeLayers.EdgesGradient) { + Row { + if (sideGradient.left) { + GradientEdge( + startColor = sideGradient.color, + endColor = Color.Transparent, + ) + } + Spacer(modifier = Modifier.weight(1f)) + if (sideGradient.right) { + GradientEdge( + startColor = Color.Transparent, + endColor = sideGradient.color, + ) + } + } } - Spacer(modifier = Modifier.weight(1f)) - if (sideGradient.right) { - GradientEdge( - startColor = Color.Transparent, - endColor = sideGradient.color - ) - } - } - }.first().measure(constraints.copy(maxHeight = mainText.height)) + .first() + .measure(constraints.copy(maxHeight = mainText.height)) } // Final layout placement layout( - width = if (mainText.width > constraints.maxWidth) constraints.maxWidth else mainText.width, - height = mainText.height + width = + if (mainText.width > constraints.maxWidth) constraints.maxWidth else mainText.width, + height = mainText.height, ) { mainText.place(offset, 0) - secondPlaceableWithOffset?.let { - it.first.place(it.second, 0) - } + secondPlaceableWithOffset?.let { it.first.place(it.second, 0) } gradient?.place(0, 0) } } } @Composable -private fun GradientEdge( - startColor: Color, endColor: Color, -) { +private fun GradientEdge(startColor: Color, endColor: Color) { Box( - modifier = Modifier - .width(10.dp) - .fillMaxHeight() - .background( - brush = Brush.horizontalGradient( - listOf(startColor, endColor) - ) - ) + modifier = + Modifier.width(10.dp) + .fillMaxHeight() + .background(brush = Brush.horizontalGradient(listOf(startColor, endColor))) ) } data class MarqueeTextGradientOptions( val color: Color = Color.Transparent, val right: Boolean = true, - val left: Boolean = true + val left: Boolean = true, ) -private enum class MarqueeLayers { MainText, SecondaryText, EdgesGradient } -private data class TextLayoutInfo(val textWidth: Int, val containerWidth: Int) \ No newline at end of file +private enum class MarqueeLayers { + MainText, + SecondaryText, + EdgesGradient, +} + +private data class TextLayoutInfo(val textWidth: Int, val containerWidth: Int) diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/text/TextSizedComponents.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/text/TextSizedComponents.kt index 87a7945..1b4bac8 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/text/TextSizedComponents.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/text/TextSizedComponents.kt @@ -31,7 +31,7 @@ fun MediumText( maxLines = maxLines, lineHeight = lineHeight, overflow = TextOverflow.Ellipsis, - modifier = modifier + modifier = modifier, ) } @@ -51,6 +51,6 @@ fun Subtext( lineHeight = 18.sp, maxLines = maxLines, overflow = TextOverflow.Ellipsis, - modifier = modifier + modifier = modifier, ) -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/text/bottomSheet/BottomSheetTexts.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/text/bottomSheet/BottomSheetTexts.kt index 506d9e3..f2d84ac 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/text/bottomSheet/BottomSheetTexts.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/text/bottomSheet/BottomSheetTexts.kt @@ -11,30 +11,24 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp @Composable -fun BottomSheetHeader( - modifier: Modifier = Modifier, - text: String -) { +fun BottomSheetHeader(modifier: Modifier = Modifier, text: String) { Text( text, fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, fontWeight = FontWeight.Medium, - modifier = modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth(), ) } @Composable -fun BottomSheetSubtitle( - modifier: Modifier = Modifier, - text: AnnotatedString -) { +fun BottomSheetSubtitle(modifier: Modifier = Modifier, text: AnnotatedString) { Text( text, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), textAlign = TextAlign.Center, - modifier = modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth(), ) -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/components/topbar/ColumnWithCollapsibleTopBar.kt b/app/ui/src/main/java/com/bobbyesp/ui/components/topbar/ColumnWithCollapsibleTopBar.kt index c17b2a6..d964a53 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/components/topbar/ColumnWithCollapsibleTopBar.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/components/topbar/ColumnWithCollapsibleTopBar.kt @@ -47,8 +47,8 @@ import kotlinx.coroutines.launch import my.nanihadesuka.compose.LazyColumnScrollbar import my.nanihadesuka.compose.ScrollbarSettings -//Most of this components are from "Lotus" app -//https://github.com/dn0ne/lotus/blob/master/app/src/main/java/com/dn0ne/player/app/presentation/components/topbar/ColumnWithCollapsibleTopBar.kt +// Most of this components are from "Lotus" app +// https://github.com/dn0ne/lotus/blob/master/app/src/main/java/com/dn0ne/player/app/presentation/components/topbar/ColumnWithCollapsibleTopBar.kt @Composable fun ColumnWithCollapsibleTopBar( @@ -63,7 +63,7 @@ fun ColumnWithCollapsibleTopBar( contentHorizontalAlignment: Alignment.Horizontal = Alignment.Start, contentVerticalArrangement: Arrangement.Vertical = Arrangement.Top, modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit + content: @Composable ColumnScope.() -> Unit, ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() @@ -71,15 +71,18 @@ fun ColumnWithCollapsibleTopBar( val isInLandscapeOrientation by rememberUpdatedState(newValue = isDeviceInLandscape()) val minHeightPx = with(density) { minTopBarHeight.toPx() } - val maxHeightPx = with(density) { - if (isInLandscapeOrientation) maxTopBarHeightLandscape.toPx() else maxTopBarHeight.toPx() - } + val maxHeightPx = + with(density) { + if (isInLandscapeOrientation) maxTopBarHeightLandscape.toPx() + else maxTopBarHeight.toPx() + } val topBarHeight = remember { Animatable( - initialValue = if (collapsedByDefault || isInLandscapeOrientation) { - minHeightPx - } else maxHeightPx + initialValue = + if (collapsedByDefault || isInLandscapeOrientation) { + minHeightPx + } else maxHeightPx ) } @@ -91,25 +94,18 @@ fun ColumnWithCollapsibleTopBar( // Using derivedStateOf to avoid unnecessary recompositions val collapseFractionState by remember { - derivedStateOf { - (topBarHeight.value - minHeightPx) / (maxHeightPx - minHeightPx) - } + derivedStateOf { (topBarHeight.value - minHeightPx) / (maxHeightPx - minHeightPx) } } - LaunchedEffect(collapseFractionState) { - collapseFraction(collapseFractionState) - } + LaunchedEffect(collapseFractionState) { collapseFraction(collapseFractionState) } val topBarScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val previousHeight = topBarHeight.value - val newHeight = (previousHeight + available.y) - .coerceIn(minHeightPx, maxHeightPx) + val newHeight = (previousHeight + available.y).coerceIn(minHeightPx, maxHeightPx) - coroutineScope.launch { - topBarHeight.snapTo(newHeight) - } + coroutineScope.launch { topBarHeight.snapTo(newHeight) } return Offset(0f, newHeight - previousHeight) } @@ -118,8 +114,9 @@ fun ColumnWithCollapsibleTopBar( val threshold = (maxHeightPx - minHeightPx) coroutineScope.launch { topBarHeight.animateTo( - targetValue = if (topBarHeight.value < threshold) minHeightPx else maxHeightPx, - animationSpec = spring(stiffness = Spring.StiffnessLow) + targetValue = + if (topBarHeight.value < threshold) minHeightPx else maxHeightPx, + animationSpec = spring(stiffness = Spring.StiffnessLow), ) } @@ -128,23 +125,17 @@ fun ColumnWithCollapsibleTopBar( } } - Box( - modifier = modifier - .nestedScroll(topBarScrollConnection) - ) { + Box(modifier = modifier.nestedScroll(topBarScrollConnection)) { Column { - Spacer( - modifier = Modifier - .height(with(density) { topBarHeight.value.toDp() }) - ) + Spacer(modifier = Modifier.height(with(density) { topBarHeight.value.toDp() })) Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(contentScrollState) - .padding(contentPadding), + modifier = + Modifier.fillMaxSize() + .verticalScroll(contentScrollState) + .padding(contentPadding), horizontalAlignment = contentHorizontalAlignment, - verticalArrangement = contentVerticalArrangement + verticalArrangement = contentVerticalArrangement, ) { content() Spacer(modifier = Modifier.height(200.dp)) @@ -152,9 +143,7 @@ fun ColumnWithCollapsibleTopBar( } Box( - modifier = Modifier - .fillMaxWidth() - .height(with(density) { topBarHeight.value.toDp() }), + modifier = Modifier.fillMaxWidth().height(with(density) { topBarHeight.value.toDp() }) ) { CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onSurface @@ -179,7 +168,7 @@ fun LazyColumnWithCollapsibleTopBar( contentVerticalArrangement: Arrangement.Vertical = Arrangement.Top, enableScrollbar: Boolean = true, modifier: Modifier = Modifier, - content: LazyListScope.() -> Unit + content: LazyListScope.() -> Unit, ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() @@ -193,15 +182,15 @@ fun LazyColumnWithCollapsibleTopBar( } else maxTopBarHeight.toPx() } } - val topBarHeight = rememberAnimatable( - initialValue = if (collapsedByDefault || isInLandscapeOrientation) { - minTopBarHeight - } else maxTopBarHeight - ) + val topBarHeight = + rememberAnimatable( + initialValue = + if (collapsedByDefault || isInLandscapeOrientation) { + minTopBarHeight + } else maxTopBarHeight + ) - LaunchedEffect(isInLandscapeOrientation) { - topBarHeight.snapTo(maxTopBarHeight) - } + LaunchedEffect(isInLandscapeOrientation) { topBarHeight.snapTo(maxTopBarHeight) } LaunchedEffect(topBarHeight.value) { collapseFraction( @@ -211,29 +200,19 @@ fun LazyColumnWithCollapsibleTopBar( val topBarScrollConnection = remember { return@remember object : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource - ): Offset { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val previousHeight = topBarHeight.value - val newHeight = if (listState.firstVisibleItemIndex >= 0 && available.y < 0) { - (previousHeight + available.y).coerceIn( - minTopBarHeight, - maxTopBarHeight - ) - } else if ( - listState.firstVisibleItemIndex == 0 && - listState.layoutInfo.visibleItemsInfo.firstOrNull()?.offset == 0 - ) { - (previousHeight + available.y).coerceIn( - minTopBarHeight, - maxTopBarHeight - ) - } else previousHeight + val newHeight = + if (listState.firstVisibleItemIndex >= 0 && available.y < 0) { + (previousHeight + available.y).coerceIn(minTopBarHeight, maxTopBarHeight) + } else if ( + listState.firstVisibleItemIndex == 0 && + listState.layoutInfo.visibleItemsInfo.firstOrNull()?.offset == 0 + ) { + (previousHeight + available.y).coerceIn(minTopBarHeight, maxTopBarHeight) + } else previousHeight - coroutineScope.launch { - topBarHeight.snapTo(newHeight) - } + coroutineScope.launch { topBarHeight.snapTo(newHeight) } return Offset(0f, newHeight - previousHeight) } @@ -241,8 +220,10 @@ fun LazyColumnWithCollapsibleTopBar( coroutineScope.launch { val threshold = (maxTopBarHeight - minTopBarHeight) topBarHeight.animateTo( - targetValue = if (topBarHeight.value < threshold) minTopBarHeight else maxTopBarHeight, - animationSpec = spring(stiffness = Spring.StiffnessLow) + targetValue = + if (topBarHeight.value < threshold) minTopBarHeight + else maxTopBarHeight, + animationSpec = spring(stiffness = Spring.StiffnessLow), ) } @@ -251,45 +232,34 @@ fun LazyColumnWithCollapsibleTopBar( } } - Box( - modifier = modifier - .nestedScroll(topBarScrollConnection) - ) { + Box(modifier = modifier.nestedScroll(topBarScrollConnection)) { Column { - Spacer( - modifier = Modifier - .height(with(density) { topBarHeight.value.toDp() }) - ) + Spacer(modifier = Modifier.height(with(density) { topBarHeight.value.toDp() })) LazyColumnScrollbar( state = listState, - settings = ScrollbarSettings( - enabled = enableScrollbar, - thumbUnselectedColor = MaterialTheme.colorScheme.surfaceContainer, - thumbSelectedColor = MaterialTheme.colorScheme.primaryContainer, - ) + settings = + ScrollbarSettings( + enabled = enableScrollbar, + thumbUnselectedColor = MaterialTheme.colorScheme.surfaceContainer, + thumbSelectedColor = MaterialTheme.colorScheme.primaryContainer, + ), ) { LazyColumn( state = listState, - modifier = Modifier - .fillMaxSize() - .padding(contentPadding), + modifier = Modifier.fillMaxSize().padding(contentPadding), horizontalAlignment = contentHorizontalAlignment, - verticalArrangement = contentVerticalArrangement + verticalArrangement = contentVerticalArrangement, ) { content() - item { - Spacer(modifier = Modifier.height(200.dp)) - } + item { Spacer(modifier = Modifier.height(200.dp)) } } } } Box( - modifier = Modifier - .fillMaxWidth() - .height(with(density) { topBarHeight.value.toDp() }), + modifier = Modifier.fillMaxWidth().height(with(density) { topBarHeight.value.toDp() }) ) { CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onSurface @@ -298,4 +268,4 @@ fun LazyColumnWithCollapsibleTopBar( } } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/ext/Color.kt b/app/ui/src/main/java/com/bobbyesp/ui/ext/Color.kt index 93a09ed..33391ec 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/ext/Color.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/ext/Color.kt @@ -4,4 +4,4 @@ import androidx.compose.ui.graphics.Color fun Color.applyAlpha(enabled: Boolean, alpha: Float = 0.62f): Color { return if (enabled) this else this.copy(alpha = alpha) -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/ext/CornerBasedShape.kt b/app/ui/src/main/java/com/bobbyesp/ui/ext/CornerBasedShape.kt index 562e4d3..d1f5bbb 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/ext/CornerBasedShape.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/ext/CornerBasedShape.kt @@ -5,4 +5,5 @@ import androidx.compose.foundation.shape.CornerSize import androidx.compose.ui.unit.dp fun CornerBasedShape.top() = copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)) -fun CornerBasedShape.bottom() = copy(topStart = CornerSize(0.dp), topEnd = CornerSize(0.dp)) \ No newline at end of file + +fun CornerBasedShape.bottom() = copy(topStart = CornerSize(0.dp), topEnd = CornerSize(0.dp)) diff --git a/app/ui/src/main/java/com/bobbyesp/ui/motion/AnimatedComposables.kt b/app/ui/src/main/java/com/bobbyesp/ui/motion/AnimatedComposables.kt index 6079082..0526a94 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/motion/AnimatedComposables.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/motion/AnimatedComposables.kt @@ -67,107 +67,113 @@ inline fun NavGraphBuilder.slideInVerticallyComposable( inline fun NavGraphBuilder.animatedComposablePredictiveBack( deepLinks: List = emptyList(), noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, -) = composable( - deepLinks = deepLinks, - enterTransition = { - materialSharedAxisXIn(initialOffsetX = { (it * 0.15f).toInt() }) - }, - exitTransition = { - materialSharedAxisXOut(targetOffsetX = { -(it * InitialOffset).toInt() }) - }, - popEnterTransition = { - materialSharedAxisXIn(initialOffsetX = { -(it * InitialOffset).toInt() }) + scaleIn( - animationSpec = tween(durationMillis = 350, easing = EmphasizedDecelerate), - initialScale = 0.9f, - ) - }, - popExitTransition = { - materialSharedAxisXOut(targetOffsetX = { (it * InitialOffset).toInt() }) + scaleOut( - targetScale = 0.9f, - animationSpec = tween(durationMillis = 350, easing = EmphasizedAccelerate), - ) - }, - content = content, -) +) = + composable( + deepLinks = deepLinks, + enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * 0.15f).toInt() }) }, + exitTransition = { + materialSharedAxisXOut(targetOffsetX = { -(it * InitialOffset).toInt() }) + }, + popEnterTransition = { + materialSharedAxisXIn(initialOffsetX = { -(it * InitialOffset).toInt() }) + + scaleIn( + animationSpec = tween(durationMillis = 350, easing = EmphasizedDecelerate), + initialScale = 0.9f, + ) + }, + popExitTransition = { + materialSharedAxisXOut(targetOffsetX = { (it * InitialOffset).toInt() }) + + scaleOut( + targetScale = 0.9f, + animationSpec = tween(durationMillis = 350, easing = EmphasizedAccelerate), + ) + }, + content = content, + ) inline fun NavGraphBuilder.animatedComposableLegacy( deepLinks: List = emptyList(), noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, -) = composable( - deepLinks = deepLinks, - enterTransition = { - materialSharedAxisXIn(initialOffsetX = { (it * InitialOffset).toInt() }) - }, - exitTransition = { - materialSharedAxisXOut(targetOffsetX = { -(it * InitialOffset).toInt() }) - }, - popEnterTransition = { - materialSharedAxisXIn(initialOffsetX = { -(it * InitialOffset).toInt() }) - }, - popExitTransition = { - materialSharedAxisXOut(targetOffsetX = { (it * InitialOffset).toInt() }) - }, - content = content, -) +) = + composable( + deepLinks = deepLinks, + enterTransition = { + materialSharedAxisXIn(initialOffsetX = { (it * InitialOffset).toInt() }) + }, + exitTransition = { + materialSharedAxisXOut(targetOffsetX = { -(it * InitialOffset).toInt() }) + }, + popEnterTransition = { + materialSharedAxisXIn(initialOffsetX = { -(it * InitialOffset).toInt() }) + }, + popExitTransition = { + materialSharedAxisXOut(targetOffsetX = { (it * InitialOffset).toInt() }) + }, + content = content, + ) inline fun NavGraphBuilder.animatedComposableVariant( deepLinks: List = emptyList(), noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, -) = composable( - deepLinks = deepLinks, - enterTransition = { - slideInHorizontally( - enterTween(), initialOffsetX = { (it * InitialOffset).toInt() }) + fadeIn(fadeSpec) - }, - exitTransition = { fadeOut(fadeSpec) }, - popEnterTransition = { fadeIn(fadeSpec) }, - popExitTransition = { - slideOutHorizontally( - exitTween(), targetOffsetX = { (it * InitialOffset).toInt() }) + fadeOut(fadeSpec) - }, - content = content, -) +) = + composable( + deepLinks = deepLinks, + enterTransition = { + slideInHorizontally(enterTween(), initialOffsetX = { (it * InitialOffset).toInt() }) + + fadeIn(fadeSpec) + }, + exitTransition = { fadeOut(fadeSpec) }, + popEnterTransition = { fadeIn(fadeSpec) }, + popExitTransition = { + slideOutHorizontally(exitTween(), targetOffsetX = { (it * InitialOffset).toInt() }) + + fadeOut(fadeSpec) + }, + content = content, + ) inline fun NavGraphBuilder.slideInVerticallyComposableLegacy( deepLinks: List = emptyList(), typeMap: Map> = emptyMap(), noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, -) = composable( - deepLinks = deepLinks, - typeMap = typeMap, - enterTransition = { - slideInVertically(initialOffsetY = { it }, animationSpec = enterTween()) + fadeIn() - }, - exitTransition = { slideOutVertically() }, - popEnterTransition = { slideInVertically() }, - popExitTransition = { - slideOutVertically(targetOffsetY = { it }, animationSpec = enterTween()) + fadeOut() - }, - content = content, -) +) = + composable( + deepLinks = deepLinks, + typeMap = typeMap, + enterTransition = { + slideInVertically(initialOffsetY = { it }, animationSpec = enterTween()) + fadeIn() + }, + exitTransition = { slideOutVertically() }, + popEnterTransition = { slideInVertically() }, + popExitTransition = { + slideOutVertically(targetOffsetY = { it }, animationSpec = enterTween()) + fadeOut() + }, + content = content, + ) inline fun NavGraphBuilder.slideInVerticallyComposablePredictiveBack( deepLinks: List = emptyList(), typeMap: Map> = emptyMap(), noinline content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, -) = composable( - deepLinks = deepLinks, - typeMap = typeMap, - enterTransition = { materialSharedAxisYIn(initialOffsetY = { (it * 0.25f).toInt() }) }, - exitTransition = { - materialSharedAxisYOut(targetOffsetY = { -(it * InitialOffset * 1.5f).toInt() }) - }, - popEnterTransition = { - scaleIn( - animationSpec = tween(durationMillis = 400, easing = EmphasizedDecelerate), - initialScale = 0.85f, - ) + materialSharedAxisYIn(initialOffsetY = { -(it * InitialOffset * 1.5f).toInt() }) - }, - popExitTransition = { - materialSharedAxisYOut(targetOffsetY = { (it * InitialOffset * 1.5f).toInt() }) + scaleOut( - targetScale = 0.85f, - animationSpec = tween(durationMillis = 400, easing = EmphasizedAccelerate), - ) - }, - content = content, -) \ No newline at end of file +) = + composable( + deepLinks = deepLinks, + typeMap = typeMap, + enterTransition = { materialSharedAxisYIn(initialOffsetY = { (it * 0.25f).toInt() }) }, + exitTransition = { + materialSharedAxisYOut(targetOffsetY = { -(it * InitialOffset * 1.5f).toInt() }) + }, + popEnterTransition = { + scaleIn( + animationSpec = tween(durationMillis = 400, easing = EmphasizedDecelerate), + initialScale = 0.85f, + ) + materialSharedAxisYIn(initialOffsetY = { -(it * InitialOffset * 1.5f).toInt() }) + }, + popExitTransition = { + materialSharedAxisYOut(targetOffsetY = { (it * InitialOffset * 1.5f).toInt() }) + + scaleOut( + targetScale = 0.85f, + animationSpec = tween(durationMillis = 400, easing = EmphasizedAccelerate), + ) + }, + content = content, + ) diff --git a/app/ui/src/main/java/com/bobbyesp/ui/motion/AnimationSpecs.kt b/app/ui/src/main/java/com/bobbyesp/ui/motion/AnimationSpecs.kt index 9b391ad..81550c0 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/motion/AnimationSpecs.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/motion/AnimationSpecs.kt @@ -16,11 +16,12 @@ fun PathInterpolator.toEasing(): Easing { return Easing { f -> this.getInterpolation(f) } } -private val path = Path().apply { - moveTo(0f, 0f) - cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F) - cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F) -} +private val path = + Path().apply { + moveTo(0f, 0f) + cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F) + cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F) + } val EmphasizedPathInterpolator = PathInterpolator(path) val EmphasizedEasing = EmphasizedPathInterpolator.toEasing() @@ -36,25 +37,17 @@ val MotionEasingStandard = CubicBezierEasing(0.4F, 0.0F, 0.2F, 1F) val tweenSpec = tween(durationMillis = DURATION_ENTER, easing = EmphasizedEasing) -fun tweenEnter( - delayMillis: Int = DURATION_EXIT, - durationMillis: Int = DURATION_ENTER -) = +fun tweenEnter(delayMillis: Int = DURATION_EXIT, durationMillis: Int = DURATION_ENTER) = tween( delayMillis = delayMillis, durationMillis = durationMillis, - easing = EmphasizedDecelerateEasing + easing = EmphasizedDecelerateEasing, ) -fun tweenExit( - durationMillis: Int = DURATION_EXIT_SHORT, -) = tween( - durationMillis = durationMillis, - easing = EmphasizedAccelerateEasing -) +fun tweenExit(durationMillis: Int = DURATION_EXIT_SHORT) = + tween(durationMillis = durationMillis, easing = EmphasizedAccelerateEasing) @OptIn(ExperimentalSharedTransitionApi::class) val DefaultBoundsTransform = BoundsTransform { _, _ -> tween(easing = EmphasizedEasing, durationMillis = DURATION) } - diff --git a/app/ui/src/main/java/com/bobbyesp/ui/motion/MaterialSharedAxis.kt b/app/ui/src/main/java/com/bobbyesp/ui/motion/MaterialSharedAxis.kt index c33fd6e..3263d6b 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/motion/MaterialSharedAxis.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/motion/MaterialSharedAxis.kt @@ -37,20 +37,15 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp - /** * Returns the provided [Dp] as an [Int] value by the [LocalDensity]. * * @param slideDistance Value to the slide distance dimension, 30dp by default. */ @Composable -fun rememberSlideDistance( - slideDistance: Dp = MotionConstants.DefaultSlideDistance, -): Int { +fun rememberSlideDistance(slideDistance: Dp = MotionConstants.DefaultSlideDistance): Int { val density = LocalDensity.current - return remember(density, slideDistance) { - with(density) { slideDistance.roundToPx() } - } + return remember(density, slideDistance) { with(density) { slideDistance.roundToPx() } } } private const val ProgressThreshold = 0.35f @@ -61,120 +56,101 @@ private val Int.ForOutgoing: Int private val Int.ForIncoming: Int get() = this - this.ForOutgoing -/** - * [materialSharedAxisX] allows to switch a layout with shared X-axis transition. - * - */ +/** [materialSharedAxisX] allows to switch a layout with shared X-axis transition. */ fun materialSharedAxisX( initialOffsetX: (fullWidth: Int) -> Int, targetOffsetX: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, -): ContentTransform = materialSharedAxisXIn( - initialOffsetX = initialOffsetX, - durationMillis = durationMillis -) togetherWith materialSharedAxisXOut( - targetOffsetX = targetOffsetX, - durationMillis = durationMillis -) +): ContentTransform = + materialSharedAxisXIn( + initialOffsetX = initialOffsetX, + durationMillis = durationMillis, + ) togetherWith + materialSharedAxisXOut(targetOffsetX = targetOffsetX, durationMillis = durationMillis) -/** - * [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition. - */ +/** [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition. */ fun materialSharedAxisXIn( initialOffsetX: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, -): EnterTransition = slideInHorizontally( - animationSpec = tween( - durationMillis = durationMillis, - easing = FastOutSlowInEasing - ), - initialOffsetX = initialOffsetX -) + fadeIn( - animationSpec = tween( - durationMillis = durationMillis.ForIncoming, - delayMillis = durationMillis.ForOutgoing, - easing = LinearOutSlowInEasing - ) -) - -/** - * [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition. - * - */ +): EnterTransition = + slideInHorizontally( + animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), + initialOffsetX = initialOffsetX, + ) + + fadeIn( + animationSpec = + tween( + durationMillis = durationMillis.ForIncoming, + delayMillis = durationMillis.ForOutgoing, + easing = LinearOutSlowInEasing, + ) + ) + +/** [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition. */ fun materialSharedAxisXOut( targetOffsetX: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, -): ExitTransition = slideOutHorizontally( - animationSpec = tween( - durationMillis = durationMillis, - easing = FastOutSlowInEasing - ), - targetOffsetX = targetOffsetX -) + fadeOut( - animationSpec = tween( - durationMillis = durationMillis.ForOutgoing, - delayMillis = 0, - easing = FastOutLinearInEasing - ) -) - -/** - * [materialSharedAxisY] allows to switch a layout with shared Y-axis transition. - * - */ +): ExitTransition = + slideOutHorizontally( + animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), + targetOffsetX = targetOffsetX, + ) + + fadeOut( + animationSpec = + tween( + durationMillis = durationMillis.ForOutgoing, + delayMillis = 0, + easing = FastOutLinearInEasing, + ) + ) + +/** [materialSharedAxisY] allows to switch a layout with shared Y-axis transition. */ fun materialSharedAxisY( initialOffsetX: (fullWidth: Int) -> Int, targetOffsetY: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, -): ContentTransform = materialSharedAxisYIn( - initialOffsetY = initialOffsetX, - durationMillis = durationMillis -) togetherWith materialSharedAxisYOut( - targetOffsetY = targetOffsetY, - durationMillis = durationMillis -) +): ContentTransform = + materialSharedAxisYIn( + initialOffsetY = initialOffsetX, + durationMillis = durationMillis, + ) togetherWith + materialSharedAxisYOut(targetOffsetY = targetOffsetY, durationMillis = durationMillis) -/** - * [materialSharedAxisYIn] allows to switch a layout with shared Y-axis enter transition. - * - */ +/** [materialSharedAxisYIn] allows to switch a layout with shared Y-axis enter transition. */ fun materialSharedAxisYIn( initialOffsetY: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, -): EnterTransition = slideInVertically( - animationSpec = tween( - durationMillis = durationMillis, - easing = FastOutSlowInEasing - ), - initialOffsetY = initialOffsetY -) + fadeIn( - animationSpec = tween( - durationMillis = durationMillis.ForIncoming, - delayMillis = durationMillis.ForOutgoing, - easing = LinearOutSlowInEasing - ) -) - -/** - * [materialSharedAxisYOut] allows to switch a layout with shared Y-axis exit transition. - * - */ +): EnterTransition = + slideInVertically( + animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), + initialOffsetY = initialOffsetY, + ) + + fadeIn( + animationSpec = + tween( + durationMillis = durationMillis.ForIncoming, + delayMillis = durationMillis.ForOutgoing, + easing = LinearOutSlowInEasing, + ) + ) + +/** [materialSharedAxisYOut] allows to switch a layout with shared Y-axis exit transition. */ fun materialSharedAxisYOut( targetOffsetY: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, -): ExitTransition = slideOutVertically( - animationSpec = tween( - durationMillis = durationMillis, - easing = FastOutSlowInEasing - ), - targetOffsetY = targetOffsetY -) + fadeOut( - animationSpec = tween( - durationMillis = durationMillis.ForOutgoing, - delayMillis = 0, - easing = FastOutLinearInEasing - ) -) +): ExitTransition = + slideOutVertically( + animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), + targetOffsetY = targetOffsetY, + ) + + fadeOut( + animationSpec = + tween( + durationMillis = durationMillis.ForOutgoing, + delayMillis = 0, + easing = FastOutLinearInEasing, + ) + ) /** * [materialSharedAxisZ] allows to switch a layout with shared Z-axis transition. @@ -185,13 +161,9 @@ fun materialSharedAxisYOut( fun materialSharedAxisZ( forward: Boolean, durationMillis: Int = MotionConstants.DefaultMotionDuration, -): ContentTransform = materialSharedAxisZIn( - forward = forward, - durationMillis = durationMillis -) togetherWith materialSharedAxisZOut( - forward = forward, - durationMillis = durationMillis -) +): ContentTransform = + materialSharedAxisZIn(forward = forward, durationMillis = durationMillis) togetherWith + materialSharedAxisZOut(forward = forward, durationMillis = durationMillis) /** * [materialSharedAxisZIn] allows to switch a layout with shared Z-axis enter transition. @@ -202,19 +174,19 @@ fun materialSharedAxisZ( fun materialSharedAxisZIn( forward: Boolean, durationMillis: Int = MotionConstants.DefaultMotionDuration, -): EnterTransition = fadeIn( - animationSpec = tween( - durationMillis = durationMillis.ForIncoming, - delayMillis = durationMillis.ForOutgoing, - easing = LinearOutSlowInEasing - ) -) + scaleIn( - animationSpec = tween( - durationMillis = durationMillis, - easing = FastOutSlowInEasing - ), - initialScale = if (forward) 0.8f else 1.1f -) +): EnterTransition = + fadeIn( + animationSpec = + tween( + durationMillis = durationMillis.ForIncoming, + delayMillis = durationMillis.ForOutgoing, + easing = LinearOutSlowInEasing, + ) + ) + + scaleIn( + animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), + initialScale = if (forward) 0.8f else 1.1f, + ) /** * [materialSharedAxisZOut] allows to switch a layout with shared Z-axis exit transition. @@ -225,16 +197,16 @@ fun materialSharedAxisZIn( fun materialSharedAxisZOut( forward: Boolean, durationMillis: Int = MotionConstants.DefaultMotionDuration, -): ExitTransition = fadeOut( - animationSpec = tween( - durationMillis = durationMillis.ForOutgoing, - delayMillis = 0, - easing = FastOutLinearInEasing - ) -) + scaleOut( - animationSpec = tween( - durationMillis = durationMillis, - easing = FastOutSlowInEasing - ), - targetScale = if (forward) 1.1f else 0.8f -) +): ExitTransition = + fadeOut( + animationSpec = + tween( + durationMillis = durationMillis.ForOutgoing, + delayMillis = 0, + easing = FastOutLinearInEasing, + ) + ) + + scaleOut( + animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), + targetScale = if (forward) 1.1f else 0.8f, + ) diff --git a/app/ui/src/main/java/com/bobbyesp/ui/motion/MotionConstants.kt b/app/ui/src/main/java/com/bobbyesp/ui/motion/MotionConstants.kt index 5ec42ad..72ce275 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/motion/MotionConstants.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/motion/MotionConstants.kt @@ -16,7 +16,6 @@ package com.bobbyesp.ui.motion * limitations under the License. */ - import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -33,4 +32,4 @@ object MotionConstants { const val DURATION_EXIT_SHORT = 100 const val InitialOffset = 0.10f -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/util/AnimatedTextContentTransformation.kt b/app/ui/src/main/java/com/bobbyesp/ui/util/AnimatedTextContentTransformation.kt new file mode 100644 index 0000000..783b41e --- /dev/null +++ b/app/ui/src/main/java/com/bobbyesp/ui/util/AnimatedTextContentTransformation.kt @@ -0,0 +1,13 @@ +package com.bobbyesp.ui.util + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import com.bobbyesp.ui.motion.materialSharedAxisXIn +import com.bobbyesp.ui.motion.materialSharedAxisXOut + +val AnimatedTextContentTransformation = + ContentTransform( + materialSharedAxisXIn(initialOffsetX = { it / 10 }), + materialSharedAxisXOut(targetOffsetX = { -it / 10 }), + sizeTransform = SizeTransform(clip = false), + ) diff --git a/app/ui/src/main/java/com/bobbyesp/ui/util/AppBar.kt b/app/ui/src/main/java/com/bobbyesp/ui/util/AppBar.kt index 6876b74..75fb9e7 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/util/AppBar.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/util/AppBar.kt @@ -27,7 +27,7 @@ fun appBarScrollBehavior( state = state, snapAnimationSpec = snapAnimationSpec, flingAnimationSpec = flingAnimationSpec, - canScroll = canScroll + canScroll = canScroll, ) @ExperimentalMaterial3Api @@ -38,35 +38,31 @@ class AppBarScrollBehavior( val canScroll: () -> Boolean = { true }, ) : TopAppBarScrollBehavior { override val isPinned: Boolean = true - override var nestedScrollConnection = object : NestedScrollConnection { - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - if (!canScroll()) return Offset.Zero - state.contentOffset += consumed.y - if (state.heightOffset == 0f || state.heightOffset == state.heightOffsetLimit) { - if (consumed.y == 0f && available.y > 0f) { - // Reset the total content offset to zero when scrolling all the way down. - // This will eliminate some float precision inaccuracies. - state.contentOffset = 0f + override var nestedScrollConnection = + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (!canScroll()) return Offset.Zero + state.contentOffset += consumed.y + if (state.heightOffset == 0f || state.heightOffset == state.heightOffsetLimit) { + if (consumed.y == 0f && available.y > 0f) { + // Reset the total content offset to zero when scrolling all the way down. + // This will eliminate some float precision inaccuracies. + state.contentOffset = 0f + } } + state.heightOffset += consumed.y + return Offset.Zero } - state.heightOffset += consumed.y - return Offset.Zero } - } } @OptIn(ExperimentalMaterial3Api::class) suspend fun TopAppBarState.resetHeightOffset() { if (heightOffset != 0f) { - animate( - initialValue = heightOffset, - targetValue = 0f - ) { value, _ -> - heightOffset = value - } + animate(initialValue = heightOffset, targetValue = 0f) { value, _ -> heightOffset = value } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/util/Compose.kt b/app/ui/src/main/java/com/bobbyesp/ui/util/Compose.kt index a78a9ef..474f702 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/util/Compose.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/util/Compose.kt @@ -19,15 +19,14 @@ import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.unit.dp -import kotlinx.coroutines.delay import kotlin.math.min +import kotlinx.coroutines.delay /** * A [Modifier] that draws a border around elements that are recomposing. The border increases in * size and interpolates from red to green as more recompositions occur before a timeout. */ -@Stable -fun Modifier.recomposeHighlighter(): Modifier = this.then(recomposeModifier) +@Stable fun Modifier.recomposeHighlighter(): Modifier = this.then(recomposeModifier) // Use a single instance + @Stable to ensure that recompositions can enable skipping optimizations // Modifier.composed will still remember unique data per call site. @@ -77,7 +76,7 @@ private val recomposeModifier = lerp( Color.Yellow.copy(alpha = 0.8f), Color.Red.copy(alpha = 0.5f), - min(1f, (numCompositionsSinceTimeout - 1).toFloat() / 100f) + min(1f, (numCompositionsSinceTimeout - 1).toFloat() / 100f), ) to numCompositionsSinceTimeout.toInt().dp.toPx() } } @@ -95,7 +94,7 @@ private val recomposeModifier = brush = SolidColor(color), topLeft = rectTopLeft, size = size, - style = style + style = style, ) } } @@ -104,4 +103,4 @@ private val recomposeModifier = @Composable fun isDeviceInLandscape(): Boolean { return LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/util/FadingEdge.kt b/app/ui/src/main/java/com/bobbyesp/ui/util/FadingEdge.kt index 776d946..900907d 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/util/FadingEdge.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/util/FadingEdge.kt @@ -13,58 +13,54 @@ fun Modifier.fadingEdge( top: Dp? = null, right: Dp? = null, bottom: Dp? = null, -): Modifier = this - .graphicsLayer(alpha = 0.99f) - .drawWithContent { +): Modifier = + this.graphicsLayer(alpha = 0.99f).drawWithContent { drawContent() top?.let { drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black), - startY = 0f, - endY = it.toPx() - ), - blendMode = BlendMode.DstIn + brush = + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black), + startY = 0f, + endY = it.toPx(), + ), + blendMode = BlendMode.DstIn, ) } bottom?.let { drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Black, Color.Transparent), - startY = size.height - it.toPx(), - endY = size.height - ), - blendMode = BlendMode.DstIn + brush = + Brush.verticalGradient( + colors = listOf(Color.Black, Color.Transparent), + startY = size.height - it.toPx(), + endY = size.height, + ), + blendMode = BlendMode.DstIn, ) } left?.let { drawRect( - brush = Brush.horizontalGradient( - colors = listOf(Color.Black, Color.Transparent), - startX = 0f, - endX = it.toPx() - ), - blendMode = BlendMode.DstIn + brush = + Brush.horizontalGradient( + colors = listOf(Color.Black, Color.Transparent), + startX = 0f, + endX = it.toPx(), + ), + blendMode = BlendMode.DstIn, ) } right?.let { drawRect( - brush = Brush.horizontalGradient( - colors = listOf(Color.Transparent, Color.Black), - startX = size.width - it.toPx(), - endX = size.width - ), - blendMode = BlendMode.DstIn + brush = + Brush.horizontalGradient( + colors = listOf(Color.Transparent, Color.Black), + startX = size.width - it.toPx(), + endX = size.width, + ), + blendMode = BlendMode.DstIn, ) } } -fun Modifier.fadingEdge( - horizontal: Dp? = null, - vertical: Dp? = null, -) = fadingEdge( - left = horizontal, - right = horizontal, - top = vertical, - bottom = vertical -) \ No newline at end of file +fun Modifier.fadingEdge(horizontal: Dp? = null, vertical: Dp? = null) = + fadingEdge(left = horizontal, right = horizontal, top = vertical, bottom = vertical) diff --git a/app/ui/src/main/java/com/bobbyesp/ui/util/RememberSaveableWithInitialValue.kt b/app/ui/src/main/java/com/bobbyesp/ui/util/RememberSaveableWithInitialValue.kt index eeeaa2b..39590d9 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/util/RememberSaveableWithInitialValue.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/util/RememberSaveableWithInitialValue.kt @@ -9,44 +9,40 @@ import androidx.compose.runtime.saveable.autoSaver import androidx.compose.runtime.saveable.rememberSaveable /** - * Allows to create a saveable with an initial value where updating the initial value will lead to updating the - * state *even if* the composable gets restored from saveable later on. + * Allows to create a saveable with an initial value where updating the initial value will lead to + * updating the state *even if* the composable gets restored from saveable later on. * - * rememberVolatileSaveable should be used in a composable, when you want to initialize a mutable state - * (e.g. for holding the value of a textinput field) with an initial value AND need the user input to survive - * configuration changes AND want to allow changes to the initial value while being on a later screen - * (i.e. while this composable is not active). + * rememberVolatileSaveable should be used in a composable, when you want to initialize a mutable + * state (e.g. for holding the value of a textinput field) with an initial value AND need the user + * input to survive configuration changes AND want to allow changes to the initial value while being + * on a later screen (i.e. while this composable is not active). */ @Composable fun rememberVolatileSaveable( initialValue: T?, - saver: Saver = autoSaver() + saver: Saver = autoSaver(), ): MutableState { return key(initialValue) { - rememberSaveable(stateSaver = saver) { - mutableStateOf(initialValue) - } + rememberSaveable(stateSaver = saver) { mutableStateOf(initialValue) } } } /** - * Allows to create a saveable with an initial value where updating the initial value will lead to updating the - * state *even if* the composable gets restored from saveable later on. + * Allows to create a saveable with an initial value where updating the initial value will lead to + * updating the state *even if* the composable gets restored from saveable later on. * - * rememberVolatileSaveable should be used in a composable, when you want to initialize a mutable state - * (e.g. for holding the value of a textinput field) with an initial value AND need the user input to survive - * configuration changes AND want to allow changes to the initial value while being on a later screen - * (i.e. while this composable is not active). + * rememberVolatileSaveable should be used in a composable, when you want to initialize a mutable + * state (e.g. for holding the value of a textinput field) with an initial value AND need the user + * input to survive configuration changes AND want to allow changes to the initial value while being + * on a later screen (i.e. while this composable is not active). */ @JvmName("rememberSaveableWithVolatileInitialValueNotNull") @Composable fun rememberVolatileSaveable( initialValue: T, - saver: Saver = autoSaver() + saver: Saver = autoSaver(), ): MutableState { return key(initialValue) { - rememberSaveable(stateSaver = saver) { - mutableStateOf(initialValue) - } + rememberSaveable(stateSaver = saver) { mutableStateOf(initialValue) } } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/util/Savers.kt b/app/ui/src/main/java/com/bobbyesp/ui/util/Savers.kt index 90c9fa1..45f887f 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/util/Savers.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/util/Savers.kt @@ -24,4 +24,4 @@ object AnimatableSaver : Saver, Float> { override fun SaverScope.save(value: Animatable): Float? { return value.value } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/com/bobbyesp/ui/util/Scroll.kt b/app/ui/src/main/java/com/bobbyesp/ui/util/Scroll.kt index e8ddf5c..90da5ce 100644 --- a/app/ui/src/main/java/com/bobbyesp/ui/util/Scroll.kt +++ b/app/ui/src/main/java/com/bobbyesp/ui/util/Scroll.kt @@ -15,17 +15,19 @@ fun LazyListState.isScrollingUp(): Boolean { var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } return remember(this) { - derivedStateOf { - if (previousIndex != firstVisibleItemIndex) { - previousIndex > firstVisibleItemIndex - } else { - previousScrollOffset >= firstVisibleItemScrollOffset - }.also { - previousIndex = firstVisibleItemIndex - previousScrollOffset = firstVisibleItemScrollOffset + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex > firstVisibleItemIndex + } else { + previousScrollOffset >= firstVisibleItemScrollOffset + } + .also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } } } - }.value + .value } @Composable @@ -33,29 +35,28 @@ fun LazyGridState.isScrollingUp(): Boolean { var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } return remember(this) { - derivedStateOf { - if (previousIndex != firstVisibleItemIndex) { - previousIndex > firstVisibleItemIndex - } else { - previousScrollOffset >= firstVisibleItemScrollOffset - }.also { - previousIndex = firstVisibleItemIndex - previousScrollOffset = firstVisibleItemScrollOffset + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex > firstVisibleItemIndex + } else { + previousScrollOffset >= firstVisibleItemScrollOffset + } + .also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } } } - }.value + .value } @Composable fun ScrollState.isScrollingUp(): Boolean { var previousScrollOffset by remember(this) { mutableIntStateOf(value) } return remember(this) { - derivedStateOf { - (previousScrollOffset >= value).also { - previousScrollOffset = value - } + derivedStateOf { (previousScrollOffset >= value).also { previousScrollOffset = value } } } - }.value + .value } @Composable @@ -63,17 +64,19 @@ fun LazyListState.isScrollingDown(): Boolean { var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } return remember(this) { - derivedStateOf { - if (previousIndex != firstVisibleItemIndex) { - previousIndex < firstVisibleItemIndex - } else { - previousScrollOffset <= firstVisibleItemScrollOffset - }.also { - previousIndex = firstVisibleItemIndex - previousScrollOffset = firstVisibleItemScrollOffset + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex < firstVisibleItemIndex + } else { + previousScrollOffset <= firstVisibleItemScrollOffset + } + .also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } } } - }.value + .value } @Composable @@ -81,27 +84,26 @@ fun LazyGridState.isScrollingDown(): Boolean { var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } return remember(this) { - derivedStateOf { - if (previousIndex != firstVisibleItemIndex) { - previousIndex < firstVisibleItemIndex - } else { - previousScrollOffset <= firstVisibleItemScrollOffset - }.also { - previousIndex = firstVisibleItemIndex - previousScrollOffset = firstVisibleItemScrollOffset + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex < firstVisibleItemIndex + } else { + previousScrollOffset <= firstVisibleItemScrollOffset + } + .also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } } } - }.value + .value } @Composable fun ScrollState.isScrollingDown(): Boolean { var previousScrollOffset by remember(this) { mutableIntStateOf(value) } return remember(this) { - derivedStateOf { - (previousScrollOffset <= value).also { - previousScrollOffset = value - } + derivedStateOf { (previousScrollOffset <= value).also { previousScrollOffset = value } } } - }.value -} \ No newline at end of file + .value +} diff --git a/app/ui/src/test/java/com/bobbyesp/ui/ExampleUnitTest.kt b/app/ui/src/test/java/com/bobbyesp/ui/ExampleUnitTest.kt index dcf5db9..5f40b50 100644 --- a/app/ui/src/test/java/com/bobbyesp/ui/ExampleUnitTest.kt +++ b/app/ui/src/test/java/com/bobbyesp/ui/ExampleUnitTest.kt @@ -13,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/app/utilities/build.gradle.kts b/app/utilities/build.gradle.kts index 15132d7..dc8bc96 100644 --- a/app/utilities/build.gradle.kts +++ b/app/utilities/build.gradle.kts @@ -4,11 +4,12 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.compose.compiler) + alias(libs.plugins.ktfmt.gradle) } android { namespace = "com.bobbyesp.utilities" - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 24 @@ -22,7 +23,7 @@ android { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) } } @@ -34,16 +35,21 @@ android { compose = true buildConfig = true } - composeCompiler { - reportsDestination = layout.buildDirectory.dir("compose_compiler") - } - kotlinOptions { - jvmTarget = "21" - } + composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") } +} + +ktfmt { + // Google style - 2 space indentation & automatically adds/removes trailing commas + // googleStyle() + + // KotlinLang style - 4 space indentation - From + // https://kotlinlang.org/docs/coding-conventions.html + kotlinLangStyle() } dependencies { implementation(libs.core.ktx) + implementation(project(":core-utilities")) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) implementation(libs.bundles.compose) @@ -59,4 +65,4 @@ dependencies { implementation(libs.compose.tooling.preview) debugImplementation(libs.compose.tooling) debugImplementation(libs.compose.test.manifest) -} \ No newline at end of file +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/Logging.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/Logging.kt index 563439f..000bf72 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/Logging.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/Logging.kt @@ -10,70 +10,54 @@ object Logging { private val callingClass = Throwable().stackTrace[1].className val isDebug = BuildConfig.DEBUG - fun i(message: String) { - Log.i(callingClass, message) - } + fun i(message: String) = Log.i(callingClass, message) - fun d(message: String) { - Log.d(callingClass, message) - } + fun d(message: String) = Log.d(callingClass, message) - fun e(message: String) { - Log.e(callingClass, message) - } + fun e(message: String) = Log.e(callingClass, message) - fun e(throwable: Throwable) { - Log.e(callingClass, throwable.message ?: "No message", throwable) - } + fun e(throwable: Throwable) = Log.e(callingClass, throwable.message ?: "No message", throwable) - fun e(message: String, throwable: Throwable) { - Log.e(callingClass, message, throwable) - } + fun e(message: String, throwable: Throwable) = Log.e(callingClass, message, throwable) - fun w(message: String) { - Log.w(callingClass, message) - } + fun w(message: String) = Log.w(callingClass, message) - fun v(message: String) { - Log.v(callingClass, message) - } + fun v(message: String) = Log.v(callingClass, message) - fun wtf(message: String) { - Log.wtf(callingClass, message) - } + fun wtf(message: String) = Log.wtf(callingClass, message) fun getVersionReport(packageInfo: PackageInfo): String { val versionName = packageInfo.versionName - - @Suppress("DEPRECATION") - val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - packageInfo.longVersionCode - } else { - packageInfo.versionCode.toLong() - } - val release = if (Build.VERSION.SDK_INT >= 30) { - Build.VERSION.RELEASE_OR_CODENAME - } else { - Build.VERSION.RELEASE - } - + val versionCode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") packageInfo.versionCode.toLong() + } + val release = + if (Build.VERSION.SDK_INT >= 30) { + Build.VERSION.RELEASE_OR_CODENAME + } else { + Build.VERSION.RELEASE + } val appName = packageInfo.applicationInfo?.name - return StringBuilder() - .append("App version: $appName $versionName ($versionCode)\n") - .append("Android version: Android $release (API ${Build.VERSION.SDK_INT})\n") - .append("Device: ${Build.MANUFACTURER} ${Build.MODEL}\n") - .append("Supported ABIs: ${Build.SUPPORTED_ABIS.contentToString()}\n") - .toString() + return """ + App version: $appName $versionName ($versionCode) + Android version: Android $release (API ${Build.VERSION.SDK_INT}) + Device: ${Build.MANUFACTURER} ${Build.MODEL} + Supported ABIs: ${Build.SUPPORTED_ABIS.contentToString()} + """ + .trimIndent() } fun createLogFile(context: Context, errorReport: String): String { val date = Time.getZuluTimeSnapshot() val fileName = "log_$date.txt" - val logFile = File(context.filesDir, fileName) - if (!logFile.exists()) { - logFile.createNewFile() - } - logFile.appendText(errorReport) + val logFile = + File(context.filesDir, fileName).apply { + if (!exists()) createNewFile() + appendText(errorReport) + } return logFile.absolutePath } -} \ No newline at end of file +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/Packages.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/Packages.kt index e321b4a..af849fc 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/Packages.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/Packages.kt @@ -5,14 +5,13 @@ import android.content.Intent import android.content.pm.PackageManager object Packages { - fun isPackageInstalled(context: Context, packageName: String): Boolean { - return try { + fun isPackageInstalled(context: Context, packageName: String) = + try { context.packageManager.getPackageInfo(packageName, 0) true } catch (e: PackageManager.NameNotFoundException) { false } - } fun Intent.launchOrAction(context: Context, action: () -> Unit) { if (resolveActivity(context.packageManager) != null) { @@ -21,4 +20,4 @@ object Packages { action() } } -} \ No newline at end of file +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/Time.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/Time.kt index 7a776ae..56aafc5 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/Time.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/Time.kt @@ -1,11 +1,13 @@ package com.bobbyesp.utilities -import kotlinx.datetime.Clock +import java.util.Locale import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime -import java.util.Locale +import kotlin.time.Clock +import kotlin.time.ExperimentalTime object Time { + @OptIn(ExperimentalTime::class) fun getZuluTimeSnapshot(): String { val instant = Clock.System.now() return instant.toLocalDateTime(TimeZone.currentSystemDefault()).toString() @@ -16,4 +18,4 @@ object Time { val seconds: Long = (duration % 60000) / 1000 return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) } -} \ No newline at end of file +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Array.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Array.kt index f0c1927..85d617e 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Array.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Array.kt @@ -2,4 +2,4 @@ package com.bobbyesp.utilities.ext fun Array?.joinToStringOrNull(separator: String = ", "): String? { return this?.joinToString(separator = separator) -} \ No newline at end of file +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Int.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Int.kt index e8e99bb..bcb7ad7 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Int.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Int.kt @@ -10,10 +10,11 @@ fun Int.bigQuantityFormatter(): String { } /** - * Extension function to convert an integer representing seconds into a formatted string of minutes and seconds. + * Extension function to convert an integer representing seconds into a formatted string of minutes + * and seconds. * - * @receiver Int The number of seconds to be converted. * @return String The formatted string in "MM:SS" format. + * @receiver Int The number of seconds to be converted. */ fun Int.toMinutes(): String { val minutes = this / 60 @@ -22,14 +23,15 @@ fun Int.toMinutes(): String { } /** - * Extension function to convert an integer representing milliseconds into a formatted string of minutes and seconds. + * Extension function to convert an integer representing milliseconds into a formatted string of + * minutes and seconds. * - * @receiver Int The number of milliseconds to be converted. * @return String The formatted string in "MM:SS" format. + * @receiver Int The number of milliseconds to be converted. */ fun Int.fromMillisToMinutes(): String { val totalSeconds = this / 1000 val minutes = totalSeconds / 60 val seconds = totalSeconds % 60 return "%02d:%02d".format(minutes, seconds) -} \ No newline at end of file +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Long.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Long.kt index 963189d..d0637b3 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Long.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/ext/Long.kt @@ -8,8 +8,8 @@ private const val GIGA_BYTES = 1024f * 1024f * 1024f private const val MEGA_BYTES = 1024f * 1024f @Composable -fun Long.toFileSizeText() = this.toFloat().run { - if (this > GIGA_BYTES) - stringResource(R.string.filesize_gb).format(this / GIGA_BYTES) - else stringResource(R.string.filesize_mb).format(this / MEGA_BYTES) -} +fun Long.toFileSizeText() = + this.toFloat().run { + if (this > GIGA_BYTES) stringResource(R.string.filesize_gb).format(this / GIGA_BYTES) + else stringResource(R.string.filesize_mb).format(this / MEGA_BYTES) + } diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ext/PropertyMap.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/ext/PropertyMap.kt index fbc80f8..39dbeda 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/ext/PropertyMap.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/ext/PropertyMap.kt @@ -3,15 +3,16 @@ package com.bobbyesp.utilities.ext import com.bobbyesp.utilities.mediastore.AudioFileMetadata /** - * Type alias for a HashMap where the key is a String and the value is an Array of Strings. - * This is used to represent a map of properties where each property can have multiple values. + * Type alias for a HashMap where the key is a String and the value is an Array of Strings. This is + * used to represent a map of properties where each property can have multiple values. */ typealias PropertyMap = HashMap> /** * Extension function for PropertyMap to convert it to an AudioFileMetadata object. * - * @param separator The separator to use when joining array elements into a single string. Default is ", ". + * @param separator The separator to use when joining array elements into a single string. Default + * is ", ". * @return An AudioFileMetadata object populated with values from the PropertyMap. */ fun PropertyMap.toAudioFileMetadata(separator: String = ", "): AudioFileMetadata { @@ -37,7 +38,8 @@ fun PropertyMap.toAudioFileMetadata(separator: String = ", "): AudioFileMetadata /** * Extension function for PropertyMap to convert it to a modifiable map. * - * @param separator The separator to use when joining array elements into a single string. Default is ", ". + * @param separator The separator to use when joining array elements into a single string. Default + * is ", ". * @return A MutableMap where the key is a String and the value is a nullable String. */ fun PropertyMap.toModifiableMap(separator: String = ", "): MutableMap { @@ -58,4 +60,4 @@ fun PropertyMap.toModifiableMap(separator: String = ", "): MutableMap { } fun String.isNumberInRange(start: Int, end: Int): Boolean { - return this.isNotEmpty() && this.isDigitsOnly() && this.length < 10 && this.toInt() >= start && this.toInt() <= end + return this.isNotEmpty() && + this.isDigitsOnly() && + this.length < 10 && + this.toInt() >= start && + this.toInt() <= end } fun String?.isNeitherNullNorBlank(): Boolean { return this != null && this.isNotBlank() -} \ No newline at end of file +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/AudioFileMetadata.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/AudioFileMetadata.kt index 545e7eb..7072459 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/AudioFileMetadata.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/AudioFileMetadata.kt @@ -22,7 +22,7 @@ data class AudioFileMetadata( val conductor: String?, val remixer: String?, val comment: String?, - val lyrics: String? + val lyrics: String?, ) { companion object { fun AudioFileMetadata.toPropertyMap(): PropertyMap { @@ -41,7 +41,7 @@ data class AudioFileMetadata( "PERFORMER" to performer.formatForField(), "REMIXER" to remixer.formatForField(), "COMMENT" to arrayOf(comment ?: ""), - "LYRICS" to arrayOf(lyrics ?: "") + "LYRICS" to arrayOf(lyrics ?: ""), ) } @@ -61,7 +61,7 @@ data class AudioFileMetadata( conductor = this["CONDUCTOR"], remixer = this["REMIXER"], comment = this["COMMENT"], - lyrics = this["LYRICS"] + lyrics = this["LYRICS"], ) } } diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/MediaStoreReceiver.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/MediaStoreReceiver.kt deleted file mode 100644 index 6a87294..0000000 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/MediaStoreReceiver.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.bobbyesp.utilities.mediastore - -import android.annotation.SuppressLint -import android.content.ContentResolver -import android.content.ContentUris -import android.content.Context -import android.net.Uri -import android.os.ParcelFileDescriptor -import android.provider.MediaStore -import android.util.Log -import com.bobbyesp.utilities.R -import com.bobbyesp.utilities.mediastore.advanced.advancedQuery -import com.bobbyesp.utilities.mediastore.advanced.observe -import com.bobbyesp.utilities.mediastore.model.Song -import kotlinx.coroutines.flow.map -import java.io.FileNotFoundException - -object MediaStoreReceiver { - - private val audioUri: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - private val projection = arrayOf( - MediaStore.Audio.Media._ID, - MediaStore.Audio.Media.TITLE, - MediaStore.Audio.Media.ARTIST, - MediaStore.Audio.Media.ALBUM, - MediaStore.Audio.Media.DURATION, - MediaStore.Audio.Media.DATA, - MediaStore.Audio.Media.ALBUM_ID - ) - private const val isMusicSelection = "${MediaStore.Audio.Media.IS_MUSIC} != 0" - - private fun buildSelection(searchTerm: String?, filterType: MediaStoreFilterType?): String { - return when { - !searchTerm.isNullOrEmpty() && filterType != null -> { - "$isMusicSelection AND ${filterType.column} LIKE '%$searchTerm%'" - } - - //Search everywhere - !searchTerm.isNullOrEmpty() -> { - "$isMusicSelection AND (${MediaStore.Audio.Media.TITLE} LIKE '%$searchTerm%' OR ${MediaStore.Audio.Media.ARTIST} LIKE '%$searchTerm%' OR ${MediaStore.Audio.Media.ALBUM} LIKE '%$searchTerm%')" - } - - else -> isMusicSelection - } - } - - private fun buildSelectionArgs( - searchTerm: String?, - filterType: MediaStoreFilterType? - ): Array? { - return when { - !searchTerm.isNullOrEmpty() && filterType != null -> arrayOf("%$searchTerm%") - !searchTerm.isNullOrEmpty() -> arrayOf("%$searchTerm%", "%$searchTerm%") - else -> null - } - } - - private fun parseCursorToSongs(cursor: android.database.Cursor): List { - val songs = mutableListOf() - val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) - val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) - val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) - val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) - val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) - val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) - val albumIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) - - while (cursor.moveToNext()) { - val song = Song( - id = cursor.getLong(idColumn), - title = cursor.getString(titleColumn), - artist = cursor.getString(artistColumn), - album = cursor.getString(albumColumn), - artworkPath = ContentUris.withAppendedId( - Uri.parse("content://media/external/audio/albumart"), - cursor.getLong(albumIdColumn) - ), - duration = cursor.getDouble(durationColumn), - path = cursor.getString(pathColumn), - fileName = cursor.getString(pathColumn).substringAfterLast("/") - ) - songs.add(song) - } - cursor.close() - return songs - } - - fun getSongs( - context: Context, - searchTerm: String? = null, - filterType: MediaStoreFilterType? = null - ): List { - val resolver = context.contentResolver - val selection = buildSelection(searchTerm, filterType) - val selectionArgs = buildSelectionArgs(searchTerm, filterType) - val sortOrder = MediaStore.Audio.Media.TITLE - - return resolver.query(audioUri, projection, selection, selectionArgs, sortOrder) - ?.use { cursor -> - parseCursorToSongs(cursor) - } ?: emptyList() - } - - @SuppressLint("Range") - fun getFileDescriptorFromPath( - context: Context, - filePath: String, - mode: String = "r" - ): ParcelFileDescriptor? { - val resolver = context.contentResolver - val selection = "${MediaStore.Files.FileColumns.DATA} = ?" - val selectionArgs = arrayOf(filePath) - - resolver.query( - audioUri, - arrayOf(MediaStore.Files.FileColumns._ID), - selection, - selectionArgs, - null - )?.use { cursor -> - if (cursor.moveToFirst()) { - val fileId = cursor.getInt(cursor.getColumnIndex(MediaStore.Files.FileColumns._ID)) - val fileUri = ContentUris.withAppendedId(audioUri, fileId.toLong()) - return try { - resolver.openFileDescriptor(fileUri, mode) - } catch (e: FileNotFoundException) { - Log.e("MediaStoreReceiver", "File not found: ${e.message}") - null - } - } - cursor.close() - } - return null - } - - object Advanced { - suspend fun ContentResolver.getSongs( - searchTerm: String? = null, - filterType: MediaStoreFilterType? = null - ): List { - val selection = buildSelection(searchTerm, filterType) - val selectionArgs = buildSelectionArgs(searchTerm, filterType) - val sortOrder = MediaStore.Audio.Media.TITLE //Order by title - - return advancedQuery( - uri = audioUri, - projection = projection, - selection = selection, - args = selectionArgs, - order = sortOrder, - ascending = true - )?.use { cursor -> - parseCursorToSongs(cursor) - } ?: emptyList() - } - - fun ContentResolver.observeSongs( - searchTerm: String? = null, - filterType: MediaStoreFilterType? = null - ) = - observe(audioUri).map { - getSongs(searchTerm, filterType) - } - } -} - -enum class MediaStoreFilterType(val column: String) { - TITLE(MediaStore.Audio.Media.TITLE), - ARTIST(MediaStore.Audio.Media.ARTIST), - ALBUM(MediaStore.Audio.Media.ALBUM); - - fun toString(context: Context): String { - return when (this) { - TITLE -> context.getString(R.string.title) - ARTIST -> context.getString(R.string.artist) - ALBUM -> context.getString(R.string.album) - } - } -} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/advanced/AdvancedContentResolverQuery.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/advanced/AdvancedContentResolverQuery.kt deleted file mode 100644 index a37b012..0000000 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/advanced/AdvancedContentResolverQuery.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.bobbyesp.utilities.mediastore.advanced - -import android.content.ContentResolver -import android.database.Cursor -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.provider.MediaStore -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -//Thanks to https://proandroiddev.com/kotlin-flow-contentresolver-and-mediastore-the-key-to-effortless-media-access-in-android-fad56db16fdd -/** - * An advanced of [ContentResolver.query] - * @see ContentResolver.query - * @param order valid column to use for orderBy. - */ -suspend fun ContentResolver.advancedQuery( - uri: Uri, - projection: Array? = null, - selection: String, - args: Array? = null, - order: String = MediaStore.MediaColumns._ID, - ascending: Boolean = true, - offset: Int = 0, - limit: Int = Int.MAX_VALUE -): Cursor? { - return withContext(Dispatchers.Default) { - // use only above android 10 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // compose the args - val args2 = Bundle().apply { - // Limit & Offset - putInt(ContentResolver.QUERY_ARG_LIMIT, limit) - putInt(ContentResolver.QUERY_ARG_OFFSET, offset) - - // order - putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, arrayOf(order)) - putInt( - ContentResolver.QUERY_ARG_SORT_DIRECTION, - if (ascending) ContentResolver.QUERY_SORT_DIRECTION_ASCENDING else ContentResolver.QUERY_SORT_DIRECTION_DESCENDING - ) - // Selection and groupBy - if (args != null) putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, args) - // add selection. - // TODO: Consider adding group by. - // currently I experienced errors in android 10 for groupBy and arg groupBy is supported - // above android 10. - putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection) - } - query(uri, projection, args2, null) - } - // below android 0 - else { - //language=SQL - val order2 = - order + (if (ascending) " ASC" else " DESC") + " LIMIT $limit OFFSET $offset" - // compose the selection. - query(uri, projection, selection, args, order2) - } - } -} \ No newline at end of file diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/data/local/repository/MediaStoreUseCaseImpl.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/data/local/repository/MediaStoreUseCaseImpl.kt new file mode 100644 index 0000000..a540267 --- /dev/null +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/data/local/repository/MediaStoreUseCaseImpl.kt @@ -0,0 +1,48 @@ +package com.bobbyesp.utilities.mediastore.data.local.repository + +import android.content.ContentUris +import android.content.Context +import android.os.ParcelFileDescriptor +import android.provider.MediaStore +import android.util.Log +import com.bobbyesp.utilities.mediastore.model.FileDescriptorMode +import com.bobbyesp.utilities.mediastore.model.repository.MediaStoreUseCase +import java.io.FileNotFoundException + +class MediaStoreUseCaseImpl(private val context: Context) : MediaStoreUseCase { + + override fun getFileDescriptorFromPath( + filePath: String, + mode: FileDescriptorMode, + ): ParcelFileDescriptor? { + val resolver = context.contentResolver + val selection = "${MediaStore.Files.FileColumns.DATA} = ?" + val selectionArgs = arrayOf(filePath) + + resolver + .query( + audioUri, + arrayOf(MediaStore.Files.FileColumns._ID), + selection, + selectionArgs, + null, + ) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val fileId = + cursor.getInt( + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) + ) + val fileUri = ContentUris.withAppendedId(audioUri, fileId.toLong()) + return try { + resolver.openFileDescriptor(fileUri, mode.modeKey) + } catch (e: FileNotFoundException) { + Log.e("MediaStoreHelper", "File not found: ${e.message}") + null + } + } + cursor.close() + } + return null + } +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/model/FileDescriptorMode.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/model/FileDescriptorMode.kt new file mode 100644 index 0000000..f0d05a2 --- /dev/null +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/model/FileDescriptorMode.kt @@ -0,0 +1,13 @@ +package com.bobbyesp.utilities.mediastore.model + +enum class FileDescriptorMode(val modeKey: String) { + READ("r"), + WRITE("w"), + READ_WRITE("rw"), + APPEND("wa"), + APPEND_READ("rwa"), + TRUNCATE("wt"), + TRUNCATE_READ("rwt"), + APPEND_TRUNCATE("wta"), + APPEND_TRUNCATE_READ("rwta"), +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/model/Song.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/model/Song.kt index cdf8986..a5efc88 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/model/Song.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/model/Song.kt @@ -3,6 +3,7 @@ package com.bobbyesp.utilities.mediastore.model import android.net.Uri import android.os.Parcelable import androidx.compose.runtime.Stable +import androidx.core.net.toUri import kotlinx.parcelize.Parcelize import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer @@ -25,7 +26,7 @@ data class Song( @Serializable(with = UriSerializer::class) val artworkPath: Uri? = null, val duration: Double, val path: String, - val fileName: String + val fileName: String, ) : Parcelable { companion object { val empty = Song(-1, "", "", "", null, 0.0, "", "") @@ -43,6 +44,6 @@ object UriSerializer : KSerializer { } override fun deserialize(decoder: Decoder): Uri { - return Uri.parse(decoder.decodeString()) + return decoder.decodeString().toUri() } } diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/model/repository/MediaStoreUseCase.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/model/repository/MediaStoreUseCase.kt new file mode 100644 index 0000000..db4daae --- /dev/null +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/model/repository/MediaStoreUseCase.kt @@ -0,0 +1,13 @@ +package com.bobbyesp.utilities.mediastore.model.repository + +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.MediaStore +import com.bobbyesp.utilities.mediastore.model.FileDescriptorMode + +interface MediaStoreUseCase { + fun getFileDescriptorFromPath(filePath: String, mode: FileDescriptorMode): ParcelFileDescriptor? + + val audioUri: Uri + get() = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/navigation/CustomNavigationArguments.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/navigation/CustomNavigationArguments.kt index 38e1b8e..bc1f656 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/navigation/CustomNavigationArguments.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/navigation/CustomNavigationArguments.kt @@ -19,46 +19,47 @@ import kotlinx.serialization.json.Json inline fun parcelableType( isNullableAllowed: Boolean = false, json: Json = Json, -) = object : NavType(isNullableAllowed = isNullableAllowed) { - /** - * Retrieves the Parcelable from the Bundle. - * - * @param bundle The Bundle containing the Parcelable. - * @param key The key associated with the Parcelable. - * @return The Parcelable object. - */ - override fun get(bundle: Bundle, key: String) = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - bundle.getParcelable(key, T::class.java) - } else { - @Suppress("DEPRECATION") bundle.getParcelable(key) - } +) = + object : NavType(isNullableAllowed = isNullableAllowed) { + /** + * Retrieves the Parcelable from the Bundle. + * + * @param bundle The Bundle containing the Parcelable. + * @param key The key associated with the Parcelable. + * @return The Parcelable object. + */ + override fun get(bundle: Bundle, key: String) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + bundle.getParcelable(key, T::class.java) + } else { + @Suppress("DEPRECATION") bundle.getParcelable(key) + } - /** - * Parses a Parcelable from a String. - * - * @param value The String to parse. - * @return The parsed Parcelable object. - */ - override fun parseValue(value: String): T = json.decodeFromString(value) + /** + * Parses a Parcelable from a String. + * + * @param value The String to parse. + * @return The parsed Parcelable object. + */ + override fun parseValue(value: String): T = json.decodeFromString(value) - /** - * Serializes a Parcelable to a String. - * - * @param value The Parcelable to serialize. - * @return The serialized String. - */ - override fun serializeAsValue(value: T): String = Uri.encode(json.encodeToString(value)) + /** + * Serializes a Parcelable to a String. + * + * @param value The Parcelable to serialize. + * @return The serialized String. + */ + override fun serializeAsValue(value: T): String = Uri.encode(json.encodeToString(value)) - /** - * Puts a Parcelable into a Bundle. - * - * @param bundle The Bundle to put the Parcelable into. - * @param key The key associated with the Parcelable. - * @param value The Parcelable to put into the Bundle. - */ - override fun put(bundle: Bundle, key: String, value: T) = bundle.putParcelable(key, value) -} + /** + * Puts a Parcelable into a Bundle. + * + * @param bundle The Bundle to put the Parcelable into. + * @param key The key associated with the Parcelable. + * @param value The Parcelable to put into the Bundle. + */ + override fun put(bundle: Bundle, key: String, value: T) = bundle.putParcelable(key, value) + } /** * Creates a custom NavType for serializable types. @@ -71,41 +72,42 @@ inline fun parcelableType( inline fun serializableType( isNullableAllowed: Boolean = false, json: Json = Json, -) = object : NavType(isNullableAllowed = isNullableAllowed) { - /** - * Retrieves the serializable object from the Bundle. - * - * @param bundle The Bundle containing the serializable object. - * @param key The key associated with the serializable object. - * @return The serializable object. - */ - override fun get(bundle: Bundle, key: String) = - bundle.getString(key)?.let(json::decodeFromString) +) = + object : NavType(isNullableAllowed = isNullableAllowed) { + /** + * Retrieves the serializable object from the Bundle. + * + * @param bundle The Bundle containing the serializable object. + * @param key The key associated with the serializable object. + * @return The serializable object. + */ + override fun get(bundle: Bundle, key: String) = + bundle.getString(key)?.let(json::decodeFromString) - /** - * Parses a serializable object from a String. - * - * @param value The String to parse. - * @return The parsed serializable object. - */ - override fun parseValue(value: String): T = json.decodeFromString(value) + /** + * Parses a serializable object from a String. + * + * @param value The String to parse. + * @return The parsed serializable object. + */ + override fun parseValue(value: String): T = json.decodeFromString(value) - /** - * Serializes a serializable object to a String. - * - * @param value The serializable object to serialize. - * @return The serialized String. - */ - override fun serializeAsValue(value: T): String = Uri.encode(json.encodeToString(value)) + /** + * Serializes a serializable object to a String. + * + * @param value The serializable object to serialize. + * @return The serialized String. + */ + override fun serializeAsValue(value: T): String = Uri.encode(json.encodeToString(value)) - /** - * Puts a serializable object into a Bundle. - * - * @param bundle The Bundle to put the serializable object into. - * @param key The key associated with the serializable object. - * @param value The serializable object to put into the Bundle. - */ - override fun put(bundle: Bundle, key: String, value: T) { - bundle.putString(key, json.encodeToString(value)) + /** + * Puts a serializable object into a Bundle. + * + * @param bundle The Bundle to put the serializable object into. + * @param key The key associated with the serializable object. + * @param value The serializable object to put into the Bundle. + */ + override fun put(bundle: Bundle, key: String, value: T) { + bundle.putString(key, json.encodeToString(value)) + } } -} \ No newline at end of file diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/states/ResourceState.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/states/ResourceState.kt index a7c5645..0811d15 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/states/ResourceState.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/states/ResourceState.kt @@ -7,10 +7,7 @@ package com.bobbyesp.utilities.states * @property data The data associated with the state, if any. * @property message The message associated with the state, if any. */ -sealed class ResourceState( - val data: T? = null, - val message: String? = null -) { +sealed class ResourceState(val data: T? = null, val message: String? = null) { /** * Represents a loading state with optional partial data. * @@ -34,10 +31,8 @@ sealed class ResourceState( * @property errorMessage The error message associated with the error state. * @property errorData The data associated with the error state, if any. */ - data class Error( - val errorMessage: String, - val errorData: T? = null - ) : ResourceState(errorData, errorMessage) + data class Error(val errorMessage: String, val errorData: T? = null) : + ResourceState(errorData, errorMessage) /** * Returns a string representation of the resource state. @@ -46,9 +41,9 @@ sealed class ResourceState( */ override fun toString(): String { return when (this) { - is Loading -> "Loading(data=$data)" - is Success -> "Success(data=$data)" - is Error -> "Error(message=$message, data=$data)" + is Loading -> "Loading(data=$partialData)" + is Success -> "Success(data=$result)" + is Error -> "Error(message=$errorMessage, data=$errorData)" } } -} \ No newline at end of file +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/states/ScreenState.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/states/ScreenState.kt index bb7e7f8..56d0407 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/states/ScreenState.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/states/ScreenState.kt @@ -6,10 +6,8 @@ package com.bobbyesp.utilities.states * @param T The type of data held by this state. */ sealed interface ScreenState { - /** - * Represents a loading state. - */ - object Loading : ScreenState + /** Represents a loading state. */ + data object Loading : ScreenState /** * Represents a successful state with data. @@ -25,4 +23,4 @@ sealed interface ScreenState { * @property exception The exception associated with the error state. */ data class Error(val exception: Throwable) : ScreenState -} \ No newline at end of file +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/Assets.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/Assets.kt deleted file mode 100644 index 6a9ec87..0000000 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/Assets.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.bobbyesp.utilities.ui - -import androidx.annotation.DrawableRes -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource - -@Composable -fun localAsset(@DrawableRes id: Int) = ImageVector.vectorResource(id = id) \ No newline at end of file diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/Colors.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/Colors.kt index fb903a4..2ae2359 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/Colors.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/Colors.kt @@ -8,4 +8,4 @@ fun Color.applyOpacity(enabled: Boolean): Color { return if (enabled) this else this.copy(alpha = 0.62f) } -fun Color.applyAlpha(alpha: Float): Color = this.copy(alpha = alpha) \ No newline at end of file +fun Color.applyAlpha(alpha: Float = 0.62f): Color = this.copy(alpha = alpha) diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/PagingStateHandler.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/PagingStateHandler.kt index e6e4309..03f9d84 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/PagingStateHandler.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/PagingStateHandler.kt @@ -1,54 +1,67 @@ package com.bobbyesp.utilities.ui +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope 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.stringResource +import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems -import com.bobbyesp.utilities.BuildConfig +import com.bobbyesp.utilities.R -fun LazyListScope.pagingStateHandler( +fun LazyListScope.handlePagingState( items: LazyPagingItems?, - itemCount: Int = 7, - loadingContent: @Composable () -> Unit + initialLoadingItemCount: Int = 7, + loadingContent: @Composable LazyItemScope.() -> Unit, + errorContent: @Composable LazyItemScope.(errorMessage: String?) -> Unit = { errorMessage -> + // Default error content. + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = errorMessage ?: stringResource(R.string.unknown_error)) + } + }, ) { - items?.apply { + items?.let { pagingItems -> when { - loadState.refresh is LoadState.Loading -> { - items(itemCount) { - // Render a loading indicator while refreshing - loadingContent() - } + pagingItems.loadState.refresh is LoadState.Loading -> { + // Initial loading state. + items(initialLoadingItemCount) { loadingContent() } + } + + pagingItems.loadState.append is LoadState.Loading -> { + // Loading more items state. + item { loadingContent() } } - loadState.append is LoadState.Loading -> { - items(itemCount) { - // Render a loading indicator at the end while loading more items - loadingContent() - } + pagingItems.loadState.refresh is LoadState.Error -> { + val error = pagingItems.loadState.refresh as LoadState.Error + item { errorContent(error.error.message) } } - loadState.refresh is LoadState.Error -> { - val errorMessage = - (loadState.refresh as LoadState.Error).error.message - item { - // Render an error message if refreshing encounters an error - if (errorMessage != null) { - if (BuildConfig.DEBUG) Text(errorMessage) - } - } + pagingItems.loadState.append is LoadState.Error -> { + val error = pagingItems.loadState.append as LoadState.Error + item { errorContent(error.error.message) } } - loadState.append is LoadState.Error -> { - val errorMessage = - (loadState.append as LoadState.Error).error.message - item { - // Render an error message if loading more items encounters an error - if (errorMessage != null) { - if (BuildConfig.DEBUG) Text(errorMessage) - } - } + else -> { + // Add a else branch for the case where state is not loading or error. + // To avoid unexpected behavior in future changes. } } } -} \ No newline at end of file + ?: run { + // Handle the case where items is null. + // For example, display an empty state or an error message. + item { errorContent(stringResource(id = R.string.list_items_null)) } + } +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/layouts/lazygrid/LazyGridKeyParams.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/layouts/lazygrid/LazyGridKeyParams.kt new file mode 100644 index 0000000..31b791a --- /dev/null +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/layouts/lazygrid/LazyGridKeyParams.kt @@ -0,0 +1,19 @@ +package com.bobbyesp.utilities.ui.layouts.lazygrid + +/** + * Data class representing the parameters used to generate a unique key for items in a lazy grid. + * + * This class encapsulates the necessary data to identify an item within a lazy grid, enabling + * features like item persistence during recomposition and efficient item reuse. + * + * @property params A string representing additional parameters relevant to the item's identity. + * This could include filters, sorting criteria, or any other context-specific data that + * distinguishes items within the grid. Defaults to an empty string. + * @property index The index of the item within the underlying data list. This is a crucial part of + * the key as it directly correlates to the item's position in the grid. + * @property scrollOffset The vertical scroll offset of the lazy grid when this item was initially + * composed. This is vital for maintaining item identity during scrolling, as the visible indices + * can change while the underlying data and scroll offset remain constant. It helps to distinguish + * the same item displayed at different scroll positions. + */ +data class LazyGridKeyParams(val params: String = "", val index: Int, val scrollOffset: Int) diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/LazyGridStateSaver.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/layouts/lazygrid/LazyGridStateSaver.kt similarity index 54% rename from app/utilities/src/main/java/com/bobbyesp/utilities/ui/LazyGridStateSaver.kt rename to app/utilities/src/main/java/com/bobbyesp/utilities/ui/layouts/lazygrid/LazyGridStateSaver.kt index f84be0f..e85cd93 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/LazyGridStateSaver.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/layouts/lazygrid/LazyGridStateSaver.kt @@ -1,23 +1,16 @@ -package com.bobbyesp.utilities.ui +package com.bobbyesp.utilities.ui.layouts.lazygrid import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.saveable.rememberSaveable -/** - * Static field, contains all scroll values - */ -val SaveMap = mutableMapOf() - -data class KeyParams( - val params: String = "", - val index: Int, - val scrollOffset: Int -) +/** Static field, contains all scroll values */ +val SaveMap = mutableMapOf() /** * Save scroll state on all time. + * * @param key value for comparing screen * @param params arguments for find different between equals screen * @param initialFirstVisibleItemIndex see [LazyGridState.firstVisibleItemIndex] @@ -28,24 +21,22 @@ fun rememberForeverLazyGridState( key: String, params: String = "", initialFirstVisibleItemIndex: Int = 0, - initialFirstVisibleItemScrollOffset: Int = 0 + initialFirstVisibleItemScrollOffset: Int = 0, ): LazyGridState { - val scrollState = rememberSaveable(saver = LazyGridState.Saver) { - var savedValue = SaveMap[key] - if (savedValue?.params != params) savedValue = null - val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex - val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset - LazyGridState( - savedIndex, - savedOffset - ) - } + val scrollState = + rememberSaveable(saver = LazyGridState.Saver) { + var savedValue = SaveMap[key] + if (savedValue?.params != params) savedValue = null + val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex + val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset + LazyGridState(savedIndex, savedOffset) + } DisposableEffect(Unit) { onDispose { val lastIndex = scrollState.firstVisibleItemIndex val lastOffset = scrollState.firstVisibleItemScrollOffset - SaveMap[key] = KeyParams(params, lastIndex, lastOffset) + SaveMap[key] = LazyGridKeyParams(params, lastIndex, lastOffset) } } return scrollState -} \ No newline at end of file +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/permission/PermissionNotGrantedDialog.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/permissions/PermissionNotGrantedDialog.kt similarity index 69% rename from app/utilities/src/main/java/com/bobbyesp/utilities/ui/permission/PermissionNotGrantedDialog.kt rename to app/utilities/src/main/java/com/bobbyesp/utilities/ui/permissions/PermissionNotGrantedDialog.kt index 22ba4a0..2e8886c 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/permission/PermissionNotGrantedDialog.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/permissions/PermissionNotGrantedDialog.kt @@ -1,4 +1,4 @@ -package com.bobbyesp.utilities.ui.permission +package com.bobbyesp.utilities.ui.permissions import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -37,73 +37,63 @@ import kotlinx.collections.immutable.persistentListOf fun PermissionNotGrantedDialog( modifier: Modifier = Modifier, neededPermissions: PersistentList, - shouldShowRationale: Boolean = false, + shouldShowRationale: Boolean = false, onGrantRequest: () -> Unit, onDismissRequest: () -> Unit, ) { - AlertDialog(icon = { - Icon( - imageVector = Icons.Outlined.NotInterested, - contentDescription = "Permission not granted" - ) - }, modifier = modifier, onDismissRequest = onDismissRequest, title = { - Text(text = stringResource(id = R.string.permission_not_granted)) - }, text = { - Column { - if (shouldShowRationale) { - Text( - text = stringResource(id = R.string.permission_not_granted_rationale_desc), - textAlign = TextAlign.Justify - ) - } else { - Text( - text = stringResource(id = R.string.permission_not_granted_description), - textAlign = TextAlign.Justify - ) - } + AlertDialog( + icon = { + Icon( + imageVector = Icons.Outlined.NotInterested, + contentDescription = "Permission not granted", + ) + }, + modifier = modifier, + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(id = R.string.permission_not_granted)) }, + text = { + Column { + if (shouldShowRationale) { + Text( + text = stringResource(id = R.string.permission_not_granted_rationale_desc), + textAlign = TextAlign.Justify, + ) + } else { + Text( + text = stringResource(id = R.string.permission_not_granted_description), + textAlign = TextAlign.Justify, + ) + } - HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) - Text(text = stringResource(id = R.string.permissions_to_grant)) + HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) + Text(text = stringResource(id = R.string.permissions_to_grant)) - Column( - modifier = Modifier.padding(6.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - neededPermissions.forEach { - TextWithDot(text = it.toPermissionString()) + Column( + modifier = Modifier.padding(6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + neededPermissions.forEach { TextWithDot(text = it.toPermissionString()) } } } - } - }, confirmButton = { - TextButton( - onClick = onGrantRequest - ) { - Text(stringResource(id = R.string.grant)) - } - }, dismissButton = { - TextButton( - onClick = onDismissRequest - ) { - Text(stringResource(id = R.string.dismiss)) - } - }) + }, + confirmButton = { + TextButton(onClick = onGrantRequest) { Text(stringResource(id = R.string.grant)) } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { Text(stringResource(id = R.string.dismiss)) } + }, + ) } @Composable fun TextWithDot(text: String) { Row(verticalAlignment = Alignment.CenterVertically) { Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - ) - Text( - text = text, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp) + modifier = + Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary) ) + Text(text = text, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp)) } } @@ -176,12 +166,16 @@ enum class PermissionType(val permission: String) { @Preview(uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) @Preview @Composable -fun PermissionNotGrantedPreview() { - PermissionNotGrantedDialog( - onGrantRequest = {}, - onDismissRequest = {}, - neededPermissions = persistentListOf( - PermissionType.READ_EXTERNAL_STORAGE, PermissionType.WRITE_EXTERNAL_STORAGE +private fun PermissionNotGrantedPreview() { + MaterialTheme { + PermissionNotGrantedDialog( + onGrantRequest = {}, + onDismissRequest = {}, + neededPermissions = + persistentListOf( + PermissionType.READ_EXTERNAL_STORAGE, + PermissionType.WRITE_EXTERNAL_STORAGE, + ), ) - ) -} \ No newline at end of file + } +} diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/permission/PermissionRequestHandler.kt b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/permissions/PermissionRequestHandler.kt similarity index 72% rename from app/utilities/src/main/java/com/bobbyesp/utilities/ui/permission/PermissionRequestHandler.kt rename to app/utilities/src/main/java/com/bobbyesp/utilities/ui/permissions/PermissionRequestHandler.kt index 8b9fa75..40f14c3 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/ui/permission/PermissionRequestHandler.kt +++ b/app/utilities/src/main/java/com/bobbyesp/utilities/ui/permissions/PermissionRequestHandler.kt @@ -1,4 +1,4 @@ -package com.bobbyesp.utilities.ui.permission +package com.bobbyesp.utilities.ui.permissions import androidx.compose.runtime.Composable import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -10,7 +10,7 @@ import com.google.accompanist.permissions.PermissionStatus fun PermissionRequestHandler( permissionState: PermissionState, deniedContent: @Composable (Boolean) -> Unit, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { when (permissionState.status) { is PermissionStatus.Granted -> { @@ -18,9 +18,7 @@ fun PermissionRequestHandler( } is PermissionStatus.Denied -> { - deniedContent( - (permissionState.status as PermissionStatus.Denied).shouldShowRationale - ) + deniedContent((permissionState.status as PermissionStatus.Denied).shouldShowRationale) } } } diff --git a/app/utilities/src/main/res/values/strings.xml b/app/utilities/src/main/res/values/strings.xml index 0cfb327..f962dd0 100644 --- a/app/utilities/src/main/res/values/strings.xml +++ b/app/utilities/src/main/res/values/strings.xml @@ -34,4 +34,6 @@ Allows the app to write to your storage Allows the app to read audio files This permission does not contain a description + There is no more items to be shown + Unknown error \ No newline at end of file diff --git a/core-utilities/.gitignore b/core-utilities/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core-utilities/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core-utilities/build.gradle.kts b/core-utilities/build.gradle.kts new file mode 100644 index 0000000..1815672 --- /dev/null +++ b/core-utilities/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.android.kotlin) + alias(libs.plugins.kotlin.ksp) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "com.bobbyesp.coreutilities" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +dependencies { + implementation(libs.core.ktx) + implementation(libs.core.appcompat) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) +} \ No newline at end of file diff --git a/core-utilities/consumer-rules.pro b/core-utilities/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/core-utilities/proguard-rules.pro b/core-utilities/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/core-utilities/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core-utilities/src/androidTest/java/com/bobbyesp/coreutilities/ExampleInstrumentedTest.kt b/core-utilities/src/androidTest/java/com/bobbyesp/coreutilities/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..a50ba13 --- /dev/null +++ b/core-utilities/src/androidTest/java/com/bobbyesp/coreutilities/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.bobbyesp.coreutilities + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.bobbyesp.coreutilities.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/core-utilities/src/main/AndroidManifest.xml b/core-utilities/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/core-utilities/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/advanced/ContentResolverObserver.kt b/core-utilities/src/main/java/com/bobbyesp/coreutilities/ContentResolver.kt similarity index 62% rename from app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/advanced/ContentResolverObserver.kt rename to core-utilities/src/main/java/com/bobbyesp/coreutilities/ContentResolver.kt index e7894ac..0590b4d 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/mediastore/advanced/ContentResolverObserver.kt +++ b/core-utilities/src/main/java/com/bobbyesp/coreutilities/ContentResolver.kt @@ -1,4 +1,4 @@ -package com.bobbyesp.utilities.mediastore.advanced +package com.bobbyesp.coreutilities import android.content.ContentResolver import android.database.ContentObserver @@ -9,15 +9,23 @@ import kotlinx.coroutines.flow.callbackFlow /** * Register an observer class that gets callbacks when data identified by a given content URI * changes. + * + * @param uri The content URI to observe. + * @return A Flow that emits a Boolean indicating whether the content has changed. */ fun ContentResolver.observe(uri: Uri) = callbackFlow { val observer = object : ContentObserver(null) { + /** + * Called when a change occurs to the content URI. + * + * @param selfChange A boolean indicating if the change was made by the observer itself. + */ override fun onChange(selfChange: Boolean) { trySend(selfChange) } } registerContentObserver(uri, true, observer) - // trigger first. + // Trigger the first emission. trySend(false) awaitClose { unregisterContentObserver(observer) diff --git a/app/utilities/src/main/java/com/bobbyesp/utilities/ConcurrentList.kt b/core-utilities/src/main/java/com/bobbyesp/coreutilities/lists/ConcurrentList.kt similarity index 99% rename from app/utilities/src/main/java/com/bobbyesp/utilities/ConcurrentList.kt rename to core-utilities/src/main/java/com/bobbyesp/coreutilities/lists/ConcurrentList.kt index b658629..e5ce3d2 100644 --- a/app/utilities/src/main/java/com/bobbyesp/utilities/ConcurrentList.kt +++ b/core-utilities/src/main/java/com/bobbyesp/coreutilities/lists/ConcurrentList.kt @@ -1,4 +1,4 @@ -package com.bobbyesp.utilities +package com.bobbyesp.coreutilities.lists import java.util.concurrent.locks.ReentrantReadWriteLock import kotlin.concurrent.read diff --git a/core-utilities/src/main/java/com/bobbyesp/coreutilities/theming/DynamicColoring.kt b/core-utilities/src/main/java/com/bobbyesp/coreutilities/theming/DynamicColoring.kt new file mode 100644 index 0000000..838a083 --- /dev/null +++ b/core-utilities/src/main/java/com/bobbyesp/coreutilities/theming/DynamicColoring.kt @@ -0,0 +1,7 @@ +package com.bobbyesp.coreutilities.theming + +import android.os.Build + +fun isDynamicColoringSupported(): Boolean { + return Build.VERSION.SDK_INT >= 31 //Build.VERSION_CODES.S +} \ No newline at end of file diff --git a/core-utilities/src/test/java/com/bobbyesp/coreutilities/ExampleUnitTest.kt b/core-utilities/src/test/java/com/bobbyesp/coreutilities/ExampleUnitTest.kt new file mode 100644 index 0000000..79c8861 --- /dev/null +++ b/core-utilities/src/test/java/com/bobbyesp/coreutilities/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.bobbyesp.coreutilities + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/crashhandler/build.gradle.kts b/crashhandler/build.gradle.kts index 7430012..3a8e913 100644 --- a/crashhandler/build.gradle.kts +++ b/crashhandler/build.gradle.kts @@ -6,7 +6,7 @@ plugins { android { namespace = "com.bobbyesp.crashhandler" - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 24 @@ -34,9 +34,6 @@ android { composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") } - kotlinOptions { - jvmTarget = "21" - } } dependencies { diff --git a/crashhandler/src/main/java/com/bobbyesp/crashhandler/CrashHandler.kt b/crashhandler/src/main/java/com/bobbyesp/crashhandler/CrashHandler.kt index 0419b4c..f246b44 100644 --- a/crashhandler/src/main/java/com/bobbyesp/crashhandler/CrashHandler.kt +++ b/crashhandler/src/main/java/com/bobbyesp/crashhandler/CrashHandler.kt @@ -6,10 +6,10 @@ import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build -import androidx.core.content.ContextCompat.startActivity import java.io.File object CrashHandler { + /** * This extension function is used to start the CrashReportActivity from any Activity. * @@ -17,6 +17,7 @@ object CrashHandler { * @param packageInfo The package information for which the report is to be generated. * @param reportInfo The report information which specifies what information should be included in the report. Defaults to a new instance of ReportInfo. * @param logfilePath The path of the log file to be passed to the CrashReportActivity. + * @param reportUrl The URL to which the report will be sent. * * The function creates a new Intent for the CrashReportActivity and sets the necessary flags. * It then adds the version report and the log file path as extras to the Intent. @@ -26,12 +27,14 @@ object CrashHandler { context: Context, packageInfo: PackageInfo, reportInfo: ReportInfo = ReportInfo(), - logfilePath: String + logfilePath: String, + reportUrl: String ) { context.startActivity(Intent(context, CrashHandlerActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK putExtra("version_report", getVersionReport(packageInfo, reportInfo)) putExtra("logfile_path", logfilePath) + putExtra("report_url", reportUrl) }, null) } @@ -39,6 +42,7 @@ object CrashHandler { * This function sets up the crash handler for the application. * * @param reportInfo The report information which specifies what information should be included in the report. Defaults to a new instance of ReportInfo. + * @param reportUrl The URL to which the report will be sent. * * The function sets the default uncaught exception handler to a new handler that creates a log file with the stack trace of the uncaught exception, * retrieves the package information for the application, and starts the CrashReportActivity with the generated log file and package information. @@ -47,7 +51,9 @@ object CrashHandler { * The package information is retrieved using the package manager and includes the version name, version code, and package name. * The CrashReportActivity is started with a new Intent that includes the version report and the path of the log file as extras. */ - fun Application.setupCrashHandler(reportInfo: ReportInfo = ReportInfo()) { + fun Application.setupCrashHandler( + reportInfo: ReportInfo = ReportInfo(), reportUrl: String + ) { Thread.setDefaultUncaughtExceptionHandler { _, throwable -> val logfile = createLogFile(this, throwable.stackTraceToString()) val packageInfo = packageManager.run { @@ -55,69 +61,65 @@ object CrashHandler { packageName, PackageManager.PackageInfoFlags.of(0) ) else getPackageInfo(packageName, 0) } - startCrashReportActivity(this, packageInfo, reportInfo, logfile) + startCrashReportActivity( + context = this, + packageInfo = packageInfo, + reportInfo = reportInfo, + logfilePath = logfile, + reportUrl = reportUrl + ) } } /** * This function generates a version report for the given package and report information. * - * @param packageInfo The package information for which the report is to be generated. - * @param info The report information which specifies what information should be included in the report. - * - * The function first retrieves the version name and version code from the package information. - * It then determines the Android release version. - * - * A map is created where each key is a boolean condition from the report information and each value is the corresponding string to be appended to the report. - * The function then iterates over the map, and for each entry, if the key (the condition) is true, the value (the string) is appended to the report. + * The report includes: + * - The application version, including package name, version name, and version code. + * - The Android version, including the release name/codename and API level, if `info.androidVersion` is true. + * - The device information, including manufacturer and model, if `info.deviceInfo` is true. + * - The supported ABIs, if `info.supportedABIs` is true. * - * @return The generated version report as a string. + * @param packageInfo The [PackageInfo] object containing information about the application package. + * @param info An optional [ReportInfo] object specifying which details to include in the report. Defaults to a [ReportInfo] instance with default settings. + * @return A [String] containing the formatted version report. */ fun getVersionReport(packageInfo: PackageInfo, info: ReportInfo = ReportInfo()): String { val versionName = packageInfo.versionName - - @Suppress("DEPRECATION") val versionCode = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - packageInfo.longVersionCode - } else { - packageInfo.versionCode.toLong() - } - + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } val androidVersion = if (Build.VERSION.SDK_INT >= 30) { Build.VERSION.RELEASE_OR_CODENAME } else { Build.VERSION.RELEASE } - - val packageName = packageInfo.packageName - - val report = - StringBuilder().append("App version: $packageName $versionName ($versionCode)\n") - - - if (info.androidVersion) { - report.append("Android version: Android $androidVersion (API ${Build.VERSION.SDK_INT})\n") - } - - if (info.deviceInfo) { - report.append("Device: ${Build.MANUFACTURER} ${Build.MODEL}\n") + val report = StringBuilder().apply { + append("App version: ${packageInfo.packageName} $versionName ($versionCode)\n") + if (info.androidVersion) append("Android version: Android $androidVersion (API ${Build.VERSION.SDK_INT})\n") + if (info.deviceInfo) append("Device: ${Build.MANUFACTURER} ${Build.MODEL}\n") + if (info.supportedABIs) append("Supported ABIs: ${Build.SUPPORTED_ABIS.contentToString()}\n") } - - if (info.supportedABIs) { - report.append("Supported ABIs: ${Build.SUPPORTED_ABIS.contentToString()}\n") - } - - return report.toString() //It's only returned supportedABIs for some reason among the appended value at the creation of the "report" val + return report.toString() } /** * This function is used to create a log file in the specified directory with the provided error report. * If the directory is not specified, it defaults to the application's files directory. * - * @param context The application context. - * @param errorReport The error report to be written to the log file. - * @param directory The directory where the log file will be created. Defaults to the application's files directory. - * @return The absolute path of the created log file. + * The function first gets the current time in milliseconds and uses it to create a unique file name for the log file. + * It then creates a new file in the specified directory with the generated file name. + * If the file does not already exist, it creates a new file. + * The function then appends the error report to the log file. + * Finally, it returns the absolute path of the log file. + * + * @param context The application context. This is used to get the default files directory if no directory is specified. + * @param errorReport The error report string that needs to be written to the log file. + * @param directory The directory in which the log file should be created. If not specified, it defaults to the application's files directory. + * @return The absolute path of the created log file as a String. */ fun createLogFile( context: Context, errorReport: String, directory: File = context.filesDir diff --git a/crashhandler/src/main/java/com/bobbyesp/crashhandler/CrashHandlerActivity.kt b/crashhandler/src/main/java/com/bobbyesp/crashhandler/CrashHandlerActivity.kt index 55becc5..1a898bf 100644 --- a/crashhandler/src/main/java/com/bobbyesp/crashhandler/CrashHandlerActivity.kt +++ b/crashhandler/src/main/java/com/bobbyesp/crashhandler/CrashHandlerActivity.kt @@ -1,54 +1,58 @@ package com.bobbyesp.crashhandler +import android.content.ClipData import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.runtime.LaunchedEffect +import androidx.activity.viewModels import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.AnnotatedString -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bobbyesp.crashhandler.ui.CrashReportPage import com.bobbyesp.crashhandler.ui.theme.CrashHandlerTheme -import java.io.File +import kotlinx.coroutines.launch class CrashHandlerActivity : ComponentActivity() { + private val viewModel: CrashHandlerViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() val versionReport: String = intent.getStringExtra("version_report").toString() val logfilePath: String = intent.getStringExtra("logfile_path").toString() + val reportUrl: String = intent.getStringExtra("report_url").toString() + + viewModel.loadLog(logfilePath) setContent { - CrashHandlerTheme { - val clipboardManager = LocalClipboardManager.current - var log by rememberSaveable(key = "log") { - mutableStateOf("") - } + val log by viewModel.log.collectAsStateWithLifecycle() + val clipboard = LocalClipboard.current - LaunchedEffect(true) { - val logFile = File(logfilePath) - log = logFile.readText() - } + val scope = rememberCoroutineScope() + CrashHandlerTheme { CrashReportPage( versionReport = versionReport, - errorMessage = log - ) { - clipboardManager.setText( - AnnotatedString(versionReport).plus( - AnnotatedString( - "\n" + errorMessage = log, + reportUrl = reportUrl, + onExitPressed = { + scope.launch { + clipboard.setClipEntry( + ClipEntry( + ClipData.newPlainText( + "Crash Report", AnnotatedString( + viewModel.generateReportToSend(versionReport) + ) + ) + ) ) - ).plus(AnnotatedString(log)) - ) - this.finishAffinity() - } + } + this.finishAffinity() + }) } } } diff --git a/crashhandler/src/main/java/com/bobbyesp/crashhandler/CrashHandlerViewModel.kt b/crashhandler/src/main/java/com/bobbyesp/crashhandler/CrashHandlerViewModel.kt new file mode 100644 index 0000000..cfb1c87 --- /dev/null +++ b/crashhandler/src/main/java/com/bobbyesp/crashhandler/CrashHandlerViewModel.kt @@ -0,0 +1,44 @@ +package com.bobbyesp.crashhandler + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import java.io.File + +class CrashHandlerViewModel : ViewModel() { + private val _log: MutableStateFlow = MutableStateFlow("") + val log: StateFlow = _log.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + "" + ) + + fun loadLog(logfilePath: String) { + val logFile = File(logfilePath) + val log = logFile.bufferedReader().use { it.readText() } + + val transformedText: String = log.let { text -> + val stringBuilder = StringBuilder() + if (text.length > 2000) { + stringBuilder + .append(text.substring(0, 2000)) + .append("...") + .toString() + } else text + } + + _log.update { transformedText } + } + + fun generateReportToSend(versionReport: String): String { + val sb: StringBuilder = StringBuilder() + + sb.append(versionReport).append("\n").append(_log.value) + + return sb.toString() + } +} \ No newline at end of file diff --git a/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/CrashHandlerPage.kt b/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/CrashHandlerPage.kt index c35fea7..cfbc54f 100644 --- a/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/CrashHandlerPage.kt +++ b/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/CrashHandlerPage.kt @@ -1,5 +1,6 @@ package com.bobbyesp.crashhandler.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,99 +13,123 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.BugReport -import androidx.compose.material.icons.outlined.PermDeviceInformation +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.CancelScheduleSend +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PermDeviceInformation import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold 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.LocalClipboardManager import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.bobbyesp.crashhandler.R import com.bobbyesp.crashhandler.ui.components.ExpandableElevatedCard import com.bobbyesp.crashhandler.ui.components.FilledButtonWithIcon -import java.net.URLEncoder +import com.bobbyesp.crashhandler.ui.components.OutlinedButtonWithIcon @Composable fun CrashReportPage( - versionReport: String = "VERSION REPORT", - errorMessage: String = error_report_fake, - onClick: () -> Unit = {} + errorMessage: String, + versionReport: String, + reportUrl: String, + onExitPressed: () -> Unit ) { val uriOpener = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current - //cut the error message to 2000 characters - val errorMessageCut = if (errorMessage.length > 2000) { - errorMessage.substring(0, 2000) + "..." - } else { - errorMessage - } - Scaffold(modifier = Modifier.fillMaxSize(), bottomBar = { HorizontalDivider() Row( - modifier = Modifier.fillMaxWidth().navigationBarsPadding().padding(vertical = 8.dp) + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically ) { - FilledButtonWithIcon( - modifier = Modifier.fillMaxWidth().padding(start = 16.dp).weight(1f), - onClick = onClick, - icon = Icons.Outlined.BugReport, - text = stringResource(R.string.copy_and_exit) + OutlinedButtonWithIcon( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + onClick = { + clipboardManager.setText(AnnotatedString(errorMessage)) + uriOpener.openUri(reportUrl) + }, icon = Icons.Rounded.CancelScheduleSend, text = stringResource(R.string.report_github) ) - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(8.dp)) FilledButtonWithIcon( - modifier = Modifier.fillMaxWidth().padding(end = 16.dp).weight(1f), onClick = { - val title = URLEncoder.encode("[App crash]", "UTF-8") - clipboardManager.setText(AnnotatedString(errorMessageCut)) - uriOpener.openUri("https://github.com/BobbyESP/Metadator/issues/new?assignees=&labels=bug&projects=&template=bug_report.yml&title=$title") - }, icon = Icons.Outlined.BugReport, text = stringResource(R.string.report_github) + modifier = Modifier + .fillMaxWidth() + .weight(1f), + onClick = onExitPressed, + icon = Icons.Rounded.Close, + text = stringResource(R.string.copy_and_exit) ) } }) { Column( - modifier = Modifier.padding(it).verticalScroll(rememberScrollState()).fillMaxSize() + modifier = Modifier + .padding(it) + .verticalScroll(rememberScrollState()) + .fillMaxSize(), ) { - Icon( - imageVector = Icons.Outlined.BugReport, - contentDescription = "Bug occurred icon", - modifier = Modifier.padding(start = 16.dp).padding(top = 16.dp).size(48.dp) - ) - Text( - text = stringResource(R.string.unknown_error_title), - style = MaterialTheme.typography.displaySmall, - modifier = Modifier.padding(top = 16.dp, bottom = 12.dp).padding(horizontal = 16.dp) - ) - ExpandableElevatedCard( - modifier = Modifier.padding(16.dp), - title = stringResource(id = R.string.device_info), - subtitle = stringResource( - id = R.string.device_info_subtitle - ), - icon = Icons.Outlined.PermDeviceInformation + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { + Icon( + imageVector = Icons.Rounded.BugReport, + contentDescription = "Bug occurred icon", + modifier = Modifier + .size(48.dp) + ) Text( - text = versionReport, - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).fillMaxWidth() + text = stringResource(R.string.unknown_error_title), + style = MaterialTheme.typography.displaySmall, + modifier = Modifier ) + ExpandableElevatedCard( + modifier = Modifier, + title = stringResource(id = R.string.device_info), + subtitle = stringResource( + id = R.string.device_info_subtitle + ), + icon = Icons.Rounded.PermDeviceInformation + ) { + Text( + text = versionReport, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) + } } HorizontalDivider(modifier = Modifier.padding(horizontal = 8.dp)) Text( text = errorMessage, - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp).fillMaxWidth() + modifier = Modifier + .padding(16.dp) + .fillMaxSize() ) } } @@ -121,4 +146,14 @@ private const val error_report_fake = at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584) at kotlinx.coroutines.scheduling.CoroutineSchedulerWorker.executeTask(CoroutineScheduler.kt:793) at kotlinx.coroutines.scheduling.CoroutineSchedulerWorker.runWorker(CoroutineScheduler.kt:697) - at kotlinx.coroutines.scheduling.CoroutineSchedulerWorker.run(CoroutineScheduler.kt:684)""" \ No newline at end of file + at kotlinx.coroutines.scheduling.CoroutineSchedulerWorker.run(CoroutineScheduler.kt:684)""" + +@Preview +@Composable +private fun CrashReportPagePreview() { + CrashReportPage( + errorMessage = error_report_fake, + versionReport = "Version: 1.0.0\nBuild: 1\nDevice: Pixel 4a", + reportUrl = "" + ) {} +} \ No newline at end of file diff --git a/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/components/ExpandableElevatedCard.kt b/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/components/ExpandableElevatedCard.kt index 41333d2..a7442c2 100644 --- a/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/components/ExpandableElevatedCard.kt +++ b/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/components/ExpandableElevatedCard.kt @@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ExpandLess import androidx.compose.material.icons.outlined.PermDeviceInformation import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -49,7 +48,9 @@ fun ExpandableElevatedCard( modifier = modifier, onClick = { expanded = !expanded }, shape = MaterialTheme.shapes.small ) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -58,7 +59,10 @@ fun ExpandableElevatedCard( contentDescription = "Device information" ) Column( - modifier = Modifier.fillMaxWidth().padding(6.dp).weight(1f), + modifier = Modifier + .fillMaxWidth() + .padding(6.dp) + .weight(1f), ) { Text( text = title, @@ -72,8 +76,8 @@ fun ExpandableElevatedCard( fontWeight = FontWeight.Normal ) } - FilledTonalIconButton(modifier = Modifier.padding().size(24.dp), - onClick = { expanded = !expanded }) { + FilledTonalIconButton( + modifier = Modifier.size(24.dp), onClick = { expanded = !expanded }) { Icon( Icons.Outlined.ExpandLess, null, diff --git a/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/components/FilledButtonWithIcon.kt b/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/components/FilledButtonWithIcon.kt index c262340..71ed2e9 100644 --- a/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/components/FilledButtonWithIcon.kt +++ b/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/components/FilledButtonWithIcon.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @Composable @@ -18,23 +19,25 @@ fun FilledButtonWithIcon( icon: ImageVector, enabled: Boolean = true, text: String, - contentDescription: String? = null + contentDescription: String? = null, + maxLines: Int = 1 ) { Button( modifier = modifier, onClick = onClick, enabled = enabled, contentPadding = ButtonDefaults.ButtonWithIconContentPadding - ) - { + ) { Icon( - modifier = Modifier.size(18.dp), + modifier = Modifier.size(24.dp), imageVector = icon, contentDescription = contentDescription ) Text( - modifier = Modifier.padding(start = 6.dp), - text = text + modifier = Modifier.padding(start = 8.dp), + text = text, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis ) } } \ No newline at end of file diff --git a/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/components/OutlinedButtonWithIcon.kt b/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/components/OutlinedButtonWithIcon.kt new file mode 100644 index 0000000..acde6db --- /dev/null +++ b/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/components/OutlinedButtonWithIcon.kt @@ -0,0 +1,43 @@ +package com.bobbyesp.crashhandler.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun OutlinedButtonWithIcon( + modifier: Modifier = Modifier, + onClick: () -> Unit, + icon: ImageVector, + enabled: Boolean = true, + text: String, + contentDescription: String? = null, + maxLines: Int = 1 +) { + OutlinedButton( + modifier = modifier, + onClick = onClick, + enabled = enabled, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = icon, + contentDescription = contentDescription + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = text, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis + ) + } +} \ No newline at end of file diff --git a/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/theme/Theme.kt b/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/theme/Theme.kt index dcd03af..1217405 100644 --- a/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/theme/Theme.kt +++ b/crashhandler/src/main/java/com/bobbyesp/crashhandler/ui/theme/Theme.kt @@ -1,6 +1,7 @@ package com.bobbyesp.crashhandler.ui.theme 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 @@ -17,19 +18,18 @@ fun CrashHandlerTheme( val context = LocalContext.current //if is android higher than 12, use dynamic color scheme, else use static color scheme based on dark theme - if (android.os.Build.VERSION.SDK_INT >= 31) { - MaterialTheme( - colorScheme = if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme( + val colorScheme: ColorScheme = + if(android.os.Build.VERSION.SDK_INT >= 31) { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme( context ) - ) { - content() - } - } else { - MaterialTheme( - colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme() - ) { - content() + } else { + if (darkTheme) darkColorScheme() else lightColorScheme() } + + MaterialTheme( + colorScheme = colorScheme, + ) { + content() } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 50abbd9..a2eb6ed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,68 +1,67 @@ [versions] #General, lifecycle and core -agp = "8.8.0" -firebaseCrashlyticsGradle = "3.0.2" -kotlin = "2.1.0" -appcompat = "1.7.0" -core-ktx = "1.15.0" +agp = "8.11.0" +firebaseCrashlyticsGradle = "3.0.4" +kotlin = "2.2.0" +appcompat = "1.7.1" +core-ktx = "1.16.0" legacySupportV4 = "1.0.0" -lifecycle-runtime-ktx = "2.8.7" -activity-compose = "1.10.0" -ksp = "2.1.0-1.0.29" +lifecycle-runtime-ktx = "2.9.1" +activity-compose = "1.10.1" +ksp = "2.2.0-2.0.2" splashscreen = "1.0.1" -media3 = "1.5.1" -leakcanary = "2.13" +media3 = "1.7.1" +leakcanary = "2.14" #Compose -compose-bom = "2025.01.00" -constraintLayout = "1.1.0" -androidx-compose-material3 = "1.3.1" -compose-util = "1.7.6" +compose-bom = "2025.06.02" +constraintLayout = "1.1.1" +androidx-compose-material3 = "1.3.2" +compose-util = "1.8.3" accompanist = "0.34.0" #More UI -androidx-paging = "3.3.5" -materialKolor = "1.7.0" +androidx-paging = "3.3.6" +materialKolor = "2.1.1" palette = "1.0.0" #Navigation -androidx-navigation = "2.8.5" +androidx-navigation = "2.9.1" #Coroutines -kotlinx-coroutines = "1.9.0" +kotlinx-coroutines = "1.10.2" #Serialization and more related -kotlinx-datetime = "0.6.1" -kotlinx-serializationJson = "1.7.3" -kotlinx-collectionsImmutable = "0.3.7" +kotlinx-datetime = "0.7.0" +kotlinx-serializationJson = "1.9.0" +kotlinx-collectionsImmutable = "0.4.0" #Image loading coil = "2.7.0" -landscapistCoil = "2.4.4" +landscapistCoil = "2.5.1" #Network -ktor = "3.0.2" +ktor = "3.2.1" #Dependency injection -koin = "4.0.0" +koin = "4.1.0" #Database -room = "2.6.1" +room = "2.7.2" #High performance key-value storage -datastore = "1.1.2" +datastore = "1.1.7" #Firebase, GMS... gmsPlayServicesAuth = "21.3.0" -googleServices = "4.4.2" -firebaseBom = "33.8.0" +googleServices = "4.4.3" +firebaseBom = "33.16.0" #Spotify API spotify-apiHandler = "4.1.3" #Others -taglib = "1.0.0-alpha25" -qrcodeKotlinAndroid = "4.2.0" +qrcodeKotlinAndroid = "4.5.0" sonner = "0.3.8" #Tests @@ -71,9 +70,10 @@ androidx-test-ext-junit = "1.2.1" espresso-core = "3.6.1" #Others -androidx-baselineprofile = "1.3.3" +androidx-baselineprofile = "1.3.4" profileinstaller = "1.4.1" scrollbar = "2.2.0" +ktfmt = "0.23.0" [libraries] #Core libraries @@ -169,9 +169,7 @@ firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashly #Others qrcode-kotlin-android = { module = "io.github.g0dkar:qrcode-kotlin-android", version.ref = "qrcodeKotlinAndroid" } sonner = { module = "io.github.dokar3:sonner", version.ref = "sonner" } -taglib = { group = "com.github.Kyant0", name = "taglib", version.ref = "taglib" } spotify-api-android = { group = "com.adamratzman", name = "spotify-api-kotlin-core", version.ref = "spotify-apiHandler" } -palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" } scrollbar = { group = "com.github.nanihadesuka", name = "LazyColumnScrollbar", version.ref = "scrollbar" } #Compose tooling and tests @@ -197,6 +195,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " androidTest = { id = "com.android.test", version.ref = "agp" } firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsGradle" } google-gms = { id = "com.google.gms.google-services", version.ref = "googleServices" } +ktfmt-gradle = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "androidx-baselineprofile" } [bundles] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 076e195..79b0810 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Mar 26 14:31:21 CET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index ca78a69..439d9c4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,3 +28,4 @@ include(":app:utilities") include(":app:ui") include(":crashhandler") include(":app:mediaplayer") +include(":core-utilities")