diff --git a/app/build.gradle b/app/build.gradle index c19dec83..4031d0fd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,13 +7,13 @@ import tm.alashow.buildSrc.App import tm.alashow.buildSrc.Deps plugins { - id "com.android.application" - id "dagger.hilt.android.plugin" - id "kotlin-android" - id "kotlin-kapt" - id "kotlin-parcelize" - id "org.jetbrains.kotlin.plugin.serialization" - id "androidx.navigation.safeargs.kotlin" + id("com.android.application") + id("dagger.hilt.android.plugin") + id("kotlin-android") + id("kotlin-kapt") + id("kotlin-parcelize") + id("org.jetbrains.kotlin.plugin.serialization") + id("androidx.navigation.safeargs.kotlin") } apply plugin: "com.github.triplet.play" @@ -27,16 +27,17 @@ play { def gitSha = "git rev-parse --short HEAD".execute([], project.rootDir).text.trim() android { - compileSdkVersion App.compileSdkVersion + compileSdkVersion = App.compileSdkVersion defaultConfig { - applicationId App.id - targetSdkVersion App.targetSdkVersion - minSdkVersion App.minSdkVersion - versionCode App.versionCode - versionName "${App.versionName}-${gitSha}" - - multiDexEnabled true + namespace = "tm.alashow.datmusic" + applicationId = App.id + versionCode = App.versionCode + versionName = "${App.versionName}-${gitSha}" + targetSdkVersion(App.targetSdkVersion) + minSdkVersion(App.minSdkVersion) + + multiDexEnabled = true vectorDrawables.useSupportLibrary = true } @@ -48,8 +49,8 @@ android { } compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 + sourceCompatibility = 1.8 + targetCompatibility = 1.8 } @@ -58,41 +59,41 @@ android { } composeOptions { - kotlinCompilerExtensionVersion Deps.Android.Compose.compilerVersion + kotlinCompilerExtensionVersion = Deps.Android.Compose.compilerVersion } signingConfigs { debug { - storeFile rootProject.file("signing/alashov-debug.jks") - storePassword "alashov" - keyPassword "alashov" - keyAlias "alashov" + storeFile = rootProject.file("signing/alashov-debug.jks") + storePassword = "alashov" + keyPassword = "alashov" + keyAlias = "alashov" } release { - storeFile getFile("signing/datmusic-release.jks", "signing/alashov-debug.jks") - storePassword prop("DATMUSIC_RELEASE_KEYSTORE_PWD", "alashov") - keyPassword prop("DATMUSIC_RELEASE_KEY_PWD", "alashov") - keyAlias prop("DATMUSIC_RELEASE_KEY_ALIAS", "alashov") + storeFile = getFile("signing/datmusic-release.jks", "signing/alashov-debug.jks") + storePassword = prop("DATMUSIC_RELEASE_KEYSTORE_PWD", "alashov") + keyPassword = prop("DATMUSIC_RELEASE_KEY_PWD", "alashov") + keyAlias = prop("DATMUSIC_RELEASE_KEY_ALIAS", "alashov") } } buildTypes { debug { - signingConfig signingConfigs.debug - versionNameSuffix "-DEBUG" - applicationIdSuffix ".debug" + signingConfig = signingConfigs.debug + versionNameSuffix = "-DEBUG" + applicationIdSuffix = ".debug" - multiDexKeepProguard file("multidex-config.pro") + multiDexKeepProguard = file("multidex-config.pro") } release { - signingConfig signingConfigs.release - minifyEnabled true - shrinkResources true - proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + signingConfig = signingConfigs.release + minifyEnabled = true + shrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") - multiDexKeepProguard file("multidex-config.pro") + multiDexKeepProguard = file("multidex-config.pro") } } @@ -106,9 +107,8 @@ android { jvmTarget = "1.8" } lint { - abortOnError false + abortOnError = false } - namespace 'tm.alashow.datmusic' } repositories { @@ -119,39 +119,42 @@ repositories { } dependencies { - implementation project(':modules:common-compose') - implementation project(":modules:common-ui-theme") - implementation project(":modules:common-ui-components") - implementation project(":modules:core-playback") - implementation project(":modules:core-ui-playback") - implementation project(":modules:core-ui-downloader") - implementation project(":modules:ui-library") - implementation project(":modules:navigation") - implementation project(":modules:ui-search") - implementation project(":modules:ui-settings") - implementation project(":modules:ui-artist") - implementation project(":modules:ui-album") - implementation project(":modules:ui-downloads") - - implementation Deps.Kotlin.coroutinesAndroid + implementation(project(":modules:common-compose")) + implementation(project(":modules:common-ui-theme")) + implementation(project(":modules:common-ui-components")) + implementation(project(":modules:core-ui")) + implementation(project(":modules:core-playback")) + implementation(project(":modules:core-ui-playback")) + implementation(project(":modules:core-ui-downloader")) + implementation(project(":modules:navigation")) + implementation(project(":modules:ui-library")) + implementation(project(":modules:ui-search")) + implementation(project(":modules:ui-settings")) + implementation(project(":modules:ui-artist")) + implementation(project(":modules:ui-album")) + implementation(project(":modules:ui-downloads")) + + implementation(Deps.Kotlin.coroutinesAndroid) // utils - implementation Deps.Utils.proguardSnippets + implementation(Deps.Utils.proguardSnippets) - kapt Deps.Android.Lifecycle.compiler + kapt(Deps.Android.Lifecycle.compiler) // dagger-2 - implementation Deps.Dagger.hilt - kapt Deps.Dagger.compiler - kapt Deps.Dagger.hiltCompiler + implementation(Deps.Dagger.hilt) + kapt(Deps.Dagger.compiler) + kapt(Deps.Dagger.hiltCompiler) // leak canary //debugImplementation Deps.LeakCanary.leakCanary // android - implementation Deps.Android.multiDex + implementation(Deps.Android.multiDex) } -apply plugin: "com.google.gms.google-services" -apply plugin: "com.google.firebase.crashlytics" -apply plugin: "kotlinx-serialization" \ No newline at end of file + + +apply(plugin: "com.google.gms.google-services") +apply(plugin: "com.google.firebase.crashlytics") +apply(plugin: "kotlinx-serialization") \ No newline at end of file diff --git a/app/src/main/kotlin/tm/alashow/datmusic/ui/AppNavigation.kt b/app/src/main/kotlin/tm/alashow/datmusic/ui/AppNavigation.kt index ac6ae81c..b396c9cc 100644 --- a/app/src/main/kotlin/tm/alashow/datmusic/ui/AppNavigation.kt +++ b/app/src/main/kotlin/tm/alashow/datmusic/ui/AppNavigation.kt @@ -27,20 +27,19 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import com.google.accompanist.navigation.animation.AnimatedNavHost import com.google.accompanist.navigation.animation.navigation -import com.google.firebase.analytics.FirebaseAnalytics -import tm.alashow.base.util.event +import tm.alashow.base.util.Analytics import tm.alashow.common.compose.LocalAnalytics import tm.alashow.common.compose.collectEvent -import tm.alashow.datmusic.ui.album.AlbumDetail -import tm.alashow.datmusic.ui.artist.ArtistDetail -import tm.alashow.datmusic.ui.downloads.Downloads -import tm.alashow.datmusic.ui.library.Library -import tm.alashow.datmusic.ui.library.playlists.create.CreatePlaylist -import tm.alashow.datmusic.ui.library.playlists.detail.PlaylistDetail -import tm.alashow.datmusic.ui.library.playlists.edit.EditPlaylist -import tm.alashow.datmusic.ui.playback.PlaybackSheet +import tm.alashow.datmusic.ui.album.AlbumDetailRoute +import tm.alashow.datmusic.ui.artist.ArtistDetailRoute +import tm.alashow.datmusic.ui.downloads.DownloadsRoute +import tm.alashow.datmusic.ui.library.LibraryRoute +import tm.alashow.datmusic.ui.library.playlists.create.CreatePlaylistRoute +import tm.alashow.datmusic.ui.library.playlists.detail.PlaylistDetailRoute +import tm.alashow.datmusic.ui.library.playlists.edit.EditPlaylistRoute +import tm.alashow.datmusic.ui.playback.PlaybackSheetRoute import tm.alashow.datmusic.ui.search.SearchRoute -import tm.alashow.datmusic.ui.settings.Settings +import tm.alashow.datmusic.ui.settings.SettingsRoute import tm.alashow.navigation.LocalNavigator import tm.alashow.navigation.NavigationEvent import tm.alashow.navigation.Navigator @@ -56,7 +55,7 @@ internal fun AppNavigation( navController: NavHostController, modifier: Modifier = Modifier, navigator: Navigator = LocalNavigator.current, - analytics: FirebaseAnalytics = LocalAnalytics.current, + analytics: Analytics = LocalAnalytics.current, ) { collectEvent(navigator.queue) { event -> analytics.event("navigator.navigate", mapOf("route" to event.route)) @@ -155,55 +154,55 @@ private fun NavGraphBuilder.addSearch() { private fun NavGraphBuilder.addSettings() { composableScreen(LeafScreen.Settings()) { - Settings() + SettingsRoute() } } private fun NavGraphBuilder.addDownloads() { composableScreen(LeafScreen.Downloads()) { - Downloads() + DownloadsRoute() } } private fun NavGraphBuilder.addLibrary() { composableScreen(LeafScreen.Library()) { - Library() + LibraryRoute() } } private fun NavGraphBuilder.addCreatePlaylist() { bottomSheetScreen(LeafScreen.CreatePlaylist()) { - CreatePlaylist() + CreatePlaylistRoute() } } private fun NavGraphBuilder.addEditPlaylist() { bottomSheetScreen(EditPlaylistScreen()) { - EditPlaylist() + EditPlaylistRoute() } } private fun NavGraphBuilder.addPlaylistDetails(root: RootScreen) { composableScreen(LeafScreen.PlaylistDetail(rootRoute = root.route)) { - PlaylistDetail() + PlaylistDetailRoute() } } private fun NavGraphBuilder.addArtistDetails(root: RootScreen) { composableScreen(LeafScreen.ArtistDetails(rootRoute = root.route)) { - ArtistDetail() + ArtistDetailRoute() } } private fun NavGraphBuilder.addAlbumDetails(root: RootScreen) { composableScreen(LeafScreen.AlbumDetails(rootRoute = root.route)) { - AlbumDetail() + AlbumDetailRoute() } } private fun NavGraphBuilder.addPlaybackSheet() { bottomSheetScreen(LeafScreen.PlaybackSheet()) { - PlaybackSheet() + PlaybackSheetRoute() } } diff --git a/app/src/main/kotlin/tm/alashow/datmusic/ui/DatmusicApp.kt b/app/src/main/kotlin/tm/alashow/datmusic/ui/DatmusicApp.kt index 79b0e8db..1d12bed4 100644 --- a/app/src/main/kotlin/tm/alashow/datmusic/ui/DatmusicApp.kt +++ b/app/src/main/kotlin/tm/alashow/datmusic/ui/DatmusicApp.kt @@ -10,70 +10,99 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel import androidx.navigation.NavHostController import androidx.navigation.plusAssign -import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.navigation.animation.rememberAnimatedNavController import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi import com.google.accompanist.navigation.material.ModalBottomSheetLayout -import com.google.firebase.analytics.FirebaseAnalytics +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import tm.alashow.base.util.Analytics import tm.alashow.common.compose.LocalAnalytics +import tm.alashow.common.compose.LocalAppVersion +import tm.alashow.common.compose.LocalIsPreviewMode import tm.alashow.common.compose.LocalSnackbarHostState +import tm.alashow.common.compose.previews.CombinedPreview +import tm.alashow.common.compose.previews.FontScalePreview +import tm.alashow.common.compose.previews.LocalePreview import tm.alashow.common.compose.rememberFlowWithLifecycle import tm.alashow.datmusic.BuildConfig +import tm.alashow.datmusic.ui.audios.AudioActionHandler import tm.alashow.datmusic.ui.audios.LocalAudioActionHandler import tm.alashow.datmusic.ui.audios.audioActionHandler +import tm.alashow.datmusic.ui.downloader.AudioDownloadItemActionHandler import tm.alashow.datmusic.ui.downloader.DownloaderHost -import tm.alashow.datmusic.ui.downloads.audio.LocalAudioDownloadItemActionHandler -import tm.alashow.datmusic.ui.downloads.audio.audioDownloadItemActionHandler +import tm.alashow.datmusic.ui.downloader.LocalAudioDownloadItemActionHandler +import tm.alashow.datmusic.ui.downloads.audioDownloadItemActionHandler import tm.alashow.datmusic.ui.home.Home import tm.alashow.datmusic.ui.playback.PlaybackHost -import tm.alashow.datmusic.ui.settings.LocalAppVersion -import tm.alashow.datmusic.ui.snackbar.SnackbarMessagesListener +import tm.alashow.datmusic.ui.playback.PlaybackViewModel +import tm.alashow.datmusic.ui.previews.PreviewDatmusicCore +import tm.alashow.datmusic.ui.snackbar.SnackbarMessagesHost import tm.alashow.navigation.NavigatorHost +import tm.alashow.navigation.activityHiltViewModel import tm.alashow.navigation.rememberBottomSheetNavigator import tm.alashow.ui.ThemeViewModel import tm.alashow.ui.theme.AppTheme -@OptIn(ExperimentalMaterialNavigationApi::class, ExperimentalAnimationApi::class) @Composable fun DatmusicApp( - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + playbackViewModel: PlaybackViewModel = activityHiltViewModel(), +) = DatmusicCore { + DatmusicAppContent( + onPlayingTitleClick = playbackViewModel::onTitleClick, + onPlayingArtistClick = playbackViewModel::onArtistClick, + ) +} + +@OptIn(ExperimentalMaterialNavigationApi::class, ExperimentalAnimationApi::class) +@Composable +private fun DatmusicAppContent( + onPlayingTitleClick: () -> Unit, + onPlayingArtistClick: () -> Unit, + modifier: Modifier = Modifier, navController: NavHostController = rememberAnimatedNavController(), - analytics: FirebaseAnalytics = FirebaseAnalytics.getInstance(LocalContext.current), ) { - CompositionLocalProvider( - LocalSnackbarHostState provides snackbarHostState, - LocalAnalytics provides analytics, - LocalAppVersion provides BuildConfig.VERSION_NAME - ) { - ProvideWindowInsets(consumeWindowInsets = false) { - DatmusicCore { - val bottomSheetNavigator = rememberBottomSheetNavigator() - navController.navigatorProvider += bottomSheetNavigator - ModalBottomSheetLayout(bottomSheetNavigator) { - Home(navController) - } - } - } + val bottomSheetNavigator = rememberBottomSheetNavigator() + navController.navigatorProvider += bottomSheetNavigator + ModalBottomSheetLayout(bottomSheetNavigator, modifier) { + Home( + navController = navController, + onPlayingTitleClick = onPlayingTitleClick, + onPlayingArtistClick = onPlayingArtistClick, + ) } } +// Could be renamed to DatmusicCoreViewModel if more things are injected +@HiltViewModel +private class AnalyticsViewModel @Inject constructor(val analytics: Analytics) : ViewModel() + @Composable private fun DatmusicCore( + modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, themeViewModel: ThemeViewModel = hiltViewModel(), + analyticsViewModel: AnalyticsViewModel = hiltViewModel(), + appVersion: String = BuildConfig.VERSION_NAME, content: @Composable () -> Unit ) { - SnackbarMessagesListener() - val themeState by rememberFlowWithLifecycle(themeViewModel.themeState) - AppTheme(themeState) { - NavigatorHost { - DownloaderHost { - PlaybackHost { - DatmusicActionHandlers { - content() + CompositionLocalProvider( + LocalSnackbarHostState provides snackbarHostState, + LocalAnalytics provides analyticsViewModel.analytics, + LocalAppVersion provides appVersion, + LocalIsPreviewMode provides false, + ) { + SnackbarMessagesHost() + val themeState by rememberFlowWithLifecycle(themeViewModel.themeState) + AppTheme(themeState, modifier) { + NavigatorHost { + DownloaderHost { + PlaybackHost { + DatmusicActionHandlers(content = content) } } } @@ -82,13 +111,26 @@ private fun DatmusicCore( } @Composable -private fun DatmusicActionHandlers(content: @Composable () -> Unit) { - val audioActionHandler = audioActionHandler() - val audioDownloadItemActionHandler = audioDownloadItemActionHandler() +private fun DatmusicActionHandlers( + content: @Composable () -> Unit, + audioActionHandler: AudioActionHandler = audioActionHandler(), + audioDownloadItemActionHandler: AudioDownloadItemActionHandler = audioDownloadItemActionHandler(), +) { CompositionLocalProvider( LocalAudioActionHandler provides audioActionHandler, - LocalAudioDownloadItemActionHandler provides audioDownloadItemActionHandler + LocalAudioDownloadItemActionHandler provides audioDownloadItemActionHandler, ) { content() } } + +@CombinedPreview +@LocalePreview +@FontScalePreview +@Composable +fun DatmusicAppPreview() = PreviewDatmusicCore { + DatmusicAppContent( + onPlayingTitleClick = {}, + onPlayingArtistClick = {} + ) +} diff --git a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/Home.kt b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/Home.kt index d62cdbc3..e3330d87 100644 --- a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/Home.kt +++ b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/Home.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.zIndex import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController -import tm.alashow.common.compose.LocalPlaybackConnection import tm.alashow.common.compose.LocalSnackbarHostState import tm.alashow.common.compose.rememberFlowWithLifecycle import tm.alashow.datmusic.playback.PlaybackConnection @@ -30,6 +29,7 @@ import tm.alashow.datmusic.playback.isActive import tm.alashow.datmusic.ui.AppNavigation import tm.alashow.datmusic.ui.currentScreenAsState import tm.alashow.datmusic.ui.hostNavGraph +import tm.alashow.datmusic.ui.playback.LocalPlaybackConnection import tm.alashow.datmusic.ui.playback.PlaybackMiniControls import tm.alashow.navigation.screens.RootScreen import tm.alashow.ui.DismissableSnackbarHost @@ -41,6 +41,9 @@ import tm.alashow.ui.theme.AppTheme @Composable internal fun Home( navController: NavHostController, + onPlayingTitleClick: () -> Unit, + onPlayingArtistClick: () -> Unit, + modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState = LocalSnackbarHostState.current, playbackConnection: PlaybackConnection = LocalPlaybackConnection.current, ) { @@ -49,7 +52,7 @@ internal fun Home( val nowPlaying by rememberFlowWithLifecycle(playbackConnection.nowPlaying) val isPlayerActive = (playbackState to nowPlaying).isActive - BoxWithConstraints { + BoxWithConstraints(modifier) { val isWideLayout = isWideLayout() val maxWidth = maxWidth Row(Modifier.fillMaxSize()) { @@ -57,7 +60,9 @@ internal fun Home( ResizableHomeNavigationRail( availableWidth = maxWidth, selectedTab = selectedTab, - navController = navController + navController = navController, + onPlayingTitleClick = onPlayingTitleClick, + onPlayingArtistClick = onPlayingArtistClick, ) Scaffold( modifier = Modifier.weight(12f), @@ -73,7 +78,7 @@ internal fun Home( HomeNavigationBar( selectedTab = selectedTab, onNavigationSelected = { selected -> navController.selectRootScreen(selected) }, - playerActive = isPlayerActive, + isPlayerActive = isPlayerActive, modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationBar.kt b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationBar.kt index 0c942f7f..18b18066 100644 --- a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationBar.kt +++ b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationBar.kt @@ -5,33 +5,47 @@ package tm.alashow.datmusic.ui.home import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush 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.PreviewParameter import androidx.compose.ui.unit.dp +import tm.alashow.common.compose.previews.BooleanPreviewParameter +import tm.alashow.common.compose.previews.CombinedPreview +import tm.alashow.datmusic.ui.previews.PreviewDatmusicCore import tm.alashow.navigation.screens.RootScreen +import tm.alashow.ui.theme.Theme import tm.alashow.ui.theme.translucentSurfaceColor internal object HomeNavigationBarDefaults { val colors @Composable get() = NavigationBarItemDefaults.colors( - indicatorColor = MaterialTheme.colorScheme.secondary, - selectedTextColor = MaterialTheme.colorScheme.secondary, - selectedIconColor = MaterialTheme.colorScheme.onSecondary, - unselectedIconColor = MaterialTheme.colorScheme.onSurface, - unselectedTextColor = MaterialTheme.colorScheme.onSurface, + indicatorColor = Theme.inanimateColorScheme.secondary, + selectedTextColor = Theme.inanimateColorScheme.secondary, + selectedIconColor = Theme.inanimateColorScheme.onSecondary, + unselectedIconColor = Theme.inanimateColorScheme.onSurface, + unselectedTextColor = Theme.inanimateColorScheme.onSurface, ) } @@ -40,11 +54,11 @@ internal fun HomeNavigationBar( selectedTab: RootScreen, onNavigationSelected: (RootScreen) -> Unit, modifier: Modifier = Modifier, - playerActive: Boolean = false, + isPlayerActive: Boolean = false, ) { - val elevation = if (playerActive) 0.dp else 8.dp - val color = if (playerActive) Color.Transparent else translucentSurfaceColor() - val backgroundMod = if (playerActive) Modifier.background(homeBottomNavigationGradient()) else Modifier + val elevation = if (isPlayerActive) 0.dp else 8.dp + val color = if (isPlayerActive) Color.Transparent else translucentSurfaceColor() + val backgroundMod = if (isPlayerActive) Modifier.background(homeBottomNavigationGradient()) else Modifier NavigationBar( tonalElevation = elevation, @@ -75,3 +89,28 @@ private fun homeBottomNavigationGradient(color: Color = MaterialTheme.colorSchem color, ) ) + +@OptIn(ExperimentalMaterial3Api::class) +@CombinedPreview +@Composable +private fun HomeNavigationBarPreview( + @PreviewParameter(BooleanPreviewParameter::class) isPlayerActive: Boolean, +) = PreviewDatmusicCore { + var selectedTab by remember { mutableStateOf(RootScreen.Search) } + Scaffold( + bottomBar = { + HomeNavigationBar( + selectedTab = selectedTab, + onNavigationSelected = { selectedTab = it }, + isPlayerActive = isPlayerActive, + ) + } + ) { + Box( + Modifier + .fillMaxSize() + .background(Theme.colorScheme.inverseSurface) + .padding(it) + ) + } +} diff --git a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationRail.kt b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationRail.kt index f66b975a..ca122691 100644 --- a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationRail.kt +++ b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationRail.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope 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.fillMaxHeight @@ -27,24 +26,30 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import tm.alashow.common.compose.LocalPlaybackConnection +import tm.alashow.common.compose.previews.CombinedPreview import tm.alashow.common.compose.rememberFlowWithLifecycle import tm.alashow.datmusic.playback.PlaybackConnection import tm.alashow.datmusic.playback.isActive import tm.alashow.datmusic.ui.home.HomeNavigationRailDefaults.ExpandedPlaybackControlsMinWidth import tm.alashow.datmusic.ui.home.HomeNavigationRailDefaults.ExpandedPlaybackModeMinHeight +import tm.alashow.datmusic.ui.playback.LocalPlaybackConnection import tm.alashow.datmusic.ui.playback.PlaybackMiniControls import tm.alashow.datmusic.ui.playback.components.PlaybackArtworkPagerWithNowPlayingAndControls import tm.alashow.datmusic.ui.playback.components.PlaybackNowPlayingDefaults +import tm.alashow.datmusic.ui.previews.PreviewDatmusicCore import tm.alashow.navigation.LocalNavigator import tm.alashow.navigation.Navigator import tm.alashow.navigation.screens.LeafScreen @@ -82,6 +87,8 @@ internal object HomeNavigationRailDefaults { internal fun HomeNavigationRail( selectedTab: RootScreen, onNavigationSelected: (RootScreen) -> Unit, + onPlayingTitleClick: () -> Unit, + onPlayingArtistClick: () -> Unit, modifier: Modifier = Modifier, extraContent: @Composable BoxScope.() -> Unit = {}, playbackConnection: PlaybackConnection = LocalPlaybackConnection.current, @@ -126,13 +133,16 @@ internal fun HomeNavigationRail( label = { Text(stringResource(item.labelRes), maxLines = 1, overflow = TextOverflow.Ellipsis) }, alwaysShowLabel = false, colors = HomeNavigationRailDefaults.colors, - modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally), ) } } } if (isExpandedPlaybackControls) { - val expandedPlaybackControlsWeight = 3f + ((maxWidth - ExpandedPlaybackControlsMinWidth) / ExpandedPlaybackControlsMinWidth * 2.5f) + val expandedPlaybackControlsWeight = + 3f + ((maxWidth - ExpandedPlaybackControlsMinWidth) / ExpandedPlaybackControlsMinWidth * 2.5f) val playbackState by rememberFlowWithLifecycle(playbackConnection.playbackState) val nowPlaying by rememberFlowWithLifecycle(playbackConnection.nowPlaying) val visible = (playbackState to nowPlaying).isActive @@ -147,13 +157,29 @@ internal fun HomeNavigationRail( onArtworkClick = { navigator.navigate(LeafScreen.PlaybackSheet().createRoute()) }, titleTextStyle = PlaybackNowPlayingDefaults.titleTextStyle.copy(fontSize = MaterialTheme.typography.bodyLarge.fontSize), artistTextStyle = PlaybackNowPlayingDefaults.artistTextStyle.copy(fontSize = MaterialTheme.typography.titleSmall.fontSize), + onTitleClick = onPlayingTitleClick, + onArtistClick = onPlayingArtistClick, ) } - } else PlaybackMiniControls( - modifier = Modifier.padding(bottom = AppTheme.specs.paddingSmall), - contentPadding = PaddingValues(end = AppTheme.specs.padding), - ) + } else PlaybackMiniControls(modifier = Modifier.padding(bottom = AppTheme.specs.paddingSmall)) } } } } + +@CombinedPreview +@Composable +private fun HomeNavigationRailPreview() = PreviewDatmusicCore { + var selectedTab by remember { mutableStateOf(RootScreen.Search) } + var widthFraction by remember { mutableStateOf(1f) } + Column { + Slider(value = widthFraction, onValueChange = { widthFraction = it }, valueRange = 0.1f..1f) + HomeNavigationRail( + selectedTab = selectedTab, + onNavigationSelected = { selectedTab = it }, + onPlayingTitleClick = {}, + onPlayingArtistClick = {}, + modifier = Modifier.fillMaxWidth(widthFraction) + ) + } +} diff --git a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationRailItemRow.kt b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationRailItemRow.kt index e953cc68..833e6a7d 100644 --- a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationRailItemRow.kt +++ b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/HomeNavigationRailItemRow.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.selectable -import androidx.compose.material.LocalContentAlpha import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme @@ -30,6 +29,7 @@ import androidx.compose.ui.graphics.lerp import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow +import tm.alashow.ui.material.ProvideContentAlpha import tm.alashow.ui.theme.AppTheme @Composable @@ -99,8 +99,9 @@ internal fun HomeNavigationItemTransition( CompositionLocalProvider( LocalContentColor provides color.copy(alpha = 1f), - LocalContentAlpha provides color.alpha, ) { - content(animationProgress) + ProvideContentAlpha(color.alpha) { + content(animationProgress) + } } } diff --git a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/ResizableHomeNavigationRail.kt b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/ResizableHomeNavigationRail.kt index b037d0b5..c6dd66a6 100644 --- a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/ResizableHomeNavigationRail.kt +++ b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/ResizableHomeNavigationRail.kt @@ -32,6 +32,8 @@ internal fun RowScope.ResizableHomeNavigationRail( viewModel: ResizableHomeNavigationRailViewModel = hiltViewModel(), dragOffset: State = rememberFlowWithLifecycle(viewModel.dragOffset), setDragOffset: (Float) -> Unit = viewModel::setDragOffset, + onPlayingTitleClick: () -> Unit, + onPlayingArtistClick: () -> Unit, ) { ResizableLayout( availableWidth = availableWidth, @@ -45,6 +47,8 @@ internal fun RowScope.ResizableHomeNavigationRail( HomeNavigationRail( selectedTab = selectedTab, onNavigationSelected = { selected -> navController.selectRootScreen(selected) }, + onPlayingTitleClick = onPlayingTitleClick, + onPlayingArtistClick = onPlayingArtistClick, modifier = Modifier .fillMaxHeight() .then(resizableModifier), diff --git a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/ResizableHomeNavigationRailViewModel.kt b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/ResizableHomeNavigationRailViewModel.kt index cc29b779..971c64ff 100644 --- a/app/src/main/kotlin/tm/alashow/datmusic/ui/home/ResizableHomeNavigationRailViewModel.kt +++ b/app/src/main/kotlin/tm/alashow/datmusic/ui/home/ResizableHomeNavigationRailViewModel.kt @@ -5,20 +5,18 @@ package tm.alashow.datmusic.ui.home import androidx.datastore.preferences.core.floatPreferencesKey -import androidx.lifecycle.SavedStateHandle -import com.google.firebase.analytics.FirebaseAnalytics import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import tm.alashow.base.ui.base.vm.ResizableLayoutViewModel +import tm.alashow.base.util.Analytics import tm.alashow.data.PreferencesStore private val HomeNavigationRailDragOffsetKey = floatPreferencesKey("HomeNavigationRailWeightKey") @HiltViewModel class ResizableHomeNavigationRailViewModel @Inject constructor( - handle: SavedStateHandle, preferencesStore: PreferencesStore, - analytics: FirebaseAnalytics, + analytics: Analytics, ) : ResizableLayoutViewModel( preferencesStore = preferencesStore, analytics = analytics, diff --git a/app/src/main/kotlin/tm/alashow/datmusic/util/RemoteConfigInitializer.kt b/app/src/main/kotlin/tm/alashow/datmusic/util/RemoteConfigInitializer.kt index d1d94fa6..9d5519ae 100644 --- a/app/src/main/kotlin/tm/alashow/datmusic/util/RemoteConfigInitializer.kt +++ b/app/src/main/kotlin/tm/alashow/datmusic/util/RemoteConfigInitializer.kt @@ -9,6 +9,8 @@ import javax.inject.Inject import tm.alashow.base.inititializer.AppInitializer import tm.alashow.data.RemoteConfig -class RemoteConfigInitializer @Inject constructor(remoteConfig: RemoteConfig) : AppInitializer { - override fun init(application: Application) {} +class RemoteConfigInitializer @Inject constructor(private val remoteConfig: RemoteConfig) : AppInitializer { + override fun init(application: Application) { + remoteConfig + } } diff --git a/app/src/main/play/release-notes/en-GB/beta.txt b/app/src/main/play/release-notes/en-GB/beta.txt index b66cb78d..7e6499e6 100644 --- a/app/src/main/play/release-notes/en-GB/beta.txt +++ b/app/src/main/play/release-notes/en-GB/beta.txt @@ -1,3 +1,6 @@ +v2.3.0-beta.3 +- UI fixes & optimizations + v2.3.0-beta.2 - Migrate to Material 3 & add support for Dynamic color palette (only on Android 12+) - Scroll to last queued download when opening Downloads diff --git a/buildSrc/src/main/java/tm/alashow/buildSrc/App.kt b/buildSrc/src/main/java/tm/alashow/buildSrc/App.kt index e77c2825..5c3a3753 100644 --- a/buildSrc/src/main/java/tm/alashow/buildSrc/App.kt +++ b/buildSrc/src/main/java/tm/alashow/buildSrc/App.kt @@ -6,6 +6,6 @@ object App { const val compileSdkVersion = 33 const val targetSdkVersion = 33 const val minSdkVersion = 21 - const val versionCode = 246 - const val versionName = "2.3.0-beta.2" + const val versionCode = 247 + const val versionName = "2.3.0-beta.3" } diff --git a/buildSrc/src/main/java/tm/alashow/buildSrc/Deps.kt b/buildSrc/src/main/java/tm/alashow/buildSrc/Deps.kt index 5b373e74..1c798828 100644 --- a/buildSrc/src/main/java/tm/alashow/buildSrc/Deps.kt +++ b/buildSrc/src/main/java/tm/alashow/buildSrc/Deps.kt @@ -28,7 +28,7 @@ object Deps { const val multiDex = "androidx.multidex:multidex:2.0.1" - const val activityVersion = "1.6.0-rc01" + const val activityVersion = "1.6.0-rc02" const val activityKtx = "androidx.activity:activity-ktx:$activityVersion" private const val navigationVersion = "2.5.1" @@ -48,7 +48,7 @@ object Deps { const val archCoreTesting = "androidx.arch.core:core-testing:2.1.0" object Compose { - const val version = "1.3.0-beta01" + const val version = "1.3.0-beta02" const val compilerVersion = "1.3.0" const val ui = "androidx.compose.ui:ui:$version" @@ -56,13 +56,12 @@ object Deps { const val uiTooling = "androidx.compose.ui:ui-tooling:$version" const val foundation = "androidx.compose.foundation:foundation:$version" const val material = "androidx.compose.material:material:$version" - const val material3 = "androidx.compose.material3:material3:1.0.0-beta01" + const val material3 = "androidx.compose.material3:material3:1.0.0-beta02" const val materialIcons = "androidx.compose.material:material-icons-core:$version" const val materialIconsExtended = "androidx.compose.material:material-icons-extended:$version" const val constraintLayout = "androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha03" const val liveData = "androidx.compose.runtime:runtime-livedata:$version" const val activity = "androidx.activity:activity-compose:$activityVersion" - const val viewModels = "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha01" const val paging = "androidx.paging:paging-compose:1.0.0-alpha16" const val uiTestJunit = "androidx.compose.ui:ui-test-junit4:$version" @@ -77,10 +76,8 @@ object Deps { } object Accompanist { - private const val version = "0.26.2-beta" + private const val version = "0.26.3-beta" - const val insets = "com.google.accompanist:accompanist-insets:$version" - const val insetsUi = "com.google.accompanist:accompanist-insets-ui:$version" const val pager = "com.google.accompanist:accompanist-pager:$version" const val permissions = "com.google.accompanist:accompanist-permissions:$version" const val placeholder = "com.google.accompanist:accompanist-placeholder-material:$version" @@ -92,7 +89,7 @@ object Deps { } object Lifecycle { - private const val version = "2.6.0-alpha01" + private const val version = "2.6.0-alpha02" const val runtime = "androidx.lifecycle:lifecycle-runtime:$version" const val runtimeKtx = "androidx.lifecycle:lifecycle-runtime-ktx:$version" @@ -100,6 +97,7 @@ object Deps { const val vmKotlin = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" const val vmSavedState = "androidx.lifecycle:lifecycle-viewmodel-savedstate:$version" const val extensions = "androidx.lifecycle:lifecycle-extensions:2.2.0" + const val composeViewModels = "androidx.lifecycle:lifecycle-viewmodel-compose:$version" } object Room { @@ -134,7 +132,7 @@ object Deps { const val threeTen = "org.threeten:threetenbp:1.6.1" - const val coilVersion = "2.2.0" + const val coilVersion = "2.2.1" const val coil = "io.coil-kt:coil:$coilVersion" const val store = "com.dropbox.mobile.store:store4:4.0.5" @@ -147,7 +145,7 @@ object Deps { const val exoPlayerOkhttp = "com.google.android.exoplayer:extension-okhttp:2.15.0" const val exoPlayerFlac = "com.github.alashow.ExoPlayer-Extensions:extension-flac:v2.15.1" - const val qonversion = "io.qonversion.android.sdk:sdk:3.3.0" + const val qonversion = "io.qonversion.android.sdk:sdk:3.3.1" } object OkHttp { @@ -187,7 +185,7 @@ object Deps { object Firebase { - const val bom = "com.google.firebase:firebase-bom:30.4.0" + const val bom = "com.google.firebase:firebase-bom:30.5.0" const val messaging = "com.google.firebase:firebase-messaging-ktx" const val remoteConfig = "com.google.firebase:firebase-config-ktx" const val analytics = "com.google.firebase:firebase-analytics-ktx" @@ -201,7 +199,7 @@ object Deps { const val robolectric = "org.robolectric:robolectric:4.8.2" const val mockito = "org.mockito:mockito-core:4.7.0" const val mockitoKotlin = "org.mockito.kotlin:mockito-kotlin:4.0.0" - const val mockk = "io.mockk:mockk:1.12.4" + const val mockk = "io.mockk:mockk:1.12.8" const val turbine = "app.cash.turbine:turbine:0.8.0" } } diff --git a/modules/base-android/src/main/java/tm/alashow/base/ui/base/vm/ResizableLayoutViewModel.kt b/modules/base-android/src/main/java/tm/alashow/base/ui/base/vm/ResizableLayoutViewModel.kt index e6bac52e..e57d294a 100644 --- a/modules/base-android/src/main/java/tm/alashow/base/ui/base/vm/ResizableLayoutViewModel.kt +++ b/modules/base-android/src/main/java/tm/alashow/base/ui/base/vm/ResizableLayoutViewModel.kt @@ -7,13 +7,12 @@ package tm.alashow.base.ui.base.vm import androidx.datastore.preferences.core.Preferences import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.firebase.analytics.FirebaseAnalytics import javax.inject.Inject import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch -import tm.alashow.base.util.event +import tm.alashow.base.util.Analytics import tm.alashow.data.PreferencesStore open class ResizableLayoutViewModel @Inject constructor( @@ -21,7 +20,7 @@ open class ResizableLayoutViewModel @Inject constructor( preferenceKey: Preferences.Key, defaultDragOffset: Float = 0f, private val analyticsPrefix: String, - private val analytics: FirebaseAnalytics, + private val analytics: Analytics, ) : ViewModel() { private val dragOffsetState = preferencesStore.getStateFlow( diff --git a/modules/base/src/main/java/tm/alashow/base/di/BaseModule.kt b/modules/base/src/main/java/tm/alashow/base/di/BaseModule.kt index ab2df6e8..9396c927 100644 --- a/modules/base/src/main/java/tm/alashow/base/di/BaseModule.kt +++ b/modules/base/src/main/java/tm/alashow/base/di/BaseModule.kt @@ -7,27 +7,23 @@ package tm.alashow.base.di import android.app.Application import android.content.Context import android.content.res.Resources -import com.google.firebase.analytics.FirebaseAnalytics import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -import tm.alashow.base.util.extensions.androidId +import tm.alashow.base.util.Analytics +import tm.alashow.base.util.FirebaseAppAnalytics @Module @InstallIn(SingletonComponent::class) object BaseModule { - @Provides - fun appContext(app: Application): Context = app.applicationContext - @Provides fun appResources(app: Application): Resources = app.resources @Singleton @Provides - fun firebaseAnalytics(app: Application) = FirebaseAnalytics.getInstance(app).apply { - setUserId(app.androidId()) - } + fun firebaseAnalytics(@ApplicationContext context: Context): Analytics = FirebaseAppAnalytics(context) } diff --git a/modules/base/src/main/java/tm/alashow/base/ui/SnackbarManager.kt b/modules/base/src/main/java/tm/alashow/base/ui/SnackbarManager.kt index f9776d25..2d87b498 100644 --- a/modules/base/src/main/java/tm/alashow/base/ui/SnackbarManager.kt +++ b/modules/base/src/main/java/tm/alashow/base/ui/SnackbarManager.kt @@ -13,16 +13,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.receiveAsFlow import tm.alashow.base.R -import tm.alashow.base.util.CoroutineDispatchers import tm.alashow.i18n.UiMessage data class SnackbarAction(val label: UiMessage<*>, val argument: T) open class SnackbarMessage(val message: UiMessage<*>, val action: SnackbarAction? = null) @Singleton -class SnackbarManager @Inject constructor( - private val dispatchers: CoroutineDispatchers, -) { +class SnackbarManager @Inject constructor() { private val messagesChannel = Channel>(Channel.CONFLATED) private val actionDismissedMessageChannel = Channel>(Channel.CONFLATED) diff --git a/modules/base/src/main/java/tm/alashow/base/util/Analytics.kt b/modules/base/src/main/java/tm/alashow/base/util/Analytics.kt new file mode 100644 index 00000000..3f9eb199 --- /dev/null +++ b/modules/base/src/main/java/tm/alashow/base/util/Analytics.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2022, Alashov Berkeli + * All rights reserved. + */ +package tm.alashow.base.util + +import android.content.Context +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import timber.log.Timber +import tm.alashow.base.util.extensions.androidId +import tm.alashow.base.util.extensions.isNotNullandNotBlank + +typealias LogArgs = Map? + +interface Analytics { + fun logEvent(event: String, args: LogArgs = null) + fun event(event: String, args: LogArgs = null) = logEvent(event, args) + fun click(event: String, args: LogArgs = null) = event("click.$event", args) +} + +internal class FirebaseAppAnalytics(private val context: Context) : Analytics { + + private val firebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(context).apply { + setUserId(context.androidId()) + } + } + + override fun logEvent(event: String, args: LogArgs) { + Timber.d("Logging event: $event, $args") + if (event.length > 40) { + Timber.e("Event name is too long: $event, truncating to 40 chars") + } + firebaseAnalytics.logEvent( + event.replace(".", "_").lowercase().take(40), + Bundle().apply { args?.forEach { putString(it.key, it.value.toString()) } } + ) + } +} + +suspend fun Flow.searchQueryAnalytics( + analytics: Analytics, + prefix: String, + debounceMillis: Long = 3000L, +) = filter { it.isNotNullandNotBlank() } + .debounce(debounceMillis) + .collectLatest { analytics.event("$prefix.query", mapOf("query" to it)) } diff --git a/modules/base/src/main/java/tm/alashow/base/util/RemoteLogger.kt b/modules/base/src/main/java/tm/alashow/base/util/RemoteLogger.kt index 90db2ed7..ade106b9 100644 --- a/modules/base/src/main/java/tm/alashow/base/util/RemoteLogger.kt +++ b/modules/base/src/main/java/tm/alashow/base/util/RemoteLogger.kt @@ -4,21 +4,16 @@ */ package tm.alashow.base.util -import android.content.Context -import android.os.Bundle import android.util.Log -import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.crashlytics.FirebaseCrashlytics -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filter import timber.log.Timber import tm.alashow.base.util.extensions.asString -import tm.alashow.base.util.extensions.isNotNullandNotBlank fun report(throwable: Throwable?) = - throwable?.run { FirebaseCrashlytics.getInstance().recordException(throwable) } + throwable?.run { + Timber.e("Reporting exception: $this") + FirebaseCrashlytics.getInstance().recordException(throwable) + } private class LogException(message: String) : Exception(message) @@ -51,25 +46,3 @@ object RemoteLogger { fun apiError(message: String?, vararg args: Any) = error(message, "API.Error", args) } - -typealias LogArgs = Map? - -fun FirebaseAnalytics.event(event: String, args: LogArgs = null) { - Timber.d("Logging event: $event, $args") - logEvent(event.replace(".", "_").lowercase(), Bundle().apply { args?.forEach { putString(it.key, it.value.toString()) } }) -} - -fun FirebaseAnalytics.click(event: String, args: LogArgs = null) = event("click.$event", args) - -fun Context.event(event: String, args: LogArgs = null) = FirebaseAnalytics.getInstance(this).event(event, args) -fun Context.click(event: String, args: LogArgs = null) = FirebaseAnalytics.getInstance(this).click(event, args) - -suspend fun Flow.searchQueryAnalytics( - analytics: FirebaseAnalytics, - prefix: String, - debounceMillis: Long = 3000L, -) = filter { it.isNotNullandNotBlank() } - .debounce(debounceMillis) - .collectLatest { - analytics.event("$prefix.query", mapOf("query" to it)) - } diff --git a/modules/base/src/main/java/tm/alashow/base/util/extensions/CollectionExtensions.kt b/modules/base/src/main/java/tm/alashow/base/util/extensions/CollectionExtensions.kt index 009c26ec..e1b795d6 100644 --- a/modules/base/src/main/java/tm/alashow/base/util/extensions/CollectionExtensions.kt +++ b/modules/base/src/main/java/tm/alashow/base/util/extensions/CollectionExtensions.kt @@ -34,3 +34,7 @@ fun List.swap(fromIdx: Int, toIdx: Int): List { Collections.swap(copy, fromIdx, toIdx) return copy } + +fun MutableList.swap(fromIdx: Int, toIdx: Int) { + Collections.swap(this, fromIdx, toIdx) +} diff --git a/modules/base/src/main/java/tm/alashow/base/util/extensions/Extensions.kt b/modules/base/src/main/java/tm/alashow/base/util/extensions/Extensions.kt index f272d044..7cda2654 100644 --- a/modules/base/src/main/java/tm/alashow/base/util/extensions/Extensions.kt +++ b/modules/base/src/main/java/tm/alashow/base/util/extensions/Extensions.kt @@ -24,8 +24,6 @@ fun Array.asString(): String { typealias Toggle = (Boolean) -> Unit -val pass: Unit = Unit - /** * Cast given variable to [T] and run [block] if it's the same cast as [T]. * @param to cast to @@ -45,8 +43,8 @@ fun randomUUID(): String = UUID.randomUUID().toString() /** * Run [block] only if [api] is >= than device's SDK version. */ -fun whenApiLevel(api: Int, block: () -> Unit) { - if (api >= android.os.Build.VERSION.SDK_INT) { +fun onlyOnApiLevel(api: Int, block: () -> Unit) { + if (api >= SDK_INT) { block() } } @@ -59,7 +57,6 @@ fun isOreo() = SDK_INT >= VERSION_CODES.O operator fun Bundle?.plus(other: Bundle?) = this.apply { (this ?: Bundle()).putAll(other ?: Bundle()) } -@OptIn(ExperimentalStdlibApi::class) fun Bundle.readable() = buildList { keySet().forEach { add("key=$it, value=${get(it)}") diff --git a/modules/base/src/main/java/tm/alashow/base/util/extensions/StateFlowExtensions.kt b/modules/base/src/main/java/tm/alashow/base/util/extensions/StateFlowExtensions.kt index 4a3bbe2a..e0378ed8 100644 --- a/modules/base/src/main/java/tm/alashow/base/util/extensions/StateFlowExtensions.kt +++ b/modules/base/src/main/java/tm/alashow/base/util/extensions/StateFlowExtensions.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -fun SavedStateHandle.getStateFlow( +fun SavedStateHandle.getMutableStateFlow( key: String, scope: CoroutineScope, initialValue: T = get(key) ?: error("No initial value for key $key") diff --git a/modules/common-compose/build.gradle b/modules/common-compose/build.gradle index a569f117..36c7ed8c 100644 --- a/modules/common-compose/build.gradle +++ b/modules/common-compose/build.gradle @@ -62,13 +62,11 @@ dependencies { api Deps.Android.Compose.materialIconsExtended api Deps.Android.Compose.constraintLayout api Deps.Android.Compose.activity - api Deps.Android.Compose.viewModels api Deps.Android.Compose.liveData api Deps.Android.Compose.paging + api Deps.Android.Lifecycle.composeViewModels // Accompanist - api Deps.Android.Accompanist.insets - api Deps.Android.Accompanist.insetsUi api Deps.Android.Accompanist.pager api Deps.Android.Accompanist.permissions api Deps.Android.Accompanist.placeholder diff --git a/modules/common-compose/src/main/java/tm/alashow/common/compose/CompositionLocals.kt b/modules/common-compose/src/main/java/tm/alashow/common/compose/CompositionLocals.kt index 70317004..22b76a50 100644 --- a/modules/common-compose/src/main/java/tm/alashow/common/compose/CompositionLocals.kt +++ b/modules/common-compose/src/main/java/tm/alashow/common/compose/CompositionLocals.kt @@ -6,15 +6,20 @@ package tm.alashow.common.compose import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.staticCompositionLocalOf -import com.google.firebase.analytics.FirebaseAnalytics -import tm.alashow.datmusic.playback.PlaybackConnection +import tm.alashow.base.util.Analytics -val LocalSnackbarHostState = staticCompositionLocalOf { error("No LocalSnackbarHostState provided") } +val LocalSnackbarHostState = staticCompositionLocalOf { + error("No LocalSnackbarHostState provided") +} -val LocalAnalytics = staticCompositionLocalOf { +val LocalAnalytics = staticCompositionLocalOf { error("No LocalAnalytics provided") } -val LocalPlaybackConnection = staticCompositionLocalOf { - error("No LocalPlaybackConnection provided") +val LocalIsPreviewMode = staticCompositionLocalOf { + error("No LocalIsPreviewMode provided") +} + +val LocalAppVersion = staticCompositionLocalOf { + error("No LocalAppVersion provided") } diff --git a/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/BooleanPreviewParameter.kt b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/BooleanPreviewParameter.kt new file mode 100644 index 00000000..21b15331 --- /dev/null +++ b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/BooleanPreviewParameter.kt @@ -0,0 +1,13 @@ +/* + * Copyright (C) 2022, Alashov Berkeli + * All rights reserved. + */ +package tm.alashow.common.compose.previews + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class BooleanPreviewParameter : PreviewParameterProvider { + + override val values: Sequence + get() = sequenceOf(false, true) +} diff --git a/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/CombinedPreview.kt b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/CombinedPreview.kt new file mode 100644 index 00000000..477b36e2 --- /dev/null +++ b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/CombinedPreview.kt @@ -0,0 +1,9 @@ +/* + * Copyright (C) 2022, Alashov Berkeli + * All rights reserved. + */ +package tm.alashow.common.compose.previews + +@ThemeOptionPreview +@DevicePreview +annotation class CombinedPreview diff --git a/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/DevicePreview.kt b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/DevicePreview.kt new file mode 100644 index 00000000..13135085 --- /dev/null +++ b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/DevicePreview.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2022, Alashov Berkeli + * All rights reserved. + */ +package tm.alashow.common.compose.previews + +import androidx.compose.ui.tooling.preview.Preview + +private const val Group = "Device Previews" + +@TabletPreview +@SmallTabletPreview +@PhonePreview +@SmallPhonePreview +annotation class DevicePreview + +@Preview( + name = "Tablet", + device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480", + group = Group, + showSystemUi = true, +) +annotation class TabletPreview + +@Preview( + name = "Tablet", + device = "spec:shape=Normal,width=600,height=480,unit=dp,dpi=480", + group = Group, + showSystemUi = true, +) +annotation class SmallTabletPreview + +@Preview( + name = "Phone", + group = Group, + device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480", + showSystemUi = true, +) +annotation class PhonePreview + +@Preview( + name = "Phone", + group = Group, + device = "spec:shape=Normal,width=270,height=480,unit=dp,dpi=480", + showSystemUi = true, +) +annotation class SmallPhonePreview diff --git a/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/FontScalePreview.kt b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/FontScalePreview.kt new file mode 100644 index 00000000..48fe760f --- /dev/null +++ b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/FontScalePreview.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022, Alashov Berkeli + * All rights reserved. + */ +package tm.alashow.common.compose.previews + +import androidx.compose.ui.tooling.preview.Preview + +private const val Group = "Font Scales" + +@Preview( + name = "Small Font Scale", + group = Group, + fontScale = 0.5f, +) +@Preview( + name = "Large Font Scale", + group = Group, + fontScale = 1.5f, +) +annotation class FontScalePreview diff --git a/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/LocalePreview.kt b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/LocalePreview.kt new file mode 100644 index 00000000..0c590380 --- /dev/null +++ b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/LocalePreview.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2022, Alashov Berkeli + * All rights reserved. + */ +package tm.alashow.common.compose.previews + +import androidx.compose.ui.tooling.preview.Preview + +private const val Group = "Locale Previews" + +@LocaleEnglishPreview +@LocaleFrenchPreview +@LocaleSpanishPreview +@LocaleGermanPreview +@LocaleTurkishPreview +@LocaleTurkmenPreview +@LocaleRussianPreview +annotation class LocalePreview + +@Preview( + name = "English", + group = Group, + locale = "en", +) +annotation class LocaleEnglishPreview + +@Preview( + name = "Spanish", + group = Group, + locale = "es", +) +annotation class LocaleSpanishPreview + +@Preview( + name = "German", + group = Group, + locale = "de", +) +annotation class LocaleGermanPreview + +@Preview( + name = "Turkmen", + group = Group, + locale = "tk", +) +annotation class LocaleTurkmenPreview + +@Preview( + name = "Turkish", + group = Group, + locale = "tr", +) +annotation class LocaleTurkishPreview + +@Preview( + name = "Russian", + group = Group, + locale = "ru", +) +annotation class LocaleRussianPreview + +@Preview( + name = "French", + group = Group, + locale = "fr", +) +annotation class LocaleFrenchPreview diff --git a/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/ThemeOptionPreview.kt b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/ThemeOptionPreview.kt new file mode 100644 index 00000000..0a1b2ee2 --- /dev/null +++ b/modules/common-compose/src/main/java/tm/alashow/common/compose/previews/ThemeOptionPreview.kt @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2022, Alashov Berkeli + * All rights reserved. + */ +package tm.alashow.common.compose.previews + +import android.content.res.Configuration +import androidx.compose.ui.tooling.preview.Preview + +@DarkThemePreview +annotation class ThemeOptionPreview + +@Preview( + name = "Dark Theme", + group = "Dark Theme", + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, +) +annotation class DarkThemePreview diff --git a/modules/common-data/src/main/java/tm/alashow/data/db/RoomRepo.kt b/modules/common-data/src/main/java/tm/alashow/data/db/RoomRepo.kt index 6b629dbd..2470e4a3 100644 --- a/modules/common-data/src/main/java/tm/alashow/data/db/RoomRepo.kt +++ b/modules/common-data/src/main/java/tm/alashow/data/db/RoomRepo.kt @@ -5,6 +5,7 @@ package tm.alashow.data.db import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -16,9 +17,10 @@ abstract class RoomRepo( private val dao: BaseDao, private val dispatchers: CoroutineDispatchers ) { - fun entry(id: ID) = dao.entry(id.toString()).flowOn(dispatchers.io) - fun entries() = dao.entries().flowOn(dispatchers.io) - fun entries(ids: List) = dao.entriesById(ids.map { it.toString() }).flowOn(dispatchers.io) + fun entry(id: ID): Flow = dao.entryNullable(id.toString()).flowOn(dispatchers.io) + fun entryNotNull(id: ID): Flow = entry(id).filterNotNull() + fun entries(): Flow> = dao.entries().flowOn(dispatchers.io) + fun entries(ids: List): Flow> = dao.entriesById(ids.map { it.toString() }).flowOn(dispatchers.io) open suspend fun insert(item: E): Long = withContext(dispatchers.io) { dao.insert(item) } open suspend fun insertAll(items: List): List = withContext(dispatchers.io) { dao.insertAll(items) } @@ -30,9 +32,10 @@ abstract class RoomRepo( fun isEmpty(): Flow = dao.observeCount().flowOn(dispatchers.io).map { it == 0 } fun count(): Flow = dao.observeCount().flowOn(dispatchers.io) - fun has(id: ID): Flow = dao.has(id.toString()).map { it > 0 } - suspend fun exists(id: ID): Boolean = dao.exists(id.toString()) > 0 + fun has(id: ID): Flow = dao.has(id.toString()).map { it > 0 }.flowOn(dispatchers.io) + suspend fun exists(id: ID): Boolean = withContext(dispatchers.io) { dao.exists(id.toString()) > 0 } - open suspend fun delete(id: ID) = withContext(dispatchers.io) { dao.delete(id.toString()) } - open suspend fun deleteAll() = withContext(dispatchers.io) { dao.deleteAll() } + open suspend fun delete(id: ID): Int = withContext(dispatchers.io) { dao.delete(id.toString()) } + open suspend fun delete(entity: E): Int = withContext(dispatchers.io) { dao.delete(entity) } + open suspend fun deleteAll(): Int = withContext(dispatchers.io) { dao.deleteAll() } } diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/Insets.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/Insets.kt index f8335b6c..71f1384e 100644 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/Insets.kt +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/Insets.kt @@ -5,26 +5,30 @@ package tm.alashow.ui import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.isImeVisible import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.google.accompanist.insets.LocalWindowInsets /** * Spacer that has a height of a software keyboard */ +@OptIn(ExperimentalLayoutApi::class) @Composable fun KeyboardSpacer( modifier: Modifier = Modifier, confirmHeight: (Dp) -> Dp = { it }, ) { - val imeVisible = LocalWindowInsets.current.ime.isVisible - val imeHeight = with(LocalDensity.current) { LocalWindowInsets.current.ime.bottom.toDp() } + val imeVisible = WindowInsets.isImeVisible + val imeHeight = with(LocalDensity.current) { WindowInsets.ime.getBottom(this).dp } val height by animateDpAsState(if (imeVisible) confirmHeight(imeHeight) else 0.dp) Spacer(modifier.height(height)) } diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/ResizableLayout.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/ResizableLayout.kt index 3fd9b029..6398d06a 100644 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/ResizableLayout.kt +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/ResizableLayout.kt @@ -27,12 +27,16 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import tm.alashow.base.util.event import tm.alashow.common.compose.LocalAnalytics +import tm.alashow.common.compose.LocalIsPreviewMode val WIDE_LAYOUT_MIN_WIDTH = 600.dp -fun BoxWithConstraintsScope.isWideLayout() = maxWidth >= WIDE_LAYOUT_MIN_WIDTH +// TODO: Enable back wide layout in preview mode when hiltViewModel works in previews +// because wide layout uses ResizablePlaybackSheetLayoutViewModel +@Composable +fun BoxWithConstraintsScope.isWideLayout(isPreviewMode: Boolean = LocalIsPreviewMode.current) = + maxWidth >= WIDE_LAYOUT_MIN_WIDTH && !isPreviewMode @Composable fun RowScope.ResizableLayout( diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/AppBars.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/AppBars.kt index f47af8df..1cff87e7 100644 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/AppBars.kt +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/AppBars.kt @@ -12,9 +12,8 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.material.ContentAlpha -import androidx.compose.material.LocalContentAlpha import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.Icon @@ -32,8 +31,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.google.accompanist.insets.statusBarsPadding import timber.log.Timber +import tm.alashow.ui.material.ContentAlpha +import tm.alashow.ui.material.ProvideContentAlpha import tm.alashow.ui.simpleClickable import tm.alashow.ui.theme.AppBarAlphas import tm.alashow.ui.theme.AppTheme @@ -80,7 +80,7 @@ fun AppTopBar( if (filterVisible) filterContent() else { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(5f)) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { + ProvideContentAlpha(ContentAlpha.high) { if (navigationIcon == null) Spacer(TitleInsetWithoutIcon) else Box(TitleIconModifier) { navigationIcon() } Row(titleModifier.alpha(collapsedProgress)) { @@ -107,7 +107,7 @@ private fun AppBarActionsRow( modifier: Modifier = Modifier, actions: @Composable RowScope.() -> Unit, ) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + ProvideContentAlpha(ContentAlpha.medium) { Row( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Button.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Button.kt index ef5e9016..a5f70c77 100644 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Button.kt +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Button.kt @@ -31,13 +31,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import tm.alashow.ui.theme.AppTheme +import tm.alashow.common.compose.previews.CombinedPreview import tm.alashow.ui.theme.Blue import tm.alashow.ui.theme.Green import tm.alashow.ui.theme.Orange +import tm.alashow.ui.theme.PreviewAppTheme import tm.alashow.ui.theme.Primary import tm.alashow.ui.theme.Theme @@ -45,8 +45,9 @@ object AppButtonDefaults { val OutlinedButtonShape @Composable get() = Theme.shapes.small @Composable - fun outlinedButtonColors(contentColor: Color = MaterialTheme.colorScheme.onSurface) = - ButtonDefaults.outlinedButtonColors(contentColor = contentColor) + fun outlinedButtonColors( + contentColor: Color = MaterialTheme.colorScheme.onSurface + ) = ButtonDefaults.outlinedButtonColors(contentColor = contentColor) } @Composable @@ -110,51 +111,33 @@ fun TextRoundedButton( } } -@Preview("buttonList") +@CombinedPreview @Composable -fun ButtonListPreview() { - AppTheme { - Column( - Modifier - .fillMaxWidth() - .height(400.dp) - .background(Primary), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - RoundButtonPreview() - RectangleButtonPreview() - ThemeShapeButtonPreview() - Spacer(Modifier.height(8.dp)) - ColoredRoundButtonPreview(Blue) - ColoredRoundButtonPreview(Orange) - ColoredRoundButtonPreview(Green) +fun ButtonListPreview() = PreviewAppTheme { + Column( + Modifier + .fillMaxWidth() + .height(400.dp) + .background(Primary), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextRoundedButton(onClick = {}, text = "Action") + AppButton(onClick = {}, shape = RectangleShape) { + Text("Action") + } + AppButton(onClick = {}, shape = MaterialTheme.shapes.small) { + Text("Action", fontSize = 8.sp) + } + Spacer(Modifier.height(8.dp)) + AppButton(onClick = {}, backgroundColor = Blue) { + Text("Action") + } + AppButton(onClick = {}, backgroundColor = Orange) { + Text("Action") + } + AppButton(onClick = {}, backgroundColor = Green) { + Text("Action") } - } -} - -@Composable -fun RoundButtonPreview() { - TextRoundedButton(onClick = {}, text = "Action") -} - -@Composable -fun RectangleButtonPreview() { - AppButton(onClick = {}, shape = RectangleShape) { - Text("Action") - } -} - -@Composable -fun ThemeShapeButtonPreview() { - AppButton(onClick = {}, shape = MaterialTheme.shapes.small) { - Text("Action", fontSize = 8.sp) - } -} - -@Composable -fun ColoredRoundButtonPreview(color: Color) { - AppButton(onClick = {}, backgroundColor = color) { - Text("Action") } } diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Chips.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Chips.kt index 85edb881..e46baf6f 100644 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Chips.kt +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Chips.kt @@ -22,8 +22,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import tm.alashow.common.compose.previews.CombinedPreview import tm.alashow.ui.theme.AppTheme import tm.alashow.ui.theme.DefaultThemeDark @@ -99,7 +99,7 @@ fun Chip( } } -@Preview +@CombinedPreview @Composable fun ChipsPreview() { val items = listOf("Songs", "Artists", "Albums") diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/CoverImage.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/CoverImage.kt index 4ae7bb91..6a4324fc 100644 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/CoverImage.kt +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/CoverImage.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.ContentAlpha import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material3.Icon @@ -40,6 +39,9 @@ import com.google.accompanist.placeholder.PlaceholderHighlight import com.google.accompanist.placeholder.material.color import com.google.accompanist.placeholder.material.placeholder import com.google.accompanist.placeholder.shimmer +import tm.alashow.common.compose.previews.CombinedPreview +import tm.alashow.ui.material.ContentAlpha +import tm.alashow.ui.theme.PreviewAppTheme import tm.alashow.ui.theme.Theme @Composable @@ -114,3 +116,11 @@ fun CoverImage( } } } + +@CombinedPreview +@Composable +fun CoverImagePreview() = PreviewAppTheme { + CoverImage( + data = "https://api.lorem.space/image/album?w=1000&h=1000" + ) +} diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/DropdownMenu.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/DropdownMenu.kt index f4a3e114..1a3b8afa 100644 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/DropdownMenu.kt +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/DropdownMenu.kt @@ -14,8 +14,6 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material.ContentAlpha -import androidx.compose.material.LocalContentAlpha import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropUp @@ -41,6 +39,8 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp +import tm.alashow.ui.material.ContentAlpha +import tm.alashow.ui.material.ProvideContentAlpha import tm.alashow.ui.theme.Theme @Composable @@ -132,7 +132,7 @@ fun SelectableDropdownMenu( if (subtitles != null) { val subtitle = subtitles[index] if (subtitle != null) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + ProvideContentAlpha(ContentAlpha.medium) { Text(text = subtitle, style = MaterialTheme.typography.bodySmall) } } diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Error.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Error.kt index 85e86ad2..5136ec38 100644 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Error.kt +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/Error.kt @@ -18,12 +18,12 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.rememberLottieComposition +import tm.alashow.common.compose.previews.CombinedPreview import tm.alashow.ui.Zoomable import tm.alashow.ui.colorFilterDynamicProperty import tm.alashow.ui.theme.AppTheme @@ -47,7 +47,7 @@ fun EmptyErrorBox( ) } -@Preview +@CombinedPreview @Composable fun ErrorBox( modifier: Modifier = Modifier, diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/IconButton.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/IconButton.kt index 2dd88d7c..982616e0 100644 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/IconButton.kt +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/IconButton.kt @@ -10,11 +10,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.ContentAlpha -import androidx.compose.material.LocalContentAlpha import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,6 +20,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import tm.alashow.ui.material.ContentAlpha +import tm.alashow.ui.material.LocalContentAlpha +import tm.alashow.ui.material.ProvideContentAlpha import tm.alashow.ui.theme.AppTheme private val RippleRadius = 24.dp @@ -60,7 +60,7 @@ fun IconButton( contentAlignment = Alignment.Center ) { val contentAlpha = if (enabled) LocalContentAlpha.current else ContentAlpha.disabled - CompositionLocalProvider(LocalContentAlpha provides contentAlpha, content = content) + ProvideContentAlpha(contentAlpha, content = content) } } diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/SearchTextField.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/SearchTextField.kt index 5c9d3e08..940571e5 100644 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/components/SearchTextField.kt +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/components/SearchTextField.kt @@ -36,8 +36,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.sp -import com.google.firebase.analytics.FirebaseAnalytics -import tm.alashow.base.util.click +import tm.alashow.base.util.Analytics import tm.alashow.common.compose.LocalAnalytics import tm.alashow.ui.theme.Theme import tm.alashow.ui.theme.borderlessTextFieldColors @@ -64,7 +63,7 @@ fun SearchTextField( capitalization = KeyboardCapitalization.Sentences ), keyboardActions: KeyboardActions = KeyboardActions(onSearch = { onSearch() }), - analytics: FirebaseAnalytics = LocalAnalytics.current, + analytics: Analytics = LocalAnalytics.current, ) { val focusRequester = remember { FocusRequester() } DisposableEffect(autoFocus) { diff --git a/modules/common-ui-components/src/main/java/tm/alashow/ui/material/Slider.kt b/modules/common-ui-components/src/main/java/tm/alashow/ui/material/Slider.kt index 1f2284e8..ccbe3df5 100644 --- a/modules/common-ui-components/src/main/java/tm/alashow/ui/material/Slider.kt +++ b/modules/common-ui-components/src/main/java/tm/alashow/ui/material/Slider.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.progressSemantics import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.ContentAlpha import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.MaterialTheme diff --git a/modules/common-ui-theme/src/main/java/tm/alashow/ui/ThemeViewModel.kt b/modules/common-ui-theme/src/main/java/tm/alashow/ui/ThemeViewModel.kt index 86b1238a..4886e34b 100644 --- a/modules/common-ui-theme/src/main/java/tm/alashow/ui/ThemeViewModel.kt +++ b/modules/common-ui-theme/src/main/java/tm/alashow/ui/ThemeViewModel.kt @@ -4,15 +4,15 @@ */ package tm.alashow.ui -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.firebase.analytics.FirebaseAnalytics import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import tm.alashow.base.ui.ThemeState -import tm.alashow.base.util.event +import tm.alashow.base.util.Analytics import tm.alashow.base.util.extensions.stateInDefault import tm.alashow.data.PreferencesStore import tm.alashow.ui.theme.DefaultTheme @@ -23,13 +23,17 @@ object PreferenceKeys { @HiltViewModel class ThemeViewModel @Inject constructor( - private val handle: SavedStateHandle, private val preferences: PreferencesStore, - private val analytics: FirebaseAnalytics + private val analytics: Analytics, ) : ViewModel() { + // Read saved theme state from preferences in a blocking manner (takes ~5 ms) + // so the app doesn't render first frames with the default theme + private val savedThemeState = runBlocking { + preferences.get(PreferenceKeys.THEME_STATE_KEY, ThemeState.serializer(), DefaultTheme).first() + } val themeState = preferences.get(PreferenceKeys.THEME_STATE_KEY, ThemeState.serializer(), DefaultTheme) - .stateInDefault(viewModelScope, DefaultTheme) + .stateInDefault(viewModelScope, savedThemeState) fun applyThemeState(themeState: ThemeState) { analytics.event("theme.apply", mapOf("darkMode" to themeState.isDarkMode, "palette" to themeState.colorPalettePreference.name)) diff --git a/modules/common-ui-theme/src/main/java/tm/alashow/ui/material/ContentAlpha.kt b/modules/common-ui-theme/src/main/java/tm/alashow/ui/material/ContentAlpha.kt new file mode 100644 index 00000000..5789e139 --- /dev/null +++ b/modules/common-ui-theme/src/main/java/tm/alashow/ui/material/ContentAlpha.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2022, Alashov Berkeli + * All rights reserved. + */ +package tm.alashow.ui.material + +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.luminance +import tm.alashow.ui.theme.Theme + +// Cloned from material2 since it was removed in material3 + +/** + * Default alpha levels used by Material components. + * + * See [LocalContentAlpha]. + */ +object ContentAlpha { + /** + * A high level of content alpha, used to represent high emphasis text such as input text in a + * selected [TextField]. + */ + val high: Float + @Composable + get() = contentAlpha( + highContrastAlpha = HighContrastContentAlpha.high, + lowContrastAlpha = LowContrastContentAlpha.high + ) + + /** + * A medium level of content alpha, used to represent medium emphasis text such as + * placeholder text in a [TextField]. + */ + val medium: Float + @Composable + get() = contentAlpha( + highContrastAlpha = HighContrastContentAlpha.medium, + lowContrastAlpha = LowContrastContentAlpha.medium + ) + + /** + * A low level of content alpha used to represent disabled components, such as text in a + * disabled [Button]. + */ + val disabled: Float + @Composable + get() = contentAlpha( + highContrastAlpha = HighContrastContentAlpha.disabled, + lowContrastAlpha = LowContrastContentAlpha.disabled + ) + + /** + * This default implementation uses separate alpha levels depending on the luminance of the + * incoming color, and whether the theme is light or dark. This is to ensure correct contrast + * and accessibility on all surfaces. + * + * See [HighContrastContentAlpha] and [LowContrastContentAlpha] for what the levels are + * used for, and under what circumstances. + */ + @Composable + private fun contentAlpha( + /*@FloatRange(from = 0.0, to = 1.0)*/ + highContrastAlpha: Float, + /*@FloatRange(from = 0.0, to = 1.0)*/ + lowContrastAlpha: Float + ): Float { + val contentColor = LocalContentColor.current + val lightTheme = Theme.isLight + return if (lightTheme) { + if (contentColor.luminance() > 0.5) highContrastAlpha else lowContrastAlpha + } else { + if (contentColor.luminance() < 0.5) highContrastAlpha else lowContrastAlpha + } + } +} + +/** + * CompositionLocal containing the preferred content alpha for a given position in the hierarchy. + * This alpha is used for text and iconography ([Text] and [Icon]) to emphasize / de-emphasize + * different parts of a component. See the Material guide on + * [Text Legibility](https://material.io/design/color/text-legibility.html) for more information on + * alpha levels used by text and iconography. + * + * See [ContentAlpha] for the default levels used by most Material components. + * + * [MaterialTheme] sets this to [ContentAlpha.high] by default, as this is the default alpha for + * body text. + * + * @sample androidx.compose.material.samples.ContentAlphaSample + */ +val LocalContentAlpha = compositionLocalOf { 1f } + +/** + * Alpha levels for high luminance content in light theme, or low luminance content in dark theme. + * + * This content will typically be placed on colored surfaces, so it is important that the + * contrast here is higher to meet accessibility standards, and increase legibility. + * + * These levels are typically used for text / iconography in primary colored tabs / + * bottom navigation / etc. + */ +private object HighContrastContentAlpha { + const val high: Float = 1.00f + const val medium: Float = 0.74f + const val disabled: Float = 0.38f +} + +/** + * Alpha levels for low luminance content in light theme, or high luminance content in dark theme. + * + * This content will typically be placed on grayscale surfaces, so the contrast here can be lower + * without sacrificing accessibility and legibility. + * + * These levels are typically used for body text on the main surface (white in light theme, grey + * in dark theme) and text / iconography in surface colored tabs / bottom navigation / etc. + */ +private object LowContrastContentAlpha { + const val high: Float = 0.87f + const val medium: Float = 0.60f + const val disabled: Float = 0.38f +} + +/** + * Provides [LocalContentAlpha] and overrides [LocalContentColor] alpha with [contentAlpha]. + * This is because M3 doesn't support providing [ContentAlpha] for components anymore. + */ +@Composable +fun ProvideContentAlpha( + contentAlpha: Float, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalContentColor provides LocalContentColor.current.copy(alpha = contentAlpha), + LocalContentAlpha provides contentAlpha, + content = content + ) +} diff --git a/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/AppTheme.kt b/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/AppTheme.kt index f86f3e6f..f688341c 100644 --- a/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/AppTheme.kt +++ b/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/AppTheme.kt @@ -67,6 +67,14 @@ object AppTheme { @Composable get() = MaterialTheme.colorScheme + /** + * Since [MaterialTheme.colorScheme] colors could get animated, this can be used to non-animated colors. + * Only useful when there are bugs with animating [MaterialTheme.colorScheme] colors + */ + val inanimateColorScheme + @Composable + get() = LocalAppColors.current.colorScheme + val shapes @Composable get() = MaterialTheme.shapes @@ -131,3 +139,16 @@ internal fun AppColors.elevatedSurface( colorScheme.surface.blendWith(tint, tintBlendPercentage), elevation ) + +@Composable +fun PreviewAppTheme( + theme: ThemeState = DefaultTheme, + changeSystemBar: Boolean = true, + content: @Composable () -> Unit +) { + AppTheme( + theme = theme, + changeSystemBar = changeSystemBar, + content = content + ) +} diff --git a/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Color.kt b/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Color.kt index ec466232..e0f05b17 100644 --- a/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Color.kt +++ b/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Color.kt @@ -5,8 +5,12 @@ package tm.alashow.ui.theme import android.graphics.Color as AndroidColor +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.background -import androidx.compose.material.ContentAlpha +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.contentColorFor import androidx.compose.material3.darkColorScheme @@ -20,8 +24,10 @@ import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.isUnspecified import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import java.security.SecureRandom import kotlin.math.ln import kotlin.random.Random +import tm.alashow.ui.material.ContentAlpha fun parseColor(hexColor: String) = Color(AndroidColor.parseColor(hexColor)) fun Int.toColor() = Color(this) @@ -137,34 +143,12 @@ fun Color.disabledAlpha(condition: Boolean): Color = copy(alpha = if (condition) @Composable fun Color.contrastComposite(alpha: Float = 0.1f) = contentColorFor(this).copy(alpha = alpha).compositeOver(this) -fun Color.colorAtElevation(tint: Color, elevation: Dp,): Color { +fun Color.colorAtElevation(tint: Color, elevation: Dp): Color { if (elevation == 0.dp) return this val alpha = ((4.5f * ln(elevation.value + 1)) + 2f) / 100f return tint.copy(alpha = alpha).compositeOver(this) } -// @Composable -// internal fun animate(colors: ColorScheme): ColorScheme { -// val animationSpec = remember { spring() } -// -// @Composable -// fun animateColor(color: Color): Color = animateColorAsState(targetValue = color, animationSpec = animationSpec).value -// -// return ColorScheme( -// primary = animateColor(colors.primary), -// secondary = animateColor(colors.secondary), -// background = animateColor(colors.background), -// surface = animateColor(colors.surface), -// error = animateColor(colors.error), -// onPrimary = animateColor(colors.onPrimary), -// onSecondary = animateColor(colors.onSecondary), -// onBackground = animateColor(colors.onBackground), -// onSurface = animateColor(colors.onSurface), -// onError = animateColor(colors.onError), -// // TODO: animate rest -// ) -// } - @Composable fun translucentSurfaceColor() = MaterialTheme.colorScheme.surface.copy(alpha = AppBarAlphas.translucentBarAlpha()) @@ -173,6 +157,57 @@ fun Modifier.translucentSurface() = composed { background(translucentSurfaceColo @Composable fun Modifier.randomBackground(memoize: Boolean = true) = background(if (memoize) remember { randomColor() } else randomColor()) -fun randomColor() = Color(Random.nextInt(255), Random.nextInt(255), Random.nextInt(255), Random.nextInt(255)) +private val Randomness = Random(SecureRandom().nextLong()) +fun randomColor() = Color(Randomness.nextInt(255), Randomness.nextInt(255), Randomness.nextInt(255), Randomness.nextInt(255)) fun Color.fallbackTo(color: Color): Color = if (isUnspecified) color else this + +/** + * Animates [colorScheme] colors when it changes. + * + * @see [Theme.un] + */ +@Composable +internal fun animate( + colorScheme: ColorScheme, + animationSpec: AnimationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) +): ColorScheme { + + @Composable + fun animateColor(color: Color): Color = animateColorAsState(targetValue = color, animationSpec = animationSpec).value + + return ColorScheme( + primary = animateColor(colorScheme.primary), + onPrimary = animateColor(colorScheme.onPrimary), + primaryContainer = animateColor(colorScheme.primaryContainer), + onPrimaryContainer = animateColor(colorScheme.onPrimaryContainer), + inversePrimary = animateColor(colorScheme.inversePrimary), + secondary = animateColor(colorScheme.secondary), + onSecondary = animateColor(colorScheme.onSecondary), + secondaryContainer = animateColor(colorScheme.secondaryContainer), + onSecondaryContainer = animateColor(colorScheme.onSecondaryContainer), + tertiary = animateColor(colorScheme.tertiary), + onTertiary = animateColor(colorScheme.onTertiary), + tertiaryContainer = animateColor(colorScheme.tertiaryContainer), + onTertiaryContainer = animateColor(colorScheme.onTertiaryContainer), + background = animateColor(colorScheme.background), + onBackground = animateColor(colorScheme.onBackground), + surface = animateColor(colorScheme.surface), + onSurface = animateColor(colorScheme.onSurface), + surfaceVariant = animateColor(colorScheme.surfaceVariant), + onSurfaceVariant = animateColor(colorScheme.onSurfaceVariant), + surfaceTint = animateColor(colorScheme.surfaceTint), + inverseSurface = animateColor(colorScheme.inverseSurface), + inverseOnSurface = animateColor(colorScheme.inverseOnSurface), + error = animateColor(colorScheme.error), + onError = animateColor(colorScheme.onError), + errorContainer = animateColor(colorScheme.errorContainer), + onErrorContainer = animateColor(colorScheme.onErrorContainer), + outline = animateColor(colorScheme.outline), + outlineVariant = animateColor(colorScheme.outlineVariant), + scrim = animateColor(colorScheme.scrim), + ) +} diff --git a/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Styles.kt b/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Styles.kt index b990d836..42119eeb 100644 --- a/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Styles.kt +++ b/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Styles.kt @@ -4,7 +4,6 @@ */ package tm.alashow.ui.theme -import androidx.compose.material.ContentAlpha import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -12,6 +11,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.text.font.FontWeight +import tm.alashow.ui.material.ContentAlpha // TODO: not sure if this is the best way to define styles @Composable diff --git a/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Theme.kt b/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Theme.kt index f95193f2..f633d9cf 100644 --- a/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Theme.kt +++ b/modules/common-ui-theme/src/main/java/tm/alashow/ui/theme/Theme.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import com.google.accompanist.systemuicontroller.rememberSystemUiController @@ -27,6 +28,7 @@ val DefaultThemeDark = ThemeState(DarkModePreference.ON) @Composable fun AppTheme( theme: ThemeState = DefaultTheme, + modifier: Modifier = Modifier, changeSystemBar: Boolean = true, content: @Composable () -> Unit ) { @@ -62,8 +64,7 @@ fun AppTheme( ProvideAppTheme(theme, colors) { MaterialTheme( - // TODO: Animate - colorScheme = colors.colorScheme, + colorScheme = animate(colors.colorScheme), typography = M3Typography, shapes = Shapes, content = { MaterialThemePatches(content) }, diff --git a/modules/core-data/src/main/java/tm/alashow/datmusic/data/SampleData.kt b/modules/core-data/src/main/java/tm/alashow/datmusic/data/SampleData.kt index 21d63001..ca14236f 100644 --- a/modules/core-data/src/main/java/tm/alashow/datmusic/data/SampleData.kt +++ b/modules/core-data/src/main/java/tm/alashow/datmusic/data/SampleData.kt @@ -4,73 +4,146 @@ */ package tm.alashow.datmusic.data -import java.util.UUID +import com.tonyodev.fetch2.Download +import com.tonyodev.fetch2.EnqueueAction +import com.tonyodev.fetch2.Error +import com.tonyodev.fetch2.NetworkType +import com.tonyodev.fetch2.Priority +import com.tonyodev.fetch2.Status +import com.tonyodev.fetch2.database.DownloadInfo +import com.tonyodev.fetch2core.Extras +import java.security.SecureRandom import kotlin.math.abs import kotlin.random.Random -import kotlin.random.nextInt import tm.alashow.datmusic.domain.entities.Album import tm.alashow.datmusic.domain.entities.Artist import tm.alashow.datmusic.domain.entities.Audio +import tm.alashow.datmusic.domain.entities.AudioDownloadItem import tm.alashow.datmusic.domain.entities.DownloadRequest import tm.alashow.datmusic.domain.entities.Playlist import tm.alashow.datmusic.domain.entities.PlaylistAudio +import tm.alashow.datmusic.domain.entities.PlaylistItem -object SampleData { - private val random = Random(1000) +/** + * TODO: Enable random chaos testing in dev environment only / disable in CI. + * Deterministic tests are required in CI for reproducibility. + * @see Random testing + */ +private const val RANDOM_CHAOS_TESTING_ENABLED = false +val Randomness = Random(if (RANDOM_CHAOS_TESTING_ENABLED) SecureRandom().nextInt() else 9999) - fun Random.id(): Long = abs(nextLong()) - fun Random.sid(): String = nextInt().toString() +private fun id() = abs(Randomness.nextLong()) +private fun sid() = Randomness.nextInt().toString() - fun randomString() = UUID.randomUUID().toString().replace("-", "") +object SampleData { + fun list(n: Int = 100, creator: SampleData.() -> T): List = buildList { repeat(n) { add(creator(SampleData)) } } val Audio: Audio = audio() val Playlist: Playlist = playlist() - val PlaylistAudio: PlaylistAudio = playlistAudioItems(Playlist, Audio).playlistAudio val Artist: Artist = artist() val Album: Album = album(Artist) fun audio() = Audio( - id = "sample-audio-${random.id()}", - primaryKey = "primary-sample-audio-${random.id()}", - searchIndex = random.nextInt(), - page = random.nextInt(), - params = random.nextInt().toString(), - artist = "Artist ${randomString()}", - title = "Title ${randomString()}", - album = "Album ${randomString()}", + id = "sample-audio-${id()}", + primaryKey = "primary-sample-audio-${id()}", + searchIndex = Randomness.nextInt(), + page = Randomness.nextInt(), + params = Randomness.nextInt().toString(), + artist = "Artist ${id()}", + title = "Title ${id()}", + album = "Album ${id()}", + coverUrl = "https://picsum.photos/seed/${id()}/600/600", downloadUrl = "https://test.com/test-download.mp3", streamUrl = "https://test.com/test-stream.mp3", - duration = random.nextInt(100, 300) + duration = Randomness.nextInt(100, 300) ) - fun playlist() = Playlist(id = random.id(), name = "Playlist ${random.id()}") + fun playlist() = Playlist(id = id(), name = "Playlist ${id()}") data class PlaylistAudioItem(val playlist: Playlist, val audio: Audio, val playlistAudio: PlaylistAudio) - fun playlistAudioItems(playlist: Playlist = playlist(), audio: Audio = audio()) = - PlaylistAudioItem(playlist, audio, PlaylistAudio(id = random.nextLong(), playlistId = playlist.id, audioId = audio.id)) + fun playlistAudioItem(playlist: Playlist = playlist(), audio: Audio = audio()) = + PlaylistAudioItem(playlist, audio, PlaylistAudio(id = Randomness.nextLong(), playlistId = playlist.id, audioId = audio.id)) + + fun playlistItem(playlist: Playlist = playlist(), audio: Audio = audio()) = + PlaylistItem(PlaylistAudio(id = Randomness.nextLong(), playlistId = playlist.id, audioId = audio.id), audio) fun album(mainArtist: Artist = artist()) = Album( - id = random.sid(), - primaryKey = "sample-album-${random.id()}", - searchIndex = random.nextInt(), - page = random.nextInt(), - artistId = random.id(), - title = "Album ${random.id()}", - year = random.nextInt(1900, 2030), - songCount = random.nextInt(1, 10), - explicit = random.nextBoolean(), + id = sid(), + primaryKey = "sample-album-${id()}", + searchIndex = Randomness.nextInt(), + page = Randomness.nextInt(), + artistId = id(), + title = "Album ${id()}", + year = Randomness.nextInt(1900, 2030), + songCount = Randomness.nextInt(1, 10), + explicit = Randomness.nextBoolean(), artists = listOf(mainArtist) ) fun artist() = Artist( - id = random.sid(), - primaryKey = "sample-artist-${random.id()}", - searchIndex = random.nextInt(), - page = random.nextInt(), - name = "Artist ${random.id()}" + id = sid(), + primaryKey = "sample-artist-${id()}", + searchIndex = Randomness.nextInt(), + page = Randomness.nextInt(), + name = "Artist ${id()}" ) fun downloadRequest(audio: Audio = audio()) = DownloadRequest.fromAudio(audio.copy(id = "${audio.id}-downloaded")) + + fun downloadInfo( + id: Int = Randomness.nextInt(), + namespace: String = "sample-namespace-${id()}", + url: String = "https://test.com/test-download.mp3", + file: String = "test-download.mp3", + group: Int = Randomness.nextInt(), + priority: Priority = Priority.HIGH, + headers: Map = mapOf(), + total: Long = Randomness.nextLong(2000000, 25000000), // 2-25MB + downloaded: Long = total / 2, + status: Status = Status.values().random(Randomness), + error: Error = Error.values().random(Randomness), + networkType: NetworkType = NetworkType.values().random(Randomness), + created: Long = Randomness.nextLong(), + tag: String? = "sample-tag-${id()}", + enqueueAction: EnqueueAction = EnqueueAction.values().random(Randomness), + identifier: Long = Randomness.nextLong(), + downloadOnEnqueue: Boolean = Randomness.nextBoolean(), + extras: Extras = Extras.emptyExtras, + autoRetryMaxAttempts: Int = Randomness.nextInt(), + autoRetryAttempts: Int = Randomness.nextInt(), + etaInMilliSeconds: Long = Randomness.nextLong(), + downloadedBytesPerSecond: Long = Randomness.nextLong() + ): Download = DownloadInfo().apply { + this.id = id + this.namespace = namespace + this.url = url + this.file = file + this.group = group + this.priority = priority + this.headers = headers + this.downloaded = downloaded + this.total = total + this.status = status + this.error = error + this.networkType = networkType + this.created = created + this.tag = tag + this.enqueueAction = enqueueAction + this.identifier = identifier + this.downloadOnEnqueue = downloadOnEnqueue + this.extras = extras + this.autoRetryMaxAttempts = autoRetryMaxAttempts + this.autoRetryAttempts = autoRetryAttempts + + this.etaInMilliSeconds = etaInMilliSeconds + this.downloadedBytesPerSecond = downloadedBytesPerSecond + } + + fun audioDownloadItem( + audio: Audio = audio(), + download: Download = downloadInfo(), + downloadRequest: DownloadRequest = downloadRequest(audio) + ) = AudioDownloadItem(downloadRequest, download, audio) } diff --git a/modules/core-data/src/main/java/tm/alashow/datmusic/data/db/DatabaseModule.kt b/modules/core-data/src/main/java/tm/alashow/datmusic/data/db/DatabaseModule.kt index 6304cb7d..73774d68 100644 --- a/modules/core-data/src/main/java/tm/alashow/datmusic/data/db/DatabaseModule.kt +++ b/modules/core-data/src/main/java/tm/alashow/datmusic/data/db/DatabaseModule.kt @@ -20,7 +20,7 @@ import tm.alashow.data.db.DatabaseTxRunner class DatabaseModule { @Singleton @Provides - fun datmusicDatabase(context: Context): AppDatabase { + fun datmusicDatabase(@ApplicationContext context: Context): AppDatabase { val builder = Room.databaseBuilder(context, AppDatabase::class.java, "app.db") .addMigrations(MIGRATION_3_4) .fallbackToDestructiveMigration() diff --git a/modules/core-data/src/test/kotlin/tm/alashow/datmusic/data/db/daos/AudiosFtsDaoTest.kt b/modules/core-data/src/test/kotlin/tm/alashow/datmusic/data/db/daos/AudiosFtsDaoTest.kt index 800849b3..247cd8f7 100644 --- a/modules/core-data/src/test/kotlin/tm/alashow/datmusic/data/db/daos/AudiosFtsDaoTest.kt +++ b/modules/core-data/src/test/kotlin/tm/alashow/datmusic/data/db/daos/AudiosFtsDaoTest.kt @@ -55,7 +55,7 @@ class AudiosFtsDaoTest : BaseTest() { @Test fun `search playlist items`() = runTest { - val playlistItems = (1..5).map { SampleData.playlistAudioItems() } + val playlistItems = (1..5).map { SampleData.playlistAudioItem() } val items = playlistItems.map { it.playlistAudio } playlistsDao.insertAll(playlistItems.map { it.playlist }) audiosDao.insertAll(playlistItems.map { it.audio }) diff --git a/modules/core-data/src/test/kotlin/tm/alashow/datmusic/data/db/daos/PlaylistsWithAudiosDaoTest.kt b/modules/core-data/src/test/kotlin/tm/alashow/datmusic/data/db/daos/PlaylistsWithAudiosDaoTest.kt index 410f0164..038e1707 100644 --- a/modules/core-data/src/test/kotlin/tm/alashow/datmusic/data/db/daos/PlaylistsWithAudiosDaoTest.kt +++ b/modules/core-data/src/test/kotlin/tm/alashow/datmusic/data/db/daos/PlaylistsWithAudiosDaoTest.kt @@ -10,7 +10,6 @@ import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules import javax.inject.Inject import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -29,7 +28,7 @@ class PlaylistsWithAudiosDaoTest : BaseTest() { @Inject lateinit var audiosDao: AudiosDao @Inject lateinit var dao: PlaylistsWithAudiosDao - private val testItems = (1..5).map { SampleData.playlistAudioItems() } + private val testItems = (1..5).map { SampleData.playlistAudioItem() } @Before override fun setUp() { diff --git a/modules/core-downloader/src/main/java/tm/alashow/datmusic/downloader/DownloadRequestsRepo.kt b/modules/core-downloader/src/main/java/tm/alashow/datmusic/downloader/DownloadRequestsRepo.kt new file mode 100644 index 00000000..2e1d4204 --- /dev/null +++ b/modules/core-downloader/src/main/java/tm/alashow/datmusic/downloader/DownloadRequestsRepo.kt @@ -0,0 +1,16 @@ +/* + * Copyright (C) 2022, Alashov Berkeli + * All rights reserved. + */ +package tm.alashow.datmusic.downloader + +import javax.inject.Inject +import tm.alashow.base.util.CoroutineDispatchers +import tm.alashow.data.db.RoomRepo +import tm.alashow.datmusic.data.db.daos.DownloadRequestsDao +import tm.alashow.datmusic.domain.entities.DownloadRequest + +class DownloadRequestsRepo @Inject constructor( + dispatchers: CoroutineDispatchers, + dao: DownloadRequestsDao, +) : RoomRepo(dao, dispatchers) diff --git a/modules/core-downloader/src/main/java/tm/alashow/datmusic/downloader/Downloader.kt b/modules/core-downloader/src/main/java/tm/alashow/datmusic/downloader/Downloader.kt index 30cd6b05..82cca20d 100644 --- a/modules/core-downloader/src/main/java/tm/alashow/datmusic/downloader/Downloader.kt +++ b/modules/core-downloader/src/main/java/tm/alashow/datmusic/downloader/Downloader.kt @@ -1,360 +1,62 @@ /* - * Copyright (C) 2021, Alashov Berkeli + * Copyright (C) 2022, Alashov Berkeli * All rights reserved. */ package tm.alashow.datmusic.downloader -import android.content.Context -import android.content.Intent import android.net.Uri -import androidx.core.net.toUri import androidx.datastore.preferences.core.stringPreferencesKey import androidx.documentfile.provider.DocumentFile -import com.google.firebase.analytics.FirebaseAnalytics -import com.tonyodev.fetch2.Request import com.tonyodev.fetch2.Status -import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File -import java.io.FileNotFoundException -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.receiveAsFlow -import okhttp3.internal.toImmutableList -import timber.log.Timber -import tm.alashow.base.ui.SnackbarManager -import tm.alashow.base.util.CoroutineDispatchers -import tm.alashow.base.util.event -import tm.alashow.data.PreferencesStore -import tm.alashow.data.db.RoomRepo -import tm.alashow.datmusic.data.db.daos.DownloadRequestsDao -import tm.alashow.datmusic.data.repos.audio.AudioSaveType -import tm.alashow.datmusic.data.repos.audio.AudiosRepo +import kotlinx.coroutines.flow.Flow import tm.alashow.datmusic.domain.DownloadsSongsGrouping import tm.alashow.datmusic.domain.entities.Audio import tm.alashow.datmusic.domain.entities.AudioDownloadItem import tm.alashow.datmusic.domain.entities.DownloadItem -import tm.alashow.datmusic.domain.entities.DownloadRequest -import tm.alashow.datmusic.downloader.manager.DownloadEnqueueFailed -import tm.alashow.datmusic.downloader.manager.DownloadEnqueueResult -import tm.alashow.datmusic.downloader.manager.DownloadEnqueueSuccessful -import tm.alashow.datmusic.downloader.manager.FetchDownloadManager -import tm.alashow.domain.models.None import tm.alashow.domain.models.Optional -import tm.alashow.domain.models.orNone -import tm.alashow.domain.models.orNull -import tm.alashow.domain.models.some -import tm.alashow.i18n.UiMessage typealias AudioDownloadItems = List data class DownloadItems(val audios: AudioDownloadItems = emptyList()) -const val INTENT_READ_WRITE_FLAG = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - -@Singleton -class Downloader @Inject constructor( - @ApplicationContext private val appContext: Context, - dispatchers: CoroutineDispatchers, - private val fetcher: FetchDownloadManager, - private val preferences: PreferencesStore, - private val dao: DownloadRequestsDao, - private val audiosRepo: AudiosRepo, - private val analytics: FirebaseAnalytics, - private val snackbarManager: SnackbarManager, -) : RoomRepo(dao, dispatchers) { - +interface Downloader { companion object { const val DOWNLOADS_STATUS_REFRESH_INTERVAL = 1500L - val DOWNLOADS_LOCATION = stringPreferencesKey("downloads_location") - val DOWNLOADS_SONGS_GROUPING = stringPreferencesKey("downloads_songs_grouping") - } - - private val newDownloadIdState = Channel(Channel.CONFLATED) - val newDownloadId = newDownloadIdState.receiveAsFlow() - - private val downloaderEventsChannel = Channel(Channel.CONFLATED) - val downloaderEvents = downloaderEventsChannel.receiveAsFlow() - - private val downloaderEventsHistory = mutableListOf() - fun clearDownloaderEvents() = downloaderEventsHistory.clear() - fun getDownloaderEvents() = downloaderEventsHistory.toImmutableList() - - private fun downloaderEvent(event: DownloaderEvent) { - downloaderEventsChannel.trySend(event) - downloaderEventsHistory.add(event) - } - - private fun downloaderMessage(message: UiMessage<*>) = snackbarManager.addMessage(message) - - /** - * Audio item pending for download. Used when waiting for download location. - */ - private var pendingEnqueableAudio: Audio? = null - - suspend fun enqueueAudio(audioId: String): Boolean { - Timber.d("Enqueue requested for: $audioId") - audiosRepo.entry(audioId).firstOrNull()?.apply { - return enqueueAudio(this) - } - return false - } - - /** - * Tries to enqueue given audio or issues error events in case of failure. - */ - suspend fun enqueueAudio(audio: Audio): Boolean { - Timber.d("Enqueue audio: $audio") - val downloadRequest = DownloadRequest.fromAudio(audio) - if (!validateNewAudioRequest(downloadRequest)) - return false - - // save audio to db so Downloads won't depend on given audios existence in audios table - audiosRepo.saveAudios(AudioSaveType.Download, audio) - - val fileDestination = getAudioDownloadFileDestination(audio) - if (fileDestination == null) { - pendingEnqueableAudio = audio - return false - } - - if (audio.downloadUrl == null) { - downloaderMessage(AudioDownloadErrorInvalidUrl) - return false - } - - val downloadUrl = Uri.parse(audio.downloadUrl).buildUpon() - .appendQueryParameter("redirect", "") - .build() - .toString() - val fetchRequest = Request(downloadUrl, fileDestination.uri) - - return when (val enqueueResult = enqueueDownloadRequest(downloadRequest, fetchRequest)) { - is DownloadEnqueueSuccessful -> { - downloaderMessage(AudioDownloadQueued) - newDownloadIdState.send(downloadRequest.id) - true - } - is DownloadEnqueueFailed -> { - Timber.e(enqueueResult.toString()) - downloaderEvent(DownloaderEvent.DownloaderFetchError(enqueueResult.error)) - false - } - } - } - - /** - * Validates new audio download request for existence. - * - * @return false if not allowed to enqueue again, true otherwise - */ - private suspend fun validateNewAudioRequest(downloadRequest: DownloadRequest): Boolean { - val existingRequest = dao.exists(downloadRequest.id) > 0 - - if (existingRequest) { - val oldRequest = dao.entry(downloadRequest.id).first() - val downloadInfo = fetcher.getDownload(oldRequest.requestId) - if (downloadInfo != null) { - when (downloadInfo.status) { - Status.FAILED, Status.CANCELLED -> { - fetcher.delete(downloadInfo.id) - delete(oldRequest.id) - Timber.i("Retriable download exists, cancelling the old one and allowing enqueue.") - return true - } - Status.PAUSED -> { - Timber.i("Resuming paused download because of new request") - fetcher.resume(oldRequest.requestId) - downloaderMessage(AudioDownloadResumedExisting) - return false - } - Status.NONE, Status.QUEUED, Status.DOWNLOADING -> { - Timber.i("File already queued, doing nothing") - downloaderMessage(AudioDownloadAlreadyQueued) - return false - } - Status.COMPLETED -> { - val fileExists = downloadInfo.fileUri.toDocumentFile(appContext).exists() - return if (!fileExists) { - fetcher.delete(downloadInfo.id) - dao.delete(oldRequest) - Timber.i("Completed status but file doesn't exist, allowing enqueue.") - true - } else { - Timber.i("Completed status and file exists=$fileExists, doing nothing.") - downloaderMessage(AudioDownloadAlreadyCompleted) - false - } - } - else -> { - Timber.d("Existing download was requested with unhandled status, doing nothing: Status: ${downloadInfo.status}") - downloaderMessage(AudioDownloadExistingUnknownStatus(downloadInfo.status)) - return false - } - } - } else { - Timber.d("Download request exists but there's no download info, deleting old request and allowing enqueue.") - dao.delete(oldRequest) - return true - } - } - return true - } - - private suspend fun enqueueDownloadRequest(downloadRequest: DownloadRequest, request: Request): DownloadEnqueueResult { - val enqueueResult = fetcher.enqueue(request) - - if (enqueueResult is DownloadEnqueueSuccessful) { - val newRequest = enqueueResult.updatedRequest - try { - dao.insert(downloadRequest.copy(requestId = newRequest.id)) - } catch (e: Exception) { - Timber.e(e, "Failed to insert audio request") - downloaderMessage(UiMessage.Error(e)) - } - } - return enqueueResult - } - suspend fun pause(vararg downloadItems: DownloadItem) { - fetcher.pause(downloadItems.map { it.downloadInfo.id }) + internal val DOWNLOADS_LOCATION = stringPreferencesKey("downloads_location") + internal val DOWNLOADS_SONGS_GROUPING = stringPreferencesKey("downloads_songs_grouping") } - suspend fun resume(vararg downloadItems: DownloadItem) { - fetcher.resume(downloadItems.map { it.downloadInfo.id }) - } + val newDownloadId: Flow + val downloaderEvents: Flow + fun clearDownloaderEvents() + fun getDownloaderEvents(): List - suspend fun cancel(vararg downloadItems: DownloadItem) { - fetcher.cancel(downloadItems.map { it.downloadInfo.id }) - } + suspend fun enqueueAudio(audioId: String): Boolean + suspend fun enqueueAudio(audio: Audio): Boolean - suspend fun retry(vararg downloadItems: DownloadItem) { - fetcher.retry(downloadItems.map { it.downloadInfo.id }) - } + suspend fun pause(vararg downloadItems: DownloadItem) + suspend fun resume(vararg downloadItems: DownloadItem) + suspend fun cancel(vararg downloadItems: DownloadItem) + suspend fun retry(vararg downloadItems: DownloadItem) + suspend fun remove(vararg downloadItems: DownloadItem) + suspend fun delete(vararg downloadItems: DownloadItem) - suspend fun remove(vararg downloadItems: DownloadItem) { - fetcher.remove(downloadItems.map { it.downloadInfo.id }) - downloadItems.forEach { - dao.delete(it.downloadRequest) - } - } + suspend fun findAudioDownload(audioId: String): Optional