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"
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\"")
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.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)
@@ -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
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
)
}
}
+7 -7
View File
@@ -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"
}
Binary file not shown.
Binary file not shown.
+1
View File
@@ -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()