feat: add settings screen #47

Merged
reya merged 6 commits from feat/settings into master 2026-07-20 03:30:27 +00:00
7 changed files with 93 additions and 23 deletions
Showing only changes of commit 6bb4ef2805 - Show all commits

View File

@@ -39,6 +39,7 @@ import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen import su.reya.coop.screens.ImportScreen
@@ -56,11 +57,16 @@ import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel import su.reya.coop.viewmodel.ChatScreenViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.ProfileCache import su.reya.coop.viewmodel.ProfileCache
import su.reya.coop.viewmodel.SettingsViewModel
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> { val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
error("No ProfileCache provided") error("No ProfileCache provided")
} }
val LocalSettings = staticCompositionLocalOf<Settings> {
error("No Settings provided")
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> { val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided") error("No SnackbarHostState provided")
} }
@@ -79,6 +85,7 @@ fun App(
profileCache: ProfileCache, profileCache: ProfileCache,
accountRepository: AccountRepository, accountRepository: AccountRepository,
chatRepository: ChatRepository, chatRepository: ChatRepository,
settingsRepository: SettingsRepository,
) { ) {
val viewModelFactory = remember { val viewModelFactory = remember {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
@@ -92,6 +99,10 @@ fun App(
accountRepository accountRepository
) )
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
settingsRepository
)
else -> throw IllegalArgumentException("Unknown ViewModel class") else -> throw IllegalArgumentException("Unknown ViewModel class")
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -102,6 +113,7 @@ fun App(
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory) val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory) val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
val context = LocalContext.current val context = LocalContext.current
val activity = context as? ComponentActivity val activity = context as? ComponentActivity
@@ -113,19 +125,24 @@ fun App(
val accountState by accountViewModel.state.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val signerRequired = accountState.signerRequired val signerRequired = accountState.signerRequired
// Get the settings
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Check if dark theme enabled // Check if dark theme enabled
val darkMode = isSystemInDarkTheme() val darkMode = when (settings.theme) {
Theme.Light -> false
Theme.Dark -> true
Theme.System -> isSystemInDarkTheme()
}
// Enabled the dynamic color scheme // Enabled the dynamic color scheme
val colorScheme = when { val colorScheme = when {
// Enable the dynamic color scheme for Android 12+ // Enable the dynamic color scheme for Android 12+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme( if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
context
)
} }
// When dark mode is enabled, use the dark color scheme // When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme() darkMode -> darkColorScheme()
@@ -190,11 +207,11 @@ fun App(
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalProfileCache provides profileCache, LocalProfileCache provides profileCache,
LocalSettings provides settings,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator, LocalNavigator provides navigator,
LocalScanResult provides qrScanResult, LocalScanResult provides qrScanResult,
) { ) {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
onBack = { onBack = {

View File

@@ -14,6 +14,7 @@ import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.MediaRepository import su.reya.coop.repository.MediaRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.viewmodel.ProfileCache import su.reya.coop.viewmodel.ProfileCache
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -25,16 +26,28 @@ class MainActivity : ComponentActivity() {
private val profileCache by lazy { ProfileCache(NostrManager.instance) } private val profileCache by lazy { ProfileCache(NostrManager.instance) }
private val scope = MainScope() private val scope = MainScope()
private val settingsRepository by lazy {
val storage = AppStore(this@MainActivity)
SettingsRepository(storage, scope)
}
private val accountRepository by lazy { private val accountRepository by lazy {
val storage = AppStore(this@MainActivity) val storage = AppStore(this@MainActivity)
val mediaRepository = MediaRepository() val mediaRepository = MediaRepository(settingsRepository)
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
AccountRepository(NostrManager.instance, storage, mediaRepository, scope, androidSigner) AccountRepository(
NostrManager.instance,
storage,
mediaRepository,
settingsRepository,
scope,
androidSigner
)
} }
private val chatRepository by lazy { private val chatRepository by lazy {
val mediaRepository = MediaRepository() val mediaRepository = MediaRepository(settingsRepository)
ChatRepository(NostrManager.instance, mediaRepository, scope) ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -82,6 +95,7 @@ class MainActivity : ComponentActivity() {
profileCache = profileCache, profileCache = profileCache,
accountRepository = accountRepository, accountRepository = accountRepository,
chatRepository = chatRepository, chatRepository = chatRepository,
settingsRepository = settingsRepository,
) )
} }
} }

View File

@@ -57,6 +57,7 @@ class AccountRepository(
private val nostr: Nostr, private val nostr: Nostr,
private val storage: AppStorage, private val storage: AppStorage,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val settingsRepository: SettingsRepository,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val externalSignerHandler: ExternalSignerHandler? = null, private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
@@ -75,9 +76,7 @@ class AccountRepository(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey -> .flatMapLatest { if (it != null) currentUserProfileFlow(it) else flowOf(null) }
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
}
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null) .stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
init { init {

View File

@@ -36,6 +36,7 @@ data class ChatState(
class ChatRepository( class ChatRepository(
private val nostr: Nostr, private val nostr: Nostr,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val settingsRepository: SettingsRepository,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() { ) : ErrorHost by createErrorHost() {

View File

@@ -8,7 +8,9 @@ import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.blossom.BlossomClient import su.reya.coop.blossom.BlossomClient
class MediaRepository { class MediaRepository(
private val settingsRepository: SettingsRepository
) {
private val httpClient = HttpClient { private val httpClient = HttpClient {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
@@ -25,12 +27,9 @@ class MediaRepository {
contentType: String? = "image/jpeg" contentType: String? = "image/jpeg"
): String? { ): String? {
return try { return try {
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient) val url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band"
val descriptor = blossom.upload( val blossom = BlossomClient(url, httpClient)
file = file, val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer)
contentType = contentType,
signer = signer,
)
descriptor?.url descriptor?.url
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e

View File

@@ -1,12 +1,19 @@
package su.reya.coop.repository package su.reya.coop.repository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import su.reya.coop.AppStorage import su.reya.coop.AppStorage
import su.reya.coop.Settings import su.reya.coop.Settings
class SettingsRepository( class SettingsRepository(
private val storage: AppStorage private val storage: AppStorage,
private val scope: CoroutineScope
) { ) {
companion object { companion object {
private const val KEY_SETTINGS = "app_settings" private const val KEY_SETTINGS = "app_settings"
@@ -17,12 +24,29 @@ class SettingsRepository(
encodeDefaults = true encodeDefaults = true
} }
suspend fun save(settings: Settings) { private val _settings = MutableStateFlow(Settings())
val settings: StateFlow<Settings> = _settings.asStateFlow()
init {
scope.launch {
_settings.value = load()
}
}
fun update(transform: (Settings) -> Settings) {
scope.launch {
val newSettings = transform(_settings.value)
_settings.value = newSettings
save(newSettings)
}
}
private suspend fun save(settings: Settings) {
val jsonString = json.encodeToString(settings) val jsonString = json.encodeToString(settings)
storage.set(KEY_SETTINGS, jsonString) storage.set(KEY_SETTINGS, jsonString)
} }
suspend fun load(): Settings { private suspend fun load(): Settings {
val jsonString = storage.get(KEY_SETTINGS) val jsonString = storage.get(KEY_SETTINGS)
return if (jsonString != null) { return if (jsonString != null) {
try { try {

View File

@@ -0,0 +1,16 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import su.reya.coop.Settings
import su.reya.coop.repository.SettingsRepository
class SettingsViewModel(
private val repository: SettingsRepository
) : ViewModel() {
val settings: StateFlow<Settings> = repository.settings
fun update(transform: (Settings) -> Settings) {
repository.update(transform)
}
}