diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6fce6fa..f6ee741 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "xyz.kusoft.qmax" minSdk = 26 targetSdk = 36 - versionCode = 57 - versionName = "1.0.0" + versionCode = 58 + versionName = "1.0.1" buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"") buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"") diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index 1fcc437..073ddf8 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.content.ContentResolver import android.content.pm.PackageManager import android.graphics.BitmapFactory +import android.graphics.Color as AndroidColor import android.media.MediaRecorder import android.net.Uri import android.os.Build @@ -18,6 +19,11 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -25,6 +31,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.transformable @@ -127,24 +134,32 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.ExperimentalComposeUiApi import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat @@ -196,6 +211,8 @@ import xyz.kusoft.qmax.ui.theme.QMaxMessageText import xyz.kusoft.qmax.ui.theme.QMaxMuted import xyz.kusoft.qmax.ui.theme.QMaxOnAccent import xyz.kusoft.qmax.ui.theme.QMaxOutgoing +import xyz.kusoft.qmax.ui.theme.QMaxOutgoingGradient +import xyz.kusoft.qmax.ui.theme.QMaxAnimateOutgoingGradient import xyz.kusoft.qmax.ui.theme.QMaxPattern import xyz.kusoft.qmax.ui.theme.QMaxShowPattern import xyz.kusoft.qmax.ui.theme.QMaxSurface @@ -352,26 +369,32 @@ private fun QMaxApp( @Composable private fun AutoLoginScreen(loading: Boolean, error: String?, onRetry: () -> Unit) { - Column( + Box( modifier = Modifier .fillMaxSize() - .background(QMaxSurface) - .padding(28.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + .background(QMaxBackground) + .padding(20.dp), + contentAlignment = Alignment.Center ) { - Text("QMAX", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = QMaxBlue) - Text("Подключение к qmax.kusoft.xyz", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp)) - Spacer(Modifier.height(24.dp)) - if (loading || error == null) { - CircularProgressIndicator(color = QMaxBlue) - } - error?.let { - Spacer(Modifier.height(14.dp)) - Text(it, color = Color(0xFFC62828), textAlign = TextAlign.Center) - Spacer(Modifier.height(14.dp)) - Button(onClick = onRetry) { - Text("Повторить") + TelegramSettingsCard { + Column( + modifier = Modifier.padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("QMAX", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = QMaxBlue) + Text("Подключение к qmax.kusoft.xyz", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp)) + Spacer(Modifier.height(24.dp)) + if (loading || error == null) { + CircularProgressIndicator(color = QMaxBlue) + } + error?.let { + Spacer(Modifier.height(14.dp)) + Text(it, color = Color(0xFFC62828), textAlign = TextAlign.Center) + Spacer(Modifier.height(14.dp)) + Button(onClick = onRetry) { + Text("Повторить") + } + } } } } @@ -380,63 +403,67 @@ private fun AutoLoginScreen(loading: Boolean, error: String?, onRetry: () -> Uni @Composable private fun LoginScreen(vm: QMaxViewModel) { val state by vm.state - Column( + Box( modifier = Modifier .fillMaxSize() - .background(QMaxSurface) - .padding(24.dp), - verticalArrangement = Arrangement.Center + .background(QMaxBackground) + .padding(16.dp), + contentAlignment = Alignment.Center ) { - Text("QMAX", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = QMaxBlue) - Text("MAX в привычном мессенджере", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp)) - Spacer(Modifier.height(28.dp)) - OutlinedTextField( - value = state.serverUrl, - onValueChange = vm::updateServerUrl, - label = { Text("Сервер") }, - enabled = state.phoneAuthChallenge == null, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = state.phoneNumber, - onValueChange = vm::updatePhoneNumber, - label = { Text("Номер телефона MAX") }, - placeholder = { Text("+7 900 000-00-00") }, - enabled = state.phoneAuthChallenge == null, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = state.pairingCode, - onValueChange = vm::updatePairingCode, - label = { Text("Код регистрации сервера (если задан)") }, - enabled = state.phoneAuthChallenge == null, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - if (state.phoneAuthChallenge == null) { - Spacer(Modifier.height(18.dp)) - Button(onClick = vm::login, enabled = state.phoneNumber.isNotBlank(), modifier = Modifier.fillMaxWidth()) { - Text("Получить код MAX") - } - } else { - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = state.maxCode, - onValueChange = vm::updateMaxCode, - label = { Text("Код из MAX") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(18.dp)) - Button(onClick = vm::completePhoneLogin, enabled = state.maxCode.isNotBlank(), modifier = Modifier.fillMaxWidth()) { - Text("Войти в QMAX") + TelegramSettingsCard { + Column(Modifier.padding(24.dp)) { + Text("QMAX", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = QMaxBlue) + Text("MAX в привычном мессенджере", color = QMaxMuted, modifier = Modifier.padding(top = 6.dp)) + Spacer(Modifier.height(28.dp)) + OutlinedTextField( + value = state.serverUrl, + onValueChange = vm::updateServerUrl, + label = { Text("Сервер") }, + enabled = state.phoneAuthChallenge == null, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = state.phoneNumber, + onValueChange = vm::updatePhoneNumber, + label = { Text("Номер телефона MAX") }, + placeholder = { Text("+7 900 000-00-00") }, + enabled = state.phoneAuthChallenge == null, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = state.pairingCode, + onValueChange = vm::updatePairingCode, + label = { Text("Код регистрации сервера (если задан)") }, + enabled = state.phoneAuthChallenge == null, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + if (state.phoneAuthChallenge == null) { + Spacer(Modifier.height(18.dp)) + Button(onClick = vm::login, enabled = state.phoneNumber.isNotBlank(), modifier = Modifier.fillMaxWidth()) { + Text("Получить код MAX") + } + } else { + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = state.maxCode, + onValueChange = vm::updateMaxCode, + label = { Text("Код из MAX") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(18.dp)) + Button(onClick = vm::completePhoneLogin, enabled = state.maxCode.isNotBlank(), modifier = Modifier.fillMaxWidth()) { + Text("Войти в QMAX") + } + } + ErrorLine(state.error) } } - ErrorLine(state.error) } } @@ -452,6 +479,13 @@ private enum class MainTab { Settings } +private enum class SettingsSection { + Appearance, + MaxConnection, + Updates, + Data +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatListScreen( @@ -461,6 +495,7 @@ private fun ChatListScreen( ) { val state by vm.state var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) } + var settingsSection by rememberSaveable { mutableStateOf(null) } var menuOpen by remember { mutableStateOf(false) } var selectionMenuOpen by remember { mutableStateOf(false) } var pendingBulkAction by remember { mutableStateOf(null) } @@ -519,6 +554,10 @@ private fun ChatListScreen( } } + BackHandler(enabled = activeTab == MainTab.Settings && settingsSection != null) { + settingsSection = null + } + LaunchedEffect(activeTab, state.searchQuery) { if (activeTab == MainTab.Channels) { vm.searchMaxChannels(state.searchQuery) @@ -538,6 +577,8 @@ private fun ChatListScreen( filteredChats = filteredChats, filteredContacts = filteredContacts, activeTab = activeTab, + settingsSection = settingsSection, + onSettingsSectionChange = { settingsSection = it }, menuOpen = menuOpen, onMenuOpenChange = { menuOpen = it }, onRefresh = { @@ -554,6 +595,7 @@ private fun ChatListScreen( onSubscribeChannel = vm::subscribeMaxChannel, onTabSelected = { tab -> activeTab = tab + settingsSection = null vm.updateSearchQuery("") vm.clearChatSelection() }, @@ -807,6 +849,8 @@ private fun TelegramChatListContent( filteredChats: List, filteredContacts: List, activeTab: MainTab, + settingsSection: SettingsSection?, + onSettingsSectionChange: (SettingsSection?) -> Unit, menuOpen: Boolean, onMenuOpenChange: (Boolean) -> Unit, onRefresh: () -> Unit, @@ -853,12 +897,13 @@ private fun TelegramChatListContent( MainTab.Channels -> "Каналов пока нет" MainTab.Settings -> null } - Box(Modifier.fillMaxSize().background(QMaxSurface)) { + val settingsDetailOpen = activeTab == MainTab.Settings && settingsSection != null + Box(Modifier.fillMaxSize().background(QMaxBackground)) { Column( Modifier .fillMaxSize() .statusBarsPadding() - .background(QMaxSurface) + .background(QMaxBackground) ) { if (selectedCount > 0) { ChatSelectionHeader( @@ -869,7 +914,7 @@ private fun TelegramChatListContent( onClearHistory = onClearSelectedHistory, onDeleteChats = onDeleteSelectedChats ) - } else { + } else if (!settingsDetailOpen) { TelegramLikeHeader( title = headerTitle, menuOpen = menuOpen, @@ -885,6 +930,8 @@ private fun TelegramChatListContent( if (activeTab == MainTab.Settings) { SettingsTabContent( state = state, + section = settingsSection, + onSectionChange = onSettingsSectionChange, onCheckUpdates = onCheckUpdates, onRefreshMaxStatus = onRefreshMaxStatus, onStartMaxLogin = onStartMaxLogin, @@ -914,49 +961,61 @@ private fun TelegramChatListContent( emptyText?.let { EmptyState(it) } ?: Spacer(Modifier.fillMaxSize()) } } else if (listTab) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 118.dp) + Surface( + color = QMaxSurface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + modifier = Modifier + .fillMaxSize() + .padding( + start = if (activeTab == MainTab.Contacts) 12.dp else 0.dp, + top = 4.dp, + end = if (activeTab == MainTab.Contacts) 12.dp else 0.dp + ) ) { - if (showChannelSearch) { - item { - ChannelSearchResults( - state = state, - onSubscribeChannel = onSubscribeChannel - ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 4.dp, bottom = 118.dp) + ) { + if (showChannelSearch) { + item { + ChannelSearchResults( + state = state, + onSubscribeChannel = onSubscribeChannel + ) + } } - } - if (activeTab == MainTab.Contacts) { - items(filteredContacts, key = { it.userId }) { contact -> - ContactRow( - contact = contact, - session = state.session, - cachedAvatarPaths = state.cachedAvatarPaths, - onCacheAvatar = onCacheAvatar, - onAdd = { onAddContact(contact) }, - onRemove = { onRemoveContact(contact) }, - onClick = { onOpenContact(contact) } - ) - } - } else { - items(filteredChats, key = { it.id }) { chat -> - TelegramChatRow( - chat = chat, - session = state.session, - cachedAvatarPaths = state.cachedAvatarPaths, - draftText = state.drafts[chat.id], - onCacheAvatar = onCacheAvatar, - isSelected = chat.id in state.selectedChatIds, - selectionActive = selectedCount > 0, - onLongClick = { onBeginChatSelection(chat.id) }, - onClick = { - if (selectedCount > 0) { - onToggleChatSelection(chat.id) - } else { - onOpenChat(chat) + if (activeTab == MainTab.Contacts) { + items(filteredContacts, key = { it.userId }) { contact -> + ContactRow( + contact = contact, + session = state.session, + cachedAvatarPaths = state.cachedAvatarPaths, + onCacheAvatar = onCacheAvatar, + onAdd = { onAddContact(contact) }, + onRemove = { onRemoveContact(contact) }, + onClick = { onOpenContact(contact) } + ) + } + } else { + items(filteredChats, key = { it.id }) { chat -> + TelegramChatRow( + chat = chat, + session = state.session, + cachedAvatarPaths = state.cachedAvatarPaths, + draftText = state.drafts[chat.id], + onCacheAvatar = onCacheAvatar, + isSelected = chat.id in state.selectedChatIds, + selectionActive = selectedCount > 0, + onLongClick = { onBeginChatSelection(chat.id) }, + onClick = { + if (selectedCount > 0) { + onToggleChatSelection(chat.id) + } else { + onOpenChat(chat) + } } - } - ) + ) + } } } } @@ -985,16 +1044,18 @@ private fun TelegramChatListContent( Icon(Icons.Filled.Refresh, contentDescription = "Синхронизировать контакты телефона") } } - TelegramBottomNavigation( - modifier = Modifier - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(horizontal = 28.dp, vertical = 10.dp), - activeTab = activeTab, - chatUnreadCount = state.chats.filterNot { it.isChannel() }.sumOf { it.unreadCount }, - channelUnreadCount = state.chats.filter { it.isChannel() }.sumOf { it.unreadCount }, - onTabSelected = onTabSelected - ) + if (!settingsDetailOpen) { + TelegramBottomNavigation( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(horizontal = 22.dp, vertical = 10.dp), + activeTab = activeTab, + chatUnreadCount = state.chats.filterNot { it.isChannel() }.sumOf { it.unreadCount }, + channelUnreadCount = state.chats.filter { it.isChannel() }.sumOf { it.unreadCount }, + onTabSelected = onTabSelected + ) + } } } @@ -1009,7 +1070,7 @@ private fun TelegramLikeHeader( Row( modifier = Modifier .fillMaxWidth() - .padding(start = 26.dp, end = 18.dp, top = 14.dp, bottom = 8.dp), + .padding(start = 18.dp, end = 10.dp, top = 12.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically ) { QMaxHeaderAvatarStack() @@ -1157,7 +1218,7 @@ private fun ChatListSearchBar(query: String, placeholder: String, onQuery: (Stri shape = RoundedCornerShape(30.dp), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 28.dp, vertical = 6.dp) + .padding(horizontal = 16.dp, vertical = 6.dp) .height(52.dp) ) { Row( @@ -1209,7 +1270,7 @@ private fun ContactRow( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 26.dp, vertical = 8.dp), + .padding(horizontal = 18.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Avatar( @@ -1285,7 +1346,7 @@ private fun TelegramChatRow( onClick = onClick, onLongClick = onLongClick ) - .padding(horizontal = 26.dp, vertical = 8.dp), + .padding(horizontal = 18.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Box { @@ -1382,9 +1443,9 @@ private fun ChatListFloatingActions(modifier: Modifier = Modifier, onNewChat: () containerColor = QMaxBlue, contentColor = QMaxOnAccent, shape = CircleShape, - modifier = Modifier.size(64.dp) + modifier = Modifier.size(58.dp) ) { - Icon(Icons.Filled.Add, contentDescription = "Новый чат", modifier = Modifier.size(34.dp)) + Icon(Icons.Filled.Add, contentDescription = "Новый чат", modifier = Modifier.size(30.dp)) } } } @@ -1407,6 +1468,8 @@ private fun MaxSessionExpiredBanner(onClick: () -> Unit) { @Composable private fun SettingsTabContent( state: QMaxUiState, + section: SettingsSection?, + onSectionChange: (SettingsSection?) -> Unit, onCheckUpdates: () -> Unit, onRefreshMaxStatus: () -> Unit, onStartMaxLogin: () -> Unit, @@ -1417,102 +1480,338 @@ private fun SettingsTabContent( appearance: AppearanceSettings, onAppearanceChange: (AppearanceSettings) -> Unit ) { + if (section == null) { + SettingsOverview( + state = state, + onSectionChange = onSectionChange, + onLogout = onLogout + ) + return + } + + val title = when (section) { + SettingsSection.Appearance -> "Настройки чатов" + SettingsSection.MaxConnection -> "Подключение MAX" + SettingsSection.Updates -> "Обновления" + SettingsSection.Data -> "Данные и синхронизация" + } + Column(Modifier.fillMaxSize()) { + SettingsDetailHeader(title = title, onBack = { onSectionChange(null) }) + when (section) { + SettingsSection.Appearance -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 28.dp) + ) { + item { + AppearanceSettingsSection( + appearance = appearance, + onAppearanceChange = onAppearanceChange + ) + } + } + SettingsSection.MaxConnection -> MaxConnectionSettings( + state = state, + onRefreshMaxStatus = onRefreshMaxStatus, + onStartMaxLogin = onStartMaxLogin, + onMaxCode = onMaxCode, + onSubmitMaxCode = onSubmitMaxCode + ) + SettingsSection.Updates -> UpdateSettings( + state = state, + onCheckUpdates = onCheckUpdates + ) + SettingsSection.Data -> DataSettings( + state = state, + onRefresh = onRefresh, + onLogout = onLogout + ) + } + } +} + +@Composable +private fun SettingsOverview( + state: QMaxUiState, + onSectionChange: (SettingsSection) -> Unit, + onLogout: () -> Unit +) { + val connectionProblem = state.maxStatus?.isAuthorized == false LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 28.dp, top = 14.dp, end = 28.dp, bottom = 118.dp) + contentPadding = PaddingValues(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 118.dp) ) { item { - AppearanceSettingsSection( - appearance = appearance, - onAppearanceChange = onAppearanceChange - ) - Spacer(Modifier.height(22.dp)) - HorizontalDivider(color = QMaxDivider) - Spacer(Modifier.height(22.dp)) - } - item { - Text("Версия приложения", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText) - Spacer(Modifier.height(8.dp)) - Text(BuildConfig.VERSION_NAME, style = MaterialTheme.typography.bodyLarge, color = QMaxMuted) - Spacer(Modifier.height(12.dp)) - Button(onClick = onCheckUpdates, enabled = !state.loading) { - Text(if (state.loading) "Проверяю..." else "Проверить обновление") + SettingsSectionLabel("НАСТРОЙКИ") + TelegramSettingsCard { + TelegramSettingsMenuRow( + icon = Icons.Filled.Edit, + iconColor = Color(0xFFF28C28), + title = "Настройки чатов", + subtitle = "Темы, цвета, размер текста и интервалы", + onClick = { onSectionChange(SettingsSection.Appearance) } + ) + SettingsMenuDivider() + TelegramSettingsMenuRow( + icon = Icons.Filled.Refresh, + iconColor = if (connectionProblem) Color(0xFFE05252) else Color(0xFF28A86B), + title = "Подключение MAX", + subtitle = maxStatusTitle(state.maxStatus), + onClick = { onSectionChange(SettingsSection.MaxConnection) } + ) + SettingsMenuDivider() + TelegramSettingsMenuRow( + icon = Icons.Filled.Check, + iconColor = Color(0xFF4B8FE2), + title = "Обновления", + subtitle = "Версия ${BuildConfig.VERSION_NAME}", + onClick = { onSectionChange(SettingsSection.Updates) } + ) + SettingsMenuDivider() + TelegramSettingsMenuRow( + icon = Icons.Filled.Chat, + iconColor = Color(0xFF18AEB5), + title = "Данные и синхронизация", + subtitle = "Обновление чатов и контактов", + onClick = { onSectionChange(SettingsSection.Data) } + ) } - state.updateMessage?.let { - Spacer(Modifier.height(10.dp)) - Text(it, color = QMaxMuted, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(18.dp)) + SettingsSectionLabel("АККАУНТ") + TelegramSettingsCard { + TelegramSettingsMenuRow( + icon = Icons.Filled.Close, + iconColor = Color(0xFFE05252), + title = "Выйти из QMAX", + subtitle = "Завершить текущую сессию", + titleColor = Color(0xFFE05252), + showChevron = false, + onClick = onLogout + ) } - Spacer(Modifier.height(22.dp)) - HorizontalDivider(color = QMaxDivider) } - item { - val status = state.maxStatus - val loginAvailable = status?.isAuthorized == false - val waitingForCode = isMaxLoginWaitingForCode(status) - val requestCodeEnabled = loginAvailable && !waitingForCode && !state.loading - val submitCodeEnabled = loginAvailable && state.maxCode.trim().isNotBlank() && !state.loading - Spacer(Modifier.height(22.dp)) - Text("MAX", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText) - Spacer(Modifier.height(8.dp)) + } +} + +@Composable +private fun SettingsDetailHeader(title: String, onBack: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack, modifier = Modifier.size(52.dp)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад", tint = QMaxText) + } + Text( + title, + color = QMaxText, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.size(52.dp)) + } +} + +@Composable +private fun SettingsSectionLabel(text: String) { + Text( + text, + color = QMaxBlue, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 14.dp, bottom = 8.dp) + ) +} + +@Composable +private fun TelegramSettingsCard(content: @Composable () -> Unit) { + Surface( + color = QMaxSurface, + shape = RoundedCornerShape(24.dp), + shadowElevation = if (QMaxIsDarkTheme) 0.dp else 1.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column(content = { content() }) + } +} + +@Composable +private fun TelegramSettingsMenuRow( + icon: ImageVector, + iconColor: Color, + title: String, + subtitle: String, + titleColor: Color = QMaxText, + showChevron: Boolean = true, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface(color = iconColor, shape = RoundedCornerShape(12.dp), modifier = Modifier.size(44.dp)) { + Box(contentAlignment = Alignment.Center) { + Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp)) + } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { Text( - maxStatusTitle(status), - style = MaterialTheme.typography.bodyLarge, - color = if (loginAvailable) Color(0xFFC62828) else QMaxMuted + title, + color = titleColor, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) + Spacer(Modifier.height(2.dp)) Text( - maxStatusHint(status), - style = MaterialTheme.typography.bodyMedium, + subtitle, color = QMaxMuted, - modifier = Modifier.padding(top = 4.dp) + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis ) - status?.lastError?.takeIf { it.isNotBlank() }?.let { - Spacer(Modifier.height(8.dp)) - Text(it, style = MaterialTheme.typography.bodyMedium, color = Color(0xFFC62828)) - } - Spacer(Modifier.height(12.dp)) - Column { - Button( - onClick = onRefreshMaxStatus, - enabled = !state.loading, - modifier = Modifier.fillMaxWidth() - ) { - Text("Обновить статус") - } - Spacer(Modifier.height(8.dp)) - Button( - onClick = onStartMaxLogin, - enabled = requestCodeEnabled, - modifier = Modifier.fillMaxWidth() - ) { - Text(if (waitingForCode) "Код запрошен" else "Получить новый код") + } + if (showChevron) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = QMaxMuted, + modifier = Modifier.size(26.dp) + ) + } + } +} + +@Composable +private fun SettingsMenuDivider() { + HorizontalDivider(color = QMaxDivider, modifier = Modifier.padding(start = 72.dp)) +} + +@Composable +private fun MaxConnectionSettings( + state: QMaxUiState, + onRefreshMaxStatus: () -> Unit, + onStartMaxLogin: () -> Unit, + onMaxCode: (String) -> Unit, + onSubmitMaxCode: () -> Unit +) { + val status = state.maxStatus + val loginAvailable = status?.isAuthorized == false + val waitingForCode = isMaxLoginWaitingForCode(status) + val requestCodeEnabled = loginAvailable && !waitingForCode && !state.loading + val submitCodeEnabled = loginAvailable && state.maxCode.trim().isNotBlank() && !state.loading + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 28.dp) + ) { + item { + SettingsSectionLabel("СОСТОЯНИЕ") + TelegramSettingsCard { + Column(Modifier.padding(18.dp)) { + Text( + maxStatusTitle(status), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = if (loginAvailable) Color(0xFFE05252) else QMaxText + ) + Text(maxStatusHint(status), color = QMaxMuted, modifier = Modifier.padding(top = 5.dp)) + status?.lastError?.takeIf { it.isNotBlank() }?.let { + Text(it, color = Color(0xFFE05252), modifier = Modifier.padding(top = 10.dp)) + } + Spacer(Modifier.height(16.dp)) + Button(onClick = onRefreshMaxStatus, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) { + Text("Обновить статус") + } } } - Spacer(Modifier.height(10.dp)) - OutlinedTextField( - value = state.maxCode, - onValueChange = onMaxCode, - enabled = loginAvailable && !state.loading, - label = { Text("Новый код MAX") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(8.dp)) - Button( - onClick = onSubmitMaxCode, - enabled = submitCodeEnabled, - modifier = Modifier.fillMaxWidth() - ) { - Text("Отдать код") + Spacer(Modifier.height(18.dp)) + SettingsSectionLabel("НОВЫЙ КОД") + TelegramSettingsCard { + Column(Modifier.padding(18.dp)) { + Button(onClick = onStartMaxLogin, enabled = requestCodeEnabled, modifier = Modifier.fillMaxWidth()) { + Text(if (waitingForCode) "Код запрошен" else "Получить новый код") + } + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = state.maxCode, + onValueChange = onMaxCode, + enabled = loginAvailable && !state.loading, + label = { Text("Новый код MAX") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(10.dp)) + Button(onClick = onSubmitMaxCode, enabled = submitCodeEnabled, modifier = Modifier.fillMaxWidth()) { + Text("Отдать код") + } + } } - Spacer(Modifier.height(22.dp)) - HorizontalDivider(color = QMaxDivider) - Spacer(Modifier.height(22.dp)) - Button(onClick = onRefresh, enabled = !state.loading) { - Text("Обновить чаты") + } + } +} + +@Composable +private fun UpdateSettings(state: QMaxUiState, onCheckUpdates: () -> Unit) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 28.dp) + ) { + item { + SettingsSectionLabel("ПРИЛОЖЕНИЕ") + TelegramSettingsCard { + Column(Modifier.padding(18.dp)) { + Text("QMAX", color = QMaxText, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text("Версия ${BuildConfig.VERSION_NAME}", color = QMaxMuted, modifier = Modifier.padding(top = 4.dp)) + Spacer(Modifier.height(16.dp)) + Button(onClick = onCheckUpdates, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) { + Text(if (state.loading) "Проверяю..." else "Проверить обновление") + } + state.updateMessage?.let { + Text(it, color = QMaxMuted, modifier = Modifier.padding(top = 12.dp)) + } + } } - Spacer(Modifier.height(8.dp)) - TextButton(onClick = onLogout) { - Text("Выйти") + } + } +} + +@Composable +private fun DataSettings(state: QMaxUiState, onRefresh: () -> Unit, onLogout: () -> Unit) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 28.dp) + ) { + item { + SettingsSectionLabel("СИНХРОНИЗАЦИЯ") + TelegramSettingsCard { + Column(Modifier.padding(18.dp)) { + Text("Чаты и контакты", color = QMaxText, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text("Загрузить актуальные данные с сервера MAX.", color = QMaxMuted, modifier = Modifier.padding(top = 4.dp)) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRefresh, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) { + Text(if (state.loading) "Обновляю..." else "Обновить данные") + } + } + } + Spacer(Modifier.height(18.dp)) + SettingsSectionLabel("СЕССИЯ") + TelegramSettingsCard { + TelegramSettingsMenuRow( + icon = Icons.Filled.Close, + iconColor = Color(0xFFE05252), + title = "Выйти из QMAX", + subtitle = "Завершить текущую сессию", + titleColor = Color(0xFFE05252), + showChevron = false, + onClick = onLogout + ) } } } @@ -1527,7 +1826,13 @@ private enum class PaletteColorRole(val title: String) { Pattern("Узор фона") } -private data class PalettePreset(val title: String, val palette: ChatPalette) +private data class PalettePreset(val title: String, val palette: ChatPalette, val emoji: String = "🎨") + +private enum class ThemeEditorGroup(val title: String) { + Wallpaper("Обои"), + Accent("Цвет акцентов"), + Outgoing("Мои сообщения") +} @Composable private fun AppearanceSettingsSection( @@ -1536,136 +1841,1008 @@ private fun AppearanceSettingsSection( ) { val isDark = QMaxIsDarkTheme val palette = appearance.palette(isDark) - var editingRole by remember { mutableStateOf(null) } + var themeSettingsOpen by remember { mutableStateOf(false) } var previewFontSize by remember(appearance.chatFontSizeSp) { mutableStateOf(appearance.chatFontSizeSp) } var previewLineSpacing by remember(appearance.chatLineSpacingSp) { mutableStateOf(appearance.chatLineSpacingSp) } val presets = if (isDark) { listOf( - PalettePreset("Ночь", ChatPalettes.Dark), - PalettePreset("Полночь", ChatPalettes.MidnightDark), - PalettePreset("Лес", ChatPalettes.ForestDark) + PalettePreset("Ночь", ChatPalettes.Dark, "🌙"), + PalettePreset("Полночь", ChatPalettes.MidnightDark, "💎"), + PalettePreset("Лес", ChatPalettes.ForestDark, "🌲") ) } else { listOf( - PalettePreset("День", ChatPalettes.Light), - PalettePreset("Океан", ChatPalettes.OceanLight), - PalettePreset("Мята", ChatPalettes.MintLight) + PalettePreset("День", ChatPalettes.Light, "☃️"), + PalettePreset("Океан", ChatPalettes.OceanLight, "💎"), + PalettePreset("Мята", ChatPalettes.MintLight, "🤓") ) } - Text("Оформление", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = QMaxText) - Text( - "Режим интерфейса и отдельные палитры чата для дня и ночи.", - style = MaterialTheme.typography.bodyMedium, - color = QMaxMuted, - modifier = Modifier.padding(top = 4.dp) - ) - Spacer(Modifier.height(14.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - ThemeMode.entries.forEach { mode -> - ThemeModeButton( - mode = mode, - selected = appearance.mode == mode, - modifier = Modifier.weight(1f), - onClick = { onAppearanceChange(appearance.copy(mode = mode)) } - ) - } - } - Spacer(Modifier.height(16.dp)) - ChatThemePreview( - palette = palette, - fontSizeSp = previewFontSize, - lineSpacingSp = previewLineSpacing - ) - Spacer(Modifier.height(18.dp)) - Text("Готовые палитры", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = QMaxText) - Spacer(Modifier.height(8.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - items(presets, key = { it.title }) { preset -> - PalettePresetButton( - preset = preset, - selected = palette == preset.palette, - onClick = { onAppearanceChange(appearance.withPalette(isDark, preset.palette)) } + SettingsSectionLabel("ВНЕШНИЙ ВИД") + TelegramSettingsCard { + Column(Modifier.padding(18.dp)) { + Text("Режим интерфейса", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = QMaxText) + Text( + "Системная, светлая или тёмная тема приложения.", + style = MaterialTheme.typography.bodyMedium, + color = QMaxMuted, + modifier = Modifier.padding(top = 4.dp) ) + Spacer(Modifier.height(14.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemeMode.entries.forEach { mode -> + ThemeModeButton( + mode = mode, + selected = appearance.mode == mode, + modifier = Modifier.weight(1f), + onClick = { onAppearanceChange(appearance.copy(mode = mode)) } + ) + } + } } } Spacer(Modifier.height(18.dp)) - Text("Цвета чата", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = QMaxText) - PaletteColorRole.entries.forEach { role -> - PaletteColorRow( - title = role.title, - color = role.colorFrom(palette), - onClick = { editingRole = role } + SettingsSectionLabel("ТЕКСТ СООБЩЕНИЙ") + TelegramSettingsCard { + Column(Modifier.padding(18.dp)) { + Text("Размер и интервал", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = QMaxText) + Text( + "Изменения сразу видны в примере чата.", + color = QMaxMuted, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp, bottom = 14.dp) + ) + ChatThemePreview( + palette = palette, + fontSizeSp = previewFontSize, + lineSpacingSp = previewLineSpacing + ) + Spacer(Modifier.height(14.dp)) + ChatTypographySlider( + label = "Размер шрифта", + value = previewFontSize, + valueRange = 12f..24f, + valueText = "${previewFontSize.toInt()} sp", + onValueChange = { previewFontSize = it }, + onValueChangeFinished = { + onAppearanceChange(appearance.copy(chatFontSizeSp = previewFontSize)) + } + ) + ChatTypographySlider( + label = "Межстрочный интервал", + value = previewLineSpacing, + valueRange = 0f..12f, + valueText = "+${previewLineSpacing.toInt()} sp", + onValueChange = { previewLineSpacing = it }, + onValueChangeFinished = { + onAppearanceChange(appearance.copy(chatLineSpacingSp = previewLineSpacing)) + } + ) + TextButton( + onClick = { + previewFontSize = 16f + previewLineSpacing = 6f + onAppearanceChange(appearance.copy(chatFontSizeSp = 16f, chatLineSpacingSp = 6f)) + }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Сбросить текст") + } + } + } + Spacer(Modifier.height(18.dp)) + SettingsSectionLabel("ЦВЕТОВАЯ ТЕМА") + TelegramSettingsCard { + Column { + Text( + "Цветовая тема", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = QMaxBlue, + modifier = Modifier.padding(start = 18.dp, top = 18.dp, end = 18.dp, bottom = 12.dp) + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 18.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(presets, key = { it.title }) { preset -> + TelegramThemeThumbnail( + preset = preset, + selected = palette == preset.palette, + onClick = { onAppearanceChange(appearance.withPalette(isDark, preset.palette)) } + ) + } + } + Spacer(Modifier.height(14.dp)) + SettingsMenuDivider() + TelegramThemeActionRow( + icon = Icons.Filled.Settings, + title = if (isDark) "Переключить на дневную тему" else "Переключить на ночную тему", + onClick = { + onAppearanceChange(appearance.copy(mode = if (isDark) ThemeMode.Light else ThemeMode.Dark)) + } + ) + SettingsMenuDivider() + TelegramThemeActionRow( + icon = Icons.Filled.Edit, + title = "Настройки темы", + onClick = { themeSettingsOpen = true } + ) + } + } + + if (themeSettingsOpen) { + TelegramThemeSettingsDialog( + appearance = appearance, + isDark = isDark, + onAppearanceChange = onAppearanceChange, + onDismiss = { themeSettingsOpen = false } ) } +} + +@Composable +private fun TelegramThemeActionRow(icon: ImageVector, title: String, onClick: () -> Unit) { Row( - modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 15.dp), verticalAlignment = Alignment.CenterVertically ) { - Column(Modifier.weight(1f)) { - Text("Узор на фоне", color = QMaxText, fontWeight = FontWeight.Medium) - Text("Геометрический рисунок поверх цвета фона", color = QMaxMuted, style = MaterialTheme.typography.bodySmall) - } - Switch( - checked = palette.showPattern, - onCheckedChange = { show -> - onAppearanceChange(appearance.withPalette(isDark, palette.copy(showPattern = show))) - } + Icon(icon, contentDescription = null, tint = QMaxBlue, modifier = Modifier.size(28.dp)) + Spacer(Modifier.width(18.dp)) + Text(title, color = QMaxBlue, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = QMaxMuted) + } +} + +private fun telegramThemePresets(isDark: Boolean): List { + return if (isDark) { + listOf( + PalettePreset("Ночь", ChatPalettes.Dark, "🌙"), + PalettePreset("Полночь", ChatPalettes.MidnightDark, "💎"), + PalettePreset("Лес", ChatPalettes.ForestDark, "🌲"), + PalettePreset( + "Графит", + ChatPalettes.Dark.copy(accent = 0xFF8F9BA8.toInt(), outgoingBubble = 0xFF394653.toInt()), + "🎮" + ), + PalettePreset( + "Слива", + ChatPalettes.MidnightDark.copy(accent = 0xFFBF6BE3.toInt(), outgoingBubble = 0xFF653B78.toInt()), + "💜" + ), + PalettePreset( + "Закат", + ChatPalettes.Dark.copy(accent = 0xFFE06C75.toInt(), outgoingBubble = 0xFF75434E.toInt()), + "🌷" + ) + ) + } else { + listOf( + PalettePreset("Классическая", ChatPalettes.Light.copy(accent = 0xFF5BAA55.toInt()), "🏠"), + PalettePreset("Мята", ChatPalettes.MintLight, "🐥"), + PalettePreset("Зима", ChatPalettes.Light, "☃️"), + PalettePreset("Океан", ChatPalettes.OceanLight, "💎"), + PalettePreset( + "Учёба", + ChatPalettes.MintLight.copy( + accent = 0xFF20B5A6.toInt(), + chatBackground = 0xFFD7F0E7.toInt(), + outgoingBubble = 0xFFD8F7E5.toInt() + ), + "🤓" + ), + PalettePreset( + "Рассвет", + ChatPalettes.Light.copy( + accent = 0xFFE78063.toInt(), + chatBackground = 0xFFFFE2CF.toInt(), + outgoingBubble = 0xFFFFE9D1.toInt(), + patternColor = 0xFFD9795E.toInt() + ), + "🌷" + ), + PalettePreset( + "Мечта", + ChatPalettes.Light.copy( + accent = 0xFFB959D0.toInt(), + chatBackground = 0xFFF4D5EF.toInt(), + outgoingBubble = 0xFFF7DFEF.toInt(), + patternColor = 0xFF9D62C0.toInt() + ), + "💜" + ), + PalettePreset( + "Праздник", + ChatPalettes.Light.copy( + accent = 0xFFE7A42D.toInt(), + chatBackground = 0xFFFFE7AF.toInt(), + outgoingBubble = 0xFFFFF1BE.toInt(), + patternColor = 0xFFDC9233.toInt() + ), + "🎄" + ), + PalettePreset( + "Игра", + ChatPalettes.OceanLight.copy( + accent = 0xFF7A59D1.toInt(), + chatBackground = 0xFFE1D4F7.toInt(), + outgoingBubble = 0xFFD8F4F4.toInt() + ), + "🎮" + ) ) } - Spacer(Modifier.height(18.dp)) - Text("Текст сообщений", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = QMaxText) - ChatTypographySlider( - label = "Размер шрифта", - value = previewFontSize, - valueRange = 12f..24f, - valueText = "${previewFontSize.toInt()} sp", - onValueChange = { previewFontSize = it }, - onValueChangeFinished = { - onAppearanceChange(appearance.copy(chatFontSizeSp = previewFontSize)) - } - ) - ChatTypographySlider( - label = "Межстрочный интервал", - value = previewLineSpacing, - valueRange = 0f..12f, - valueText = "+${previewLineSpacing.toInt()} sp", - onValueChange = { previewLineSpacing = it }, - onValueChangeFinished = { - onAppearanceChange(appearance.copy(chatLineSpacingSp = previewLineSpacing)) - } - ) - Spacer(Modifier.height(8.dp)) - TextButton(onClick = { onAppearanceChange(appearance.resetPalette(isDark)) }) { - Text("Сбросить палитру ${if (isDark) "тёмной" else "светлой"} темы") - } - TextButton( - onClick = { - previewFontSize = 16f - previewLineSpacing = 6f - onAppearanceChange(appearance.copy(chatFontSizeSp = 16f, chatLineSpacingSp = 6f)) - } - ) { - Text("Сбросить размер и интервал текста") +} + +@Composable +private fun TelegramThemeSettingsDialog( + appearance: AppearanceSettings, + isDark: Boolean, + onAppearanceChange: (AppearanceSettings) -> Unit, + onDismiss: () -> Unit +) { + var editorOpen by remember { mutableStateOf(false) } + var editorSession by remember { mutableStateOf(0) } + var createConfirmationOpen by remember { mutableStateOf(false) } + var editorInitialPalette by remember { mutableStateOf(appearance.palette(isDark)) } + + fun openEditor() { + editorInitialPalette = appearance.palette(isDark) + editorSession += 1 + editorOpen = true } - editingRole?.let { role -> - ColorEditorDialog( - title = role.title, - initialColor = role.colorFrom(palette), - onDismiss = { editingRole = null }, - onConfirm = { color -> - editingRole = null - val updatedPalette = role.update(palette, color) - onAppearanceChange(appearance.withPalette(isDark, updatedPalette)) + Dialog( + onDismissRequest = {}, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false) + ) { + val dialogView = LocalView.current + val dialogIsDark = QMaxIsDarkTheme + SideEffect { + (dialogView.parent as? DialogWindowProvider)?.window?.let { dialogWindow -> + WindowCompat.getInsetsController(dialogWindow, dialogWindow.decorView).apply { + isAppearanceLightStatusBars = !dialogIsDark + isAppearanceLightNavigationBars = !dialogIsDark + } + } + } + Surface(color = QMaxBackground, modifier = Modifier.fillMaxSize()) { + if (editorOpen) { + TelegramThemeEditorScreen( + session = editorSession, + initialPalette = editorInitialPalette, + onBack = { editorOpen = false }, + onSave = { updated -> + onAppearanceChange(appearance.withPalette(isDark, updated)) + editorOpen = false + } + ) + } else { + TelegramThemeSettingsScreen( + appearance = appearance, + isDark = isDark, + onAppearanceChange = onAppearanceChange, + onEditTheme = ::openEditor, + onCreateTheme = { createConfirmationOpen = true }, + onBack = onDismiss + ) + } + } + } + + if (createConfirmationOpen) { + AlertDialog( + onDismissRequest = { createConfirmationOpen = false }, + title = { Text("Новая цветовая тема") }, + text = { + Text( + "Настройте нужные цвета, чтобы создать собственную тему оформления. " + + "В этом же разделе можно вернуться к теме по умолчанию." + ) + }, + confirmButton = { + TextButton( + onClick = { + createConfirmationOpen = false + openEditor() + } + ) { Text("Создать тему") } + }, + dismissButton = { + TextButton(onClick = { createConfirmationOpen = false }) { Text("Отмена") } } ) } } +@Composable +private fun TelegramThemeSettingsScreen( + appearance: AppearanceSettings, + isDark: Boolean, + onAppearanceChange: (AppearanceSettings) -> Unit, + onEditTheme: () -> Unit, + onCreateTheme: () -> Unit, + onBack: () -> Unit +) { + BackHandler(onBack = onBack) + val palette = appearance.palette(isDark) + val presets = telegramThemePresets(isDark) + Column(Modifier.fillMaxSize().statusBarsPadding()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack, modifier = Modifier.size(52.dp)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад", tint = QMaxText) + } + Text( + "Настройки темы", + color = QMaxText, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + onAppearanceChange(appearance.copy(mode = if (isDark) ThemeMode.Light else ThemeMode.Dark)) + }, + modifier = Modifier.size(52.dp) + ) { + Text(if (isDark) "☀️" else "🌙", style = MaterialTheme.typography.headlineSmall) + } + } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 14.dp, top = 6.dp, end = 14.dp, bottom = 32.dp) + ) { + item { + TelegramSettingsCard { + Column(Modifier.padding(14.dp)) { + Text("Выбрать тему", color = QMaxBlue, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(14.dp)) + presets.chunked(3).forEach { rowPresets -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + rowPresets.forEach { preset -> + TelegramThemeThumbnail( + preset = preset, + selected = palette == preset.palette, + onClick = { onAppearanceChange(appearance.withPalette(isDark, preset.palette)) }, + modifier = Modifier.weight(1f) + ) + } + repeat(3 - rowPresets.size) { Spacer(Modifier.weight(1f)) } + } + Spacer(Modifier.height(10.dp)) + } + } + } + Spacer(Modifier.height(16.dp)) + TelegramFullThemePreview( + palette = palette, + modifier = Modifier.fillMaxWidth().height(255.dp), + compact = true + ) + Spacer(Modifier.height(16.dp)) + TelegramSettingsCard { + Column(Modifier.padding(top = 16.dp, bottom = 8.dp)) { + Text( + "Создать тему", + color = QMaxBlue, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 18.dp) + ) + Spacer(Modifier.height(14.dp)) + LazyRow( + contentPadding = PaddingValues(horizontal = 18.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + val baseThemes = listOf( + PalettePreset("Классическая", ChatPalettes.MintLight, ""), + PalettePreset("Дневная", ChatPalettes.Light, ""), + PalettePreset("Тёмная", ChatPalettes.Dark, ""), + PalettePreset("Ночная", ChatPalettes.MidnightDark, "") + ) + items(baseThemes, key = { it.title }) { preset -> + BaseThemeTile( + preset = preset, + selected = palette == preset.palette, + onClick = { + val targetDark = preset.title == "Тёмная" || preset.title == "Ночная" + onAppearanceChange( + appearance + .copy(mode = if (targetDark) ThemeMode.Dark else ThemeMode.Light) + .withPalette(targetDark, preset.palette) + ) + } + ) + } + } + Spacer(Modifier.height(16.dp)) + LazyRow( + contentPadding = PaddingValues(horizontal = 18.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(ThemeAccentSwatches, key = { it }) { color -> + ThemeAccentSwatch( + color = color, + selected = palette.accent == color, + onClick = { + onAppearanceChange(appearance.withPalette(isDark, palette.copy(accent = color))) + } + ) + } + } + Spacer(Modifier.height(10.dp)) + } + } + Spacer(Modifier.height(16.dp)) + TelegramSettingsCard { + TelegramThemeActionRow(Icons.Filled.Edit, "Изменить тему", onEditTheme) + SettingsMenuDivider() + TelegramThemeActionRow(Icons.Filled.Add, "Создать новую тему", onCreateTheme) + SettingsMenuDivider() + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text("Узор на фоне", color = QMaxText, fontWeight = FontWeight.SemiBold) + Text("Геометрический рисунок поверх фона", color = QMaxMuted, style = MaterialTheme.typography.bodySmall) + } + Switch( + checked = palette.showPattern, + onCheckedChange = { show -> + onAppearanceChange(appearance.withPalette(isDark, palette.copy(showPattern = show))) + } + ) + } + } + } + } + } +} + +private val ThemeAccentSwatches = listOf( + 0xFF20B5A6.toInt(), + 0xFF3498C5.toInt(), + 0xFFB74FD3.toInt(), + 0xFFD94B7D.toInt(), + 0xFF8A5AD6.toInt(), + 0xFF2EB875.toInt(), + 0xFFE28A3F.toInt() +) + +@Composable +private fun ThemeAccentSwatch(color: Int, selected: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(Color(color)) + .border(if (selected) 4.dp else 1.dp, if (selected) QMaxSurface else QMaxDivider, CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Box(Modifier.size(18.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.75f))) + if (selected) { + Surface(color = Color.Transparent, shape = CircleShape, border = androidx.compose.foundation.BorderStroke(2.dp, Color.White), modifier = Modifier.matchParentSize()) {} + } + } +} + +@Composable +private fun BaseThemeTile(preset: PalettePreset, selected: Boolean, onClick: () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .width(92.dp) + .height(122.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(preset.palette.chatBackground)) + .border(if (selected) 3.dp else 1.dp, if (selected) QMaxBlue else QMaxDivider, RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(10.dp) + ) { + Box( + Modifier + .align(Alignment.TopStart) + .fillMaxWidth(0.72f) + .height(24.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color(preset.palette.incomingBubble)) + ) + Box( + Modifier + .align(Alignment.CenterEnd) + .fillMaxWidth(0.68f) + .height(24.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color(preset.palette.outgoingBubble)) + ) + Box( + Modifier + .align(Alignment.BottomCenter) + .size(24.dp) + .border(3.dp, if (selected) QMaxBlue else QMaxMuted, CircleShape) + ) + } + Text(preset.title, color = QMaxText, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 6.dp)) + } +} + +@Composable +private fun TelegramFullThemePreview( + palette: ChatPalette, + modifier: Modifier = Modifier, + compact: Boolean = false +) { + val outgoingColors = palette.outgoingGradientColors.takeIf { it.size > 1 } + ?.map(::Color) + ?: listOf(Color(palette.outgoingBubble), Color(palette.outgoingBubble)) + val transition = rememberInfiniteTransition(label = "theme preview gradient") + val gradientShift by transition.animateFloat( + initialValue = -0.35f, + targetValue = 0.35f, + animationSpec = infiniteRepeatable(tween(3200), RepeatMode.Reverse), + label = "theme preview gradient shift" + ) + val outgoingBrush = Brush.linearGradient( + colors = outgoingColors, + start = Offset(if (palette.animateOutgoingGradient) gradientShift * 440f else 0f, 0f), + end = Offset(if (palette.animateOutgoingGradient) (gradientShift + 1f) * 440f else 440f, 440f) + ) + Box( + modifier = modifier + .clip(RoundedCornerShape(24.dp)) + .background(Color(palette.chatBackground)) + .padding(14.dp) + ) { + if (palette.showPattern) { + Canvas(Modifier.matchParentSize()) { + val pattern = Color(palette.patternColor).copy(alpha = 0.17f) + val step = 42.dp.toPx() + var y = -step + var row = 0 + while (y < size.height + step) { + var x = if (row % 2 == 0) 0f else step / 2f + while (x < size.width + step) { + drawCircle(pattern, 5.dp.toPx(), Offset(x, y)) + drawLine(pattern, Offset(x + 12.dp.toPx(), y + 12.dp.toPx()), Offset(x + 26.dp.toPx(), y + 22.dp.toPx()), 1.dp.toPx()) + x += step + } + y += step + row += 1 + } + } + } + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (!compact) { + Box( + Modifier + .align(Alignment.End) + .fillMaxWidth(0.65f) + .clip(RoundedCornerShape(20.dp)) + .background(outgoingBrush) + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Column { + Text("Голосовое сообщение", color = Color(palette.messageText), fontWeight = FontWeight.SemiBold) + Text("0:03 22:12 ✓✓", color = Color(palette.messageText).copy(alpha = 0.58f), style = MaterialTheme.typography.labelSmall) + } + } + } + Surface( + color = Color(palette.incomingBubble), + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth(0.72f) + ) { + Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { + Text("Sevenhill", color = Color(palette.accent), fontWeight = FontWeight.Bold) + Text("Доброе утро! 👋", color = Color(palette.messageText)) + Text("Знаешь, который час? 22:11", color = Color(palette.messageText), style = MaterialTheme.typography.bodyMedium) + } + } + Box( + Modifier + .align(Alignment.End) + .fillMaxWidth(0.68f) + .clip(RoundedCornerShape(20.dp)) + .background(outgoingBrush) + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Column { + Text("Test User", color = Color(palette.accent), fontWeight = FontWeight.Bold) + Text("В Токио утро 😎 22:26 ✓✓", color = Color(palette.messageText)) + } + } + } + } +} + +private fun ThemeEditorGroup.roles(): List = when (this) { + ThemeEditorGroup.Wallpaper -> listOf(PaletteColorRole.ChatBackground, PaletteColorRole.Pattern) + ThemeEditorGroup.Accent -> listOf(PaletteColorRole.Accent, PaletteColorRole.IncomingBubble) + ThemeEditorGroup.Outgoing -> listOf(PaletteColorRole.OutgoingBubble) +} + +@Composable +private fun TelegramThemeEditorScreen( + session: Int, + initialPalette: ChatPalette, + onBack: () -> Unit, + onSave: (ChatPalette) -> Unit +) { + var draftPalette by remember(session) { mutableStateOf(initialPalette) } + var group by remember(session) { mutableStateOf(ThemeEditorGroup.Accent) } + var selectedRole by remember(session) { mutableStateOf(PaletteColorRole.Accent) } + var outgoingColorIndex by remember(session) { mutableStateOf(0) } + var groupMenuOpen by remember { mutableStateOf(false) } + var optionsMenuOpen by remember { mutableStateOf(false) } + var discardDialogOpen by remember { mutableStateOf(false) } + val outgoingColors = draftPalette.outgoingGradientColors.takeIf { it.isNotEmpty() } + ?: listOf(draftPalette.outgoingBubble) + val selectedColor = if (group == ThemeEditorGroup.Outgoing) { + outgoingColors.getOrElse(outgoingColorIndex) { outgoingColors.first() } + } else { + selectedRole.colorFrom(draftPalette) + } + var hexText by remember(group, selectedRole, outgoingColorIndex, selectedColor) { + mutableStateOf(formatThemeColor(selectedColor)) + } + + fun updateSelectedColor(color: Int) { + if (group == ThemeEditorGroup.Outgoing) { + val colors = outgoingColors.toMutableList() + val index = outgoingColorIndex.coerceIn(0, colors.lastIndex) + colors[index] = color + draftPalette = draftPalette.copy( + outgoingBubble = colors.first(), + outgoingGradientColors = if (colors.size > 1) colors else emptyList() + ) + } else { + draftPalette = selectedRole.update(draftPalette, color) + } + } + + fun requestBack() { + if (draftPalette != initialPalette) discardDialogOpen = true else onBack() + } + + BackHandler(onBack = ::requestBack) + Column(Modifier.fillMaxSize().background(QMaxBackground).statusBarsPadding()) { + Row( + modifier = Modifier.fillMaxWidth().height(64.dp).padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = ::requestBack, modifier = Modifier.size(52.dp)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад", tint = QMaxText) + } + Box(Modifier.weight(1f)) { + Row( + modifier = Modifier.clickable { groupMenuOpen = true }.padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(group.title, color = QMaxText, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Spacer(Modifier.width(6.dp)) + Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null, tint = QMaxText) + } + DropdownMenu(expanded = groupMenuOpen, onDismissRequest = { groupMenuOpen = false }) { + ThemeEditorGroup.entries.forEach { item -> + DropdownMenuItem( + text = { Text(item.title) }, + onClick = { + group = item + selectedRole = item.roles().first() + outgoingColorIndex = 0 + groupMenuOpen = false + } + ) + } + } + } + TextButton(onClick = { onSave(draftPalette) }) { + Text("Сохранить", color = QMaxText, fontWeight = FontWeight.Bold) + } + } + Box(Modifier.weight(1f).fillMaxWidth()) { + TelegramFullThemePreview( + palette = draftPalette, + modifier = Modifier.fillMaxSize() + ) + if (group == ThemeEditorGroup.Outgoing) { + Row( + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ThemeEditorToolChip( + title = "Анимация", + selected = draftPalette.animateOutgoingGradient, + onClick = { + draftPalette = draftPalette.copy(animateOutgoingGradient = !draftPalette.animateOutgoingGradient) + } + ) + Surface( + color = Color.Black.copy(alpha = 0.78f), + shape = CircleShape, + modifier = Modifier.size(52.dp).clickable { + if (outgoingColors.size > 1) { + val rotated = outgoingColors.drop(1) + outgoingColors.first() + draftPalette = draftPalette.copy(outgoingBubble = rotated.first(), outgoingGradientColors = rotated) + outgoingColorIndex = 0 + } + } + ) { + Icon(Icons.Filled.Refresh, contentDescription = "Повернуть цвета", tint = Color.White, modifier = Modifier.padding(13.dp)) + } + ThemeEditorToolChip(title = "Цвета", selected = true, onClick = {}) + } + } + } + Surface( + color = QMaxSurface, + shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp), + shadowElevation = 8.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = hexText, + onValueChange = { input -> + if (input.length <= 7) { + hexText = input.uppercase(Locale.ROOT) + parseThemeColor(input)?.let(::updateSelectedColor) + } + }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = QMaxSurfaceVariant, + unfocusedContainerColor = QMaxSurfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedTextColor = QMaxText, + unfocusedTextColor = QMaxText, + cursorColor = QMaxBlue + ), + shape = RoundedCornerShape(28.dp), + modifier = Modifier.width(132.dp).height(54.dp) + ) + Spacer(Modifier.width(12.dp)) + if (group == ThemeEditorGroup.Outgoing) { + outgoingColors.forEachIndexed { index, color -> + ThemeEditorColorDot( + color = color, + selected = outgoingColorIndex == index, + onClick = { outgoingColorIndex = index } + ) + Spacer(Modifier.width(8.dp)) + } + if (outgoingColors.size < 4) { + IconButton( + onClick = { + val hsv = FloatArray(3).also { AndroidColor.colorToHSV(outgoingColors.last(), it) } + val added = AndroidColor.HSVToColor(floatArrayOf((hsv[0] + 38f) % 360f, hsv[1], hsv[2])) + val updated = outgoingColors + added + draftPalette = draftPalette.copy(outgoingBubble = updated.first(), outgoingGradientColors = updated) + outgoingColorIndex = updated.lastIndex + } + ) { + Icon(Icons.Filled.Add, contentDescription = "Добавить цвет", tint = QMaxText, modifier = Modifier.size(30.dp)) + } + } + } else { + group.roles().forEach { role -> + ThemeEditorColorDot( + color = role.colorFrom(draftPalette), + selected = selectedRole == role, + onClick = { selectedRole = role } + ) + Spacer(Modifier.width(8.dp)) + } + } + Spacer(Modifier.weight(1f)) + Box { + IconButton(onClick = { optionsMenuOpen = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = "Дополнительно", tint = QMaxText) + } + DropdownMenu(expanded = optionsMenuOpen, onDismissRequest = { optionsMenuOpen = false }) { + if (group == ThemeEditorGroup.Outgoing && outgoingColors.size > 1) { + DropdownMenuItem( + text = { Text("Удалить выбранный цвет") }, + onClick = { + val index = outgoingColorIndex.coerceIn(0, outgoingColors.lastIndex) + val updated = outgoingColors.filterIndexed { itemIndex, _ -> itemIndex != index } + draftPalette = draftPalette.copy( + outgoingBubble = updated.first(), + outgoingGradientColors = if (updated.size > 1) updated else emptyList() + ) + outgoingColorIndex = outgoingColorIndex.coerceAtMost(updated.lastIndex) + optionsMenuOpen = false + } + ) + } + DropdownMenuItem( + text = { Text("Сбросить эту группу") }, + onClick = { + draftPalette = when (group) { + ThemeEditorGroup.Wallpaper -> draftPalette.copy( + chatBackground = initialPalette.chatBackground, + patternColor = initialPalette.patternColor, + showPattern = initialPalette.showPattern + ) + ThemeEditorGroup.Accent -> draftPalette.copy( + accent = initialPalette.accent, + incomingBubble = initialPalette.incomingBubble + ) + ThemeEditorGroup.Outgoing -> draftPalette.copy( + outgoingBubble = initialPalette.outgoingBubble, + outgoingGradientColors = initialPalette.outgoingGradientColors, + animateOutgoingGradient = initialPalette.animateOutgoingGradient + ) + } + outgoingColorIndex = 0 + optionsMenuOpen = false + } + ) + } + } + } + TelegramColorSpectrum( + color = selectedColor, + onColorChange = { color -> + hexText = formatThemeColor(color) + updateSelectedColor(color) + } + ) + } + } + } + + if (discardDialogOpen) { + AlertDialog( + onDismissRequest = { discardDialogOpen = false }, + title = { Text("Сохранить изменения?") }, + text = { Text("Применить выбранные вами новые цвета?") }, + confirmButton = { + TextButton(onClick = { discardDialogOpen = false; onSave(draftPalette) }) { Text("Сохранить") } + }, + dismissButton = { + TextButton(onClick = { discardDialogOpen = false; onBack() }) { Text("Сбросить") } + } + ) + } +} + +@Composable +private fun ThemeEditorToolChip(title: String, selected: Boolean, onClick: () -> Unit) { + Surface( + color = Color.Black.copy(alpha = 0.78f), + shape = RoundedCornerShape(28.dp), + modifier = Modifier.clickable(onClick = onClick) + ) { + Row(Modifier.padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(24.dp) + .border(3.dp, Color.White, CircleShape) + .then(if (selected) Modifier.background(QMaxBlue, CircleShape).padding(5.dp) else Modifier) + ) + Spacer(Modifier.width(8.dp)) + Text(title, color = Color.White, fontWeight = FontWeight.SemiBold) + } + } +} + +@Composable +private fun ThemeEditorColorDot(color: Int, selected: Boolean, onClick: () -> Unit) { + Box( + Modifier + .size(46.dp) + .clip(CircleShape) + .background(Color(color)) + .border(if (selected) 4.dp else 1.dp, if (selected) QMaxBlue else QMaxDivider, CircleShape) + .clickable(onClick = onClick) + ) +} + +@Composable +private fun TelegramColorSpectrum(color: Int, onColorChange: (Int) -> Unit) { + val hsv = remember(color) { FloatArray(3).also { AndroidColor.colorToHSV(color, it) } } + var spectrumSize by remember { mutableStateOf(IntSize.Zero) } + val hueColors = listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + + fun updateSpectrum(position: Offset) { + if (spectrumSize.width <= 0 || spectrumSize.height <= 0) return + val hue = (position.x / spectrumSize.width.toFloat()).coerceIn(0f, 1f) * 360f + val saturation = 1f - (position.y / spectrumSize.height.toFloat()).coerceIn(0f, 1f) + onColorChange(AndroidColor.HSVToColor(floatArrayOf(hue, saturation, hsv[2]))) + } + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(230.dp) + .onSizeChanged { spectrumSize = it } + .pointerInput(color, spectrumSize) { + detectTapGestures { position -> updateSpectrum(position) } + } + .pointerInput(color, spectrumSize, "drag") { + detectDragGestures { change, _ -> + change.consume() + updateSpectrum(change.position) + } + } + ) { + drawRect(Brush.horizontalGradient(hueColors)) + drawRect(Brush.verticalGradient(listOf(Color.Transparent, Color.White))) + if (hsv[2] < 0.999f) { + drawRect(Color.Black.copy(alpha = 1f - hsv[2])) + } + val center = Offset(hsv[0] / 360f * size.width, (1f - hsv[1]) * size.height) + drawCircle(Color.White, radius = 14.dp.toPx(), center = center) + drawCircle(Color(color), radius = 10.dp.toPx(), center = center) + } + TelegramBrightnessSlider(color = color, onColorChange = onColorChange) +} + +@Composable +private fun TelegramBrightnessSlider(color: Int, onColorChange: (Int) -> Unit) { + val hsv = remember(color) { FloatArray(3).also { AndroidColor.colorToHSV(color, it) } } + var sliderSize by remember { mutableStateOf(IntSize.Zero) } + val brightColor = Color(AndroidColor.HSVToColor(floatArrayOf(hsv[0], hsv[1], 1f))) + + fun updateBrightness(position: Offset) { + if (sliderSize.width <= 0) return + val brightness = (1f - position.x / sliderSize.width.toFloat()).coerceIn(0f, 1f) + onColorChange(AndroidColor.HSVToColor(floatArrayOf(hsv[0], hsv[1], brightness))) + } + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(76.dp) + .padding(horizontal = 24.dp, vertical = 20.dp) + .onSizeChanged { sliderSize = it } + .pointerInput(color, sliderSize) { detectTapGestures { updateBrightness(it) } } + .pointerInput(color, sliderSize, "drag") { + detectDragGestures { change, _ -> + change.consume() + updateBrightness(change.position) + } + } + ) { + val trackHeight = 12.dp.toPx() + drawRoundRect( + brush = Brush.horizontalGradient(listOf(brightColor, Color.Black)), + topLeft = Offset(0f, (size.height - trackHeight) / 2f), + size = androidx.compose.ui.geometry.Size(size.width, trackHeight), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(trackHeight / 2f, trackHeight / 2f) + ) + val center = Offset((1f - hsv[2]) * size.width, size.height / 2f) + drawCircle(Color.White, radius = 15.dp.toPx(), center = center) + drawCircle(Color(color), radius = 11.dp.toPx(), center = center) + } +} + @Composable private fun ThemeModeButton( mode: ThemeMode, @@ -1761,58 +2938,67 @@ private fun ChatThemePreview( } @Composable -private fun PalettePresetButton( +private fun TelegramThemeThumbnail( preset: PalettePreset, selected: Boolean, - onClick: () -> Unit + onClick: () -> Unit, + modifier: Modifier = Modifier.width(112.dp) ) { - Surface( - color = QMaxSurface, - shape = RoundedCornerShape(14.dp), - border = androidx.compose.foundation.BorderStroke(2.dp, if (selected) QMaxBlue else QMaxDivider), - modifier = Modifier.width(104.dp).clickable(onClick = onClick) + Box( + modifier = modifier + .height(148.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(preset.palette.chatBackground)) + .border(3.dp, if (selected) QMaxBlue else Color.Transparent, RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(8.dp) ) { - Column(Modifier.padding(9.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - listOf(preset.palette.chatBackground, preset.palette.incomingBubble, preset.palette.outgoingBubble).forEach { color -> - Box( - Modifier - .size(22.dp) - .clip(CircleShape) - .background(Color(color)) - .border(1.dp, Color(preset.palette.patternColor).copy(alpha = 0.35f), CircleShape) - ) + if (preset.palette.showPattern) { + Canvas(Modifier.matchParentSize()) { + val color = Color(preset.palette.patternColor).copy(alpha = 0.16f) + val step = 22.dp.toPx() + var y = 0f + while (y < size.height) { + var x = 0f + while (x < size.width) { + drawCircle(color, 2.dp.toPx(), Offset(x, y)) + x += step + } + y += step } } - Text( - preset.title, - color = QMaxText, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(top = 7.dp) - ) } - } -} - -@Composable -private fun PaletteColorRow(title: String, color: Int, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = 9.dp), - verticalAlignment = Alignment.CenterVertically - ) { Box( Modifier - .size(38.dp) - .clip(CircleShape) - .background(Color(color)) - .border(1.dp, QMaxDivider, CircleShape) + .align(Alignment.TopEnd) + .fillMaxWidth(0.72f) + .height(28.dp) + .clip(RoundedCornerShape(13.dp)) + .background(Color(preset.palette.outgoingBubble)) ) - Spacer(Modifier.width(12.dp)) - Text(title, color = QMaxText, modifier = Modifier.weight(1f)) - Text(formatThemeColor(color), color = QMaxMuted, style = MaterialTheme.typography.labelLarge) + Box( + Modifier + .align(Alignment.CenterStart) + .fillMaxWidth(0.68f) + .height(28.dp) + .clip(RoundedCornerShape(13.dp)) + .background(Color(preset.palette.incomingBubble)) + ) + Text( + preset.emoji, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.align(Alignment.BottomCenter) + ) + if (selected) { + Surface( + color = QMaxBlue, + shape = CircleShape, + modifier = Modifier.align(Alignment.BottomEnd).size(24.dp) + ) + { + Icon(Icons.Filled.Check, contentDescription = null, tint = QMaxOnAccent, modifier = Modifier.padding(4.dp)) + } + } } } @@ -1838,119 +3024,6 @@ private fun ChatTypographySlider( ) } -@Composable -private fun ColorEditorDialog( - title: String, - initialColor: Int, - onDismiss: () -> Unit, - onConfirm: (Int) -> Unit -) { - var workingColor by remember(initialColor) { mutableStateOf(initialColor) } - var hexText by remember(initialColor) { mutableStateOf(formatThemeColor(initialColor)) } - val parsedColor = parseThemeColor(hexText) - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - Column(Modifier.fillMaxWidth()) { - Box( - Modifier - .fillMaxWidth() - .height(54.dp) - .clip(RoundedCornerShape(14.dp)) - .background(Color(workingColor)) - .border(1.dp, QMaxDivider, RoundedCornerShape(14.dp)) - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = hexText, - onValueChange = { input -> - if (input.length <= 7) { - hexText = input.uppercase(Locale.ROOT) - parseThemeColor(input)?.let { workingColor = it } - } - }, - label = { Text("HEX") }, - supportingText = { - if (parsedColor == null) Text("Формат: #RRGGBB", color = MaterialTheme.colorScheme.error) - }, - isError = parsedColor == null, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(8.dp)) - ColorComponentSlider("R", workingColor, 16) { color -> - workingColor = color - hexText = formatThemeColor(color) - } - ColorComponentSlider("G", workingColor, 8) { color -> - workingColor = color - hexText = formatThemeColor(color) - } - ColorComponentSlider("B", workingColor, 0) { color -> - workingColor = color - hexText = formatThemeColor(color) - } - Spacer(Modifier.height(8.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(7.dp)) { - items(ColorEditorSwatches, key = { it }) { swatch -> - Box( - Modifier - .size(32.dp) - .clip(CircleShape) - .background(Color(swatch)) - .border(if (swatch == workingColor) 3.dp else 1.dp, QMaxDivider, CircleShape) - .clickable { - workingColor = swatch - hexText = formatThemeColor(swatch) - } - ) - } - } - } - }, - confirmButton = { - Button(onClick = { onConfirm(parsedColor ?: workingColor) }, enabled = parsedColor != null) { - Text("Применить") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Отмена") } - } - ) -} - -@Composable -private fun ColorComponentSlider(label: String, color: Int, shift: Int, onColorChange: (Int) -> Unit) { - val component = (color shr shift) and 0xFF - Row(verticalAlignment = Alignment.CenterVertically) { - Text(label, color = QMaxText, modifier = Modifier.width(20.dp), fontWeight = FontWeight.Bold) - Slider( - value = component.toFloat(), - onValueChange = { value -> - val componentMask = 0xFF shl shift - val updated = (color and componentMask.inv()) or (value.roundToWhole().toInt() shl shift) - onColorChange(updated or 0xFF000000.toInt()) - }, - valueRange = 0f..255f, - modifier = Modifier.weight(1f) - ) - Text(component.toString(), color = QMaxMuted, textAlign = TextAlign.End, modifier = Modifier.width(34.dp)) - } -} - -private val ColorEditorSwatches = listOf( - 0xFFFFFFFF.toInt(), - 0xFF17212B.toInt(), - 0xFF3390EC.toInt(), - 0xFF20A98A.toInt(), - 0xFF8774E1.toInt(), - 0xFFE85D75.toInt(), - 0xFFF29D38.toInt(), - 0xFFE7F7C8.toInt(), - 0xFF2B5278.toInt() -) - private fun PaletteColorRole.colorFrom(palette: ChatPalette): Int = when (this) { PaletteColorRole.Accent -> palette.accent PaletteColorRole.ChatBackground -> palette.chatBackground @@ -2090,8 +3163,8 @@ private fun TelegramBottomNavigation( ) { Surface( color = QMaxSurface, - shape = RoundedCornerShape(30.dp), - shadowElevation = 8.dp, + shape = RoundedCornerShape(32.dp), + shadowElevation = 12.dp, modifier = modifier.fillMaxWidth() ) { Row( @@ -2624,16 +3697,16 @@ private fun ChatScreen(vm: QMaxViewModel) { TextField( value = chatSearchQuery, onValueChange = { chatSearchQuery = it }, - placeholder = { Text("Поиск в чате", color = QMaxOnAccent.copy(alpha = 0.75f)) }, + placeholder = { Text("Поиск в чате", color = QMaxMuted) }, singleLine = true, colors = TextFieldDefaults.colors( - focusedTextColor = QMaxOnAccent, - unfocusedTextColor = QMaxOnAccent, + focusedTextColor = QMaxText, + unfocusedTextColor = QMaxText, focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, - cursorColor = QMaxOnAccent + cursorColor = QMaxBlue ), modifier = Modifier.fillMaxWidth() ) @@ -2653,7 +3726,7 @@ private fun ChatScreen(vm: QMaxViewModel) { if (chatPresenceText != null) { Text( chatPresenceText, - color = QMaxOnAccent, + color = QMaxBlue, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -2676,17 +3749,17 @@ private fun ChatScreen(vm: QMaxViewModel) { } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = QMaxBlue, - titleContentColor = QMaxOnAccent, - navigationIconContentColor = QMaxOnAccent, - actionIconContentColor = QMaxOnAccent + containerColor = QMaxSurface.copy(alpha = 0.96f), + titleContentColor = QMaxText, + navigationIconContentColor = QMaxBlue, + actionIconContentColor = QMaxBlue ), actions = { if (chatSearchMode) { if (state.chatSearchLoading && chatSearchQuery.isNotBlank()) { CircularProgressIndicator( modifier = Modifier.size(16.dp), - color = QMaxOnAccent, + color = QMaxBlue, strokeWidth = 2.dp ) } @@ -2697,7 +3770,7 @@ private fun ChatScreen(vm: QMaxViewModel) { val current = if (searchResultIndexes.isEmpty()) 0 else activeSearchPosition.coerceIn(0, searchResultIndexes.lastIndex) + 1 "$current/${searchResultIndexes.size}" }, - color = QMaxOnAccent, + color = QMaxMuted, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 4.dp) ) @@ -3364,16 +4437,38 @@ private fun MessageRow( val showAlbumGrid = visualAttachments.size > 1 && nonVisualAttachments.isEmpty() val edgeToEdgeMedia = visualAttachments.isNotEmpty() val shape = messageShape(outgoing) + val outgoingGradient = QMaxOutgoingGradient + val animateOutgoingGradient = QMaxAnimateOutgoingGradient + val gradientTransition = rememberInfiniteTransition(label = "outgoing message gradient") + val gradientShift by gradientTransition.animateFloat( + initialValue = -0.35f, + targetValue = 0.35f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 3600), + repeatMode = RepeatMode.Reverse + ), + label = "outgoing message gradient shift" + ) val bubbleColor = when { isActiveSearchMatch -> Color(0xFFFFF1A8) isSearchMatch -> Color(0xFFFFF7D6) outgoing -> QMaxOutgoing else -> QMaxIncoming } + val useOutgoingGradient = outgoing && outgoingGradient.size > 1 && !isSearchMatch && !isActiveSearchMatch + val outgoingBrush = if (useOutgoingGradient) { + Brush.linearGradient( + colors = outgoingGradient, + start = Offset(if (animateOutgoingGradient) gradientShift * 520f else 0f, 0f), + end = Offset(if (animateOutgoingGradient) (gradientShift + 1f) * 520f else 520f, 520f) + ) + } else { + null + } var bubbleModifier = Modifier .widthIn(min = 56.dp, max = 320.dp) .clip(shape) - .background(bubbleColor) + .then(if (outgoingBrush != null) Modifier.background(outgoingBrush) else Modifier.background(bubbleColor)) .padding(if (edgeToEdgeMedia) 0.dp else 8.dp) if (isActiveSearchMatch) { bubbleModifier = bubbleModifier.border(2.dp, QMaxBlue, shape) @@ -4824,7 +5919,7 @@ private fun ImagePreview( verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = onClose, modifier = Modifier.size(44.dp)) { - Icon(Icons.Filled.Close, contentDescription = "Закрыть", tint = Color.White) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад", tint = Color.White) } Column(Modifier.weight(1f)) { Text( @@ -4875,23 +5970,23 @@ private fun ImagePreview( } } } - Row( - modifier = Modifier - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(bottom = 18.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Surface(color = Color(0x99000000), shape = RoundedCornerShape(18.dp)) { - Text( - text = "${(scale * 100).toInt()}%", - color = Color.White, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp) - ) - } - if (scale > 1.02f || offset != Offset.Zero) { + if (scale > 1.02f || offset != Offset.Zero) { + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 18.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface(color = Color(0x99000000), shape = RoundedCornerShape(18.dp)) { + Text( + text = "${(scale * 100).toInt()}%", + color = Color.White, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp) + ) + } TextButton(onClick = { resetZoom() }) { Text("Сбросить", color = Color.White) } diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/settings/AppearanceSettings.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/settings/AppearanceSettings.kt index 76885da..6418104 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/settings/AppearanceSettings.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/settings/AppearanceSettings.kt @@ -13,7 +13,9 @@ data class ChatPalette( val outgoingBubble: Int, val messageText: Int, val patternColor: Int, - val showPattern: Boolean = true + val showPattern: Boolean = true, + val outgoingGradientColors: List = emptyList(), + val animateOutgoingGradient: Boolean = false ) object ChatPalettes { diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/settings/AppearanceStore.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/settings/AppearanceStore.kt index 488551a..be60433 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/settings/AppearanceStore.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/settings/AppearanceStore.kt @@ -52,6 +52,8 @@ class AppearanceStore(private val context: Context) { val messageText = intPreferencesKey("${prefix}_message_text") val patternColor = intPreferencesKey("${prefix}_pattern_color") val showPattern = booleanPreferencesKey("${prefix}_show_pattern") + val outgoingGradientColors = stringPreferencesKey("${prefix}_outgoing_gradient_colors") + val animateOutgoingGradient = booleanPreferencesKey("${prefix}_animate_outgoing_gradient") } private fun Preferences.readPalette(keys: PaletteKeys, fallback: ChatPalette): ChatPalette { @@ -62,7 +64,13 @@ class AppearanceStore(private val context: Context) { outgoingBubble = this[keys.outgoingBubble] ?: fallback.outgoingBubble, messageText = this[keys.messageText] ?: fallback.messageText, patternColor = this[keys.patternColor] ?: fallback.patternColor, - showPattern = this[keys.showPattern] ?: fallback.showPattern + showPattern = this[keys.showPattern] ?: fallback.showPattern, + outgoingGradientColors = this[keys.outgoingGradientColors] + ?.split(',') + ?.mapNotNull { value -> value.toLongOrNull(16)?.toInt() } + ?.take(4) + ?: fallback.outgoingGradientColors, + animateOutgoingGradient = this[keys.animateOutgoingGradient] ?: fallback.animateOutgoingGradient ) } @@ -77,5 +85,9 @@ class AppearanceStore(private val context: Context) { this[keys.messageText] = palette.messageText this[keys.patternColor] = palette.patternColor this[keys.showPattern] = palette.showPattern + this[keys.outgoingGradientColors] = palette.outgoingGradientColors + .take(4) + .joinToString(",") { color -> color.toUInt().toString(16) } + this[keys.animateOutgoingGradient] = palette.animateOutgoingGradient } } diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/theme/QMaxTheme.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/theme/QMaxTheme.kt index 5121f7f..247cdd3 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/ui/theme/QMaxTheme.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/theme/QMaxTheme.kt @@ -29,6 +29,8 @@ private data class QMaxThemeColors( val chatBackground: Color, val incomingBubble: Color, val outgoingBubble: Color, + val outgoingGradientColors: List, + val animateOutgoingGradient: Boolean, val messageText: Color, val pattern: Color, val showPattern: Boolean, @@ -106,6 +108,16 @@ val QMaxOutgoing: Color @ReadOnlyComposable get() = LocalQMaxColors.current.outgoingBubble +val QMaxOutgoingGradient: List + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.outgoingGradientColors + +val QMaxAnimateOutgoingGradient: Boolean + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.animateOutgoingGradient + val QMaxPattern: Color @Composable @ReadOnlyComposable @@ -174,15 +186,17 @@ private fun colorsFor( return QMaxThemeColors( accent = accent, onAccent = if (accent.luminance() > 0.48f) Color.Black else Color.White, - appBackground = if (isDark) Color(0xFF0E1621) else Color(0xFFF3F7FA), - surface = if (isDark) Color(0xFF17212B) else Color.White, - surfaceVariant = if (isDark) Color(0xFF232E3B) else Color(0xFFE9EEF2), - divider = if (isDark) Color(0xFF2A3948) else Color(0xFFE7EEF3), - text = if (isDark) Color(0xFFF5F7FA) else Color(0xFF17212B), - muted = if (isDark) Color(0xFFA7B3BF) else Color(0xFF6C7883), + appBackground = if (isDark) Color(0xFF111416) else Color(0xFFF2F2F4), + surface = if (isDark) Color(0xFF1C2023) else Color.White, + surfaceVariant = if (isDark) Color(0xFF292E32) else Color(0xFFF0F1F3), + divider = if (isDark) Color(0xFF30363A) else Color(0xFFE5E7E9), + text = if (isDark) Color(0xFFF4F7F7) else Color(0xFF111617), + muted = if (isDark) Color(0xFF9AA3A7) else Color(0xFF858D91), chatBackground = Color(palette.chatBackground), incomingBubble = Color(palette.incomingBubble), outgoingBubble = Color(palette.outgoingBubble), + outgoingGradientColors = palette.outgoingGradientColors.map(::Color), + animateOutgoingGradient = palette.animateOutgoingGradient, messageText = Color(palette.messageText), pattern = Color(palette.patternColor), showPattern = palette.showPattern, diff --git a/deploy/releases/android/latest.json b/deploy/releases/android/latest.json index d8db9db..1cd55a9 100644 --- a/deploy/releases/android/latest.json +++ b/deploy/releases/android/latest.json @@ -1,14 +1,14 @@ { "slug": "qmax", "name": "QMAX", - "version": "1.0.0", - "androidVersionCode": 57, + "version": "1.0.1", + "androidVersionCode": 58, "channel": "stable", "platform": "android", "packageKind": "apk", - "downloadPath": "/api/app-updates/android/download/qmax-1.0.0-stable.apk", - "packageSizeBytes": 17911217, - "sha256": "f21d2f6ed8584ba204560c75fecae6d78041b20f620fb4ef992e11e6a230b5bb", - "notes": "QMAX 1.0.0", - "publishedAt": "2026-07-15T09:12:25.7697332Z" + "downloadPath": "/api/app-updates/android/download/qmax-1.0.1-stable.apk", + "packageSizeBytes": 17976753, + "sha256": "ff59d69d2b783d53fbd100ffb4354c150f9c342c5a2b24bb4ea9063bac98bcf9", + "notes": "QMAX 1.0.1", + "publishedAt": "2026-07-15T21:06:52.7275098Z" } \ No newline at end of file diff --git a/deploy/releases/android/qmax-1.0.1-stable.apk b/deploy/releases/android/qmax-1.0.1-stable.apk new file mode 100644 index 0000000..9fd4085 Binary files /dev/null and b/deploy/releases/android/qmax-1.0.1-stable.apk differ