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.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.prepareSharedViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -69,8 +70,7 @@ class MainActivity : AppCompatActivity() {
Log.d("Lifetime Event", "MainActivity.onCreate") Log.d("Lifetime Event", "MainActivity.onCreate")
setContent { setContent {
val sharedPreferencesViewModel = prepareSharedViewModel(act = this) val sharedPreferencesViewModel = prepareSharedViewModel()
AmethystTheme(sharedPreferencesViewModel) { AmethystTheme(sharedPreferencesViewModel) {
val accountStateViewModel: AccountStateViewModel = viewModel() val accountStateViewModel: AccountStateViewModel = viewModel()
@@ -24,24 +24,16 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import com.vitorpamplona.amethyst.R 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.loggedIn.LoggedInPage
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -56,61 +48,54 @@ fun AccountScreen(
Crossfade( Crossfade(
targetState = accountState, targetState = accountState,
animationSpec = tween(durationMillis = 100), animationSpec = tween(durationMillis = 100),
label = "AccountState",
) { state -> ) { state ->
when (state) { when (state) {
is AccountState.Loading -> { is AccountState.Loading -> LoadingSetup()
// A surface container using the 'background' color from the theme is AccountState.LoggedOff -> LoggedOffSetup(accountStateViewModel)
Surface( is AccountState.LoggedIn -> LoggedInSetup(state, accountStateViewModel, sharedPreferencesViewModel)
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()
}
}
}
} }
} }
} }
class AccountCentricViewModelStore( @Composable
val accountSettings: AccountSettings, fun LoadingSetup() {
) : ViewModelStoreOwner { // A surface container using the 'background' color from the theme
override val viewModelStore = ViewModelStore() Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(stringRes(R.string.loading_account))
}
}
} }
@Composable @Composable
fun LoadingAccounts() { fun LoggedOffSetup(accountStateViewModel: AccountStateViewModel) {
Column( Surface(
Modifier.fillMaxHeight().fillMaxWidth(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, color = MaterialTheme.colorScheme.background,
verticalArrangement = Arrangement.Center,
) { ) {
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 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.compose.runtime.Stable
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
sealed class AccountState { sealed class AccountState {
@@ -33,6 +39,28 @@ sealed class AccountState {
val accountSettings: AccountSettings, val accountSettings: AccountSettings,
var route: String? = null, var route: String? = null,
) : AccountState() { ) : 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 * 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. * 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.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
@@ -36,33 +35,39 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.adaptive.calculateDisplayFeatures 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.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable @Composable
fun prepareSharedViewModel(act: Activity): SharedPreferencesViewModel { fun prepareSharedViewModel(): SharedPreferencesViewModel {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
val displayFeatures = calculateDisplayFeatures(act)
val windowSizeClass = calculateWindowSizeClass(act)
LaunchedEffect(key1 = sharedPreferencesViewModel) { LaunchedEffect(key1 = sharedPreferencesViewModel) {
sharedPreferencesViewModel.init() sharedPreferencesViewModel.init()
} }
LaunchedEffect(sharedPreferencesViewModel, displayFeatures, windowSizeClass) { MonitorDisplaySize(sharedPreferencesViewModel)
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
ManageConnectivity(sharedPreferencesViewModel) ManageConnectivity(sharedPreferencesViewModel)
return 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 @Composable
fun ManageConnectivity(sharedPreferencesViewModel: SharedPreferencesViewModel) { fun ManageConnectivity(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val context = LocalContext.current val context = LocalContext.current
@@ -25,10 +25,6 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable 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.core.os.LocaleListCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -44,78 +40,13 @@ import com.vitorpamplona.amethyst.model.ThemeType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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 @Stable
class SharedPreferencesViewModel : ViewModel() { class SharedPreferencesViewModel : ViewModel() {
val sharedPrefs: SettingsState = SettingsState() val sharedPrefs: SharedSettingsState = SharedSettingsState()
fun init() { fun init() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
Log.d("SharedPreferencesViewModel", "init")
val savedSettings = val savedSettings =
LocalPreferences.loadSharedSettings() ?: Settings() LocalPreferences.loadSharedSettings() ?: Settings()
@@ -252,6 +183,7 @@ class SharedPreferencesViewModel : ViewModel() {
fun saveSharedSettings() { fun saveSharedSettings() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
Log.d("SharedPreferencesViewModel", "Saving Shared Settings")
LocalPreferences.saveSharedSettings( LocalPreferences.saveSharedSettings(
Settings( Settings(
sharedPrefs.theme, 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.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger 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.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.CardFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -140,7 +140,7 @@ import kotlinx.coroutines.withContext
@Stable @Stable
class AccountViewModel( class AccountViewModel(
accountSettings: AccountSettings, accountSettings: AccountSettings,
val settings: SettingsState, val settings: SharedSettingsState,
) : ViewModel(), ) : ViewModel(),
Dao { Dao {
val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope) val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope)
@@ -1281,7 +1281,7 @@ class AccountViewModel(
class Factory( class Factory(
val accountSettings: AccountSettings, val accountSettings: AccountSettings,
val settings: SettingsState, val settings: SharedSettingsState,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel = AccountViewModel(accountSettings, settings) as AccountViewModel override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel = AccountViewModel(accountSettings, settings) as AccountViewModel
} }