Release QMAX 1.0.0 with customizable themes
This commit is contained in:
@@ -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
|
||||
fun QMaxTheme(content: @Composable () -> Unit) {
|
||||
@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(
|
||||
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 = Colors,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user