| 1 | package io.github.krolyxon.budctl |
| 2 | |
| 3 | import android.content.Context |
| 4 | import android.content.res.AssetManager |
| 5 | import android.os.Build |
| 6 | import androidx.compose.animation.core.AnimationSpec |
| 7 | import androidx.compose.animation.core.CubicBezierEasing |
| 8 | import androidx.compose.animation.core.snap |
| 9 | import androidx.compose.animation.core.tween |
| 10 | import androidx.compose.runtime.Immutable |
| 11 | import androidx.compose.runtime.staticCompositionLocalOf |
| 12 | import androidx.compose.ui.graphics.Color |
| 13 | import androidx.compose.ui.text.font.Font |
| 14 | import androidx.compose.ui.text.font.FontFamily |
| 15 | import androidx.compose.ui.text.font.FontWeight |
| 16 | |
| 17 | /** |
| 18 | * The desktop app's look (CLAUDE.md, GUI): Nothing OS's shapes and type in Material You colors. |
| 19 | * The colors come by role, as in crates/budctl-gui/src/theme.rs: a ladder of surfaces, and the |
| 20 | * "on" states on top. |
| 21 | */ |
| 22 | @Immutable |
| 23 | data class Palette( |
| 24 | /** The page (`surfaceLowest`). */ |
| 25 | val bg: Color, |
| 26 | /** Cards (`surfaceLow`). */ |
| 27 | val s1: Color, |
| 28 | /** Icon circles, tracks, the switches' "off" fill (`surfaceHigh`). */ |
| 29 | val s2: Color, |
| 30 | /** Unlit dots, pressed rows (`surfaceHighest`). */ |
| 31 | val s3: Color, |
| 32 | /** Inactive things: the pages not open, an off switch's knob (`outlineVariant`). */ |
| 33 | val mute: Color, |
| 34 | /** Caps labels (`outline`). */ |
| 35 | val grey: Color, |
| 36 | /** Text (`onSurface`). */ |
| 37 | val fg: Color, |
| 38 | /** Secondary text (`onSurfaceVariant`). */ |
| 39 | val fgDim: Color, |
| 40 | /** What is on or chosen: switches, the chosen segment, lit dots (`primary`). */ |
| 41 | val fill: Color, |
| 42 | /** Text and icons on [fill] (`onPrimary`). */ |
| 43 | val fillFg: Color, |
| 44 | /** Live things, one at a time: the open page's dot, "Saving…", charging (`tertiary`). */ |
| 45 | val live: Color, |
| 46 | val liveFg: Color, |
| 47 | val err: Color, |
| 48 | val errFg: Color, |
| 49 | ) |
| 50 | |
| 51 | /** Near-black surfaces, white "on" states and a red for live things: the desktop's own. */ |
| 52 | val BUILT_IN = Palette( |
| 53 | bg = Color(0xFF000000), |
| 54 | s1 = Color(0xFF111112), |
| 55 | s2 = Color(0xFF1C1C1E), |
| 56 | s3 = Color(0xFF2A2A2D), |
| 57 | mute = Color(0xFF454549), |
| 58 | grey = Color(0xFF8E8E93), |
| 59 | fg = Color(0xFFF2F2F2), |
| 60 | fgDim = Color(0xFFB8B8BC), |
| 61 | fill = Color(0xFFF2F2F2), |
| 62 | fillFg = Color(0xFF000000), |
| 63 | live = Color(0xFFD71921), |
| 64 | liveFg = Color(0xFFFFFFFF), |
| 65 | err = Color(0xFFFFB4AB), |
| 66 | errFg = Color(0xFF690005), |
| 67 | ) |
| 68 | |
| 69 | /** |
| 70 | * The phone's Material You colors (from the wallpaper), dark, in the same roles as the desktop |
| 71 | * maps Aurora's palette. Android 14 names every role; before it, the tonal palettes stand in. |
| 72 | */ |
| 73 | fun materialYou(context: Context): Palette { |
| 74 | fun c(id: Int) = Color(context.getColor(id)) |
| 75 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { |
| 76 | Palette( |
| 77 | bg = c(android.R.color.system_surface_container_lowest_dark), |
| 78 | s1 = c(android.R.color.system_surface_container_low_dark), |
| 79 | s2 = c(android.R.color.system_surface_container_high_dark), |
| 80 | s3 = c(android.R.color.system_surface_container_highest_dark), |
| 81 | mute = c(android.R.color.system_outline_variant_dark), |
| 82 | grey = c(android.R.color.system_outline_dark), |
| 83 | fg = c(android.R.color.system_on_surface_dark), |
| 84 | fgDim = c(android.R.color.system_on_surface_variant_dark), |
| 85 | fill = c(android.R.color.system_primary_dark), |
| 86 | fillFg = c(android.R.color.system_on_primary_dark), |
| 87 | live = c(android.R.color.system_tertiary_dark), |
| 88 | liveFg = c(android.R.color.system_on_tertiary_dark), |
| 89 | err = c(android.R.color.system_error_dark), |
| 90 | errFg = c(android.R.color.system_on_error_dark), |
| 91 | ) |
| 92 | } else { |
| 93 | Palette( |
| 94 | bg = c(android.R.color.system_neutral1_1000), |
| 95 | s1 = c(android.R.color.system_neutral1_900), |
| 96 | s2 = c(android.R.color.system_neutral1_800), |
| 97 | s3 = c(android.R.color.system_neutral1_700), |
| 98 | mute = c(android.R.color.system_neutral2_700), |
| 99 | grey = c(android.R.color.system_neutral2_400), |
| 100 | fg = c(android.R.color.system_neutral1_100), |
| 101 | fgDim = c(android.R.color.system_neutral2_200), |
| 102 | fill = c(android.R.color.system_accent1_200), |
| 103 | fillFg = c(android.R.color.system_accent1_800), |
| 104 | live = c(android.R.color.system_accent3_200), |
| 105 | liveFg = c(android.R.color.system_accent3_800), |
| 106 | err = BUILT_IN.err, |
| 107 | errFg = BUILT_IN.errFg, |
| 108 | ) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * The desktop app's fonts, read from crates/budctl-gui/fonts (OFL 1.1), which Gradle adds to the |
| 114 | * app's assets: Space Grotesk for text, Space Mono for caps labels, and Doto Rounded for numbers |
| 115 | * and page words. Doto is never set under 26 sp: smaller, the dots blur. |
| 116 | */ |
| 117 | class Fonts(assets: AssetManager) { |
| 118 | val ui = FontFamily( |
| 119 | Font("SpaceGrotesk-Regular.otf", assets, FontWeight.Normal), |
| 120 | Font("SpaceGrotesk-Medium.otf", assets, FontWeight.Medium), |
| 121 | Font("SpaceGrotesk-SemiBold.otf", assets, FontWeight.SemiBold), |
| 122 | ) |
| 123 | val mono = FontFamily(Font("SpaceMono-Regular.ttf", assets)) |
| 124 | val dot = FontFamily(Font("DotoRounded-Black.ttf", assets, FontWeight.Black)) |
| 125 | } |
| 126 | |
| 127 | /** What the screen draws with: the colors, the fonts, and whether things move. */ |
| 128 | @Immutable |
| 129 | data class Look(val p: Palette, val fonts: Fonts, val motion: Boolean) |
| 130 | |
| 131 | val LocalLook = staticCompositionLocalOf<Look> { error("no Look provided") } |
| 132 | |
| 133 | /** |
| 134 | * Nothing's motion (crates/budctl-gui/src/motion.rs): 150-250 ms, eased out on |
| 135 | * `cubic-bezier(0.25, 0.1, 0.25, 1)`, no bounce; things fade rather than slide. |
| 136 | */ |
| 137 | object Motion { |
| 138 | val EASE = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f) |
| 139 | |
| 140 | /** A press and a small change of state. */ |
| 141 | const val HOVER = 150 |
| 142 | |
| 143 | /** A switch turning, a choice changing. */ |
| 144 | const val STATE = 200 |
| 145 | |
| 146 | /** A page fading in. */ |
| 147 | const val PAGE = 240 |
| 148 | |
| 149 | /** The battery rings lighting up, dot after dot. */ |
| 150 | const val SWEEP = 600 |
| 151 | |
| 152 | /** A blinking light: lit, then dark, this long each. */ |
| 153 | const val BLINK = 500 |
| 154 | |
| 155 | fun <T> eased(millis: Int, motion: Boolean): AnimationSpec<T> = |
| 156 | if (motion) tween(millis, easing = EASE) else snap() |
| 157 | } |