From 255c840847c5748d1d12aad26369faa172b78159 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 18 Jul 2026 16:11:34 +0700 Subject: [PATCH 1/6] clean up --- .gitignore | 1 + .../su/reya/coop/screens/ContactListScreen.kt | 3 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 8 +-- .../su/reya/coop/screens/ImportScreen.kt | 7 --- .../su/reya/coop/screens/NewChatScreen.kt | 3 +- .../su/reya/coop/screens/OnboardingScreen.kt | 7 --- .../su/reya/coop/screens/RelayScreen.kt | 5 +- .../reya/coop/repository/AccountRepository.kt | 56 ++++++++----------- .../reya/coop/viewmodel/AccountViewModel.kt | 6 -- 9 files changed, 36 insertions(+), 60 deletions(-) diff --git a/.gitignore b/.gitignore index adfa9bf..5edbcd0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ captures !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings node_modules/ +.artifacts/ \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt index 175285a..d0eda89 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -73,7 +73,8 @@ fun ContactListScreen( ) { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val contactList by accountViewModel.contactList.collectAsStateWithLifecycle() + val accountState by accountViewModel.state.collectAsStateWithLifecycle() + val contactList = accountState.contactList var openAddContactDialog by remember { mutableStateOf(false) } var contactToDelete by remember { mutableStateOf(null) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 1e97291..400c708 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -126,13 +126,13 @@ fun HomeScreen( val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() - val isRelayListEmpty by accountViewModel.isRelayListEmpty.collectAsStateWithLifecycle() + val accountState by accountViewModel.state.collectAsStateWithLifecycle() + val isRelayListEmpty = accountState.isRelayListEmpty + val isBannerDismissed = accountState.isNotificationBannerDismissed + val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() - val accountState by accountViewModel.state.collectAsStateWithLifecycle() - val isBannerDismissed = accountState.isNotificationBannerDismissed - val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } var showBottomSheet by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index 70052d3..9aa39a9 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -101,13 +101,6 @@ fun ImportScreen(viewModel: AccountViewModel) { } } - // Show import errors via snackbar - LaunchedEffect(accountState.importError) { - accountState.importError?.let { - snackbarHostState.showSnackbar(it) - } - } - Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index 4ded00f..71f535f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -75,7 +75,8 @@ fun NewChatScreen( val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current - val contactList by accountViewModel.contactList.collectAsStateWithLifecycle() + val accountState by accountViewModel.state.collectAsStateWithLifecycle() + val contactList = accountState.contactList var query by remember { mutableStateOf("") } val createGroup = remember { mutableStateOf(false) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt index 0726066..d8aeba0 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -70,13 +70,6 @@ fun OnboardingScreen(viewModel: AccountViewModel) { } } - // Show connection errors - LaunchedEffect(accountState.importError) { - accountState.importError?.let { - snackbarHostState.showSnackbar(it) - } - } - val logoPainter = painterResource(Res.drawable.coop) val expressiveFont = getExpressiveFontFamily() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt index 036008a..5048959 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt @@ -100,8 +100,9 @@ fun RelayScreen(viewModel: AccountViewModel) { viewModel.loadCurrentUserMsgRelayList() } - val loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle() - val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle() + val accountState by viewModel.state.collectAsStateWithLifecycle() + val loadedRelayList = accountState.userRelayList + val loadedMsgRelayList = accountState.userMsgRelayList LaunchedEffect(loadedRelayList) { if (loadedRelayList.isNotEmpty()) { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt index f92956f..c0f5cb9 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt @@ -47,7 +47,10 @@ data class AccountState( val signerRequired: Boolean? = null, val isNotificationBannerDismissed: Boolean = false, val isImporting: Boolean = false, - val importError: String? = null, + val isRelayListEmpty: Boolean = false, + val contactList: Set = emptySet(), + val userRelayList: Map = emptyMap(), + val userMsgRelayList: List = emptyList(), ) class AccountRepository( @@ -77,18 +80,6 @@ class AccountRepository( } .stateIn(scope, SharingStarted.WhileSubscribed(5000), null) - private val _contactList = MutableStateFlow>(emptySet()) - val contactList: StateFlow> = _contactList.asStateFlow() - - private val _isRelayListEmpty = MutableStateFlow(false) - val isRelayListEmpty: StateFlow = _isRelayListEmpty.asStateFlow() - - private val _userRelayList = MutableStateFlow>(emptyMap()) - val userRelayList: StateFlow> = _userRelayList.asStateFlow() - - private val _userMsgRelayList = MutableStateFlow>(emptyList()) - val userMsgRelayList: StateFlow> = _userMsgRelayList.asStateFlow() - init { checkNotificationBannerDismissedStatus() login() @@ -116,7 +107,7 @@ class AccountRepository( } ?: emptyList() // Automatically update the warning state - _isRelayListEmpty.value = relays.isEmpty() + _state.update { it.copy(isRelayListEmpty = relays.isEmpty()) } } } } @@ -229,7 +220,7 @@ class AccountRepository( fun importIdentity(secret: String, password: String? = null) { scope.launch { - _state.update { it.copy(isImporting = true, importError = null) } + _state.update { it.copy(isImporting = true) } try { val (signer, decryptedSecret) = createSigner(secret, password) @@ -239,14 +230,14 @@ class AccountRepository( _state.update { it.copy(signerRequired = false, isImporting = false) } } catch (e: Exception) { showError("Import failed: ${e.message}") - _state.update { it.copy(isImporting = false, importError = e.message) } + _state.update { it.copy(isImporting = false) } } } } fun connectExternalSigner() { scope.launch { - _state.update { it.copy(isImporting = true, importError = null) } + _state.update { it.copy(isImporting = true) } try { val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") @@ -276,7 +267,7 @@ class AccountRepository( _state.update { it.copy(signerRequired = false, isImporting = false) } } catch (e: Exception) { showError("External signer connection failed: ${e.message}") - _state.update { it.copy(isImporting = false, importError = e.message) } + _state.update { it.copy(isImporting = false) } } } } @@ -288,7 +279,7 @@ class AccountRepository( contentType: String? = null ) { scope.launch { - _state.update { it.copy(isImporting = true, importError = null) } + _state.update { it.copy(isImporting = true) } try { val keys = Keys.generate() val secret = keys.secretKey().toBech32() @@ -307,7 +298,7 @@ class AccountRepository( _state.update { it.copy(signerRequired = false, isImporting = false) } } catch (e: Exception) { showError("Identity creation failed: ${e.message}") - _state.update { it.copy(isImporting = false, importError = e.message) } + _state.update { it.copy(isImporting = false) } } } } @@ -355,14 +346,13 @@ class AccountRepository( scope.launch { nostr.waitUntilInitialized() nostr.profiles.contactListUpdates.collect { contacts -> - _contactList.value = contacts.toSet() + _state.update { it.copy(contactList = contacts.toSet()) } } } } fun resetInternalState() { - _contactList.value = emptySet() - _isRelayListEmpty.value = false + _state.update { it.copy(contactList = emptySet(), isRelayListEmpty = false) } } fun addContact(address: String) { @@ -378,12 +368,12 @@ class AccountRepository( return@launch } - if (pubkey in _contactList.value) return@launch + if (pubkey in _state.value.contactList) return@launch try { - val updated = _contactList.value + pubkey + val updated = _state.value.contactList + pubkey nostr.profiles.setContactList(updated.toList()) - _contactList.update { it + pubkey } + _state.update { it.copy(contactList = it.contactList + pubkey) } } catch (e: Exception) { showError("Error: ${e.message}") } @@ -392,12 +382,12 @@ class AccountRepository( fun removeContact(publicKey: PublicKey) { scope.launch { - if (publicKey !in _contactList.value) return@launch + if (publicKey !in _state.value.contactList) return@launch try { - val updated = _contactList.value - publicKey + val updated = _state.value.contactList - publicKey nostr.profiles.setContactList(updated.toList()) - _contactList.update { it - publicKey } + _state.update { it.copy(contactList = it.contactList - publicKey) } } catch (e: Exception) { showError("Error: ${e.message}") } @@ -460,7 +450,7 @@ class AccountRepository( } fun dismissRelayWarning() { - _isRelayListEmpty.value = false + _state.update { it.copy(isRelayListEmpty = false) } } fun refetchMsgRelays() { @@ -487,7 +477,8 @@ class AccountRepository( scope.launch { try { val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - _userRelayList.value = nostr.relays.getRelayList(user) + val relayList = nostr.relays.getRelayList(user) + _state.update { it.copy(userRelayList = relayList) } } catch (e: Exception) { showError("Error: ${e.message}") } @@ -550,7 +541,8 @@ class AccountRepository( scope.launch { try { val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - _userMsgRelayList.value = nostr.relays.getMsgRelays(user) + val msgRelays = nostr.relays.getMsgRelays(user) + _state.update { it.copy(userMsgRelayList = msgRelays) } } catch (e: Exception) { showError("Error: ${e.message}") } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt index 8030dab..5a99450 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt @@ -3,8 +3,6 @@ package su.reya.coop.viewmodel import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.StateFlow import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.RelayMetadata -import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Timestamp import su.reya.coop.Profile import su.reya.coop.repository.AccountRepository @@ -16,10 +14,6 @@ class AccountViewModel( val state: StateFlow = repository.state val isUpdatingProfile: StateFlow = repository.isUpdatingProfile val currentUserProfile: StateFlow = repository.currentUserProfile - val contactList: StateFlow> = repository.contactList - val isRelayListEmpty: StateFlow = repository.isRelayListEmpty - val userRelayList: StateFlow> = repository.userRelayList - val userMsgRelayList: StateFlow> = repository.userMsgRelayList fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout) fun dismissNotificationBanner() = repository.dismissNotificationBanner() -- 2.49.1 From e4e73da2295d51b37c7378937501df4e7f7f74d1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 20 Jul 2026 08:32:34 +0700 Subject: [PATCH 2/6] update settings --- .../kotlin/su/reya/coop/Settings.kt | 22 +++++++++++ .../coop/repository/SettingsRepository.kt | 37 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Settings.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/repository/SettingsRepository.kt diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Settings.kt b/shared/src/commonMain/kotlin/su/reya/coop/Settings.kt new file mode 100644 index 0000000..c427767 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Settings.kt @@ -0,0 +1,22 @@ +package su.reya.coop + +import kotlinx.serialization.Serializable + +@Serializable +data class Settings( + val theme: Theme = Theme.System, + val dynamicColor: Boolean = true, + val media: Media = Media.AlwaysEnabled, + val screening: Boolean = true, + val blossomServer: String? = "https://blossom.band", +) + +@Serializable +enum class Theme { + Light, Dark, System +} + +@Serializable +enum class Media { + Disabled, DisabledForMobileData, AlwaysEnabled +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/SettingsRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/SettingsRepository.kt new file mode 100644 index 0000000..693d0f6 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/SettingsRepository.kt @@ -0,0 +1,37 @@ +package su.reya.coop.repository + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import su.reya.coop.AppStorage +import su.reya.coop.Settings + +class SettingsRepository( + private val storage: AppStorage +) { + companion object { + private const val KEY_SETTINGS = "app_settings" + } + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + suspend fun save(settings: Settings) { + val jsonString = json.encodeToString(settings) + storage.set(KEY_SETTINGS, jsonString) + } + + suspend fun load(): Settings { + val jsonString = storage.get(KEY_SETTINGS) + return if (jsonString != null) { + try { + json.decodeFromString(jsonString) + } catch (_: Exception) { + Settings() + } + } else { + Settings() + } + } +} -- 2.49.1 From 6bb4ef2805c0be59f017df62209d2eb540a5ae46 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 20 Jul 2026 08:52:38 +0700 Subject: [PATCH 3/6] add settings viewmodel --- .../androidMain/kotlin/su/reya/coop/App.kt | 29 ++++++++++++++---- .../kotlin/su/reya/coop/MainActivity.kt | 22 +++++++++++--- .../reya/coop/repository/AccountRepository.kt | 5 ++-- .../su/reya/coop/repository/ChatRepository.kt | 1 + .../reya/coop/repository/MediaRepository.kt | 13 ++++---- .../coop/repository/SettingsRepository.kt | 30 +++++++++++++++++-- .../reya/coop/viewmodel/SettingsViewModel.kt | 16 ++++++++++ 7 files changed, 93 insertions(+), 23 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/SettingsViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index e9101cd..1515407 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -39,6 +39,7 @@ import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.launch import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.ChatRepository +import su.reya.coop.repository.SettingsRepository import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.HomeScreen 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.ChatViewModel import su.reya.coop.viewmodel.ProfileCache +import su.reya.coop.viewmodel.SettingsViewModel val LocalProfileCache = staticCompositionLocalOf { error("No ProfileCache provided") } +val LocalSettings = staticCompositionLocalOf { + error("No Settings provided") +} + val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } @@ -79,6 +85,7 @@ fun App( profileCache: ProfileCache, accountRepository: AccountRepository, chatRepository: ChatRepository, + settingsRepository: SettingsRepository, ) { val viewModelFactory = remember { object : ViewModelProvider.Factory { @@ -92,6 +99,10 @@ fun App( accountRepository ) + modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel( + settingsRepository + ) + else -> throw IllegalArgumentException("Unknown ViewModel class") } @Suppress("UNCHECKED_CAST") @@ -102,6 +113,7 @@ fun App( val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory) val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory) + val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory) val context = LocalContext.current val activity = context as? ComponentActivity @@ -113,19 +125,24 @@ fun App( val accountState by accountViewModel.state.collectAsStateWithLifecycle() val signerRequired = accountState.signerRequired + // Get the settings + val settings by settingsViewModel.settings.collectAsStateWithLifecycle() + // Snackbar val snackbarHostState = remember { SnackbarHostState() } // 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 val colorScheme = when { // Enable the dynamic color scheme for Android 12+ - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme( - context - ) + settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } // When dark mode is enabled, use the dark color scheme darkMode -> darkColorScheme() @@ -190,11 +207,11 @@ fun App( ) { CompositionLocalProvider( LocalProfileCache provides profileCache, + LocalSettings provides settings, LocalSnackbarHostState provides snackbarHostState, LocalNavigator provides navigator, LocalScanResult provides qrScanResult, ) { - NavDisplay( backStack = backStack, onBack = { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index a50c8c2..a1745ef 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -14,6 +14,7 @@ import su.reya.coop.nostr.NostrManager import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.ChatRepository import su.reya.coop.repository.MediaRepository +import su.reya.coop.repository.SettingsRepository import su.reya.coop.viewmodel.ProfileCache import kotlin.system.exitProcess @@ -25,16 +26,28 @@ class MainActivity : ComponentActivity() { private val profileCache by lazy { ProfileCache(NostrManager.instance) } private val scope = MainScope() + private val settingsRepository by lazy { + val storage = AppStore(this@MainActivity) + SettingsRepository(storage, scope) + } + private val accountRepository by lazy { val storage = AppStore(this@MainActivity) - val mediaRepository = MediaRepository() + val mediaRepository = MediaRepository(settingsRepository) 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 { - val mediaRepository = MediaRepository() - ChatRepository(NostrManager.instance, mediaRepository, scope) + val mediaRepository = MediaRepository(settingsRepository) + ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope) } override fun onCreate(savedInstanceState: Bundle?) { @@ -82,6 +95,7 @@ class MainActivity : ComponentActivity() { profileCache = profileCache, accountRepository = accountRepository, chatRepository = chatRepository, + settingsRepository = settingsRepository, ) } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt index c0f5cb9..b1d068c 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt @@ -57,6 +57,7 @@ class AccountRepository( private val nostr: Nostr, private val storage: AppStorage, private val mediaRepository: MediaRepository, + private val settingsRepository: SettingsRepository, private val scope: CoroutineScope, private val externalSignerHandler: ExternalSignerHandler? = null, private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, @@ -75,9 +76,7 @@ class AccountRepository( @OptIn(ExperimentalCoroutinesApi::class) val currentUserProfile: StateFlow = nostr.signer.publicKeyFlow - .flatMapLatest { pubkey -> - if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null) - } + .flatMapLatest { if (it != null) currentUserProfileFlow(it) else flowOf(null) } .stateIn(scope, SharingStarted.WhileSubscribed(5000), null) init { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt index 5cbda77..2cabfe3 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt @@ -36,6 +36,7 @@ data class ChatState( class ChatRepository( private val nostr: Nostr, private val mediaRepository: MediaRepository, + private val settingsRepository: SettingsRepository, private val scope: CoroutineScope, private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ErrorHost by createErrorHost() { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt index 82380f4..a8c04e8 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt @@ -8,7 +8,9 @@ import kotlinx.serialization.json.Json import rust.nostr.sdk.AsyncNostrSigner import su.reya.coop.blossom.BlossomClient -class MediaRepository { +class MediaRepository( + private val settingsRepository: SettingsRepository +) { private val httpClient = HttpClient { install(ContentNegotiation) { json(Json { @@ -25,12 +27,9 @@ class MediaRepository { contentType: String? = "image/jpeg" ): String? { return try { - val blossom = BlossomClient(url = "https://blossom.band", client = httpClient) - val descriptor = blossom.upload( - file = file, - contentType = contentType, - signer = signer, - ) + val url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band" + val blossom = BlossomClient(url, httpClient) + val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer) descriptor?.url } catch (e: CancellationException) { throw e diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/SettingsRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/SettingsRepository.kt index 693d0f6..3f51b3e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/SettingsRepository.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/SettingsRepository.kt @@ -1,12 +1,19 @@ 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.json.Json import su.reya.coop.AppStorage import su.reya.coop.Settings class SettingsRepository( - private val storage: AppStorage + private val storage: AppStorage, + private val scope: CoroutineScope ) { companion object { private const val KEY_SETTINGS = "app_settings" @@ -17,12 +24,29 @@ class SettingsRepository( encodeDefaults = true } - suspend fun save(settings: Settings) { + private val _settings = MutableStateFlow(Settings()) + val settings: StateFlow = _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) storage.set(KEY_SETTINGS, jsonString) } - suspend fun load(): Settings { + private suspend fun load(): Settings { val jsonString = storage.get(KEY_SETTINGS) return if (jsonString != null) { try { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/SettingsViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..3d45a45 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/SettingsViewModel.kt @@ -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 = repository.settings + + fun update(transform: (Settings) -> Settings) { + repository.update(transform) + } +} -- 2.49.1 From d243c62157f96f8656a076ec528e1ae91c5221ff Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 20 Jul 2026 09:21:06 +0700 Subject: [PATCH 4/6] use settings --- .../src/androidMain/AndroidManifest.xml | 2 + .../reya/coop/AndroidConnectivityMonitor.kt | 49 +++++++++++++++++++ .../androidMain/kotlin/su/reya/coop/App.kt | 9 ++++ .../kotlin/su/reya/coop/MainActivity.kt | 3 ++ .../su/reya/coop/screens/chat/ChatMessage.kt | 22 +++++++-- .../su/reya/coop/screens/chat/ChatScreen.kt | 6 ++- .../kotlin/su/reya/coop/shared/Avatar.kt | 45 ++++++++++++----- .../su/reya/coop/ConnectivityMonitor.kt | 7 +++ .../kotlin/su/reya/coop/Settings.kt | 4 +- 9 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/AndroidConnectivityMonitor.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/ConnectivityMonitor.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index a25745c..9468b3a 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -7,6 +7,8 @@ android:required="false" /> + + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidConnectivityMonitor.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidConnectivityMonitor.kt new file mode 100644 index 0000000..20984a9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/AndroidConnectivityMonitor.kt @@ -0,0 +1,49 @@ +package su.reya.coop + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class AndroidConnectivityMonitor(context: Context) : ConnectivityMonitor { + private val manager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + private val _isMobileData = MutableStateFlow(checkIsMobileData()) + override val isMobileData: StateFlow = _isMobileData.asStateFlow() + + init { + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + manager.registerNetworkCallback( + networkRequest, + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + _isMobileData.value = checkIsMobileData() + } + + override fun onLost(network: Network) { + _isMobileData.value = checkIsMobileData() + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + _isMobileData.value = checkIsMobileData() + } + }) + } + + private fun checkIsMobileData(): Boolean { + val activeNetwork = manager.activeNetwork ?: return false + val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return false + return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 1515407..3570b27 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -67,6 +67,10 @@ val LocalSettings = staticCompositionLocalOf { error("No Settings provided") } +val LocalConnectivity = staticCompositionLocalOf { + false +} + val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } @@ -86,6 +90,7 @@ fun App( accountRepository: AccountRepository, chatRepository: ChatRepository, settingsRepository: SettingsRepository, + connectivityMonitor: ConnectivityMonitor, ) { val viewModelFactory = remember { object : ViewModelProvider.Factory { @@ -128,6 +133,9 @@ fun App( // Get the settings val settings by settingsViewModel.settings.collectAsStateWithLifecycle() + // Get connectivity status + val isMobileData by connectivityMonitor.isMobileData.collectAsStateWithLifecycle() + // Snackbar val snackbarHostState = remember { SnackbarHostState() } @@ -208,6 +216,7 @@ fun App( CompositionLocalProvider( LocalProfileCache provides profileCache, LocalSettings provides settings, + LocalConnectivity provides isMobileData, LocalSnackbarHostState provides snackbarHostState, LocalNavigator provides navigator, LocalScanResult provides qrScanResult, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index a1745ef..86b9171 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -26,6 +26,8 @@ class MainActivity : ComponentActivity() { private val profileCache by lazy { ProfileCache(NostrManager.instance) } private val scope = MainScope() + private val connectivityMonitor by lazy { AndroidConnectivityMonitor(this@MainActivity) } + private val settingsRepository by lazy { val storage = AppStore(this@MainActivity) SettingsRepository(storage, scope) @@ -96,6 +98,7 @@ class MainActivity : ComponentActivity() { accountRepository = accountRepository, chatRepository = chatRepository, settingsRepository = settingsRepository, + connectivityMonitor = connectivityMonitor, ) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt index 213c9b9..a01ff76 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt @@ -38,6 +38,9 @@ import coil3.compose.AsyncImage import rust.nostr.sdk.EventId import rust.nostr.sdk.PublicKey import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.LocalConnectivity +import su.reya.coop.LocalSettings +import su.reya.coop.MediaConfig import su.reya.coop.URL_REGEX import su.reya.coop.formatAsTime import su.reya.coop.isImageUrl @@ -56,14 +59,27 @@ data class MessageModel( @Composable fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel { - return remember(event, currentUser) { + val settings = LocalSettings.current + val isMobileData = LocalConnectivity.current + + return remember(event, currentUser, settings, isMobileData) { val id = event.ensureId().id()!! val isMine = currentUser == event.author() val content = event.content() val replyEventIds = event.tags().eventIds() - val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList() - val cleanedContent = content.removeImageUrls() + val showMedia = when (settings.media) { + MediaConfig.AlwaysEnabled -> true + MediaConfig.Disabled -> false + MediaConfig.DisabledForMobileData -> !isMobileData + } + + val images = if (showMedia) { + URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList() + } else { + emptyList() + } + val cleanedContent = if (showMedia) content.removeImageUrls() else content val annotatedString = buildAnnotatedString { var lastIndex = 0 diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index bda1743..9f2eb67 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -93,6 +93,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNavigator import su.reya.coop.LocalProfileCache +import su.reya.coop.LocalSettings import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room import su.reya.coop.RoomUiState @@ -115,6 +116,7 @@ fun ChatScreen( val clipboardManager = LocalClipboard.current val navigator = LocalNavigator.current val profileCache = LocalProfileCache.current + val settings = LocalSettings.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() @@ -265,7 +267,7 @@ fun ChatScreen( .fillMaxSize() .padding(bottom = innerPadding.calculateBottomPadding()) ) { - if (requireScreening) { + if (requireScreening && settings.screening) { room?.let { ScreenerCard(accountViewModel, it) } } @@ -349,7 +351,7 @@ fun ChatScreen( } } - when (requireScreening) { + when (requireScreening && settings.screening) { true -> { Row( modifier = Modifier diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/Avatar.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/Avatar.kt index 023f871..6c8fc46 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/Avatar.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/Avatar.kt @@ -1,5 +1,6 @@ package su.reya.coop.shared +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable @@ -13,6 +14,9 @@ import coil3.compose.AsyncImage import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.avatar import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalConnectivity +import su.reya.coop.LocalSettings +import su.reya.coop.MediaConfig @Composable fun Avatar( @@ -22,17 +26,36 @@ fun Avatar( size: Dp = 48.dp, shape: Shape = CircleShape ) { + val settings = LocalSettings.current + val isMobileData = LocalConnectivity.current val placeholder = painterResource(Res.drawable.avatar) - AsyncImage( - model = picture, - contentDescription = description, - modifier = modifier - .size(size) - .clip(shape), - contentScale = ContentScale.Crop, - fallback = placeholder, - error = placeholder, - placeholder = placeholder - ) + val showMedia = when (settings.media) { + MediaConfig.AlwaysEnabled -> true + MediaConfig.Disabled -> false + MediaConfig.DisabledForMobileData -> !isMobileData + } + + if (showMedia) { + AsyncImage( + model = picture, + contentDescription = description, + modifier = modifier + .size(size) + .clip(shape), + contentScale = ContentScale.Crop, + fallback = placeholder, + error = placeholder, + placeholder = placeholder + ) + } else { + Image( + painter = placeholder, + contentDescription = description, + modifier = modifier + .size(size) + .clip(shape), + contentScale = ContentScale.Crop + ) + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ConnectivityMonitor.kt b/shared/src/commonMain/kotlin/su/reya/coop/ConnectivityMonitor.kt new file mode 100644 index 0000000..8d7c49d --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/ConnectivityMonitor.kt @@ -0,0 +1,7 @@ +package su.reya.coop + +import kotlinx.coroutines.flow.StateFlow + +interface ConnectivityMonitor { + val isMobileData: StateFlow +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Settings.kt b/shared/src/commonMain/kotlin/su/reya/coop/Settings.kt index c427767..53134b8 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Settings.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Settings.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable data class Settings( val theme: Theme = Theme.System, val dynamicColor: Boolean = true, - val media: Media = Media.AlwaysEnabled, + val media: MediaConfig = MediaConfig.AlwaysEnabled, val screening: Boolean = true, val blossomServer: String? = "https://blossom.band", ) @@ -17,6 +17,6 @@ enum class Theme { } @Serializable -enum class Media { +enum class MediaConfig { Disabled, DisabledForMobileData, AlwaysEnabled } -- 2.49.1 From 79616a214c46e0ecea3c86d7c2823345968badb8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 20 Jul 2026 09:39:53 +0700 Subject: [PATCH 5/6] add settings screen --- .../androidMain/kotlin/su/reya/coop/App.kt | 4 ++ .../kotlin/su/reya/coop/Navigation.kt | 3 + .../kotlin/su/reya/coop/screens/HomeScreen.kt | 2 +- .../su/reya/coop/screens/RelayScreen.kt | 8 +-- .../su/reya/coop/screens/SettingsScreen.kt | 55 +++++++++++++++++++ 5 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/SettingsScreen.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 3570b27..786d339 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -51,6 +51,7 @@ import su.reya.coop.screens.ProfileScreen import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.RequestListScreen import su.reya.coop.screens.ScanScreen +import su.reya.coop.screens.SettingsScreen import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.screens.chat.ChatScreen import su.reya.coop.viewmodel.AccountViewModel @@ -293,6 +294,9 @@ fun App( entry { RelayScreen(accountViewModel) } + entry { + SettingsScreen(settingsViewModel) + } } ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index e7b72a9..cc3b4e0 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -58,4 +58,7 @@ sealed interface Screen : NavKey { @Serializable data object Relay : Screen + + @Serializable + data object Settings : Screen } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 400c708..91fe388 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -760,7 +760,7 @@ fun BottomMenuList( "Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, "Contact List" to { navigator.navigate(Screen.ContactList) }, "Relay Management" to { navigator.navigate(Screen.Relay) }, - "Settings" to { } + "Settings" to { navigator.navigate(Screen.Settings) } ) Column( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt index 5048959..d74dba9 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt @@ -95,15 +95,15 @@ fun RelayScreen(viewModel: AccountViewModel) { var openAddRelayDialog by remember { mutableStateOf(false) } var relayToDelete by remember { mutableStateOf(null) } + val accountState by viewModel.state.collectAsStateWithLifecycle() + val loadedRelayList = accountState.userRelayList + val loadedMsgRelayList = accountState.userMsgRelayList + LaunchedEffect(Unit) { viewModel.loadCurrentUserRelayList() viewModel.loadCurrentUserMsgRelayList() } - val accountState by viewModel.state.collectAsStateWithLifecycle() - val loadedRelayList = accountState.userRelayList - val loadedMsgRelayList = accountState.userMsgRelayList - LaunchedEffect(loadedRelayList) { if (loadedRelayList.isNotEmpty()) { relayList.clear() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/SettingsScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/SettingsScreen.kt new file mode 100644 index 0000000..fd09a91 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/SettingsScreen.kt @@ -0,0 +1,55 @@ +package su.reya.coop.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalNavigator +import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.viewmodel.SettingsViewModel + +@Composable +fun SettingsScreen(viewModel: SettingsViewModel) { + val navigator = LocalNavigator.current + val snackbarHostState = LocalSnackbarHostState.current + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + text = "Settings", + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + navigationIcon = { + IconButton(onClick = { navigator.goBack() }) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + ) + }, + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize()) { + Text("Settings") + } + } +} \ No newline at end of file -- 2.49.1 From 7883b2c25e724288fd2fee6a562b30fa422ec416 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 20 Jul 2026 10:29:49 +0700 Subject: [PATCH 6/6] update settings screen --- .../su/reya/coop/screens/SettingsScreen.kt | 244 +++++++++++++++++- 1 file changed, 240 insertions(+), 4 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/SettingsScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/SettingsScreen.kt index fd09a91..103e7ef 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/SettingsScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/SettingsScreen.kt @@ -1,30 +1,69 @@ package su.reya.coop.screens -import androidx.compose.foundation.layout.Box +import android.content.Intent +import android.provider.Settings as AndroidSettings +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import org.jetbrains.compose.resources.painterResource import su.reya.coop.LocalNavigator import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.MediaConfig +import su.reya.coop.Theme import su.reya.coop.viewmodel.SettingsViewModel +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SettingsScreen(viewModel: SettingsViewModel) { val navigator = LocalNavigator.current + val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current + val settings by viewModel.settings.collectAsState() + + var showThemeDialog by remember { mutableStateOf(false) } + var showMediaDialog by remember { mutableStateOf(false) } + var showBlossomDialog by remember { mutableStateOf(false) } Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( @@ -48,8 +87,205 @@ fun SettingsScreen(viewModel: SettingsViewModel) { ) }, ) { innerPadding -> - Box(modifier = Modifier.fillMaxSize()) { - Text("Settings") + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "General", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 8.dp) + ) + Column( + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap) + ) { + SegmentedListItem( + onClick = { viewModel.update { it.copy(screening = !it.screening) } }, + shapes = ListItemDefaults.segmentedShapes(index = 0, count = 4), + content = { Text("Screening") }, + supportingContent = { Text("Filter unknown contacts") }, + trailingContent = { + Switch( + checked = settings.screening, + onCheckedChange = { viewModel.update { s -> s.copy(screening = it) } } + ) + } + ) + SegmentedListItem( + onClick = { showMediaDialog = true }, + shapes = ListItemDefaults.segmentedShapes(index = 1, count = 4), + content = { Text("Media Preview") }, + supportingContent = { + Text( + when (settings.media) { + MediaConfig.Disabled -> "Disabled" + MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data" + MediaConfig.AlwaysEnabled -> "Always Enabled" + } + ) + } + ) + SegmentedListItem( + onClick = { showBlossomDialog = true }, + shapes = ListItemDefaults.segmentedShapes(index = 2, count = 4), + content = { Text("Blossom Server") }, + supportingContent = { Text(settings.blossomServer ?: "Default") } + ) + SegmentedListItem( + onClick = { + val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(AndroidSettings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + }, + shapes = ListItemDefaults.segmentedShapes(index = 3, count = 4), + content = { Text("Notifications") }, + supportingContent = { Text("System notification settings") } + ) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Appearance", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 8.dp) + ) + Column( + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap) + ) { + SegmentedListItem( + onClick = { showThemeDialog = true }, + shapes = ListItemDefaults.segmentedShapes(index = 0, count = 2), + content = { Text("Theme") }, + supportingContent = { Text(settings.theme.name) } + ) + SegmentedListItem( + onClick = { viewModel.update { it.copy(dynamicColor = !it.dynamicColor) } }, + shapes = ListItemDefaults.segmentedShapes(index = 1, count = 2), + content = { Text("Dynamic Color") }, + trailingContent = { + Switch( + checked = settings.dynamicColor, + onCheckedChange = { + viewModel.update { s -> s.copy(dynamicColor = it) } + } + ) + } + ) + } + } } } -} \ No newline at end of file + + if (showThemeDialog) { + OptionDialog( + title = "Select Theme", + options = Theme.entries.map { it.name }, + selected = settings.theme.name, + onSelected = { name -> + viewModel.update { it.copy(theme = Theme.valueOf(name)) } + showThemeDialog = false + }, + onDismiss = { showThemeDialog = false } + ) + } + + if (showMediaDialog) { + OptionDialog( + title = "Media Preview", + options = listOf("Disabled", "Disabled for Mobile Data", "Always Enabled"), + selected = when (settings.media) { + MediaConfig.Disabled -> "Disabled" + MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data" + MediaConfig.AlwaysEnabled -> "Always Enabled" + }, + onSelected = { choice -> + val newConfig = when (choice) { + "Disabled" -> MediaConfig.Disabled + "Disabled for Mobile Data" -> MediaConfig.DisabledForMobileData + else -> MediaConfig.AlwaysEnabled + } + viewModel.update { it.copy(media = newConfig) } + showMediaDialog = false + }, + onDismiss = { showMediaDialog = false } + ) + } + + if (showBlossomDialog) { + var text by remember { mutableStateOf(settings.blossomServer ?: "") } + AlertDialog( + onDismissRequest = { showBlossomDialog = false }, + title = { Text("Blossom Server URL") }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("https://...") }, + singleLine = true + ) + }, + confirmButton = { + TextButton(onClick = { + viewModel.update { it.copy(blossomServer = text.ifBlank { null }) } + showBlossomDialog = false + }) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = { showBlossomDialog = false }) { + Text("Cancel") + } + } + ) + } +} + +@Composable +fun OptionDialog( + title: String, + options: List, + selected: String, + onSelected: (String) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(modifier = Modifier.selectableGroup()) { + options.forEach { text -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = (text == selected), + onClick = { onSelected(text) }, + role = Role.RadioButton + ) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = (text == selected), onClick = null) + Spacer(Modifier.size(16.dp)) + Text(text = text, style = MaterialTheme.typography.bodyLarge) + } + } + } + }, + confirmButton = {} + ) +} -- 2.49.1