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/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 e9101cd..786d339 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
@@ -50,17 +51,27 @@ 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
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 LocalConnectivity = staticCompositionLocalOf {
+ false
+}
+
val LocalSnackbarHostState = staticCompositionLocalOf {
error("No SnackbarHostState provided")
}
@@ -79,6 +90,8 @@ fun App(
profileCache: ProfileCache,
accountRepository: AccountRepository,
chatRepository: ChatRepository,
+ settingsRepository: SettingsRepository,
+ connectivityMonitor: ConnectivityMonitor,
) {
val viewModelFactory = remember {
object : ViewModelProvider.Factory {
@@ -92,6 +105,10 @@ fun App(
accountRepository
)
+ modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
+ settingsRepository
+ )
+
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
@Suppress("UNCHECKED_CAST")
@@ -102,6 +119,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 +131,27 @@ fun App(
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val signerRequired = accountState.signerRequired
+ // Get the settings
+ val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
+
+ // Get connectivity status
+ val isMobileData by connectivityMonitor.isMobileData.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 +216,12 @@ fun App(
) {
CompositionLocalProvider(
LocalProfileCache provides profileCache,
+ LocalSettings provides settings,
+ LocalConnectivity provides isMobileData,
LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator,
LocalScanResult provides qrScanResult,
) {
-
NavDisplay(
backStack = backStack,
onBack = {
@@ -267,6 +294,9 @@ fun App(
entry {
RelayScreen(accountViewModel)
}
+ entry {
+ SettingsScreen(settingsViewModel)
+ }
}
)
}
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt
index a50c8c2..86b9171 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,30 @@ 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)
+ }
+
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 +97,8 @@ class MainActivity : ComponentActivity() {
profileCache = profileCache,
accountRepository = accountRepository,
chatRepository = chatRepository,
+ settingsRepository = settingsRepository,
+ connectivityMonitor = connectivityMonitor,
)
}
}
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/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..91fe388 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) }
@@ -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/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..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,14 +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 loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle()
- val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle()
-
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..103e7ef
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/SettingsScreen.kt
@@ -0,0 +1,291 @@
+package su.reya.coop.screens
+
+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(
+ 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 ->
+ 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) }
+ }
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+
+ 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 = {}
+ )
+}
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
new file mode 100644
index 0000000..53134b8
--- /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: MediaConfig = MediaConfig.AlwaysEnabled,
+ val screening: Boolean = true,
+ val blossomServer: String? = "https://blossom.band",
+)
+
+@Serializable
+enum class Theme {
+ Light, Dark, System
+}
+
+@Serializable
+enum class MediaConfig {
+ Disabled, DisabledForMobileData, AlwaysEnabled
+}
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..b1d068c 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt
@@ -47,13 +47,17 @@ 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(
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,
@@ -72,23 +76,9 @@ 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)
- private val _contactList = MutableStateFlow>(emptySet())
- val contactList: StateFlow> = _contactList.asStateFlow()
-
- private val _isRelayListEmpty = MutableStateFlow(false)
- val isRelayListEmpty: StateFlow = _isRelayListEmpty.asStateFlow()
-
- private val _userRelayList = MutableStateFlow