diff --git a/app/build.gradle b/app/build.gradle
index 9bf1577..21a485e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -47,8 +47,8 @@ android {
applicationId "io.musicorum.mobile"
minSdk 28
targetSdk 34
- versionCode 69
- versionName "1.24"
+ versionCode 70
+ versionName "2.0"
//compileSdkPreview = "UpsideDownCake"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -170,10 +170,11 @@ dependencies {
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
- implementation 'androidx.activity:activity-compose:1.8.0-rc01'
+ implementation 'androidx.activity:activity-compose:1.8.2'
+ implementation 'androidx.core:core-splashscreen:1.0.0'
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.ui:ui-tooling-preview"
- implementation 'androidx.compose.material3:material3:1.2.0-alpha11'
+ implementation 'androidx.compose.material3:material3:1.2.0-beta01'
implementation 'com.google.accompanist:accompanist-placeholder-material:0.26.5-rc'
implementation 'io.coil-kt:coil-compose:2.4.0'
implementation 'androidx.datastore:datastore-preferences:1.0.0'
diff --git a/app/src/debug/res/drawable/splash_background.xml b/app/src/debug/res/drawable/splash_background.xml
index 0617877..cc43a89 100644
--- a/app/src/debug/res/drawable/splash_background.xml
+++ b/app/src/debug/res/drawable/splash_background.xml
@@ -12,7 +12,7 @@
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a53e1ea..3e66210 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -36,7 +36,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:testOnly="false"
- android:theme="@style/Theme.Musicorum"
+ android:theme="@style/Theme.Musicorum.Starting"
tools:targetApi="31">
diff --git a/app/src/main/java/io/musicorum/mobile/MainActivity.kt b/app/src/main/java/io/musicorum/mobile/MainActivity.kt
index e0d3cf1..98c6db2 100644
--- a/app/src/main/java/io/musicorum/mobile/MainActivity.kt
+++ b/app/src/main/java/io/musicorum/mobile/MainActivity.kt
@@ -8,35 +8,31 @@ import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
+import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
-import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
-import androidx.compose.animation.slideOutVertically
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
+import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
-import androidx.core.view.WindowCompat
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
@@ -49,7 +45,6 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.crowdin.platform.Crowdin
-import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.android.gms.common.ConnectionResult.SERVICE_MISSING
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
@@ -68,10 +63,9 @@ import io.musicorum.mobile.datastore.UserData
import io.musicorum.mobile.ktor.endpoints.UserEndpoint
import io.musicorum.mobile.models.FetchPeriod
import io.musicorum.mobile.repositories.LocalUserRepository
-import io.musicorum.mobile.router.BottomNavBar
import io.musicorum.mobile.ui.theme.KindaBlack
+import io.musicorum.mobile.ui.theme.LighterGray
import io.musicorum.mobile.ui.theme.MusicorumMobileTheme
-import io.musicorum.mobile.utils.CrowdinUtils
import io.musicorum.mobile.utils.LocalSnackbar
import io.musicorum.mobile.utils.LocalSnackbarContext
import io.musicorum.mobile.utils.MessagingService
@@ -81,6 +75,7 @@ import io.musicorum.mobile.views.RecentScrobbles
import io.musicorum.mobile.views.charts.BaseChartDetail
import io.musicorum.mobile.views.charts.Charts
import io.musicorum.mobile.views.collage.Collage
+import io.musicorum.mobile.views.friendlist.FriendList
import io.musicorum.mobile.views.home.Home
import io.musicorum.mobile.views.individual.Album
import io.musicorum.mobile.views.individual.AlbumTracklist
@@ -88,7 +83,7 @@ import io.musicorum.mobile.views.individual.Artist
import io.musicorum.mobile.views.individual.PartialAlbum
import io.musicorum.mobile.views.individual.TagScreen
import io.musicorum.mobile.views.individual.Track
-import io.musicorum.mobile.views.individual.User
+import io.musicorum.mobile.views.individual.user.User
import io.musicorum.mobile.views.login.loginGraph
import io.musicorum.mobile.views.mostListened.MostListened
import io.musicorum.mobile.views.scrobbling.Scrobbling
@@ -130,7 +125,7 @@ class MainActivity : ComponentActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
- enableEdgeToEdge()
+ installSplashScreen()
super.onCreate(savedInstanceState)
val remoteConfig: FirebaseRemoteConfig = Firebase.remoteConfig
@@ -171,7 +166,6 @@ class MainActivity : ComponentActivity() {
}
}
- CrowdinUtils.initCrowdin(applicationContext)
askNotificationPermission()
MessagingService.createNotificationChannel(applicationContext)
@@ -181,7 +175,7 @@ class MainActivity : ComponentActivity() {
GoogleApiAvailability.getInstance().makeGooglePlayServicesAvailable(this)
}
- WindowCompat.setDecorFitsSystemWindows(window, true)
+ //WindowCompat.setDecorFitsSystemWindows(window, true)
firebaseAnalytics = Firebase.analytics
/*window.setFlags(
@@ -206,12 +200,10 @@ class MainActivity : ComponentActivity() {
}
setContent {
- val useDarkIcons = !isSystemInDarkTheme()
navController = rememberNavController().withSentryObservableEffect()
val ctx = LocalContext.current
val snackHostState = remember { SnackbarHostState() }
- val systemUiController = rememberSystemUiController()
if (intent?.data == null) {
LaunchedEffect(Unit) {
@@ -235,18 +227,14 @@ class MainActivity : ComponentActivity() {
}
- DisposableEffect(systemUiController, useDarkIcons) {
- systemUiController.setNavigationBarColor(Color.Transparent)
- systemUiController.setSystemBarsColor(KindaBlack)
-
- onDispose { }
- }
-
val bottomBarDestinations =
listOf("home", "scrobbling", "profile", "charts", "discover")
- val showNav =
- navController.currentBackStackEntryAsState().value?.destination?.route in bottomBarDestinations
+ enableEdgeToEdge(
+ statusBarStyle = SystemBarStyle.dark(KindaBlack.toArgb()),
+ navigationBarStyle = SystemBarStyle.dark(LighterGray.toArgb()),
+ )
+ val navStackBackEntry by navController.currentBackStackEntryAsState()
CompositionLocalProvider(
LocalSnackbar provides LocalSnackbarContext(snackHostState),
@@ -254,191 +242,184 @@ class MainActivity : ComponentActivity() {
LocalAnalytics provides firebaseAnalytics
) {
MusicorumMobileTheme {
- Scaffold(
- bottomBar = {
- AnimatedVisibility(
- visible = showNav,
- enter = slideInVertically(initialOffsetY = { it }),
- exit = slideOutVertically(targetOffsetY = { it }),
- ) {
- BottomNavBar(nav = navController)
- }
- },
- snackbarHost = { SnackbarHost(hostState = snackHostState) }
- ) { pv ->
- Surface(
- Modifier
- .fillMaxSize()
- .padding(pv)
- ) {
- val animationCurve = CubicBezierEasing(0.76f, 0f, 0.24f, 1f)
- val newRoute = navController.currentBackStackEntry?.destination?.route
- NavHost(
- navController = navController,
- startDestination = "home",
- enterTransition = {
- if (newRoute in bottomBarDestinations) {
- fadeIn(tween(200))
- } else {
- slideInHorizontally(tween(350)) { fullWidth -> fullWidth }
- }
- },
- exitTransition = {
- if (newRoute in bottomBarDestinations) {
- fadeOut(tween(200))
- } else {
- slideOutHorizontally(tween(350)) { fullWidth -> -fullWidth / 2 }
- }
- },
- popExitTransition = {
- slideOutHorizontally(
+ Surface(
+ Modifier
+ .safeDrawingPadding()
+ .fillMaxSize()
+ ) {
+ val animationCurve = CubicBezierEasing(0.76f, 0f, 0.24f, 1f)
+ val destination = navStackBackEntry?.destination?.route
+ NavHost(
+ navController = navController,
+ startDestination = "home",
+ enterTransition = {
+ if (destination in bottomBarDestinations) {
+ EnterTransition.None
+ } else {
+ slideInHorizontally(tween(350)) { fullWidth -> fullWidth }
+ }
+ },
+ exitTransition = {
+ if (destination in bottomBarDestinations) {
+ ExitTransition.None
+ } else {
+ slideOutHorizontally(tween(350)) { fullWidth -> -fullWidth / 2 }
+ }
+ },
+ popExitTransition = {
+ slideOutHorizontally(
+ tween(
+ 500,
+ easing = animationCurve
+ )
+ ) { fullWidth -> fullWidth }
+
+ },
+ popEnterTransition = {
+ if (destination in bottomBarDestinations) {
+ fadeIn(tween(200))
+ } else {
+ slideInHorizontally(
tween(
500,
easing = animationCurve
)
- ) { fullWidth -> fullWidth }
-
- },
- popEnterTransition = {
- if (newRoute in bottomBarDestinations) {
- fadeIn(tween(200))
- } else {
- slideInHorizontally(
- tween(
- 500,
- easing = animationCurve
- )
- )
- }
- }
- ) {
- /* WIP val views =
- Reflections("io.musicorum.mobile")
- .getMethodsAnnotatedWith(Route::class.java)
- Log.d("REGISTRY", "${views.size} routes were found.")
- views.forEach { method ->
- val routeName = method.annotations[0].annotationClass.simpleName
- ?: return@forEach
- composable(route = routeName) { method.invoke(null) }
- }*/
-
- loginGraph(navController = navController)
-
- composable("home") { Home() }
-
- composable("recentScrobbles") { RecentScrobbles() }
-
- composable(
- route = "collage?entity={entity}&period={period}",
- arguments = listOf(
- navArgument("entity") {
- type = NavType.StringType
- defaultValue = "artist"
- },
- navArgument("period") {
- type = NavType.StringType
- defaultValue = "7day"
- }
)
- ) {
- Collage()
}
+ }
+ ) {
+ /* WIP val views =
+ Reflections("io.musicorum.mobile")
+ .getMethodsAnnotatedWith(Route::class.java)
+ Log.d("REGISTRY", "${views.size} routes were found.")
+ views.forEach { method ->
+ val routeName = method.annotations[0].annotationClass.simpleName
+ ?: return@forEach
+ composable(route = routeName) { method.invoke(null) }
+ }*/
+
+ loginGraph(navController = navController)
+
+ composable("home") { Home() }
+
+ composable("recentScrobbles") { RecentScrobbles() }
+
+ composable(
+ route = "collage?entity={entity}&period={period}",
+ arguments = listOf(
+ navArgument("entity") {
+ type = NavType.StringType
+ defaultValue = "artist"
+ },
+ navArgument("period") {
+ type = NavType.StringType
+ defaultValue = "7day"
+ }
+ )
+ ) {
+ Collage()
+ }
- composable(
- "charts/detail?index={index}&period={period}",
- arguments = listOf(
- navArgument("index") {
- type = NavType.IntType
- defaultValue = 1
- },
- navArgument("period") {
- type = NavType.StringType
- defaultValue = FetchPeriod.WEEK.value
- }
- )
- ) { backStack ->
- BaseChartDetail(backStack.arguments?.getInt("index")!!)
- }
+ composable(
+ "charts/detail?index={index}&period={period}",
+ arguments = listOf(
+ navArgument("index") {
+ type = NavType.IntType
+ defaultValue = 1
+ },
+ navArgument("period") {
+ type = NavType.StringType
+ defaultValue = FetchPeriod.WEEK.value
+ }
+ )
+ ) { backStack ->
+ BaseChartDetail(backStack.arguments?.getInt("index")!!)
+ }
- composable("mostListened") {
- MostListened()
- }
+ composable("mostListened") {
+ MostListened()
+ }
- composable(
- "user/{usernameArg}",
- arguments = listOf(navArgument("usernameArg") {
- type = NavType.StringType
- })
- ) {
- User()
- }
+ composable(
+ "user/{usernameArg}",
+ arguments = listOf(navArgument("usernameArg") {
+ type = NavType.StringType
+ })
+ ) {
+ User()
+ }
- composable(
- "artist/{artistName}",
- arguments = listOf(navArgument("artistName") {
- type = NavType.StringType
- })
- ) {
- Artist(
- artistName = it.arguments?.getString("artistName")!!,
- )
- }
+ composable(
+ "artist/{artistName}",
+ arguments = listOf(navArgument("artistName") {
+ type = NavType.StringType
+ })
+ ) {
+ Artist(
+ artistName = it.arguments?.getString("artistName")!!,
+ )
+ }
- composable(
- "album/{albumData}",
- arguments = listOf(navArgument("albumData") {
- type = NavType.StringType
- }),
- ) {
- Album(it.arguments?.getString("albumData"), nav = navController)
- }
+ composable(
+ "album/{albumData}",
+ arguments = listOf(navArgument("albumData") {
+ type = NavType.StringType
+ }),
+ ) {
+ Album(it.arguments?.getString("albumData"), nav = navController)
+ }
- composable("discover") { Discover() }
- composable("scrobbling") { Scrobbling() }
- composable("charts") { Charts() }
- composable("profile") { Account() }
-
- composable(
- "track/{trackData}",
- arguments = listOf(
- navArgument("trackData") {
- type = NavType.StringType
- },
- )
- ) {
- Track(it.arguments?.getString("trackData"))
- }
+ composable("discover") { Discover() }
+ composable("scrobbling") { Scrobbling() }
+ composable("charts") { Charts() }
+ composable("profile") { Account() }
- composable(
- "albumTracklist/{albumData}",
- arguments = listOf(navArgument("albumData") {
+ composable(
+ "track/{trackData}",
+ arguments = listOf(
+ navArgument("trackData") {
type = NavType.StringType
- })
- ) {
- val partialAlbum = Json.decodeFromString(
- it.arguments!!.getString("albumData")!!
- )
- AlbumTracklist(partialAlbum = partialAlbum)
- }
+ },
+ )
+ ) {
+ Track(it.arguments?.getString("trackData"))
+ }
- composable("settings") { Settings() }
- composable("settings/scrobble") { ScrobbleSettings() }
- composable("settings/pendingScrobbles") { PendingScrobbles() }
+ composable(
+ "albumTracklist/{albumData}",
+ arguments = listOf(navArgument("albumData") {
+ type = NavType.StringType
+ })
+ ) {
+ val partialAlbum = Json.decodeFromString(
+ it.arguments!!.getString("albumData")!!
+ )
+ AlbumTracklist(partialAlbum = partialAlbum)
+ }
- composable(
- "tag/{tagName}",
- arguments = listOf(
- navArgument("tagName") {
- type = NavType.StringType
- },
- )
- ) {
- TagScreen()
- }
+ composable("settings") { Settings() }
+ composable("settings/scrobble") { ScrobbleSettings() }
+ composable("settings/pendingScrobbles") { PendingScrobbles() }
+
+ composable(
+ "tag/{tagName}",
+ arguments = listOf(
+ navArgument("tagName") {
+ type = NavType.StringType
+ },
+ )
+ ) {
+ TagScreen()
+ }
+
+ composable("friends") {
+ FriendList()
}
}
+
}
+
}
}
}
diff --git a/app/src/main/java/io/musicorum/mobile/components/FriendActivity.kt b/app/src/main/java/io/musicorum/mobile/components/FriendActivity.kt
index 2e619f2..77bcd15 100644
--- a/app/src/main/java/io/musicorum/mobile/components/FriendActivity.kt
+++ b/app/src/main/java/io/musicorum/mobile/components/FriendActivity.kt
@@ -3,11 +3,25 @@ package io.musicorum.mobile.components
import android.text.format.DateUtils
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.PushPin
+import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.Text
@@ -17,6 +31,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.rotate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -45,7 +60,9 @@ import kotlinx.serialization.json.Json
fun FriendActivity(
track: Track,
friendImageUrl: String?,
- friendUsername: String?
+ friendUsername: String?,
+ isPinned: Boolean,
+ onUnpin: (() -> Unit)? = null,
) {
val analytics = LocalAnalytics.current!!
val nav = LocalNavigation.current
@@ -78,6 +95,19 @@ fun FriendActivity(
}
}
)
+ if (isPinned) {
+ ListItem(
+ colors = colors,
+ headlineContent = { Text("Unpin $friendUsername") },
+ leadingContent = {
+ Icon(Icons.Rounded.Close, null)
+ },
+ modifier = Modifier.clickable {
+ showSheet.value = false
+ onUnpin?.invoke()
+ }
+ )
+ }
}
}
@@ -122,6 +152,26 @@ fun FriendActivity(
nav?.navigate("user/$friendUsername")
}
)
+ if (isPinned) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .offset(5.dp, (-10).dp)
+ .size(30.dp)
+ .clip(CircleShape)
+ .background(color = LighterGray, shape = CircleShape)
+ .border(3.dp, KindaBlack, CircleShape)
+ .padding(7.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.PushPin,
+ contentDescription = null,
+ modifier = Modifier
+ .rotate((30f))
+ .align(Alignment.Center)
+ )
+ }
+ }
}
Spacer(Modifier.height(10.dp))
Row {
diff --git a/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt b/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt
index 0f52900..67b430f 100644
--- a/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt
+++ b/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt
@@ -2,28 +2,25 @@ package io.musicorum.mobile.components
import android.app.SearchManager
import android.content.Intent
-import android.content.pm.PackageManager
import android.net.Uri
import android.provider.MediaStore
-import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.FavoriteBorder
+import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -43,11 +40,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.core.graphics.drawable.toBitmap
import coil.compose.AsyncImage
import io.musicorum.mobile.LocalNavigation
import io.musicorum.mobile.coil.defaultImageRequestBuilder
@@ -68,7 +63,7 @@ import kotlinx.serialization.json.Json
fun TrackSheet(
track: Track,
show: MutableState,
- additionalSheetItems: (@Composable () -> Unit)? = null
+ additionalSheetItems: (@Composable (ColumnScope.() -> Unit))? = null
) {
val coroutine = rememberCoroutineScope()
val trackLoved = rememberSaveable { mutableStateOf(track.loved) }
@@ -78,17 +73,19 @@ fun TrackSheet(
containerColor = LighterGray
)
val sheetState = rememberModalBottomSheetState()
- val spotifyIntent = ctx.packageManager.getLaunchIntentForPackage("com.spotify.music")
- ?.setAction(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH)
- ?.putExtra(SearchManager.QUERY, "${track.artist.name} ${track.name}")
+ val playIntent = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH).apply {
+ putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/audio")
+ putExtra(MediaStore.EXTRA_MEDIA_TITLE, track.name)
+ putExtra(MediaStore.EXTRA_MEDIA_ARTIST, track.artist.name)
+ putExtra(SearchManager.QUERY, track.name)
+ }
ModalBottomSheet(
onDismissRequest = { show.value = false },
containerColor = LighterGray,
sheetState = sheetState,
-
- ) {
-
+ modifier = Modifier.safeDrawingPadding()
+ ) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -184,30 +181,16 @@ fun TrackSheet(
leadingContent = { Icon(Icons.Rounded.Album, null) }
)
- val spotify = try {
- ctx.packageManager.getApplicationIcon("com.spotify.music")
- .toBitmap()
- .asImageBitmap()
- } catch (_: PackageManager.NameNotFoundException) {
- null
- }
-
- spotify?.let {
ListItem(
modifier = Modifier
.fillMaxWidth()
- .clickable { ctx.startActivity(spotifyIntent) },
- headlineContent = { Text(text = "Play on Spotify") },
+ .clickable { ctx.startActivity(playIntent) },
+ headlineContent = { Text(text = "Play") },
leadingContent = {
- Image(
- bitmap = it,
- contentDescription = null,
- modifier = Modifier.size(24.dp)
- )
+ Icon(imageVector = Icons.Rounded.PlayArrow, contentDescription = null)
},
colors = listColors
)
- }
ListItem(
modifier = Modifier
@@ -228,6 +211,6 @@ fun TrackSheet(
leadingContent = { Icon(Icons.Rounded.Share, null) },
colors = listColors
)
- Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
+ //Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
\ No newline at end of file
diff --git a/app/src/main/java/io/musicorum/mobile/datastore/UserData.kt b/app/src/main/java/io/musicorum/mobile/datastore/UserData.kt
index 1c14cbe..47e709d 100644
--- a/app/src/main/java/io/musicorum/mobile/datastore/UserData.kt
+++ b/app/src/main/java/io/musicorum/mobile/datastore/UserData.kt
@@ -1,8 +1,10 @@
package io.musicorum.mobile.datastore
import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
object UserData {
const val DataStoreName = "userdata"
val SESSION_KEY = stringPreferencesKey("session_key")
+ val PINNED_USERS = stringSetPreferencesKey("pinned_users")
}
\ No newline at end of file
diff --git a/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt b/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt
index 700b19f..6f9fa43 100644
--- a/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt
+++ b/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt
@@ -1,7 +1,5 @@
package io.musicorum.mobile.router
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
import androidx.compose.material.icons.rounded.BarChart
@@ -14,20 +12,19 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
-import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
+import io.musicorum.mobile.LocalNavigation
import io.musicorum.mobile.ui.theme.LighterGray
import io.musicorum.mobile.ui.theme.MostlyRed
import java.util.Locale
@Composable
-fun BottomNavBar(nav: NavHostController) {
+fun BottomNavBar() {
val items = listOf("Home", "Discover", "Scrobbling", "Charts", "Profile")
+ val nav = LocalNavigation.current!!
val icons = listOf(
Icons.Rounded.Home,
Icons.Rounded.Search,
@@ -41,30 +38,28 @@ fun BottomNavBar(nav: NavHostController) {
selectedTextColor = Color.White
)
- val navBackStackEntry by nav.currentBackStackEntryAsState()
- val currentDestination = navBackStackEntry?.destination
+ val navBackStackEntry = nav.currentBackStackEntryAsState()
+ val currentDestination = navBackStackEntry.value?.destination
- Box(modifier = Modifier.background(LighterGray)) {
- NavigationBar(containerColor = Color.Transparent) {
- items.forEachIndexed { index, s ->
- NavigationBarItem(
- selected = currentDestination?.hierarchy?.any { it.route?.lowercase() == s.lowercase() } == true,
- label = { Text(text = s, maxLines = 1) },
- onClick = {
- nav.navigate(s.lowercase(Locale.ROOT))
- {
- launchSingleTop = true
- restoreState = true
- popUpTo(nav.graph.findStartDestination().id) {
- saveState = true
- }
+ NavigationBar(containerColor = LighterGray) {
+ items.forEachIndexed { index, s ->
+ NavigationBarItem(
+ selected = currentDestination?.hierarchy?.any { it.route?.lowercase() == s.lowercase() } == true,
+ label = { Text(text = s, maxLines = 1) },
+ onClick = {
+ nav.navigate(s.lowercase(Locale.ROOT))
+ {
+ launchSingleTop = true
+ restoreState = true
+ popUpTo(nav.graph.findStartDestination().id) {
+ saveState = true
}
- },
- icon = { Icon(icons[index], contentDescription = "nav icon") },
- alwaysShowLabel = false,
- colors = navItemColors
- )
- }
+ }
+ },
+ icon = { Icon(icons[index], contentDescription = "nav icon") },
+ alwaysShowLabel = false,
+ colors = navItemColors
+ )
}
}
}
diff --git a/app/src/main/java/io/musicorum/mobile/router/Controller.kt b/app/src/main/java/io/musicorum/mobile/router/Controller.kt
index e5f6114..144ec7b 100644
--- a/app/src/main/java/io/musicorum/mobile/router/Controller.kt
+++ b/app/src/main/java/io/musicorum/mobile/router/Controller.kt
@@ -16,7 +16,7 @@ import io.musicorum.mobile.views.charts.Charts
import io.musicorum.mobile.views.home.Home
import io.musicorum.mobile.views.individual.Album
import io.musicorum.mobile.views.individual.Track
-import io.musicorum.mobile.views.individual.User
+import io.musicorum.mobile.views.individual.user.User
import io.musicorum.mobile.views.login.loginGraph
import io.musicorum.mobile.views.scrobbling.Scrobbling
diff --git a/app/src/main/java/io/musicorum/mobile/router/Routes.kt b/app/src/main/java/io/musicorum/mobile/router/Routes.kt
index f604944..43b1eb1 100644
--- a/app/src/main/java/io/musicorum/mobile/router/Routes.kt
+++ b/app/src/main/java/io/musicorum/mobile/router/Routes.kt
@@ -41,4 +41,5 @@ object Routes {
val entityString = entity?.entityName ?: "ARTIST"
return "collage?period=$periodString&entity=$entityString"
}
+ const val friends = "friends"
}
\ No newline at end of file
diff --git a/app/src/main/java/io/musicorum/mobile/viewmodels/UserViewModel.kt b/app/src/main/java/io/musicorum/mobile/viewmodels/UserViewModel.kt
deleted file mode 100644
index ef51b2f..0000000
--- a/app/src/main/java/io/musicorum/mobile/viewmodels/UserViewModel.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-package io.musicorum.mobile.viewmodels
-
-import android.app.Application
-import androidx.lifecycle.AndroidViewModel
-import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.SavedStateHandle
-import androidx.lifecycle.viewModelScope
-import io.musicorum.mobile.ktor.endpoints.UserEndpoint
-import io.musicorum.mobile.ktor.endpoints.musicorum.MusicorumArtistEndpoint
-import io.musicorum.mobile.models.FetchPeriod
-import io.musicorum.mobile.repositories.LocalUserRepository
-import io.musicorum.mobile.serialization.RecentTracks
-import io.musicorum.mobile.serialization.TopAlbumsResponse
-import io.musicorum.mobile.serialization.TopArtistsResponse
-import io.musicorum.mobile.serialization.User
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-class UserViewModel @Inject constructor(
- application: Application,
- savedStateHandle: SavedStateHandle
-) : AndroidViewModel(application) {
- val user by lazy { MutableLiveData() }
- val topArtists by lazy { MutableLiveData() }
- val recentTracks by lazy { MutableLiveData() }
- val topAlbums by lazy { MutableLiveData() }
- val isRefreshing by lazy { MutableStateFlow(false) }
- val errored by lazy { MutableLiveData(false) }
- val ctx = application
- private val usernameArg = savedStateHandle.get("usernameArg")
- private val localUser = LocalUserRepository(ctx)
-
- fun refresh() {
- isRefreshing.value = true
- recentTracks.value = null
- getRecentTracks(limit = 4, null)
- }
-
- fun getUser() = kotlin.runCatching {
- viewModelScope.launch {
- if (usernameArg == null) {
- val localUser = LocalUserRepository(ctx).fetch()
- user.value = localUser
- return@launch
- }
- val userInfo = UserEndpoint.getUser(usernameArg)
- user.value = userInfo
- if (userInfo == null) errored.value = true
- }
- }
-
- fun getTopArtists(limit: Int?, period: FetchPeriod?) = kotlin.runCatching {
- viewModelScope.launch {
- val username = usernameArg ?: localUser.getUser().username
- val res = UserEndpoint.getTopArtists(username, limit, period)
- if (res != null) {
- val musRes =
- MusicorumArtistEndpoint.fetchArtist(res.topArtists.artists)
- musRes.forEachIndexed { index, trackResponse ->
- val trackImageUrl = trackResponse.resources.getOrNull(0)?.bestImageUrl ?: ""
- res.topArtists.artists[index].bestImageUrl = trackImageUrl
- }
- topArtists.value = res
- }
- }
- }
-
- fun getRecentTracks(limit: Int?, extended: Boolean?) = kotlin.runCatching {
- viewModelScope.launch {
- val username = usernameArg ?: localUser.getUser().username
- val res = UserEndpoint.getRecentTracks(username, null, limit, extended)
- recentTracks.value = res
- isRefreshing.value = false
- }
- }
-
- fun getTopAlbums(period: FetchPeriod?, limit: Int?) = kotlin.runCatching {
- viewModelScope.launch {
- val username = usernameArg ?: localUser.getUser().username
- val res = UserEndpoint.getTopAlbums(username, period, limit)
- topAlbums.value = res
- }
- }
-
- init {
- getUser()
- getTopArtists(null, FetchPeriod.MONTH)
- getRecentTracks(limit = 4, null)
- getTopAlbums(FetchPeriod.MONTH, null)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/io/musicorum/mobile/views/Account.kt b/app/src/main/java/io/musicorum/mobile/views/Account.kt
index bac08a3..3346070 100644
--- a/app/src/main/java/io/musicorum/mobile/views/Account.kt
+++ b/app/src/main/java/io/musicorum/mobile/views/Account.kt
@@ -5,7 +5,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.lifecycle.viewmodel.compose.viewModel
import io.musicorum.mobile.components.CenteredLoadingSpinner
import io.musicorum.mobile.viewmodels.AccountVm
-import io.musicorum.mobile.views.individual.User
+import io.musicorum.mobile.views.individual.user.User
@Composable
fun Account(model: AccountVm = viewModel()) {
diff --git a/app/src/main/java/io/musicorum/mobile/views/Discover.kt b/app/src/main/java/io/musicorum/mobile/views/Discover.kt
index b7eae71..4eca7a9 100644
--- a/app/src/main/java/io/musicorum/mobile/views/Discover.kt
+++ b/app/src/main/java/io/musicorum/mobile/views/Discover.kt
@@ -19,6 +19,7 @@ import androidx.compose.material3.DockedSearchBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -44,6 +45,7 @@ import io.musicorum.mobile.components.AlbumListItem
import io.musicorum.mobile.components.ArtistListItem
import io.musicorum.mobile.components.CenteredLoadingSpinner
import io.musicorum.mobile.components.TrackListItem
+import io.musicorum.mobile.router.BottomNavBar
import io.musicorum.mobile.router.Routes
import io.musicorum.mobile.ui.theme.ContentSecondary
import io.musicorum.mobile.ui.theme.KindaBlack
@@ -67,102 +69,105 @@ fun Discover(viewModel: DiscoverVm = viewModel()) {
val nav = LocalNavigation.current
- Column(
- modifier = Modifier
- .padding(vertical = 20.dp)
- .fillMaxSize()
- .background(KindaBlack)
- .verticalScroll(rememberScrollState())
- ) {
- Text(
- stringResource(R.string.discover),
- style = Typography.displaySmall,
- modifier = Modifier.padding(start = 20.dp)
- )
-
- DockedSearchBar(
- query = query,
- onQueryChange = { viewModel.updateQuery(it) },
- onSearch = {
- viewModel.search()
- presentResults.value = true
- },
- active = false,
- onActiveChange = {},
- colors = searchBarColors,
+ Scaffold(bottomBar = { BottomNavBar() }) { pv ->
+ Column(
modifier = Modifier
- .padding(top = 10.dp)
- .align(CenterHorizontally),
- placeholder = { Text(stringResource(R.string.search_on_last_fm)) },
- leadingIcon = { Icon(Icons.Rounded.Search, null) }
- ) {}
-
- if (!presentResults.value) return
+ .padding(vertical = 20.dp)
+ .padding(pv)
+ .fillMaxSize()
+ .background(KindaBlack)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ stringResource(R.string.discover),
+ style = Typography.displaySmall,
+ modifier = Modifier.padding(start = 20.dp)
+ )
- if (busy) {
- CenteredLoadingSpinner()
- return
- }
+ DockedSearchBar(
+ query = query,
+ onQueryChange = { viewModel.updateQuery(it) },
+ onSearch = {
+ viewModel.search()
+ presentResults.value = true
+ },
+ active = false,
+ onActiveChange = {},
+ colors = searchBarColors,
+ modifier = Modifier
+ .padding(top = 10.dp)
+ .align(CenterHorizontally),
+ placeholder = { Text(stringResource(R.string.search_on_last_fm)) },
+ leadingIcon = { Icon(Icons.Rounded.Search, null) }
+ ) {}
- Header(
- title = stringResource(R.string.tracks),
- results = tracks.size,
- icon = Icons.Rounded.Audiotrack
- )
+ if (!presentResults.value) return@Column
- if (tracks.isNotEmpty()) {
- tracks.take(4).forEach {
- TrackListItem(track = it)
+ if (busy) {
+ CenteredLoadingSpinner()
+ return@Column
}
- }
- Header(
- title = stringResource(id = R.string.albums),
- results = albums.size,
- icon = Icons.Outlined.Album
- )
- if (albums.isNotEmpty()) {
- albums.take(4).forEach {
- AlbumListItem(it)
+ Header(
+ title = stringResource(R.string.tracks),
+ results = tracks.size,
+ icon = Icons.Rounded.Audiotrack
+ )
+
+ if (tracks.isNotEmpty()) {
+ tracks.take(4).forEach {
+ TrackListItem(track = it)
+ }
}
- }
- Header(
- title = stringResource(id = R.string.artists),
- results = artists.size,
- icon = Icons.Rounded.Star
- )
- if (artists.isNotEmpty()) {
- artists.take(4).forEach {
- ArtistListItem(artist = it)
+ Header(
+ title = stringResource(id = R.string.albums),
+ results = albums.size,
+ icon = Icons.Outlined.Album
+ )
+ if (albums.isNotEmpty()) {
+ albums.take(4).forEach {
+ AlbumListItem(it)
+ }
}
- }
- Header(
- title = "Users",
- results = users.size,
- icon = Icons.Rounded.Person
- )
- if (users.isNotEmpty()) {
- val model = defaultImageRequestBuilder(
- url = users.first().user.bestImageUrl,
- PlaceholderType.USER
+ Header(
+ title = stringResource(id = R.string.artists),
+ results = artists.size,
+ icon = Icons.Rounded.Star
)
- ListItem(
- headlineContent = { Text(users.first().user.name) },
- leadingContent = {
- AsyncImage(
- model = model,
- contentDescription = null,
- modifier = Modifier
- .size(50.dp)
- .clip(CircleShape)
- )
- },
- modifier = Modifier.clickable {
- nav?.navigate(Routes.user(users.first().user.name))
+ if (artists.isNotEmpty()) {
+ artists.take(4).forEach {
+ ArtistListItem(artist = it)
}
+ }
+
+ Header(
+ title = "Users",
+ results = users.size,
+ icon = Icons.Rounded.Person
)
+ if (users.isNotEmpty()) {
+ val model = defaultImageRequestBuilder(
+ url = users.first().user.bestImageUrl,
+ PlaceholderType.USER
+ )
+ ListItem(
+ headlineContent = { Text(users.first().user.name) },
+ leadingContent = {
+ AsyncImage(
+ model = model,
+ contentDescription = null,
+ modifier = Modifier
+ .size(50.dp)
+ .clip(CircleShape)
+ )
+ },
+ modifier = Modifier.clickable {
+ nav?.navigate(Routes.user(users.first().user.name))
+ }
+ )
+ }
}
}
}
diff --git a/app/src/main/java/io/musicorum/mobile/views/charts/Charts.kt b/app/src/main/java/io/musicorum/mobile/views/charts/Charts.kt
index dad4e0f..bf7185f 100644
--- a/app/src/main/java/io/musicorum/mobile/views/charts/Charts.kt
+++ b/app/src/main/java/io/musicorum/mobile/views/charts/Charts.kt
@@ -72,6 +72,7 @@ import io.musicorum.mobile.R
import io.musicorum.mobile.coil.defaultImageRequestBuilder
import io.musicorum.mobile.components.CenteredLoadingSpinner
import io.musicorum.mobile.models.ResourceEntity
+import io.musicorum.mobile.router.BottomNavBar
import io.musicorum.mobile.router.Routes
import io.musicorum.mobile.serialization.NavigationTrack
import io.musicorum.mobile.serialization.entities.Album
@@ -106,7 +107,9 @@ fun Charts() {
val userGradient = getDarkenGradient(userColor)
- Scaffold(floatingActionButton = { CollageFab() }) { paddingValues ->
+ Scaffold(
+ floatingActionButton = { CollageFab() },
+ bottomBar = { BottomNavBar() }) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityState.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityState.kt
new file mode 100644
index 0000000..e66c8eb
--- /dev/null
+++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityState.kt
@@ -0,0 +1,9 @@
+package io.musicorum.mobile.views.friendlist
+
+import io.musicorum.mobile.serialization.entities.Track
+
+data class FriendActivityState(
+ val track: Track? = null,
+ val loading: Boolean = false,
+ val nowPlaying: Boolean = false
+)
\ No newline at end of file
diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityViewModel.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityViewModel.kt
new file mode 100644
index 0000000..524be59
--- /dev/null
+++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendActivityViewModel.kt
@@ -0,0 +1,57 @@
+package io.musicorum.mobile.views.friendlist
+
+import android.app.Application
+import android.content.Intent
+import android.net.Uri
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import io.musicorum.mobile.ktor.endpoints.UserEndpoint
+import io.musicorum.mobile.ktor.endpoints.musicorum.MusicorumTrackEndpoint
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class FriendActivityViewModel(application: Application) : AndroidViewModel(application) {
+ val state = MutableStateFlow(FriendActivityState())
+ val app = application
+
+ fun fetchActivity(username: String) {
+ if (state.value.track != null) return
+ viewModelScope.launch {
+ state.update {
+ it.copy(loading = true)
+ }
+ val res = UserEndpoint.getRecentTracks(username, null, 1)
+ res?.let {
+ val track = res.recentTracks.tracks.first()
+ state.update {
+ it.copy(
+ track = track,
+ loading = false,
+ nowPlaying = track.attributes?.nowPlaying?.toBooleanStrictOrNull() ?: false
+ )
+ }
+ val musRes = MusicorumTrackEndpoint.fetchTracks(res.recentTracks.tracks)
+ res.recentTracks.tracks.onEach {
+ musRes[0]?.let { r ->
+ it.bestImageUrl = r.bestResource?.bestImageUrl ?: ""
+ }
+ }
+
+ state.update {
+ it.copy(
+ track = track,
+ loading = false,
+ nowPlaying = track.attributes?.nowPlaying?.toBooleanStrictOrNull() ?: false
+ )
+ }
+ }
+ }
+ }
+
+ fun launchWebProfile(username: String) {
+ val uri = Uri.parse("https://last.fm/user/$username")
+ val intent = Intent(Intent.ACTION_VIEW, uri)
+ app.startActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendList.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendList.kt
new file mode 100644
index 0000000..4a1cc67
--- /dev/null
+++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendList.kt
@@ -0,0 +1,260 @@
+package io.musicorum.mobile.views.friendlist
+
+import android.text.format.DateUtils
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.indication
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MediumTopAppBar
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ColorMatrix
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import coil.compose.AsyncImage
+import com.google.accompanist.placeholder.PlaceholderHighlight
+import com.google.accompanist.placeholder.material.shimmer
+import com.google.accompanist.placeholder.placeholder
+import io.musicorum.mobile.LocalNavigation
+import io.musicorum.mobile.R
+import io.musicorum.mobile.coil.PlaceholderType
+import io.musicorum.mobile.coil.defaultImageRequestBuilder
+import io.musicorum.mobile.components.CenteredLoadingSpinner
+import io.musicorum.mobile.components.TrackSheet
+import io.musicorum.mobile.router.Routes
+import io.musicorum.mobile.serialization.NavigationTrack
+import io.musicorum.mobile.serialization.UserData
+import io.musicorum.mobile.ui.theme.ContentSecondary
+import io.musicorum.mobile.ui.theme.KindaBlack
+import io.musicorum.mobile.ui.theme.Typography
+import io.musicorum.mobile.utils.Rive
+
+@Composable
+fun FriendList(vm: FriendListViewModel = viewModel()) {
+ val state by vm.state.collectAsState()
+
+ Scaffold(
+ topBar = { AppBar() },
+ snackbarHost = { SnackbarHost(vm.snackbarHostState) }
+ ) { pv ->
+ Column(Modifier.padding(pv)) {
+ if (state.loading) {
+ CenteredLoadingSpinner()
+ } else {
+ Text(
+ "${state.friends.size} friends",
+ modifier = Modifier.padding(start = 15.dp, bottom = 15.dp),
+ color = ContentSecondary
+ )
+ LazyVerticalGrid(
+ columns = GridCells.FixedSize(160.dp),
+ horizontalArrangement = Arrangement.SpaceAround,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(state.friends) { user ->
+ val activityViewModel: FriendActivityViewModel = viewModel(key = user.name)
+ val isPinned = user.name in state.pinnedUsers
+ FriendActivity(user = user, isPinned = isPinned, vm = activityViewModel) {
+ if (isPinned) vm.unpinUser(user)
+ else vm.pinUser(user)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AppBar() {
+ val nav = LocalNavigation.current
+ MediumTopAppBar(title = { Text("Friends") }, navigationIcon = {
+ IconButton(onClick = { nav?.popBackStack() }) {
+ Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null)
+ }
+ })
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun FriendActivity(
+ vm: FriendActivityViewModel,
+ user: UserData,
+ isPinned: Boolean,
+ onPin: () -> Unit
+) {
+ val state by vm.state.collectAsState()
+ val reqModel = defaultImageRequestBuilder(url = user.bestImageUrl)
+ val showBottomSheet = remember { mutableStateOf(false) }
+ val showTrackBottomSheet = remember { mutableStateOf(false) }
+ val haptic = LocalHapticFeedback.current
+ val interactionSource = remember { MutableInteractionSource() }
+ val trackRowISource = remember { MutableInteractionSource() }
+ val nav = LocalNavigation.current
+
+
+ LaunchedEffect(key1 = Unit) {
+ vm.fetchActivity(user.name)
+ }
+
+ if (showBottomSheet.value) {
+ UserBottomSheet(
+ userData = user,
+ isPinned = isPinned,
+ onDismiss = { showBottomSheet.value = false }
+ ) {
+ showBottomSheet.value = false
+ onPin()
+ }
+ }
+
+ if (showTrackBottomSheet.value) {
+ state.track?.let {
+ TrackSheet(track = it, showTrackBottomSheet) {}
+ }
+ }
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .combinedClickable(
+ indication = null,
+ interactionSource = interactionSource,
+ onClick = {
+ nav?.navigate(Routes.user(user.name))
+ },
+ onLongClick = {
+ showBottomSheet.value = true
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+ )
+ .consumeWindowInsets(WindowInsets.navigationBars)
+ ) {
+ AsyncImage(
+ model = reqModel,
+ contentDescription = null,
+ modifier = Modifier
+ .clip(CircleShape)
+ .size(105.dp)
+ .indication(interactionSource, LocalIndication.current)
+ )
+ Text(
+ text = user.name,
+ style = Typography.titleMedium,
+ modifier = Modifier.padding(vertical = 5.dp)
+ )
+ Spacer(modifier = Modifier.height(3.dp))
+ Row(
+ horizontalArrangement = Arrangement.Start,
+ modifier = Modifier
+ .placeholder(
+ visible = state.loading,
+ color = KindaBlack,
+ shape = RoundedCornerShape(3.dp),
+ highlight = PlaceholderHighlight.shimmer()
+ )
+ .clip(RoundedCornerShape(3.dp))
+ .combinedClickable(
+ indication = LocalIndication.current,
+ interactionSource = trackRowISource,
+ onClick = {
+ state.track?.let {
+ nav?.navigate(Routes.track(NavigationTrack(it.name, it.artist.name)))
+ }
+ },
+ onLongClick = { showTrackBottomSheet.value = true },
+ )
+ ) {
+ val albumModel =
+ defaultImageRequestBuilder(url = state.track?.bestImageUrl, PlaceholderType.ALBUM)
+ AsyncImage(
+ model = albumModel, contentDescription = null, modifier = Modifier
+ .clip(RoundedCornerShape(3.dp))
+ .size(40.dp)
+ .indication(trackRowISource, LocalIndication.current),
+ colorFilter = if (!state.nowPlaying) {
+ ColorFilter.colorMatrix(ColorMatrix().apply { this.setToSaturation(0F) })
+ } else null
+ )
+ Column(
+ modifier = Modifier
+ .padding(start = 5.dp)
+ .fillMaxWidth()
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (state.nowPlaying) {
+ Rive.AnimationFor(id = R.raw.nowplaying, modifier = Modifier.size(10.dp))
+ }
+ Text(
+ text = state.track?.name ?: "Unknown",
+ style = Typography.labelSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ if (state.nowPlaying) {
+ Text(
+ text = state.track?.artist?.name ?: "Unknown",
+ style = Typography.labelSmall,
+ color = ContentSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ } else {
+ val now = System.currentTimeMillis()
+ state.track?.let {
+ val string = DateUtils.getRelativeTimeSpanString(
+ it.date!!.uts.toLong() * 1000,
+ now,
+ DateUtils.SECOND_IN_MILLIS
+ ).toString()
+ Text(
+ text = string,
+ style = Typography.labelSmall,
+ color = ContentSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListState.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListState.kt
new file mode 100644
index 0000000..28f6109
--- /dev/null
+++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListState.kt
@@ -0,0 +1,9 @@
+package io.musicorum.mobile.views.friendlist
+
+import io.musicorum.mobile.serialization.UserData
+
+data class FriendListState(
+ val friends: List = emptyList(),
+ val loading: Boolean = false,
+ val pinnedUsers: Set = emptySet()
+)
\ No newline at end of file
diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListViewModel.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListViewModel.kt
new file mode 100644
index 0000000..8f4e8b3
--- /dev/null
+++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/FriendListViewModel.kt
@@ -0,0 +1,87 @@
+package io.musicorum.mobile.views.friendlist
+
+import android.app.Application
+import androidx.compose.material3.SnackbarHostState
+import androidx.datastore.preferences.core.edit
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import io.musicorum.mobile.datastore.UserData
+import io.musicorum.mobile.ktor.endpoints.UserEndpoint
+import io.musicorum.mobile.repositories.LocalUserRepository
+import io.musicorum.mobile.userData
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import io.musicorum.mobile.serialization.UserData as LastfmUserData
+
+class FriendListViewModel(application: Application) : AndroidViewModel(application) {
+ val state = MutableStateFlow(FriendListState())
+ private val ctx = application
+ val snackbarHostState = SnackbarHostState()
+
+ private fun fetchFriends() {
+ viewModelScope.launch {
+ state.update {
+ it.copy(loading = true)
+ }
+ val localUser = LocalUserRepository(getApplication()).getUser()
+ val res = UserEndpoint.getFriends(localUser.username, null)
+ res?.let {
+ state.update {
+ it.copy(friends = res.friends.users, loading = false)
+ }
+ }
+ }
+ }
+
+ fun pinUser(userData: LastfmUserData) {
+ viewModelScope.launch {
+ val currentSet = ctx.userData.data.map {
+ it[UserData.PINNED_USERS] ?: emptySet()
+ }.first().toMutableSet()
+
+ currentSet.add(userData.name)
+
+ ctx.userData.edit {
+ it[UserData.PINNED_USERS] = currentSet.toSet()
+ }
+ state.update {
+ it.copy(pinnedUsers = currentSet.toSet())
+ }
+ snackbarHostState.showSnackbar("${userData.name} pinned on home screen")
+ }
+ }
+
+ fun unpinUser(userData: LastfmUserData) {
+ viewModelScope.launch {
+ val currentSet = ctx.userData.data.map {
+ it[UserData.PINNED_USERS] ?: emptySet()
+ }.first().toMutableSet()
+
+ currentSet.remove(userData.name)
+
+ ctx.userData.edit {
+ it[UserData.PINNED_USERS] = currentSet.toSet()
+ }
+
+ state.update {
+ it.copy(pinnedUsers = currentSet.toSet())
+ }
+ snackbarHostState.showSnackbar("${userData.name} removed from pinned users")
+ }
+ }
+
+ init {
+ fetchFriends()
+ viewModelScope.launch {
+ val pinnedUsers = ctx.userData.data.map {
+ it[UserData.PINNED_USERS] ?: emptySet()
+ }.first()
+ state.update {
+ it.copy(pinnedUsers = pinnedUsers)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/musicorum/mobile/views/friendlist/UserBottomSheet.kt b/app/src/main/java/io/musicorum/mobile/views/friendlist/UserBottomSheet.kt
new file mode 100644
index 0000000..7d4650f
--- /dev/null
+++ b/app/src/main/java/io/musicorum/mobile/views/friendlist/UserBottomSheet.kt
@@ -0,0 +1,129 @@
+package io.musicorum.mobile.views.friendlist
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.OpenInNew
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.PushPin
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import io.musicorum.mobile.LocalNavigation
+import io.musicorum.mobile.coil.PlaceholderType
+import io.musicorum.mobile.coil.defaultImageRequestBuilder
+import io.musicorum.mobile.router.Routes
+import io.musicorum.mobile.serialization.UserData
+import io.musicorum.mobile.ui.theme.LighterGray
+import io.musicorum.mobile.ui.theme.Typography
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun UserBottomSheet(
+ userData: UserData,
+ isPinned: Boolean,
+ onDismiss: () -> Unit,
+ onPin: () -> Unit,
+) {
+ val nav = LocalNavigation.current
+ val userUri = Uri.parse("https://last.fm/user/${userData.name}")
+ val userIntent = Intent(Intent.ACTION_VIEW, userUri)
+ val ctx = LocalContext.current
+
+ val userModel = defaultImageRequestBuilder(
+ url = userData.bestImageUrl,
+ placeholderType = PlaceholderType.USER
+ )
+ val listItemColors = ListItemDefaults.colors(
+ containerColor = LighterGray,
+ )
+ ModalBottomSheet(
+ modifier = Modifier.consumeWindowInsets(WindowInsets.navigationBars),
+ onDismissRequest = { onDismiss() },
+ containerColor = LighterGray,
+ ) {
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(horizontal = 20.dp)
+ ) {
+ AsyncImage(
+ model = userModel, contentDescription = null, modifier = Modifier
+ .clip(CircleShape)
+ .size(80.dp)
+ )
+ Column(modifier = Modifier.padding(start = 10.dp)) {
+ Text(userData.name, style = Typography.headlineMedium)
+ userData.realName?.let {
+ Text(it, style = Typography.titleMedium)
+ }
+ }
+ }
+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
+
+ if (isPinned) {
+ ListItem(
+ colors = listItemColors,
+ headlineContent = { Text("Unpin") },
+ leadingContent = { Icon(Icons.Rounded.Close, contentDescription = null) },
+ modifier = Modifier.clickable { onPin() }
+ )
+ } else {
+ ListItem(
+ colors = listItemColors,
+ headlineContent = { Text("Pin on home screen") },
+ leadingContent = { Icon(Icons.Rounded.PushPin, contentDescription = null) },
+ modifier = Modifier.clickable { onPin() }
+ )
+ }
+
+ ListItem(
+ colors = listItemColors,
+ headlineContent = { Text("View ${userData.name}'s profile") },
+ leadingContent = {
+ AsyncImage(
+ model = userModel,
+ contentDescription = null,
+ modifier = Modifier
+ .clip(CircleShape)
+ .size(25.dp)
+ )
+ },
+ modifier = Modifier.clickable {
+ nav?.navigate(Routes.user(userData.name))
+ }
+ )
+
+ ListItem(
+ colors = listItemColors,
+ headlineContent = { Text("Open on Last.fm") },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.OpenInNew,
+ contentDescription = null
+ )
+ },
+ modifier = Modifier.clickable { ctx.startActivity(userIntent) }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/musicorum/mobile/views/home/Home.kt b/app/src/main/java/io/musicorum/mobile/views/home/Home.kt
index 98a3bde..498a3a5 100644
--- a/app/src/main/java/io/musicorum/mobile/views/home/Home.kt
+++ b/app/src/main/java/io/musicorum/mobile/views/home/Home.kt
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
@@ -29,6 +30,8 @@ import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -62,6 +65,7 @@ import io.musicorum.mobile.components.LabelType
import io.musicorum.mobile.components.RewindCard
import io.musicorum.mobile.components.skeletons.GenericCardPlaceholder
import io.musicorum.mobile.models.PartialUser
+import io.musicorum.mobile.router.BottomNavBar
import io.musicorum.mobile.router.Route
import io.musicorum.mobile.router.Routes
import io.musicorum.mobile.ui.theme.ContentSecondary
@@ -80,199 +84,222 @@ fun Home(vm: HomeViewModel = hiltViewModel()) {
val state by vm.state.collectAsState()
val nav = LocalNavigation.current
- SwipeRefresh(
- state = rememberSwipeRefreshState(state.isRefreshing),
- onRefresh = { vm.refresh() }) {
- Column(
- Modifier
- .verticalScroll(rememberScrollState())
- .background(KindaBlack)
- .fillMaxSize()
- .padding(top = 20.dp, bottom = 20.dp)
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = CenterVertically
+ Scaffold(
+ snackbarHost = { SnackbarHost(vm.snackbarHostState) },
+ bottomBar = { BottomNavBar() }) { pv ->
+ SwipeRefresh(
+ state = rememberSwipeRefreshState(state.isRefreshing),
+ modifier = Modifier.padding(pv),
+ onRefresh = { vm.refresh() }) {
+ Column(
+ Modifier
+ .safeDrawingPadding()
+ .verticalScroll(rememberScrollState())
+ .background(KindaBlack)
+ .fillMaxSize()
+ .padding(top = 20.dp, bottom = 20.dp)
) {
- Text(
- text = "Home",
- style = Typography.displaySmall,
- modifier = Modifier.padding(start = 20.dp)
- )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = CenterVertically
+ ) {
+ Text(
+ text = "Home",
+ style = Typography.displaySmall,
+ modifier = Modifier.padding(start = 20.dp)
+ )
- BadgedBox(
- modifier = Modifier
- .clip(CircleShape)
- .clickable { nav?.navigate(Routes.settings) }
- .padding(12.dp),
- badge = {
- if (state.showSettingsBade) Badge(containerColor = MostlyRed)
+ BadgedBox(
+ modifier = Modifier
+ .clip(CircleShape)
+ .clickable { nav?.navigate(Routes.settings) }
+ .padding(12.dp),
+ badge = {
+ if (state.showSettingsBade) Badge(containerColor = MostlyRed)
+ }
+ ) {
+ //IconButton(onClick = { nav.navigate("settings") }) {
+ Icon(Icons.Rounded.Settings, contentDescription = null)
+ //}
}
- ) {
- //IconButton(onClick = { nav.navigate("settings") }) {
- Icon(Icons.Rounded.Settings, contentDescription = null)
- //}
}
- }
- if (state.showRewindCard) {
- RewindCard(description = state.rewindCardMessage) {
- vm.launchRewind()
+ if (state.showRewindCard) {
+ RewindCard(description = state.rewindCardMessage) {
+ vm.launchRewind()
+ }
}
- }
- if (state.user != null && state.userPalette != null) {
- UserCard(state.user!!, state.userPalette!!, state.weeklyScrobbles)
- } else {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(150.dp)
- .padding(20.dp, 20.dp, 20.dp)
- .clip(RoundedCornerShape(15.dp))
- .placeholder(
- true,
- color = LighterGray,
- highlight = PlaceholderHighlight.shimmer()
- )
- )
- }
+ if (state.user != null && state.userPalette != null) {
+ UserCard(state.user!!, state.userPalette!!, state.weeklyScrobbles)
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(150.dp)
+ .padding(20.dp, 20.dp, 20.dp)
+ .clip(RoundedCornerShape(15.dp))
+ .placeholder(
+ true,
+ color = LighterGray,
+ highlight = PlaceholderHighlight.shimmer()
+ )
+ )
+ }
- Spacer(Modifier.height(20.dp))
+ Spacer(Modifier.height(20.dp))
- if (state.isOffline) {
- Box(
- modifier = Modifier
- .padding(horizontal = 20.dp)
- .fillMaxWidth()
- .border(1.dp, EvenLighterGray, RoundedCornerShape(12.dp))
- .padding(16.dp)
- ) {
- Row {
- Icon(
- Icons.Rounded.WifiOff,
- contentDescription = null,
- modifier = Modifier
- .align(CenterVertically)
- .size(30.dp)
- )
- Column(modifier = Modifier.padding(start = 22.dp)) {
- Text(
- stringResource(id = R.string.youre_offline),
- style = Typography.titleMedium
+ if (state.isOffline) {
+ Box(
+ modifier = Modifier
+ .padding(horizontal = 20.dp)
+ .fillMaxWidth()
+ .border(1.dp, EvenLighterGray, RoundedCornerShape(12.dp))
+ .padding(16.dp)
+ ) {
+ Row {
+ Icon(
+ Icons.Rounded.WifiOff,
+ contentDescription = null,
+ modifier = Modifier
+ .align(CenterVertically)
+ .size(30.dp)
+ )
+ Column(modifier = Modifier.padding(start = 22.dp)) {
+ Text(
+ stringResource(id = R.string.youre_offline),
+ style = Typography.titleMedium
+ )
+ Text(
+ stringResource(R.string.outdated_data_notice),
+ style = Typography.bodySmall
+ )
+ }
+ }
+ }
+ if (state.hasPendingScrobbles) {
+ Row(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) {
+ Icon(
+ Icons.Rounded.Error,
+ null,
+ tint = ContentSecondary,
+ modifier = Modifier.size(20.dp)
)
Text(
- stringResource(R.string.outdated_data_notice),
- style = Typography.bodySmall
+ stringResource(R.string.pending_scrobbles_notice),
+ style = Typography.labelSmall,
+ color = ContentSecondary,
+ modifier = Modifier
+ .align(CenterVertically)
+ .padding(start = 10.dp)
)
}
+ Spacer(Modifier.height(20.dp))
}
}
- if (state.hasPendingScrobbles) {
- Row(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) {
- Icon(
- Icons.Rounded.Error,
- null,
- tint = ContentSecondary,
- modifier = Modifier.size(20.dp)
- )
- Text(
- stringResource(R.string.pending_scrobbles_notice),
- style = Typography.labelSmall,
- color = ContentSecondary,
- modifier = Modifier
- .align(CenterVertically)
- .padding(start = 10.dp)
- )
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp)
+ .clickable { nav?.navigate("recentScrobbles") },
+ verticalAlignment = CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.recent_scrobbles),
+ style = Typography.headlineSmall,
+ modifier = Modifier.padding(start = 20.dp)
+ )
+ IconButton(onClick = { nav?.navigate("recentScrobbles") }) {
+ Icon(Icons.Rounded.ChevronRight, contentDescription = null)
}
- Spacer(Modifier.height(20.dp))
}
- }
-
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier
- .fillMaxWidth()
- .height(50.dp)
- .clickable { nav?.navigate("recentScrobbles") },
- verticalAlignment = CenterVertically
- ) {
- Text(
- text = stringResource(R.string.recent_scrobbles),
- style = Typography.headlineSmall,
- modifier = Modifier.padding(start = 20.dp)
+ Spacer(modifier = Modifier.height(10.dp))
+ HorizontalTracksRow(
+ tracks = state.recentTracks,
+ labelType = LabelType.DATE
)
- IconButton(onClick = { nav?.navigate("recentScrobbles") }) {
- Icon(Icons.Rounded.ChevronRight, contentDescription = null)
- }
- }
- Spacer(modifier = Modifier.height(10.dp))
- HorizontalTracksRow(
- tracks = state.recentTracks,
- labelType = LabelType.DATE
- )
- Spacer(Modifier.height(20.dp))
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier
- .fillMaxWidth()
- .height(50.dp)
- .clickable { nav?.navigate("mostListened") },
- verticalAlignment = CenterVertically
- ) {
- Text(
- text = stringResource(R.string.most_listened_week),
- style = Typography.headlineSmall,
- modifier = Modifier.padding(start = 20.dp)
- )
- IconButton(onClick = { nav?.navigate("mostListened") }) {
- Icon(Icons.Rounded.ChevronRight, contentDescription = null)
+ Spacer(Modifier.height(20.dp))
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp)
+ .clickable { nav?.navigate("mostListened") },
+ verticalAlignment = CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.most_listened_week),
+ style = Typography.headlineSmall,
+ modifier = Modifier.padding(start = 20.dp)
+ )
+ IconButton(onClick = { nav?.navigate(Routes.mostListened) }) {
+ Icon(Icons.Rounded.ChevronRight, contentDescription = null)
+ }
}
- }
- Spacer(modifier = Modifier.height(10.dp))
- HorizontalTracksRow(
- tracks = state.weekTracks,
- labelType = LabelType.ARTIST_NAME
- )
+ Spacer(modifier = Modifier.height(10.dp))
+ HorizontalTracksRow(
+ tracks = state.weekTracks,
+ labelType = LabelType.ARTIST_NAME
+ )
- Spacer(Modifier.height(20.dp))
- Text(
- text = stringResource(R.string.friends_activity),
- style = Typography.headlineSmall,
- modifier = Modifier.padding(start = 20.dp)
- )
- Spacer(modifier = Modifier.height(10.dp))
- Row(
- modifier = Modifier
- .padding(start = 20.dp)
- .fillMaxWidth()
- .horizontalScroll(rememberScrollState()),
- horizontalArrangement = Arrangement.spacedBy(10.dp)
- ) {
- if (state.isOffline) {
- Text(text = stringResource(R.string.youre_offline))
- } else {
- if (state.friendsActivity == null && state.friends == null) {
- if (state.hasError == true) {
- Text(
- text = stringResource(R.string.empty_friendlist_message),
- softWrap = true,
- style = Subtitle1
- )
- } else {
- GenericCardPlaceholder(visible = true)
- GenericCardPlaceholder(visible = true)
- GenericCardPlaceholder(visible = true)
- }
+ Spacer(Modifier.height(20.dp))
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp)
+ .clickable { nav?.navigate(Routes.friends) },
+ verticalAlignment = CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.friends_activity),
+ style = Typography.headlineSmall,
+ modifier = Modifier.padding(start = 20.dp)
+ )
+ IconButton(onClick = { nav?.navigate(Routes.friends) }) {
+ Icon(Icons.Rounded.ChevronRight, contentDescription = null)
+ }
+ }
+ Spacer(modifier = Modifier.height(10.dp))
+ Row(
+ modifier = Modifier
+ .padding(start = 20.dp)
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ if (state.isOffline) {
+ Text(text = stringResource(R.string.youre_offline))
} else {
- state.friendsActivity?.forEachIndexed { i, rt ->
- FriendActivity(
- track = rt.recentTracks.tracks[0],
- friendImageUrl = state.friends?.get(i)?.bestImageUrl,
- friendUsername = state.friends?.get(i)?.name
- )
+ if (state.friendsActivity == null && state.friends == null) {
+ if (state.hasError) {
+ Text(
+ text = stringResource(R.string.empty_friendlist_message),
+ softWrap = true,
+ style = Subtitle1
+ )
+ } else {
+ GenericCardPlaceholder(visible = true)
+ GenericCardPlaceholder(visible = true)
+ GenericCardPlaceholder(visible = true)
+ }
+ } else {
+ state.friendsActivity?.forEachIndexed { i, rt ->
+ FriendActivity(
+ isPinned = state.friends?.get(i)?.name in state.pinnedUsers,
+ track = rt.recentTracks.tracks[0],
+ friendImageUrl = state.friends?.get(i)?.bestImageUrl,
+ friendUsername = state.friends?.get(i)?.name,
+ onUnpin = {
+ vm.unpinUser(state.friends?.get(i)?.name)
+ state.user?.username?.let { vm.fetchFriends(it) }
+ }
+ )
+ }
}
}
}
diff --git a/app/src/main/java/io/musicorum/mobile/views/home/HomeState.kt b/app/src/main/java/io/musicorum/mobile/views/home/HomeState.kt
index 509fb31..5370e5e 100644
--- a/app/src/main/java/io/musicorum/mobile/views/home/HomeState.kt
+++ b/app/src/main/java/io/musicorum/mobile/views/home/HomeState.kt
@@ -20,5 +20,6 @@ data class HomeState(
val rewindCardMessage: String = "",
val showSettingsBade: Boolean = false,
val isOffline: Boolean = false,
- val friendsActivity: List? = null
+ val friendsActivity: List? = null,
+ val pinnedUsers: Set = emptySet()
)
diff --git a/app/src/main/java/io/musicorum/mobile/views/home/HomeViewModel.kt b/app/src/main/java/io/musicorum/mobile/views/home/HomeViewModel.kt
index 7350377..656650d 100644
--- a/app/src/main/java/io/musicorum/mobile/views/home/HomeViewModel.kt
+++ b/app/src/main/java/io/musicorum/mobile/views/home/HomeViewModel.kt
@@ -5,6 +5,8 @@ import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
+import androidx.compose.material3.SnackbarHostState
+import androidx.datastore.preferences.core.edit
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.crashlytics.ktx.crashlytics
@@ -13,6 +15,7 @@ import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import dagger.hilt.android.lifecycle.HiltViewModel
import io.musicorum.mobile.database.CachedScrobblesDb
import io.musicorum.mobile.database.PendingScrobblesDb
+import io.musicorum.mobile.datastore.UserData
import io.musicorum.mobile.ktor.endpoints.UserEndpoint
import io.musicorum.mobile.ktor.endpoints.musicorum.MusicorumTrackEndpoint
import io.musicorum.mobile.models.CachedScrobble
@@ -24,15 +27,18 @@ import io.musicorum.mobile.repositories.ScrobbleRepository
import io.musicorum.mobile.serialization.Image
import io.musicorum.mobile.serialization.RecentTracks
import io.musicorum.mobile.serialization.entities.Track
+import io.musicorum.mobile.userData
import io.musicorum.mobile.utils.createPalette
import io.musicorum.mobile.utils.getBitmap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.net.UnknownHostException
import java.time.Instant
import javax.inject.Inject
+import io.musicorum.mobile.serialization.UserData as LastFmUser
@HiltViewModel
class HomeViewModel @Inject constructor(
@@ -44,7 +50,7 @@ class HomeViewModel @Inject constructor(
val ctx = application as Context
private val remoteConfig = FirebaseRemoteConfig.getInstance()
val state = MutableStateFlow(HomeState())
-
+ val snackbarHostState = SnackbarHostState()
fun refresh() {
state.update {
@@ -65,71 +71,92 @@ class HomeViewModel @Inject constructor(
ctx.startActivity(intent)
}
- private fun fetchRecentTracks(username: String, from: String?) {
+ fun unpinUser(username: String?) {
viewModelScope.launch {
- val cachedDao = CachedScrobblesDb.getDatabase(ctx).cachedScrobblesDao()
- val cacheRepository = CachedScrobblesRepository(cachedDao)
- val res = kotlin.runCatching {
- val res = UserEndpoint.getRecentTracks(username, from, 15, true)
- state.update { state ->
- state.copy(isOffline = false)
- }
- if (res != null) {
- val musRes = MusicorumTrackEndpoint.fetchTracks(res.recentTracks.tracks)
- res.recentTracks.tracks.onEachIndexed { index, track ->
- track.bestImageUrl =
- musRes.getOrNull(index)?.bestResource?.bestImageUrl
- ?: return@onEachIndexed
- }
- scrobbleRepository.recentScrobbles.value = res
- state.update {
- it.copy(
- recentTracks = scrobbleRepository.recentScrobbles.value?.recentTracks?.tracks,
- weeklyScrobbles =
- res.recentTracks.recentTracksAttributes.total.toInt(),
- isRefreshing = false
- )
+ val pinned = ctx.userData.data.map {
+ it[UserData.PINNED_USERS] ?: emptySet()
+ }.first()
+ val newSet = pinned.toMutableSet()
+ newSet.remove(username)
+ ctx.userData.edit {
+ it[UserData.PINNED_USERS] = newSet.toSet()
+ }
+ val currentPinned = state.value.pinnedUsers.toMutableSet()
+ currentPinned.remove(username)
+ state.update {
+ it.copy(pinnedUsers = currentPinned.toSet())
+ }
+ snackbarHostState.showSnackbar("$username unpinned")
+ }
+ }
+
+ private fun fetchRecentTracks(username: String, from: String?) {
+ runCatching {
+ viewModelScope.launch {
+ val cachedDao = CachedScrobblesDb.getDatabase(ctx).cachedScrobblesDao()
+ val cacheRepository = CachedScrobblesRepository(cachedDao)
+ val res = kotlin.runCatching {
+ val res = UserEndpoint.getRecentTracks(username, from, 15, true)
+ state.update { state ->
+ state.copy(isOffline = false)
}
- cacheRepository.deleteAll()
- state.value.recentTracks?.take(10)?.forEach {
- cacheRepository.insert(
- CachedScrobble(
- trackName = it.name,
- artistName = it.artist.name,
- scrobbleDate = it.date?.uts?.toLong() ?: 0,
- imageUrl = it.bestImageUrl,
- isTopTrack = false
+ if (res != null) {
+ val musRes = MusicorumTrackEndpoint.fetchTracks(res.recentTracks.tracks)
+ res.recentTracks.tracks.onEachIndexed { index, track ->
+ track.bestImageUrl =
+ musRes.getOrNull(index)?.bestResource?.bestImageUrl
+ ?: return@onEachIndexed
+ }
+ scrobbleRepository.recentScrobbles.value = res
+ state.update {
+ it.copy(
+ recentTracks = scrobbleRepository.recentScrobbles.value?.recentTracks?.tracks,
+ weeklyScrobbles =
+ res.recentTracks.recentTracksAttributes.total.toInt(),
+ isRefreshing = false
)
- )
+ }
+ cacheRepository.deleteAll()
+ state.value.recentTracks?.take(10)?.forEach {
+ cacheRepository.insert(
+ CachedScrobble(
+ trackName = it.name,
+ artistName = it.artist.name,
+ scrobbleDate = it.date?.uts?.toLong() ?: 0,
+ imageUrl = it.bestImageUrl,
+ isTopTrack = false
+ )
+ )
+ }
}
}
- }
- if (res.exceptionOrNull() is UnknownHostException) {
- val pendingDao = PendingScrobblesDb.getDatabase(ctx).pendingScrobblesDao()
- val pendingRepo = PendingScrobblesRepository(pendingDao)
- val pendingScrobbles = pendingRepo.getAllScrobblesStream().first()
- state.update {
- it.copy(isOffline = true)
- }
- val list = mutableListOf