Refactors SharedSettings state to:

- Minimize recompositions
- Improve code quality
This commit is contained in:
Vitor Pamplona
2025-04-01 18:59:53 -04:00
parent 1bd32c854c
commit 834efe02bc
7 changed files with 193 additions and 143 deletions
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.prepareSharedViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -69,8 +70,7 @@ class MainActivity : AppCompatActivity() {
Log.d("Lifetime Event", "MainActivity.onCreate")
setContent {
val sharedPreferencesViewModel = prepareSharedViewModel(act = this)
val sharedPreferencesViewModel = prepareSharedViewModel()
AmethystTheme(sharedPreferencesViewModel) {
val accountStateViewModel: AccountStateViewModel = viewModel()
@@ -24,24 +24,16 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
import com.vitorpamplona.amethyst.ui.stringRes
@@ -56,61 +48,54 @@ fun AccountScreen(
Crossfade(
targetState = accountState,
animationSpec = tween(durationMillis = 100),
label = "AccountState",
) { state ->
when (state) {
is AccountState.Loading -> {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
LoadingAccounts()
}
}
is AccountState.LoggedOff -> { // A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
LoginOrSignupScreen(null, accountStateViewModel, isFirstLogin = true)
}
}
is AccountState.LoggedIn -> {
CompositionLocalProvider(
LocalViewModelStoreOwner provides state.currentViewModelStore,
) {
LoggedInPage(
state.accountSettings,
state.route,
accountStateViewModel,
sharedPreferencesViewModel,
)
}
DisposableEffect(key1 = accountState) {
onDispose {
state.currentViewModelStore.viewModelStore.clear()
}
}
}
is AccountState.Loading -> LoadingSetup()
is AccountState.LoggedOff -> LoggedOffSetup(accountStateViewModel)
is AccountState.LoggedIn -> LoggedInSetup(state, accountStateViewModel, sharedPreferencesViewModel)
}
}
}
class AccountCentricViewModelStore(
val accountSettings: AccountSettings,
) : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
@Composable
fun LoadingSetup() {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(stringRes(R.string.loading_account))
}
}
}
@Composable
fun LoadingAccounts() {
Column(
Modifier.fillMaxHeight().fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
fun LoggedOffSetup(accountStateViewModel: AccountStateViewModel) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Text(stringRes(R.string.loading_account))
LoginOrSignupScreen(null, accountStateViewModel, isFirstLogin = true)
}
}
@Composable
fun LoggedInSetup(
state: AccountState.LoggedIn,
accountStateViewModel: AccountStateViewModel,
sharedPreferencesViewModel: SharedPreferencesViewModel,
) {
SetAccountCentricViewModelStore(state) {
LoggedInPage(
state.accountSettings,
state.route,
accountStateViewModel,
sharedPreferencesViewModel,
)
}
}
@@ -20,7 +20,13 @@
*/
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import com.vitorpamplona.amethyst.model.AccountSettings
sealed class AccountState {
@@ -33,6 +39,28 @@ sealed class AccountState {
val accountSettings: AccountSettings,
var route: String? = null,
) : AccountState() {
val currentViewModelStore = AccountCentricViewModelStore(accountSettings)
val currentViewModelStore = AccountCentricViewModelStore()
}
}
@Composable
fun SetAccountCentricViewModelStore(
state: AccountState.LoggedIn,
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalViewModelStoreOwner provides state.currentViewModelStore,
) {
content()
}
DisposableEffect(key1 = state) {
onDispose {
state.currentViewModelStore.viewModelStore.clear()
}
}
}
class AccountCentricViewModelStore : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
}
@@ -18,9 +18,8 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui
package com.vitorpamplona.amethyst.ui.screen
import android.app.Activity
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
@@ -36,33 +35,39 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.components.getActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun prepareSharedViewModel(act: Activity): SharedPreferencesViewModel {
fun prepareSharedViewModel(): SharedPreferencesViewModel {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
val displayFeatures = calculateDisplayFeatures(act)
val windowSizeClass = calculateWindowSizeClass(act)
LaunchedEffect(key1 = sharedPreferencesViewModel) {
sharedPreferencesViewModel.init()
}
LaunchedEffect(sharedPreferencesViewModel, displayFeatures, windowSizeClass) {
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
MonitorDisplaySize(sharedPreferencesViewModel)
ManageConnectivity(sharedPreferencesViewModel)
return sharedPreferencesViewModel
}
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun MonitorDisplaySize(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val act = LocalContext.current.getActivity()
val displayFeatures = calculateDisplayFeatures(act)
val windowSizeClass = calculateWindowSizeClass(act)
LaunchedEffect(sharedPreferencesViewModel, displayFeatures, windowSizeClass) {
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
}
@Composable
fun ManageConnectivity(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val context = LocalContext.current
@@ -25,10 +25,6 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -44,78 +40,13 @@ import com.vitorpamplona.amethyst.model.ThemeType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Stable
class SettingsState {
var theme by mutableStateOf(ThemeType.SYSTEM)
var language by mutableStateOf<String?>(null)
var automaticallyShowImages by mutableStateOf(ConnectivityType.ALWAYS)
var automaticallyStartPlayback by mutableStateOf(ConnectivityType.ALWAYS)
var automaticallyShowUrlPreview by mutableStateOf(ConnectivityType.ALWAYS)
var automaticallyHideNavigationBars by mutableStateOf(BooleanType.ALWAYS)
var automaticallyShowProfilePictures by mutableStateOf(ConnectivityType.ALWAYS)
var dontShowPushNotificationSelector by mutableStateOf<Boolean>(false)
var dontAskForNotificationPermissions by mutableStateOf<Boolean>(false)
var featureSet by mutableStateOf(FeatureSetType.SIMPLIFIED)
var gallerySet by mutableStateOf(ProfileGalleryType.CLASSIC)
var isOnMobileOrMeteredConnection by mutableStateOf(false)
var currentNetworkId by mutableStateOf(0L)
var windowSizeClass = mutableStateOf<WindowSizeClass?>(null)
var displayFeatures = mutableStateOf<List<DisplayFeature>>(emptyList())
val showProfilePictures =
derivedStateOf {
when (automaticallyShowProfilePictures) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
val modernGalleryStyle =
derivedStateOf {
when (gallerySet) {
ProfileGalleryType.CLASSIC -> false
ProfileGalleryType.MODERN -> true
}
}
val showUrlPreview =
derivedStateOf {
when (automaticallyShowUrlPreview) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
val startVideoPlayback =
derivedStateOf {
when (automaticallyStartPlayback) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
val showImages =
derivedStateOf {
when (automaticallyShowImages) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
}
@Stable
class SharedPreferencesViewModel : ViewModel() {
val sharedPrefs: SettingsState = SettingsState()
val sharedPrefs: SharedSettingsState = SharedSettingsState()
fun init() {
viewModelScope.launch(Dispatchers.IO) {
Log.d("SharedPreferencesViewModel", "init")
val savedSettings =
LocalPreferences.loadSharedSettings() ?: Settings()
@@ -252,6 +183,7 @@ class SharedPreferencesViewModel : ViewModel() {
fun saveSharedSettings() {
viewModelScope.launch(Dispatchers.IO) {
Log.d("SharedPreferencesViewModel", "Saving Shared Settings")
LocalPreferences.saveSharedSettings(
Settings(
sharedPrefs.theme,
@@ -0,0 +1,100 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.window.layout.DisplayFeature
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.ThemeType
@Stable
class SharedSettingsState {
var theme by mutableStateOf(ThemeType.SYSTEM)
var language by mutableStateOf<String?>(null)
var automaticallyShowImages by mutableStateOf(ConnectivityType.ALWAYS)
var automaticallyStartPlayback by mutableStateOf(ConnectivityType.ALWAYS)
var automaticallyShowUrlPreview by mutableStateOf(ConnectivityType.ALWAYS)
var automaticallyHideNavigationBars by mutableStateOf(BooleanType.ALWAYS)
var automaticallyShowProfilePictures by mutableStateOf(ConnectivityType.ALWAYS)
var dontShowPushNotificationSelector by mutableStateOf<Boolean>(false)
var dontAskForNotificationPermissions by mutableStateOf<Boolean>(false)
var featureSet by mutableStateOf(FeatureSetType.SIMPLIFIED)
var gallerySet by mutableStateOf(ProfileGalleryType.CLASSIC)
var isOnMobileOrMeteredConnection by mutableStateOf(false)
var currentNetworkId by mutableStateOf(0L)
var windowSizeClass = mutableStateOf<WindowSizeClass?>(null)
var displayFeatures = mutableStateOf<List<DisplayFeature>>(emptyList())
val showProfilePictures =
derivedStateOf {
when (automaticallyShowProfilePictures) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
val modernGalleryStyle =
derivedStateOf {
when (gallerySet) {
ProfileGalleryType.CLASSIC -> false
ProfileGalleryType.MODERN -> true
}
}
val showUrlPreview =
derivedStateOf {
when (automaticallyShowUrlPreview) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
val startVideoPlayback =
derivedStateOf {
when (automaticallyStartPlayback) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
val showImages =
derivedStateOf {
when (automaticallyShowImages) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
}
@@ -67,8 +67,8 @@ import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.SettingsState
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedSettingsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.stringRes
@@ -140,7 +140,7 @@ import kotlinx.coroutines.withContext
@Stable
class AccountViewModel(
accountSettings: AccountSettings,
val settings: SettingsState,
val settings: SharedSettingsState,
) : ViewModel(),
Dao {
val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope)
@@ -1281,7 +1281,7 @@ class AccountViewModel(
class Factory(
val accountSettings: AccountSettings,
val settings: SettingsState,
val settings: SharedSettingsState,
) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel = AccountViewModel(accountSettings, settings) as AccountViewModel
}