Настройки

This commit is contained in:
Халимов Рустам
2026-04-14 11:46:55 +03:00
parent 3310a3c4a4
commit 58bbdae26c
14 changed files with 311 additions and 96 deletions

View File

@@ -5,18 +5,23 @@ import auth.data.remote.dto.AuthResponse
import core.domain.model.ServerConfigModel
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
interface AuthApi {
@POST("auth/login")
@Headers("Cache-Control: no-cache")
suspend fun login(@Body request: AuthRequest): AuthResponse
@POST("auth/register")
@Headers("Cache-Control: no-cache")
suspend fun register(@Body request: AuthRequest): AuthResponse
@GET("config")
@Headers("Cache-Control: no-cache")
suspend fun getConfig(): ServerConfigModel
@POST("auth/push-token")
@Headers("Cache-Control: no-cache")
suspend fun updatePushToken(@Body token: String): Unit
}

View File

@@ -10,7 +10,9 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chats.presentation.components.ChatItem
import stories.presentation.StoryViewModel
@@ -44,9 +46,10 @@ fun ChatListScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.chats_title)) },
title = { Text(stringResource(R.string.chats_title), fontWeight = FontWeight.Bold) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
containerColor = Color.Transparent,
titleContentColor = Color.White
)
)
}

View File

@@ -3,18 +3,55 @@ package core.domain.model
import com.google.gson.annotations.SerializedName
data class ServerConfigModel(
@SerializedName("features") val features: FeaturesConfig = FeaturesConfig(),
@SerializedName("limits") val limits: LimitsConfig = LimitsConfig()
@SerializedName("stories") val stories: StoriesConfig = StoriesConfig(),
@SerializedName("messages") val messages: MessagesConfig = MessagesConfig(),
@SerializedName("chats") val chats: ChatsConfig = ChatsConfig(),
@SerializedName("webRtc") val webRtc: WebRtcConfig = WebRtcConfig()
) {
// Helper to keep the presentation layer simple
val features: FeaturesConfig
get() = FeaturesConfig(
stories = stories.enabled,
polls = messages.allowPolls,
calls = webRtc.enabled && webRtc.enableVoiceCalls,
groups = true // Static for now as chats config doesn't have a simple toggle
)
val limits: LimitsConfig
get() = LimitsConfig(
maxFileSize = messages.maxFileSize,
maxGroupMembers = chats.maxGroupParticipants
)
}
data class StoriesConfig(
@SerializedName("enabled") val enabled: Boolean = true
)
data class MessagesConfig(
@SerializedName("maxFileSize") val maxFileSize: Long = 100 * 1024 * 1024,
@SerializedName("allowPolls") val allowPolls: Boolean = true
)
data class ChatsConfig(
@SerializedName("maxGroupParticipants") val maxGroupParticipants: Int = 200
)
data class WebRtcConfig(
@SerializedName("enabled") val enabled: Boolean = true,
@SerializedName("enableVoiceCalls") val enableVoiceCalls: Boolean = true,
@SerializedName("enableVideoCalls") val enableVideoCalls: Boolean = true
)
// DTOs to maintain compatibility with existing UI code if possible
data class FeaturesConfig(
@SerializedName("stories") val stories: Boolean = true,
@SerializedName("polls") val polls: Boolean = true,
@SerializedName("calls") val calls: Boolean = true,
@SerializedName("groups") val groups: Boolean = true
val stories: Boolean,
val polls: Boolean,
val calls: Boolean,
val groups: Boolean
)
data class LimitsConfig(
@SerializedName("maxFileSize") val maxFileSize: Long = 100 * 1024 * 1024, // 100MB default
@SerializedName("maxGroupMembers") val maxGroupMembers: Int = 200
val maxFileSize: Long,
val maxGroupMembers: Int
)

View File

@@ -31,7 +31,8 @@ class ServerConfig @Inject constructor(
}
fun saveServerConfig(config: ServerConfigModel) {
prefs.edit().putString(KEY_CONFIG_DATA, gson.toJson(config)).apply()
android.util.Log.d("ServerConfig", "Saving new config: $config")
prefs.edit().putString(KEY_CONFIG_DATA, gson.toJson(config)).commit()
}
fun getServerConfig(): ServerConfigModel {

View File

@@ -7,13 +7,14 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.QuestionAnswer
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
@@ -35,25 +36,13 @@ fun GlassNavigationBar(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
.height(72.dp)
.padding(horizontal = 16.dp, vertical = 12.dp)
.height(64.dp)
.clip(RoundedCornerShape(32.dp))
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
)
)
)
.background(Color(0xFF1C1C1E).copy(alpha = 0.95f))
.border(
1.dp,
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.2f),
Color.Transparent
)
),
0.5.dp,
Color.White.copy(alpha = 0.1f),
RoundedCornerShape(32.dp)
),
contentAlignment = Alignment.Center
@@ -64,13 +53,13 @@ fun GlassNavigationBar(
verticalAlignment = Alignment.CenterVertically
) {
NavItem(
icon = Icons.Default.ChatBubble,
icon = Icons.Default.QuestionAnswer,
label = stringResource(R.string.chats),
isSelected = currentRoute == "chat_list",
onClick = { onNavigate("chat_list") }
)
NavItem(
icon = Icons.Default.People,
icon = Icons.Default.AccountCircle,
label = stringResource(R.string.contacts_tab),
isSelected = currentRoute == "contacts",
onClick = { onNavigate("contacts") }
@@ -93,62 +82,70 @@ fun GlassNavigationBar(
}
@Composable
private fun NavItem(
private fun RowScope.NavItem(
icon: ImageVector,
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val contentColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
val backgroundColor = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) else Color.Transparent
val contentColor = if (isSelected) Color(0xFF3390EC) else Color(0xFF94A3B8)
Column(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(backgroundColor)
.weight(1f)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(icon, contentDescription = label, tint = contentColor, modifier = Modifier.size(24.dp))
Text(text = label, color = contentColor, fontSize = 10.sp, modifier = Modifier.padding(top = 2.dp))
Icon(
icon,
contentDescription = label,
tint = contentColor,
modifier = Modifier.size(26.dp)
)
Text(
text = label,
color = contentColor,
fontSize = 10.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.padding(top = 2.dp)
)
}
}
@Composable
private fun ProfileNavItem(
private fun RowScope.ProfileNavItem(
avatarUrl: String?,
username: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
val contentColor = if (isSelected) Color(0xFF3390EC) else Color(0xFF94A3B8)
Column(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.weight(1f)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp),
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(28.dp)
.border(2.dp, borderColor, CircleShape)
.padding(2.dp)
.size(26.dp)
.clip(CircleShape)
) {
AppAvatar(
url = avatarUrl,
name = username,
size = 24.dp
size = 26.dp
)
}
Text(
text = stringResource(R.string.profile_tab),
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
color = contentColor,
fontSize = 10.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.padding(top = 2.dp)
)
}

View File

@@ -1,14 +1,24 @@
package core.presentation.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import ru.knot.messager.R
@@ -22,20 +32,38 @@ fun SettingsScreen(
val state by viewModel.state.collectAsState()
var baseUrl by remember(state.baseUrl) { mutableStateOf(state.baseUrl) }
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
viewModel.loadConfig()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
Scaffold(
containerColor = Color(0xFF0F0F10),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings)) },
title = { Text(stringResource(R.string.settings), fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back), tint = Color.White)
}
},
actions = {
IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) {
Icon(Icons.Default.Save, contentDescription = stringResource(R.string.save))
IconButton(onClick = { viewModel.loadConfig() }) {
Icon(Icons.Default.Sync, contentDescription = "Refresh", tint = Color.White)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color(0xFF0F0F10),
titleContentColor = Color.White
)
)
}
) { paddingValues ->
@@ -43,60 +71,181 @@ fun SettingsScreen(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
Text(
text = stringResource(R.string.server_connection),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
label = { Text(stringResource(R.string.api_base_url)) },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("https://example.com/api/") }
)
if (state.isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
color = Color(0xFF3390EC)
)
}
if (state.error != null) {
Text(
text = "Ошибка обновления: ${state.error}",
color = Color.Red,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Server Section
SettingsSection(title = stringResource(R.string.server_connection)) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = stringResource(R.string.api_base_url),
style = MaterialTheme.typography.labelMedium,
color = Color(0xFF3390EC)
)
Row(verticalAlignment = Alignment.CenterVertically) {
TextField(
value = baseUrl,
onValueChange = { baseUrl = it },
modifier = Modifier.weight(1f),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedIndicatorColor = Color(0xFF3390EC),
unfocusedIndicatorColor = Color.Gray.copy(alpha = 0.5f)
),
placeholder = { Text("https://...", color = Color.Gray) }
)
IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) {
Icon(Icons.Default.Save, contentDescription = null, tint = Color(0xFF3390EC))
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Features Section
SettingsSection(title = stringResource(R.string.server_features)) {
Column {
SettingsToggleItem(
icon = Icons.Default.History,
label = stringResource(R.string.stories),
enabled = state.config.features.stories
)
SettingsToggleItem(
icon = Icons.Default.Poll,
label = stringResource(R.string.polls),
enabled = state.config.features.polls
)
SettingsToggleItem(
icon = Icons.Default.Call,
label = stringResource(R.string.calls),
enabled = state.config.features.calls
)
SettingsToggleItem(
icon = Icons.Default.Group,
label = stringResource(R.string.groups),
enabled = state.config.features.groups
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Limits Section
SettingsSection(title = stringResource(R.string.limits)) {
Column {
SettingsInfoItem(
icon = Icons.Default.Storage,
label = stringResource(R.string.max_file_size),
value = "${state.config.limits.maxFileSize / (1024 * 1024)} MB"
)
SettingsInfoItem(
icon = Icons.Default.People,
label = stringResource(R.string.max_group_members),
value = state.config.limits.maxGroupMembers.toString()
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Logout Button
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE53935)),
shape = RoundedCornerShape(12.dp)
) {
Icon(Icons.Default.Logout, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.log_out), fontWeight = FontWeight.Bold)
}
Text(
text = stringResource(R.string.server_features),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
FeatureStatusItem(stringResource(R.string.stories), state.config.features.stories)
FeatureStatusItem(stringResource(R.string.polls), state.config.features.polls)
FeatureStatusItem(stringResource(R.string.calls), state.config.features.calls)
FeatureStatusItem(stringResource(R.string.groups), state.config.features.groups)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.limits),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text("${stringResource(R.string.max_file_size)}: ${state.config.limits.maxFileSize / (1024 * 1024)} MB")
Text("${stringResource(R.string.max_group_members)}: ${state.config.limits.maxGroupMembers}")
Spacer(modifier = Modifier.height(80.dp)) // Space for bottom bar
}
}
}
@Composable
fun FeatureStatusItem(name: String, enabled: Boolean) {
fun SettingsSection(title: String, content: @Composable () -> Unit) {
Column {
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF3390EC),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
)
Surface(
color = Color(0xFF1C1C1E),
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth()
) {
content()
}
}
}
@Composable
fun SettingsToggleItem(icon: ImageVector, label: String, enabled: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(name)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(16.dp))
Text(label, color = Color.White, fontSize = 16.sp)
}
Text(
text = if (enabled) stringResource(R.string.enabled) else stringResource(R.string.disabled),
color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
color = if (enabled) Color(0xFF3390EC) else Color(0xFFE53935),
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
}
}
@Composable
fun SettingsInfoItem(icon: ImageVector, label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(16.dp))
Text(label, color = Color.White, fontSize = 16.sp)
}
Text(
text = value,
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
}
}

View File

@@ -1,5 +1,6 @@
package core.presentation.settings
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import core.domain.model.ServerConfigModel
@@ -14,18 +15,26 @@ import javax.inject.Inject
data class SettingsState(
val baseUrl: String = "",
val config: ServerConfigModel = ServerConfigModel()
val config: ServerConfigModel = ServerConfigModel(),
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val serverConfig: ServerConfig
private val serverConfig: ServerConfig,
private val authRepository: auth.domain.repository.AuthRepository
) : ViewModel() {
private val _state = MutableStateFlow(SettingsState())
val state: StateFlow<SettingsState> = _state.asStateFlow()
init {
refreshState()
loadConfig()
}
private fun refreshState() {
_state.update {
it.copy(
baseUrl = serverConfig.getBaseUrl(),
@@ -34,6 +43,20 @@ class SettingsViewModel @Inject constructor(
}
}
fun loadConfig() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val result = authRepository.fetchConfig()
Log.d("SettingsViewModel", "Fetch result: ${result.isSuccess}")
if (result.isSuccess) {
refreshState()
} else {
_state.update { it.copy(error = result.exceptionOrNull()?.message) }
}
_state.update { it.copy(isLoading = false) }
}
}
fun saveBaseUrl(url: String) {
serverConfig.setBaseUrl(url)
_state.update { it.copy(baseUrl = url) }