diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 94fafd8..6fce6fa 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 = 55 - versionName = "0.1.54" + versionCode = 57 + versionName = "1.0.0" 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 8a936d6..1fcc437 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -104,6 +104,7 @@ import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -114,6 +115,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -128,6 +130,7 @@ import androidx.compose.ui.geometry.Offset 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.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager @@ -140,6 +143,7 @@ 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.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.ExperimentalComposeUiApi import androidx.core.content.ContextCompat @@ -147,6 +151,7 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.MediaItem import androidx.media3.common.Player @@ -174,13 +179,27 @@ import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto import xyz.kusoft.qmax.core.model.MessageDto import xyz.kusoft.qmax.core.model.PhoneContactDto import xyz.kusoft.qmax.core.model.QMaxSession +import xyz.kusoft.qmax.core.settings.AppearanceSettings +import xyz.kusoft.qmax.core.settings.ChatPalette +import xyz.kusoft.qmax.core.settings.ChatPalettes +import xyz.kusoft.qmax.core.settings.ThemeMode import xyz.kusoft.qmax.ui.QMaxUiState import xyz.kusoft.qmax.ui.QMaxViewModel import xyz.kusoft.qmax.ui.theme.QMaxBackground import xyz.kusoft.qmax.ui.theme.QMaxBlue +import xyz.kusoft.qmax.ui.theme.QMaxChatBackground +import xyz.kusoft.qmax.ui.theme.QMaxChatTextStyle +import xyz.kusoft.qmax.ui.theme.QMaxDivider import xyz.kusoft.qmax.ui.theme.QMaxIncoming +import xyz.kusoft.qmax.ui.theme.QMaxIsDarkTheme +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.QMaxPattern +import xyz.kusoft.qmax.ui.theme.QMaxShowPattern +import xyz.kusoft.qmax.ui.theme.QMaxSurface +import xyz.kusoft.qmax.ui.theme.QMaxSurfaceVariant import xyz.kusoft.qmax.ui.theme.QMaxText import xyz.kusoft.qmax.ui.theme.QMaxTheme import xyz.kusoft.qmax.ui.theme.QMaxUnread @@ -198,7 +217,18 @@ class MainActivity : ComponentActivity() { pendingOpenChatId.value = intent.chatIdExtra() val container = (application as QMaxApplication).container setContent { - QMaxTheme { + val appearance by container.appearanceStore.settings.collectAsStateWithLifecycle( + initialValue = AppearanceSettings() + ) + val appearanceScope = rememberCoroutineScope() + QMaxTheme(appearance = appearance) { + val isDark = QMaxIsDarkTheme + SideEffect { + WindowCompat.getInsetsController(window, window.decorView).apply { + isAppearanceLightStatusBars = !isDark + isAppearanceLightNavigationBars = !isDark + } + } val vm: QMaxViewModel = viewModel(factory = QMaxViewModel.Factory(container.repository)) val chatId = pendingOpenChatId.value LaunchedEffect(chatId) { @@ -207,7 +237,13 @@ class MainActivity : ComponentActivity() { pendingOpenChatId.value = null } } - QMaxApp(vm) + QMaxApp( + vm = vm, + appearance = appearance, + onAppearanceChange = { updated -> + appearanceScope.launch { container.appearanceStore.save(updated) } + } + ) } } } @@ -255,7 +291,11 @@ private fun readPhoneContacts(contentResolver: ContentResolver): List Unit +) { val state by vm.state val context = LocalContext.current var notificationPermissionRequested by rememberSaveable { mutableStateOf(false) } @@ -277,7 +317,7 @@ private fun QMaxApp(vm: QMaxViewModel) { when { state.session == null -> LoginScreen(vm) state.selectedChat != null -> ChatScreen(vm) - else -> ChatListScreen(vm) + else -> ChatListScreen(vm, appearance, onAppearanceChange) } state.previewImageUrl?.let { url -> @@ -315,7 +355,7 @@ private fun AutoLoginScreen(loading: Boolean, error: String?, onRetry: () -> Uni Column( modifier = Modifier .fillMaxSize() - .background(Color.White) + .background(QMaxSurface) .padding(28.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally @@ -343,7 +383,7 @@ private fun LoginScreen(vm: QMaxViewModel) { Column( modifier = Modifier .fillMaxSize() - .background(Color.White) + .background(QMaxSurface) .padding(24.dp), verticalArrangement = Arrangement.Center ) { @@ -414,7 +454,11 @@ private enum class MainTab { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ChatListScreen(vm: QMaxViewModel) { +private fun ChatListScreen( + vm: QMaxViewModel, + appearance: AppearanceSettings, + onAppearanceChange: (AppearanceSettings) -> Unit +) { val state by vm.state var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) } var menuOpen by remember { mutableStateOf(false) } @@ -535,7 +579,9 @@ private fun ChatListScreen(vm: QMaxViewModel) { onDeleteSelectedChats = { selectionMenuOpen = false pendingBulkAction = ChatBulkUiAction.DeleteChats - } + }, + appearance = appearance, + onAppearanceChange = onAppearanceChange ) pendingBulkAction?.let { action -> @@ -621,16 +667,16 @@ private fun ChatListScreen(vm: QMaxViewModel) { TextField( value = state.searchQuery, onValueChange = vm::updateSearchQuery, - placeholder = { Text("Поиск", color = Color(0xCCFFFFFF)) }, + placeholder = { Text("Поиск", color = QMaxOnAccent.copy(alpha = 0.75f)) }, singleLine = true, colors = TextFieldDefaults.colors( - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, + focusedTextColor = QMaxOnAccent, + unfocusedTextColor = QMaxOnAccent, focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, - cursorColor = Color.White + cursorColor = QMaxOnAccent ), modifier = Modifier.fillMaxWidth() ) @@ -661,9 +707,9 @@ private fun ChatListScreen(vm: QMaxViewModel) { }, colors = TopAppBarDefaults.topAppBarColors( containerColor = QMaxBlue, - titleContentColor = Color.White, - navigationIconContentColor = Color.White, - actionIconContentColor = Color.White + titleContentColor = QMaxOnAccent, + navigationIconContentColor = QMaxOnAccent, + actionIconContentColor = QMaxOnAccent ), actions = { if (!searchMode) { @@ -701,15 +747,15 @@ private fun ChatListScreen(vm: QMaxViewModel) { FloatingActionButton( onClick = { newChatOpen = true }, containerColor = QMaxBlue, - contentColor = Color.White, + contentColor = QMaxOnAccent, shape = CircleShape ) { Icon(Icons.Filled.Edit, contentDescription = "Новый чат") } }, - containerColor = Color.White + containerColor = QMaxSurface ) { padding -> - Column(Modifier.padding(padding).fillMaxSize().background(Color.White)) { + Column(Modifier.padding(padding).fillMaxSize().background(QMaxSurface)) { ServiceBanner(vm) state.updateMessage?.let { Text( @@ -736,7 +782,7 @@ private fun ChatListScreen(vm: QMaxViewModel) { onCacheAvatar = vm::cacheAvatar, onClick = { vm.openChat(chat) } ) - HorizontalDivider(color = Color(0xFFE9EEF2), thickness = 0.6.dp, modifier = Modifier.padding(start = 74.dp)) + HorizontalDivider(color = QMaxDivider, thickness = 0.6.dp, modifier = Modifier.padding(start = 74.dp)) } } } @@ -786,7 +832,9 @@ private fun TelegramChatListContent( onToggleChatSelection: (String) -> Unit, onBeginChatSelection: (String) -> Unit, onClearSelectedHistory: () -> Unit, - onDeleteSelectedChats: () -> Unit + onDeleteSelectedChats: () -> Unit, + appearance: AppearanceSettings, + onAppearanceChange: (AppearanceSettings) -> Unit ) { val selectedCount = state.selectedChatIds.size val listTab = activeTab != MainTab.Settings @@ -805,12 +853,12 @@ private fun TelegramChatListContent( MainTab.Channels -> "Каналов пока нет" MainTab.Settings -> null } - Box(Modifier.fillMaxSize().background(Color.White)) { + Box(Modifier.fillMaxSize().background(QMaxSurface)) { Column( Modifier .fillMaxSize() .statusBarsPadding() - .background(Color.White) + .background(QMaxSurface) ) { if (selectedCount > 0) { ChatSelectionHeader( @@ -843,7 +891,9 @@ private fun TelegramChatListContent( onMaxCode = onMaxCode, onSubmitMaxCode = onSubmitMaxCode, onRefresh = onRefresh, - onLogout = onLogout + onLogout = onLogout, + appearance = appearance, + onAppearanceChange = onAppearanceChange ) } else { ChatListSearchBar( @@ -925,7 +975,7 @@ private fun TelegramChatListContent( FloatingActionButton( onClick = onImportPhoneContacts, containerColor = QMaxBlue, - contentColor = Color.White, + contentColor = QMaxOnAccent, shape = CircleShape, modifier = Modifier .align(Alignment.BottomEnd) @@ -966,7 +1016,7 @@ private fun TelegramLikeHeader( Spacer(Modifier.width(14.dp)) Text( title, - color = if (title.startsWith("Соединение", ignoreCase = true)) Color(0xFF20BFA9) else QMaxText, + color = if (title.startsWith("Соединение", ignoreCase = true)) QMaxBlue else QMaxText, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, maxLines = 1, @@ -1003,7 +1053,7 @@ private fun QMaxHeaderAvatarStack() { Box(Modifier.width(100.dp).height(54.dp)) { Avatar(title = "Q", size = 50.dp) Surface( - color = Color.White, + color = QMaxSurface, shape = CircleShape, modifier = Modifier .align(Alignment.Center) @@ -1013,7 +1063,7 @@ private fun QMaxHeaderAvatarStack() { Avatar(title = "M", size = 50.dp) } Surface( - color = Color.White, + color = QMaxSurface, shape = CircleShape, modifier = Modifier .align(Alignment.CenterEnd) @@ -1023,7 +1073,7 @@ private fun QMaxHeaderAvatarStack() { Modifier .padding(2.dp) .clip(CircleShape) - .background(Color.White), + .background(QMaxSurface), contentAlignment = Alignment.Center ) { Text("MAX", color = QMaxBlue, fontWeight = FontWeight.Black, style = MaterialTheme.typography.labelMedium) @@ -1103,7 +1153,7 @@ private fun ChatSelectionHeader( @Composable private fun ChatListSearchBar(query: String, placeholder: String, onQuery: (String) -> Unit) { Surface( - color = Color(0xFFF1F1F3), + color = QMaxSurfaceVariant, shape = RoundedCornerShape(30.dp), modifier = Modifier .fillMaxWidth() @@ -1114,12 +1164,12 @@ private fun ChatListSearchBar(query: String, placeholder: String, onQuery: (Stri modifier = Modifier.fillMaxSize().padding(start = 18.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Filled.Search, contentDescription = null, tint = Color(0xFF6E747A), modifier = Modifier.size(28.dp)) + Icon(Icons.Filled.Search, contentDescription = null, tint = QMaxMuted, modifier = Modifier.size(28.dp)) TextField( value = query, onValueChange = onQuery, placeholder = { - Text(placeholder, color = Color(0xFF8A8E93), style = MaterialTheme.typography.titleMedium) + Text(placeholder, color = QMaxMuted, style = MaterialTheme.typography.titleMedium) }, singleLine = true, colors = TextFieldDefaults.colors( @@ -1129,7 +1179,7 @@ private fun ChatListSearchBar(query: String, placeholder: String, onQuery: (Stri unfocusedContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, - cursorColor = Color(0xFF20BFA9) + cursorColor = QMaxBlue ), modifier = Modifier.weight(1f) ) @@ -1222,8 +1272,8 @@ private fun TelegramChatRow( val isMediaPreview = !hasDraft && isGenericMediaPreview(chat.lastMessagePreview) val unreadText = chat.unreadCount.coerceAtMost(999).toString() val rowBackground = when { - isSelected -> Color(0xFFEAF7FF) - selectionActive -> Color(0xFFFAFCFD) + isSelected -> QMaxBlue.copy(alpha = 0.14f) + selectionActive -> QMaxSurfaceVariant.copy(alpha = 0.45f) else -> Color.Transparent } @@ -1251,7 +1301,7 @@ private fun TelegramChatRow( Surface( color = QMaxBlue, shape = CircleShape, - border = androidx.compose.foundation.BorderStroke(2.dp, Color.White), + border = androidx.compose.foundation.BorderStroke(2.dp, QMaxSurface), modifier = Modifier .align(Alignment.BottomEnd) .size(22.dp) @@ -1259,7 +1309,7 @@ private fun TelegramChatRow( Icon( Icons.Filled.Check, contentDescription = null, - tint = Color.White, + tint = QMaxOnAccent, modifier = Modifier.padding(3.dp) ) } @@ -1278,11 +1328,11 @@ private fun TelegramChatRow( modifier = Modifier.weight(1f) ) if (chat.isPinned) { - Icon(Icons.Filled.PushPin, contentDescription = null, tint = Color(0xFFB6BFC4), modifier = Modifier.size(16.dp)) + Icon(Icons.Filled.PushPin, contentDescription = null, tint = QMaxMuted, modifier = Modifier.size(16.dp)) } if (chat.isMuted) { Spacer(Modifier.width(4.dp)) - Icon(Icons.Filled.NotificationsOff, contentDescription = null, tint = Color(0xFFB6BFC4), modifier = Modifier.size(18.dp)) + Icon(Icons.Filled.NotificationsOff, contentDescription = null, tint = QMaxMuted, modifier = Modifier.size(18.dp)) } } Spacer(Modifier.height(4.dp)) @@ -1290,8 +1340,8 @@ private fun TelegramChatRow( preview, color = when { hasDraft -> Color(0xFFD84343) - isMediaPreview -> Color(0xFF20BFA9) - else -> Color(0xFF7B8085) + isMediaPreview -> QMaxBlue + else -> QMaxMuted }, style = MaterialTheme.typography.bodyLarge, maxLines = 1, @@ -1302,7 +1352,7 @@ private fun TelegramChatRow( Column(horizontalAlignment = Alignment.End, modifier = Modifier.widthIn(min = 52.dp)) { Text( formatChatTime(chat.lastMessageAt), - color = Color(0xFF8A8E93), + color = QMaxMuted, style = MaterialTheme.typography.bodyLarge, maxLines = 1, textAlign = TextAlign.End @@ -1312,7 +1362,7 @@ private fun TelegramChatRow( Surface(color = QMaxBlue, shape = RoundedCornerShape(18.dp)) { Text( unreadText, - color = Color.White, + color = QMaxOnAccent, modifier = Modifier.widthIn(min = 34.dp).padding(horizontal = 8.dp, vertical = 2.dp), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, @@ -1329,8 +1379,8 @@ private fun ChatListFloatingActions(modifier: Modifier = Modifier, onNewChat: () Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { FloatingActionButton( onClick = onNewChat, - containerColor = Color(0xFF20BFA9), - contentColor = Color.White, + containerColor = QMaxBlue, + contentColor = QMaxOnAccent, shape = CircleShape, modifier = Modifier.size(64.dp) ) { @@ -1363,12 +1413,23 @@ private fun SettingsTabContent( onMaxCode: (String) -> Unit, onSubmitMaxCode: () -> Unit, onRefresh: () -> Unit, - onLogout: () -> Unit + onLogout: () -> Unit, + appearance: AppearanceSettings, + onAppearanceChange: (AppearanceSettings) -> Unit ) { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(start = 28.dp, top = 14.dp, end = 28.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)) @@ -1382,7 +1443,7 @@ private fun SettingsTabContent( Text(it, color = QMaxMuted, style = MaterialTheme.typography.bodyMedium) } Spacer(Modifier.height(22.dp)) - HorizontalDivider(color = Color(0xFFE7EEF3)) + HorizontalDivider(color = QMaxDivider) } item { val status = state.maxStatus @@ -1444,7 +1505,7 @@ private fun SettingsTabContent( Text("Отдать код") } Spacer(Modifier.height(22.dp)) - HorizontalDivider(color = Color(0xFFE7EEF3)) + HorizontalDivider(color = QMaxDivider) Spacer(Modifier.height(22.dp)) Button(onClick = onRefresh, enabled = !state.loading) { Text("Обновить чаты") @@ -1457,6 +1518,467 @@ private fun SettingsTabContent( } } +private enum class PaletteColorRole(val title: String) { + Accent("Акцент и шапка"), + ChatBackground("Фон чата"), + IncomingBubble("Входящие сообщения"), + OutgoingBubble("Исходящие сообщения"), + MessageText("Текст сообщений"), + Pattern("Узор фона") +} + +private data class PalettePreset(val title: String, val palette: ChatPalette) + +@Composable +private fun AppearanceSettingsSection( + appearance: AppearanceSettings, + onAppearanceChange: (AppearanceSettings) -> Unit +) { + val isDark = QMaxIsDarkTheme + val palette = appearance.palette(isDark) + var editingRole by remember { mutableStateOf(null) } + 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) + ) + } else { + listOf( + 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)) } + ) + } + } + 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 } + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.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))) + } + ) + } + 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("Сбросить размер и интервал текста") + } + + 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)) + } + ) + } +} + +@Composable +private fun ThemeModeButton( + mode: ThemeMode, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + val label = when (mode) { + ThemeMode.System -> "Система" + ThemeMode.Light -> "Светлая" + ThemeMode.Dark -> "Тёмная" + } + Surface( + color = if (selected) QMaxBlue.copy(alpha = 0.14f) else QMaxSurfaceVariant, + contentColor = if (selected) QMaxBlue else QMaxText, + shape = RoundedCornerShape(14.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, if (selected) QMaxBlue else QMaxDivider), + modifier = modifier.clickable(onClick = onClick) + ) { + Text( + label, + textAlign = TextAlign.Center, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 11.dp) + ) + } +} + +@Composable +private fun ChatThemePreview( + palette: ChatPalette, + fontSizeSp: Float, + lineSpacingSp: Float +) { + Surface( + shape = RoundedCornerShape(18.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, QMaxDivider), + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(palette.chatBackground)) + .padding(14.dp) + ) { + if (palette.showPattern) { + Canvas(Modifier.matchParentSize()) { + val pattern = Color(palette.patternColor).copy(alpha = 0.18f) + val step = 44.dp.toPx() + var y = 0f + while (y < size.height) { + var x = 0f + while (x < size.width) { + drawCircle(pattern, 3.dp.toPx(), Offset(x, y)) + x += step + } + y += step + } + } + } + Column(Modifier.fillMaxWidth()) { + Surface( + color = Color(palette.incomingBubble), + shape = RoundedCornerShape(14.dp), + modifier = Modifier.widthIn(max = 245.dp) + ) { + Text( + "Так будет выглядеть текст\nв сообщениях чата", + color = Color(palette.messageText), + fontSize = fontSizeSp.sp, + lineHeight = (fontSizeSp + lineSpacingSp).sp, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + Spacer(Modifier.height(8.dp)) + Surface( + color = Color(palette.outgoingBubble), + shape = RoundedCornerShape(14.dp), + modifier = Modifier.align(Alignment.End).widthIn(max = 245.dp) + ) { + Text( + "Цвета применяются сразу", + color = Color(palette.messageText), + fontSize = fontSizeSp.sp, + lineHeight = (fontSizeSp + lineSpacingSp).sp, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } + } + } +} + +@Composable +private fun PalettePresetButton( + preset: PalettePreset, + selected: Boolean, + onClick: () -> Unit +) { + 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) + ) { + 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) + ) + } + } + 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) + ) + Spacer(Modifier.width(12.dp)) + Text(title, color = QMaxText, modifier = Modifier.weight(1f)) + Text(formatThemeColor(color), color = QMaxMuted, style = MaterialTheme.typography.labelLarge) + } +} + +@Composable +private fun ChatTypographySlider( + label: String, + value: Float, + valueRange: ClosedFloatingPointRange, + valueText: String, + onValueChange: (Float) -> Unit, + onValueChangeFinished: () -> Unit +) { + Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(label, color = QMaxText, modifier = Modifier.weight(1f)) + Text(valueText, color = QMaxBlue, fontWeight = FontWeight.SemiBold) + } + Slider( + value = value, + onValueChange = { onValueChange(it.roundToWhole()) }, + onValueChangeFinished = onValueChangeFinished, + valueRange = valueRange, + steps = (valueRange.endInclusive - valueRange.start).toInt() - 1 + ) +} + +@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 + PaletteColorRole.IncomingBubble -> palette.incomingBubble + PaletteColorRole.OutgoingBubble -> palette.outgoingBubble + PaletteColorRole.MessageText -> palette.messageText + PaletteColorRole.Pattern -> palette.patternColor +} + +private fun PaletteColorRole.update(palette: ChatPalette, color: Int): ChatPalette = when (this) { + PaletteColorRole.Accent -> palette.copy(accent = color) + PaletteColorRole.ChatBackground -> palette.copy(chatBackground = color) + PaletteColorRole.IncomingBubble -> palette.copy(incomingBubble = color) + PaletteColorRole.OutgoingBubble -> palette.copy(outgoingBubble = color) + PaletteColorRole.MessageText -> palette.copy(messageText = color) + PaletteColorRole.Pattern -> palette.copy(patternColor = color) +} + +private fun formatThemeColor(color: Int): String = String.format(Locale.ROOT, "#%06X", color and 0xFFFFFF) + +private fun parseThemeColor(value: String): Int? { + val hex = value.trim().removePrefix("#") + if (hex.length != 6 || hex.any { it !in "0123456789abcdefABCDEF" }) return null + return 0xFF000000.toInt() or hex.toInt(16) +} + +private fun Float.roundToWhole(): Float = kotlin.math.round(this) + private fun isMaxLoginWaitingForCode(status: MaxBridgeStatusDto?): Boolean { return status?.loginStage.equals("Code", ignoreCase = true) || status?.status.equals("Code", ignoreCase = true) @@ -1511,7 +2033,7 @@ private fun ChannelSearchResults( ChannelSearchRow(result = result, onSubscribe = { onSubscribeChannel(result) }) } if (joinableResults.isNotEmpty()) { - HorizontalDivider(color = Color(0xFFE7EEF3), modifier = Modifier.padding(start = 88.dp)) + HorizontalDivider(color = QMaxDivider, modifier = Modifier.padding(start = 88.dp)) } } } @@ -1522,7 +2044,7 @@ private fun ChannelSearchRow(result: MaxChannelSearchResultDto, onSubscribe: () modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically ) { - Surface(color = Color(0xFFE6F4F1), shape = CircleShape, modifier = Modifier.size(54.dp)) { + Surface(color = QMaxBlue.copy(alpha = 0.12f), shape = CircleShape, modifier = Modifier.size(54.dp)) { Box(contentAlignment = Alignment.Center) { Text( result.title.firstOrNull()?.uppercaseChar()?.toString() ?: "#", @@ -1567,7 +2089,7 @@ private fun TelegramBottomNavigation( onTabSelected: (MainTab) -> Unit ) { Surface( - color = Color.White, + color = QMaxSurface, shape = RoundedCornerShape(30.dp), shadowElevation = 8.dp, modifier = modifier.fillMaxWidth() @@ -1614,13 +2136,13 @@ private fun RowScope.TelegramBottomNavItem( badge: Int? = null, onClick: () -> Unit ) { - val tint = if (selected) Color(0xFF20BFA9) else QMaxText + val tint = if (selected) QMaxBlue else QMaxText Column( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(26.dp)) .clickable(onClick = onClick) - .background(if (selected) Color(0xFFE6FAF5) else Color.Transparent) + .background(if (selected) QMaxBlue.copy(alpha = 0.12f) else Color.Transparent) .padding(vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -1633,10 +2155,10 @@ private fun RowScope.TelegramBottomNavItem( } } badge?.let { - Surface(color = Color(0xFF20BFA9), shape = CircleShape, modifier = Modifier.padding(start = 18.dp)) { + Surface(color = QMaxBlue, shape = CircleShape, modifier = Modifier.padding(start = 18.dp)) { Text( it.coerceAtMost(99).toString(), - color = Color.White, + color = QMaxOnAccent, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 6.dp, vertical = 1.dp) @@ -1707,9 +2229,9 @@ private fun SearchField(value: String, onValueChange: (String) -> Unit, modifier singleLine = true, shape = RoundedCornerShape(22.dp), colors = TextFieldDefaults.colors( - focusedContainerColor = Color(0xFFF1F4F7), - unfocusedContainerColor = Color(0xFFF1F4F7), - disabledContainerColor = Color(0xFFF1F4F7), + focusedContainerColor = QMaxSurfaceVariant, + unfocusedContainerColor = QMaxSurfaceVariant, + disabledContainerColor = QMaxSurfaceVariant, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent ), @@ -1725,7 +2247,7 @@ private fun ServiceBanner(vm: QMaxViewModel) { return } - Surface(color = Color(0xFFEFF6FF), tonalElevation = 0.dp) { + Surface(color = QMaxSurfaceVariant, tonalElevation = 0.dp) { Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) { Text("MAX требует входа", fontWeight = FontWeight.SemiBold, color = QMaxText) Text(status.loginStage ?: status.status, color = QMaxMuted, style = MaterialTheme.typography.bodySmall) @@ -1826,7 +2348,7 @@ private fun ChatRow( Surface(color = QMaxUnread, shape = CircleShape) { Text( chat.unreadCount.coerceAtMost(999).toString(), - color = Color.White, + color = QMaxOnAccent, modifier = Modifier.widthIn(min = 20.dp).padding(horizontal = 6.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center, @@ -2102,16 +2624,16 @@ private fun ChatScreen(vm: QMaxViewModel) { TextField( value = chatSearchQuery, onValueChange = { chatSearchQuery = it }, - placeholder = { Text("Поиск в чате", color = Color(0xCCFFFFFF)) }, + placeholder = { Text("Поиск в чате", color = QMaxOnAccent.copy(alpha = 0.75f)) }, singleLine = true, colors = TextFieldDefaults.colors( - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, + focusedTextColor = QMaxOnAccent, + unfocusedTextColor = QMaxOnAccent, focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, - cursorColor = Color.White + cursorColor = QMaxOnAccent ), modifier = Modifier.fillMaxWidth() ) @@ -2131,7 +2653,7 @@ private fun ChatScreen(vm: QMaxViewModel) { if (chatPresenceText != null) { Text( chatPresenceText, - color = Color.White, + color = QMaxOnAccent, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -2155,16 +2677,16 @@ private fun ChatScreen(vm: QMaxViewModel) { }, colors = TopAppBarDefaults.topAppBarColors( containerColor = QMaxBlue, - titleContentColor = Color.White, - navigationIconContentColor = Color.White, - actionIconContentColor = Color.White + titleContentColor = QMaxOnAccent, + navigationIconContentColor = QMaxOnAccent, + actionIconContentColor = QMaxOnAccent ), actions = { if (chatSearchMode) { if (state.chatSearchLoading && chatSearchQuery.isNotBlank()) { CircularProgressIndicator( modifier = Modifier.size(16.dp), - color = Color.White, + color = QMaxOnAccent, strokeWidth = 2.dp ) } @@ -2175,7 +2697,7 @@ private fun ChatScreen(vm: QMaxViewModel) { val current = if (searchResultIndexes.isEmpty()) 0 else activeSearchPosition.coerceIn(0, searchResultIndexes.lastIndex) + 1 "$current/${searchResultIndexes.size}" }, - color = Color.White, + color = QMaxOnAccent, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 4.dp) ) @@ -2250,7 +2772,7 @@ private fun ChatScreen(vm: QMaxViewModel) { ) } }, - containerColor = QMaxBackground + containerColor = QMaxChatBackground ) { padding -> Box( modifier = Modifier @@ -2323,7 +2845,7 @@ private fun ChatScreen(vm: QMaxViewModel) { listState.animateScrollToItem(timeline.lastIndex) } }, - containerColor = Color.White, + containerColor = QMaxSurface, contentColor = QMaxBlue, shape = CircleShape, modifier = Modifier @@ -2440,7 +2962,7 @@ private fun ForwardMessageDialog( } } ) - HorizontalDivider(color = Color(0xFFE9EEF2), thickness = 0.6.dp, modifier = Modifier.padding(start = 56.dp)) + HorizontalDivider(color = QMaxDivider, thickness = 0.6.dp, modifier = Modifier.padding(start = 56.dp)) } } } @@ -2506,7 +3028,7 @@ private fun ForwardChatRow( if (selected) { Surface(color = QMaxBlue, shape = CircleShape, modifier = Modifier.size(26.dp)) { Box(contentAlignment = Alignment.Center) { - Icon(Icons.Filled.Check, contentDescription = "Выбрано", tint = Color.White, modifier = Modifier.size(18.dp)) + Icon(Icons.Filled.Check, contentDescription = "Выбрано", tint = QMaxOnAccent, modifier = Modifier.size(18.dp)) } } } else { @@ -2517,7 +3039,7 @@ private fun ForwardChatRow( @Composable private fun ForwardMessagePreview(message: MessageDto) { - Surface(color = Color(0xFFE7F1FA), shape = RoundedCornerShape(10.dp)) { + Surface(color = QMaxSurfaceVariant, shape = RoundedCornerShape(10.dp)) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically @@ -2551,7 +3073,7 @@ private fun ForwardMessagePreview(message: MessageDto) { @Composable private fun ForwardSearchField(query: String, onQuery: (String) -> Unit) { - Surface(color = Color(0xFFF1F1F3), shape = RoundedCornerShape(24.dp)) { + Surface(color = QMaxSurfaceVariant, shape = RoundedCornerShape(24.dp)) { Row( modifier = Modifier.fillMaxWidth().height(46.dp).padding(start = 14.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically @@ -2560,7 +3082,7 @@ private fun ForwardSearchField(query: String, onQuery: (String) -> Unit) { TextField( value = query, onValueChange = onQuery, - placeholder = { Text("Кому переслать", color = Color(0xFF8A8E93)) }, + placeholder = { Text("Кому переслать", color = QMaxMuted) }, singleLine = true, colors = TextFieldDefaults.colors( focusedTextColor = QMaxText, @@ -2714,7 +3236,7 @@ private fun MaterialsMediaCell( val cachedImagePath = cachedImagePaths[attachment.id] Box( modifier = modifier - .background(Color(0xFFE1E8ED)) + .background(QMaxSurfaceVariant) .clickable { if (attachment.isImageAttachment()) { vm.openImage(cachedImagePath ?: url, imageGallery) @@ -2752,7 +3274,7 @@ private fun MaterialsFileList(items: List, vm: QMaxViewModel LazyColumn(Modifier.fillMaxSize()) { items(items, key = { it.attachment.id }) { item -> FileAttachment(item.attachment, vm, onLongPress = {}) - HorizontalDivider(color = Color(0xFFE7EEF3)) + HorizontalDivider(color = QMaxDivider) } } } @@ -2763,15 +3285,18 @@ private fun MaterialsVoiceList(items: List, session: QMaxSes items(items, key = { it.attachment.id }) { item -> val url = "${session.serverUrl.trimEnd('/')}${item.attachment.downloadPath}" VoiceAttachment(item.attachment, url, session.accessToken, onLongPress = {}) - HorizontalDivider(color = Color(0xFFE7EEF3)) + HorizontalDivider(color = QMaxDivider) } } } @Composable private fun ChatWallpaper() { - Canvas(Modifier.fillMaxSize().background(Color(0xFFE7EEF3))) { - val color = Color(0x1F6C7883) + val patternColor = QMaxPattern.copy(alpha = 0.14f) + val showPattern = QMaxShowPattern + Canvas(Modifier.fillMaxSize().background(QMaxChatBackground)) { + if (!showPattern) return@Canvas + val color = patternColor val step = 86.dp.toPx() val radius = 10.dp.toPx() var y = -step @@ -2805,7 +3330,7 @@ private fun ChatWallpaper() { @Composable private fun DaySeparator(label: String) { Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.Center) { - Surface(color = Color(0xCCDDE6ED), shape = RoundedCornerShape(14.dp)) { + Surface(color = QMaxSurface.copy(alpha = 0.86f), shape = RoundedCornerShape(14.dp)) { Text(label, color = QMaxMuted, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)) } } @@ -2902,7 +3427,12 @@ private fun MessageRow( } } message.text?.takeIf { it.isNotBlank() }?.let { - Text(it, color = QMaxText, modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) + Text( + text = it, + color = QMaxMessageText, + style = QMaxChatTextStyle, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) + ) } ReactionChips( message = message, @@ -3028,7 +3558,7 @@ private fun ReactionPickerRow(message: MessageDto, onReaction: (String) -> Unit) ) { QuickReactions.forEach { emoji -> Surface( - color = if (selected == emoji) Color(0xFFE7F1FA) else Color(0xFFF3F6F8), + color = if (selected == emoji) QMaxBlue.copy(alpha = 0.16f) else QMaxSurfaceVariant, shape = CircleShape, modifier = Modifier .size(38.dp) @@ -3040,7 +3570,7 @@ private fun ReactionPickerRow(message: MessageDto, onReaction: (String) -> Unit) } } } - HorizontalDivider(color = Color(0xFFE7EEF3)) + HorizontalDivider(color = QMaxDivider) } @Composable @@ -3060,7 +3590,7 @@ private fun ReactionChips( ) { message.reactions.forEach { reaction -> Surface( - color = if (reaction.reactedByMe) Color(0xFFDDF0FF) else Color(0xFFE9EEF2), + color = if (reaction.reactedByMe) QMaxBlue.copy(alpha = 0.18f) else QMaxSurfaceVariant, shape = RoundedCornerShape(14.dp), modifier = Modifier.clickable { onReaction(message, reaction.emoji) } ) { @@ -3102,7 +3632,7 @@ private fun EditPreviewBlock(message: MessageDto, modifier: Modifier = Modifier) modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .background(Color(0xFFE7F1FA)) + .background(QMaxSurfaceVariant) .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -3132,7 +3662,7 @@ private fun ReplyPreviewBlock(message: MessageDto, modifier: Modifier = Modifier modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .background(Color(0xFFE7F1FA)) + .background(QMaxSurfaceVariant) .clickable(onClick = onClick) .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically @@ -3271,7 +3801,7 @@ private fun MediaAlbumCell( Box( modifier = modifier - .background(Color(0xFFE1E8ED)) + .background(QMaxSurfaceVariant) ) { if (attachment.isImageAttachment()) { LaunchedEffect(attachment.id, url) { @@ -3483,7 +4013,7 @@ private fun ImageAttachment( .fillMaxWidth() .aspectRatio(aspectRatio) .clip(mediaShape) - .background(Color(0xFFE1E8ED)) + .background(QMaxSurfaceVariant) .combinedClickable( onClick = { vm.openImage(previewSource, imagePreviewSources) }, onLongClick = onLongPress @@ -3537,7 +4067,7 @@ private fun MediaImagePlaceholder(failed: Boolean) { Box( modifier = Modifier .fillMaxSize() - .background(Color(0xFFE1E8ED)), + .background(QMaxSurfaceVariant), contentAlignment = Alignment.Center ) { if (failed) { @@ -3658,7 +4188,7 @@ private fun VoiceAttachment(attachment: AttachmentDto, url: String, token: Strin Icon( if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, contentDescription = if (isPlaying) "Пауза" else "Воспроизвести", - tint = Color.White + tint = QMaxOnAccent ) } } @@ -3709,7 +4239,7 @@ private fun ContactAttachment(attachment: AttachmentDto, vm: QMaxViewModel, onLo .padding(horizontal = 4.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically ) { - Surface(color = Color(0xFFE9F3FD), shape = CircleShape) { + Surface(color = QMaxBlue.copy(alpha = 0.12f), shape = CircleShape) { Icon( Icons.Filled.Person, contentDescription = null, @@ -3756,7 +4286,7 @@ private fun FileAttachment(attachment: AttachmentDto, vm: QMaxViewModel, onLongP .padding(horizontal = 4.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { - Surface(color = Color(0xFFE9F3FD), shape = CircleShape) { + Surface(color = QMaxBlue.copy(alpha = 0.12f), shape = CircleShape) { Icon( Icons.AutoMirrored.Filled.InsertDriveFile, contentDescription = null, @@ -3865,7 +4395,7 @@ private fun Composer( Row( Modifier .fillMaxWidth() - .background(Color.White) + .background(QMaxSurface) .padding(horizontal = 10.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -3882,7 +4412,7 @@ private fun Composer( } Surface(color = QMaxBlue, shape = CircleShape) { IconButton(onClick = { stopRecording(send = true) }, modifier = Modifier.size(44.dp)) { - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Отправить", tint = Color.White) + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Отправить", tint = QMaxOnAccent) } } } @@ -3898,7 +4428,7 @@ private fun Composer( Row( Modifier .fillMaxWidth() - .background(Color.White) + .background(QMaxSurface) .padding(start = 10.dp, top = 7.dp, end = 8.dp, bottom = 2.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -3914,7 +4444,7 @@ private fun Composer( Row( Modifier .fillMaxWidth() - .background(Color.White) + .background(QMaxSurface) .padding(start = 10.dp, top = 7.dp, end = 8.dp, bottom = 2.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -3940,7 +4470,7 @@ private fun Composer( verticalAlignment = Alignment.CenterVertically ) { Surface( - color = Color.White, + color = QMaxSurface, shape = RoundedCornerShape(24.dp), tonalElevation = 1.dp, shadowElevation = 1.dp, @@ -3995,14 +4525,14 @@ private fun Composer( if (isSending) { CircularProgressIndicator( modifier = Modifier.size(22.dp), - color = Color.White, + color = QMaxOnAccent, strokeWidth = 2.dp ) } else { Icon( if (isEditing || canSend) Icons.AutoMirrored.Filled.Send else Icons.Filled.Mic, contentDescription = if (canSend) "Отправить" else "Записать голосовое", - tint = Color.White + tint = QMaxOnAccent ) } } @@ -4018,7 +4548,7 @@ private fun PendingAttachmentStrip( attachments: List, onRemove: (Uri) -> Unit ) { - Surface(color = Color.White, shadowElevation = 1.dp) { + Surface(color = QMaxSurface, shadowElevation = 1.dp) { LazyRow( modifier = Modifier .fillMaxWidth() @@ -4041,7 +4571,7 @@ private fun PendingAttachmentChip(uri: Uri, onRemove: (Uri) -> Unit) { .width(width) .height(72.dp) .clip(RoundedCornerShape(12.dp)) - .background(Color(0xFFE1E8ED)) + .background(QMaxSurfaceVariant) ) { when { preview.isImage -> AsyncImage( @@ -4161,15 +4691,17 @@ private fun Avatar( } } val avatarModel = cachedAvatarPath?.let(::File) + val fallbackColor = avatarColor(title) + val fallbackContentColor = if (fallbackColor.luminance() > 0.48f) Color.Black else Color.White Box( Modifier .size(size) .clip(CircleShape) - .background(avatarColor(title)), + .background(fallbackColor), contentAlignment = Alignment.Center ) { - Text(title.take(1).uppercase(), color = Color.White, fontWeight = FontWeight.Bold) + Text(title.take(1).uppercase(), color = fallbackContentColor, fontWeight = FontWeight.Bold) if (avatarModel != null) { AsyncImage( model = avatarModel, @@ -4181,6 +4713,7 @@ private fun Avatar( } } +@Composable private fun avatarColor(title: String): Color { val colors = listOf( QMaxBlue, diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxContainer.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxContainer.kt index ddd4a0c..ebcfba6 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxContainer.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxContainer.kt @@ -5,10 +5,12 @@ import xyz.kusoft.qmax.core.local.AvatarDiskCache import xyz.kusoft.qmax.core.local.AttachmentDiskCache import xyz.kusoft.qmax.core.local.LocalMessageCache import xyz.kusoft.qmax.core.network.QMaxApi +import xyz.kusoft.qmax.core.settings.AppearanceStore import xyz.kusoft.qmax.core.settings.MessageDraftStore import xyz.kusoft.qmax.core.settings.TokenStore class QMaxContainer(context: Context) { + val appearanceStore = AppearanceStore(context) val tokenStore = TokenStore(context) val draftStore = MessageDraftStore(context) val messageCache = LocalMessageCache(context) 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 new file mode 100644 index 0000000..76885da --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/settings/AppearanceSettings.kt @@ -0,0 +1,87 @@ +package xyz.kusoft.qmax.core.settings + +enum class ThemeMode { + System, + Light, + Dark +} + +data class ChatPalette( + val accent: Int, + val chatBackground: Int, + val incomingBubble: Int, + val outgoingBubble: Int, + val messageText: Int, + val patternColor: Int, + val showPattern: Boolean = true +) + +object ChatPalettes { + val Light = ChatPalette( + accent = 0xFF3390EC.toInt(), + chatBackground = 0xFFE7EEF3.toInt(), + incomingBubble = 0xFFFFFFFF.toInt(), + outgoingBubble = 0xFFE7F7C8.toInt(), + messageText = 0xFF17212B.toInt(), + patternColor = 0xFF6C7883.toInt() + ) + + val Dark = ChatPalette( + accent = 0xFF5AA7E8.toInt(), + chatBackground = 0xFF0E1621.toInt(), + incomingBubble = 0xFF182533.toInt(), + outgoingBubble = 0xFF2B5278.toInt(), + messageText = 0xFFF5F7FA.toInt(), + patternColor = 0xFF6E7F91.toInt() + ) + + val OceanLight = Light.copy( + accent = 0xFF168ACD.toInt(), + chatBackground = 0xFFDCEEF6.toInt(), + incomingBubble = 0xFFF8FCFE.toInt(), + outgoingBubble = 0xFFCDEAF7.toInt(), + patternColor = 0xFF5192AE.toInt() + ) + + val MintLight = Light.copy( + accent = 0xFF20A98A.toInt(), + chatBackground = 0xFFDDEFE8.toInt(), + incomingBubble = 0xFFFFFFFF.toInt(), + outgoingBubble = 0xFFCFEFDF.toInt(), + patternColor = 0xFF4C8C78.toInt() + ) + + val MidnightDark = Dark.copy( + accent = 0xFF8774E1.toInt(), + chatBackground = 0xFF10131A.toInt(), + incomingBubble = 0xFF20232D.toInt(), + outgoingBubble = 0xFF5B4C95.toInt(), + patternColor = 0xFF666B85.toInt() + ) + + val ForestDark = Dark.copy( + accent = 0xFF4DBB8B.toInt(), + chatBackground = 0xFF101A17.toInt(), + incomingBubble = 0xFF1B2924.toInt(), + outgoingBubble = 0xFF285C4A.toInt(), + patternColor = 0xFF55796D.toInt() + ) +} + +data class AppearanceSettings( + val mode: ThemeMode = ThemeMode.System, + val lightPalette: ChatPalette = ChatPalettes.Light, + val darkPalette: ChatPalette = ChatPalettes.Dark, + val chatFontSizeSp: Float = 16f, + val chatLineSpacingSp: Float = 6f +) { + fun palette(isDark: Boolean): ChatPalette = if (isDark) darkPalette else lightPalette + + fun withPalette(isDark: Boolean, palette: ChatPalette): AppearanceSettings { + return if (isDark) copy(darkPalette = palette) else copy(lightPalette = palette) + } + + fun resetPalette(isDark: Boolean): AppearanceSettings { + return withPalette(isDark, if (isDark) ChatPalettes.Dark else ChatPalettes.Light) + } +} 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 new file mode 100644 index 0000000..488551a --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/settings/AppearanceStore.kt @@ -0,0 +1,81 @@ +package xyz.kusoft.qmax.core.settings + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.qmaxAppearanceDataStore by preferencesDataStore("qmax_appearance") + +class AppearanceStore(private val context: Context) { + private val modeKey = stringPreferencesKey("theme_mode") + private val chatFontSizeKey = floatPreferencesKey("chat_font_size_sp") + private val chatLineSpacingKey = floatPreferencesKey("chat_line_spacing_sp") + + private val lightKeys = PaletteKeys("light") + private val darkKeys = PaletteKeys("dark") + + val settings: Flow = context.qmaxAppearanceDataStore.data.map { preferences -> + val mode = preferences[modeKey] + ?.let { saved -> ThemeMode.entries.firstOrNull { it.name == saved } } + ?: ThemeMode.System + AppearanceSettings( + mode = mode, + lightPalette = preferences.readPalette(lightKeys, ChatPalettes.Light), + darkPalette = preferences.readPalette(darkKeys, ChatPalettes.Dark), + chatFontSizeSp = (preferences[chatFontSizeKey] ?: 16f).coerceIn(12f, 24f), + chatLineSpacingSp = (preferences[chatLineSpacingKey] ?: 6f).coerceIn(0f, 12f) + ) + } + + suspend fun save(settings: AppearanceSettings) { + context.qmaxAppearanceDataStore.edit { preferences -> + preferences[modeKey] = settings.mode.name + preferences.writePalette(lightKeys, settings.lightPalette) + preferences.writePalette(darkKeys, settings.darkPalette) + preferences[chatFontSizeKey] = settings.chatFontSizeSp.coerceIn(12f, 24f) + preferences[chatLineSpacingKey] = settings.chatLineSpacingSp.coerceIn(0f, 12f) + } + } + + private data class PaletteKeys(val prefix: String) { + val accent = intPreferencesKey("${prefix}_accent") + val chatBackground = intPreferencesKey("${prefix}_chat_background") + val incomingBubble = intPreferencesKey("${prefix}_incoming_bubble") + val outgoingBubble = intPreferencesKey("${prefix}_outgoing_bubble") + val messageText = intPreferencesKey("${prefix}_message_text") + val patternColor = intPreferencesKey("${prefix}_pattern_color") + val showPattern = booleanPreferencesKey("${prefix}_show_pattern") + } + + private fun Preferences.readPalette(keys: PaletteKeys, fallback: ChatPalette): ChatPalette { + return ChatPalette( + accent = this[keys.accent] ?: fallback.accent, + chatBackground = this[keys.chatBackground] ?: fallback.chatBackground, + incomingBubble = this[keys.incomingBubble] ?: fallback.incomingBubble, + outgoingBubble = this[keys.outgoingBubble] ?: fallback.outgoingBubble, + messageText = this[keys.messageText] ?: fallback.messageText, + patternColor = this[keys.patternColor] ?: fallback.patternColor, + showPattern = this[keys.showPattern] ?: fallback.showPattern + ) + } + + private fun androidx.datastore.preferences.core.MutablePreferences.writePalette( + keys: PaletteKeys, + palette: ChatPalette + ) { + this[keys.accent] = palette.accent + this[keys.chatBackground] = palette.chatBackground + this[keys.incomingBubble] = palette.incomingBubble + this[keys.outgoingBubble] = palette.outgoingBubble + this[keys.messageText] = palette.messageText + this[keys.patternColor] = palette.patternColor + this[keys.showPattern] = palette.showPattern + } +} 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 7f4cb1d..5121f7f 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 @@ -1,35 +1,221 @@ package xyz.kusoft.qmax.ui.theme +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp +import xyz.kusoft.qmax.core.settings.AppearanceSettings +import xyz.kusoft.qmax.core.settings.ChatPalette +import xyz.kusoft.qmax.core.settings.ThemeMode -val QMaxBlue = Color(0xFF3390EC) -val QMaxText = Color(0xFF17212B) -val QMaxMuted = Color(0xFF6C7883) -val QMaxBackground = Color(0xFFF3F7FA) -val QMaxIncoming = Color.White -val QMaxOutgoing = Color(0xFFE7F7C8) -val QMaxUnread = QMaxBlue - -private val Colors: ColorScheme = lightColorScheme( - primary = QMaxBlue, - onPrimary = Color.White, - background = QMaxBackground, - onBackground = QMaxText, - surface = Color.White, - onSurface = QMaxText, - surfaceVariant = Color(0xFFE9EEF2), - onSurfaceVariant = QMaxMuted +private data class QMaxThemeColors( + val accent: Color, + val onAccent: Color, + val appBackground: Color, + val surface: Color, + val surfaceVariant: Color, + val divider: Color, + val text: Color, + val muted: Color, + val chatBackground: Color, + val incomingBubble: Color, + val outgoingBubble: Color, + val messageText: Color, + val pattern: Color, + val showPattern: Boolean, + val isDark: Boolean, + val chatFontSizeSp: Float, + val chatLineSpacingSp: Float ) +private val DefaultAppearance = AppearanceSettings() +private val DefaultColors = colorsFor( + palette = DefaultAppearance.lightPalette, + isDark = false, + chatFontSizeSp = DefaultAppearance.chatFontSizeSp, + chatLineSpacingSp = DefaultAppearance.chatLineSpacingSp +) +private val LocalQMaxColors = staticCompositionLocalOf { DefaultColors } + +val QMaxBlue: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.accent + +val QMaxOnAccent: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.onAccent + +val QMaxText: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.text + +val QMaxMessageText: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.messageText + +val QMaxMuted: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.muted + +val QMaxBackground: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.appBackground + +val QMaxSurface: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.surface + +val QMaxSurfaceVariant: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.surfaceVariant + +val QMaxDivider: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.divider + +val QMaxChatBackground: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.chatBackground + +val QMaxIncoming: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.incomingBubble + +val QMaxOutgoing: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.outgoingBubble + +val QMaxPattern: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.pattern + +val QMaxShowPattern: Boolean + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.showPattern + +val QMaxIsDarkTheme: Boolean + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.isDark + +val QMaxChatTextStyle: TextStyle + @Composable + @ReadOnlyComposable + get() { + val colors = LocalQMaxColors.current + return MaterialTheme.typography.bodyLarge.copy( + fontSize = colors.chatFontSizeSp.sp, + lineHeight = (colors.chatFontSizeSp + colors.chatLineSpacingSp).sp + ) + } + +val QMaxUnread: Color + @Composable + @ReadOnlyComposable + get() = LocalQMaxColors.current.accent + @Composable -fun QMaxTheme(content: @Composable () -> Unit) { - MaterialTheme( - colorScheme = Colors, - typography = MaterialTheme.typography, - content = content +fun QMaxTheme( + appearance: AppearanceSettings = AppearanceSettings(), + content: @Composable () -> Unit +) { + val isDark = when (appearance.mode) { + ThemeMode.System -> isSystemInDarkTheme() + ThemeMode.Light -> false + ThemeMode.Dark -> true + } + val colors = colorsFor( + palette = appearance.palette(isDark), + isDark = isDark, + chatFontSizeSp = appearance.chatFontSizeSp, + chatLineSpacingSp = appearance.chatLineSpacingSp + ) + val materialColors = materialColorScheme(colors) + + CompositionLocalProvider(LocalQMaxColors provides colors) { + MaterialTheme( + colorScheme = materialColors, + typography = MaterialTheme.typography, + content = content + ) + } +} + +private fun colorsFor( + palette: ChatPalette, + isDark: Boolean, + chatFontSizeSp: Float, + chatLineSpacingSp: Float +): QMaxThemeColors { + val accent = Color(palette.accent) + 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), + chatBackground = Color(palette.chatBackground), + incomingBubble = Color(palette.incomingBubble), + outgoingBubble = Color(palette.outgoingBubble), + messageText = Color(palette.messageText), + pattern = Color(palette.patternColor), + showPattern = palette.showPattern, + isDark = isDark, + chatFontSizeSp = chatFontSizeSp, + chatLineSpacingSp = chatLineSpacingSp ) } + +private fun materialColorScheme(colors: QMaxThemeColors): ColorScheme { + return if (colors.isDark) { + darkColorScheme( + primary = colors.accent, + onPrimary = colors.onAccent, + background = colors.appBackground, + onBackground = colors.text, + surface = colors.surface, + onSurface = colors.text, + surfaceVariant = colors.surfaceVariant, + onSurfaceVariant = colors.muted, + outline = colors.divider + ) + } else { + lightColorScheme( + primary = colors.accent, + onPrimary = colors.onAccent, + background = colors.appBackground, + onBackground = colors.text, + surface = colors.surface, + onSurface = colors.text, + surfaceVariant = colors.surfaceVariant, + onSurfaceVariant = colors.muted, + outline = colors.divider + ) + } +} diff --git a/deploy/releases/android/latest.json b/deploy/releases/android/latest.json index aabb370..d8db9db 100644 --- a/deploy/releases/android/latest.json +++ b/deploy/releases/android/latest.json @@ -1,14 +1,14 @@ { "slug": "qmax", "name": "QMAX", - "version": "0.1.54", - "androidVersionCode": 55, + "version": "1.0.0", + "androidVersionCode": 57, "channel": "stable", "platform": "android", "packageKind": "apk", - "downloadPath": "/api/app-updates/android/download/qmax-0.1.54-stable.apk", - "packageSizeBytes": 17862069, - "sha256": "69a65b8c8d00c16f2ece211f1e993b0a2da46776f8afa436db384fb818c2f147", - "notes": "QMAX 0.1.54", - "publishedAt": "2026-07-13T19:13:39.6141836Z" + "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" } \ No newline at end of file diff --git a/deploy/releases/android/qmax-0.1.55-stable.apk b/deploy/releases/android/qmax-0.1.55-stable.apk new file mode 100644 index 0000000..87d6a7a Binary files /dev/null and b/deploy/releases/android/qmax-0.1.55-stable.apk differ diff --git a/deploy/releases/android/qmax-1.0.0-stable.apk b/deploy/releases/android/qmax-1.0.0-stable.apk new file mode 100644 index 0000000..75e757e Binary files /dev/null and b/deploy/releases/android/qmax-1.0.0-stable.apk differ diff --git a/pymax-worker/src/server.py b/pymax-worker/src/server.py index 101e0dd..8fc9aa7 100644 --- a/pymax-worker/src/server.py +++ b/pymax-worker/src/server.py @@ -438,6 +438,7 @@ class PyMaxRuntime: @client.on_start() async def on_start(c: Client) -> None: self.client = c + self.last_error = None me = getattr(c, "me", None) self.last_title = str(getattr(me, "first_name", "") or getattr(me, "name", "") or "PyMax") self.last_started_at = utc_now()