1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
|
package org.terst.doot.widget.ui
import android.content.Context
import android.content.res.Configuration
import androidx.compose.ui.graphics.Color
/**
* Chrome colors (body text, dividers) for the widget grid, rendered directly
* over the transparent home-screen background -- no card/background of its
* own. Per-source accent colors (see sourceColor() usage) are separate and
* not part of this palette.
*/
data class WidgetPalette(
val textPrimary: Color,
val textSecondary: Color,
val textMuted: Color,
val divider: Color,
val nowLine: Color,
// ShadowedText's halo needs to be the OPPOSITE tone of the text it's
// outlining -- a black halo behind already-dark text (every light-mode
// branch below: system_*_900/_700 shades) does essentially nothing,
// since dark-on-dark blends into itself regardless of what's behind it.
// Bug found 2026-08-06: this was hardcoded black in ShadowedText
// (correct for dark-mode/CLASSIC's light text, silently ineffective for
// every light-mode palette variant).
val haloColor: Color
)
enum class WidgetColorTheme {
/** Mostly neutral (grayscale) text; the wallpaper's accent hue shows only on the "now" line. */
NEUTRAL,
/** Primary/secondary text itself tinted by the wallpaper's dominant accent hue. */
ACCENT,
/** Primary text stays neutral for readability; secondary/muted text picks up the wallpaper's two complementary accent hues. */
TONAL,
/**
* All three text roles pull from a DIFFERENT one of the wallpaper's three
* accent slots (accent1/accent2/accent3), instead of anchoring primary to
* neutral (TONAL) or one hue everywhere (ACCENT) -- the most saturated,
* multi-hue option, added 2026-08-06 in response to "more diverse colors."
*/
VIVID,
/** The widget's original fixed dark palette, regardless of wallpaper. */
CLASSIC;
companion object {
fun fromPref(raw: String?): WidgetColorTheme =
raw?.let { name -> entries.find { it.name == name } } ?: NEUTRAL
}
}
// Bumped 2026-08-06 (0.45/0.55 -> 0.65/0.7): "legibility is better but still
// not great" after the corner-halo + luminance-matching fixes -- turning up
// the one dial that's pure risk-free strength (opacity, not reach/geometry)
// before reaching for a structurally different approach (true 8-neighbor
// outline, or a backdrop behind the text).
private val HALO_DARK = Color.Black.copy(alpha = 0.65f)
private val HALO_LIGHT = Color.White.copy(alpha = 0.7f)
// Grid lines (divider, now-line) are solid -- no alpha. Alpha over a
// transparent widget blends unpredictably with whatever's under the
// wallpaper at that spot instead of reading as a consistent line.
private val CLASSIC_PALETTE = WidgetPalette(
textPrimary = Color.White,
textSecondary = Color.White.copy(alpha = 0.6f),
textMuted = Color.White.copy(alpha = 0.5f),
divider = Color(0xFF3D3D3D),
nowLine = Color(0xFFD9D9D9),
haloColor = HALO_DARK
)
/**
* Builds the widget's chrome palette from the wallpaper-derived
* system_neutral/accent color resources (Material You / "Monet", API 31+ --
* minSdk is 36, so these always exist). No alpha tweaking on top of any
* resource color -- a "muted" role means picking a lower-contrast shade
* number, not adding transparency over a higher-contrast one, and grid
* lines are always fully opaque for the same reason as CLASSIC's. Light vs.
* dark shades are chosen from the system's current dark-theme setting so
* text stays legible either way.
*
* Divider shade picked for LOW contrast against its own background, not
* high visibility -- a hairline should read as subtle structure, not a
* bold rule. neutral2_700 in dark mode sits close to the near-black
* background; neutral2_100 in light mode sits close to the near-white one.
* (Was neutral2_400/200, which read as too heavy against the background --
* corrected 2026-08-05.)
*/
fun buildWidgetPalette(context: Context, theme: WidgetColorTheme): WidgetPalette {
if (theme == WidgetColorTheme.CLASSIC) return CLASSIC_PALETTE
val isNight = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES
fun sysColor(resId: Int): Color = Color(context.getColor(resId))
return when (theme) {
WidgetColorTheme.ACCENT -> if (isNight) {
WidgetPalette(
textPrimary = sysColor(android.R.color.system_accent1_100),
textSecondary = sysColor(android.R.color.system_accent1_300),
textMuted = sysColor(android.R.color.system_neutral2_500),
divider = sysColor(android.R.color.system_neutral2_700),
nowLine = sysColor(android.R.color.system_accent2_200),
haloColor = HALO_DARK
)
} else {
WidgetPalette(
textPrimary = sysColor(android.R.color.system_accent1_900),
textSecondary = sysColor(android.R.color.system_accent1_700),
textMuted = sysColor(android.R.color.system_neutral2_500),
divider = sysColor(android.R.color.system_neutral2_100),
nowLine = sysColor(android.R.color.system_accent2_600),
haloColor = HALO_LIGHT
)
}
WidgetColorTheme.TONAL -> if (isNight) {
WidgetPalette(
textPrimary = sysColor(android.R.color.system_neutral1_50),
textSecondary = sysColor(android.R.color.system_accent2_200),
textMuted = sysColor(android.R.color.system_accent3_300),
divider = sysColor(android.R.color.system_neutral2_700),
nowLine = sysColor(android.R.color.system_accent1_200),
haloColor = HALO_DARK
)
} else {
WidgetPalette(
textPrimary = sysColor(android.R.color.system_neutral1_900),
textSecondary = sysColor(android.R.color.system_accent2_700),
textMuted = sysColor(android.R.color.system_accent3_600),
divider = sysColor(android.R.color.system_neutral2_100),
nowLine = sysColor(android.R.color.system_accent1_600),
haloColor = HALO_LIGHT
)
}
WidgetColorTheme.VIVID -> if (isNight) {
WidgetPalette(
textPrimary = sysColor(android.R.color.system_accent1_200),
textSecondary = sysColor(android.R.color.system_accent2_200),
textMuted = sysColor(android.R.color.system_accent3_300),
divider = sysColor(android.R.color.system_neutral2_700),
nowLine = sysColor(android.R.color.system_accent3_200),
haloColor = HALO_DARK
)
} else {
WidgetPalette(
textPrimary = sysColor(android.R.color.system_accent1_700),
textSecondary = sysColor(android.R.color.system_accent2_700),
textMuted = sysColor(android.R.color.system_accent3_600),
divider = sysColor(android.R.color.system_neutral2_100),
nowLine = sysColor(android.R.color.system_accent3_600),
haloColor = HALO_LIGHT
)
}
// NEUTRAL, and the fallback for any unrecognized pref value (see fromPref).
else -> if (isNight) {
WidgetPalette(
textPrimary = sysColor(android.R.color.system_neutral1_50),
textSecondary = sysColor(android.R.color.system_neutral2_300),
textMuted = sysColor(android.R.color.system_neutral2_500),
divider = sysColor(android.R.color.system_neutral2_700),
nowLine = sysColor(android.R.color.system_accent1_200),
haloColor = HALO_DARK
)
} else {
WidgetPalette(
textPrimary = sysColor(android.R.color.system_neutral1_900),
textSecondary = sysColor(android.R.color.system_neutral2_700),
textMuted = sysColor(android.R.color.system_neutral2_500),
divider = sysColor(android.R.color.system_neutral2_100),
nowLine = sysColor(android.R.color.system_accent1_600),
haloColor = HALO_LIGHT
)
}
}
}
|