Release QMAX 1.0.0 with customizable themes

This commit is contained in:
Курнат Андрей
2026-07-15 12:22:55 +03:00
parent 8e9a4c1151
commit 0febe58b69
10 changed files with 1026 additions and 136 deletions
+2 -2
View File
@@ -22,8 +22,8 @@ android {
applicationId = "xyz.kusoft.qmax" applicationId = "xyz.kusoft.qmax"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 55 versionCode = 57
versionName = "0.1.54" versionName = "1.0.0"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"") buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"") buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
File diff suppressed because it is too large Load Diff
@@ -5,10 +5,12 @@ import xyz.kusoft.qmax.core.local.AvatarDiskCache
import xyz.kusoft.qmax.core.local.AttachmentDiskCache import xyz.kusoft.qmax.core.local.AttachmentDiskCache
import xyz.kusoft.qmax.core.local.LocalMessageCache import xyz.kusoft.qmax.core.local.LocalMessageCache
import xyz.kusoft.qmax.core.network.QMaxApi 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.MessageDraftStore
import xyz.kusoft.qmax.core.settings.TokenStore import xyz.kusoft.qmax.core.settings.TokenStore
class QMaxContainer(context: Context) { class QMaxContainer(context: Context) {
val appearanceStore = AppearanceStore(context)
val tokenStore = TokenStore(context) val tokenStore = TokenStore(context)
val draftStore = MessageDraftStore(context) val draftStore = MessageDraftStore(context)
val messageCache = LocalMessageCache(context) val messageCache = LocalMessageCache(context)
@@ -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)
}
}
@@ -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<AppearanceSettings> = 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
}
}
@@ -1,35 +1,221 @@
package xyz.kusoft.qmax.ui.theme package xyz.kusoft.qmax.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable 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.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) private data class QMaxThemeColors(
val QMaxText = Color(0xFF17212B) val accent: Color,
val QMaxMuted = Color(0xFF6C7883) val onAccent: Color,
val QMaxBackground = Color(0xFFF3F7FA) val appBackground: Color,
val QMaxIncoming = Color.White val surface: Color,
val QMaxOutgoing = Color(0xFFE7F7C8) val surfaceVariant: Color,
val QMaxUnread = QMaxBlue val divider: Color,
val text: Color,
private val Colors: ColorScheme = lightColorScheme( val muted: Color,
primary = QMaxBlue, val chatBackground: Color,
onPrimary = Color.White, val incomingBubble: Color,
background = QMaxBackground, val outgoingBubble: Color,
onBackground = QMaxText, val messageText: Color,
surface = Color.White, val pattern: Color,
onSurface = QMaxText, val showPattern: Boolean,
surfaceVariant = Color(0xFFE9EEF2), val isDark: Boolean,
onSurfaceVariant = QMaxMuted 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 @Composable
fun QMaxTheme(content: @Composable () -> Unit) { fun QMaxTheme(
MaterialTheme( appearance: AppearanceSettings = AppearanceSettings(),
colorScheme = Colors, content: @Composable () -> Unit
typography = MaterialTheme.typography, ) {
content = content 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
)
}
}
+7 -7
View File
@@ -1,14 +1,14 @@
{ {
"slug": "qmax", "slug": "qmax",
"name": "QMAX", "name": "QMAX",
"version": "0.1.54", "version": "1.0.0",
"androidVersionCode": 55, "androidVersionCode": 57,
"channel": "stable", "channel": "stable",
"platform": "android", "platform": "android",
"packageKind": "apk", "packageKind": "apk",
"downloadPath": "/api/app-updates/android/download/qmax-0.1.54-stable.apk", "downloadPath": "/api/app-updates/android/download/qmax-1.0.0-stable.apk",
"packageSizeBytes": 17862069, "packageSizeBytes": 17911217,
"sha256": "69a65b8c8d00c16f2ece211f1e993b0a2da46776f8afa436db384fb818c2f147", "sha256": "f21d2f6ed8584ba204560c75fecae6d78041b20f620fb4ef992e11e6a230b5bb",
"notes": "QMAX 0.1.54", "notes": "QMAX 1.0.0",
"publishedAt": "2026-07-13T19:13:39.6141836Z" "publishedAt": "2026-07-15T09:12:25.7697332Z"
} }
Binary file not shown.
Binary file not shown.
+1
View File
@@ -438,6 +438,7 @@ class PyMaxRuntime:
@client.on_start() @client.on_start()
async def on_start(c: Client) -> None: async def on_start(c: Client) -> None:
self.client = c self.client = c
self.last_error = None
me = getattr(c, "me", None) me = getattr(c, "me", None)
self.last_title = str(getattr(me, "first_name", "") or getattr(me, "name", "") or "PyMax") self.last_title = str(getattr(me, "first_name", "") or getattr(me, "name", "") or "PyMax")
self.last_started_at = utc_now() self.last_started_at = utc_now()