Skip to content

Commit 0304f6e

Browse files
committed
feat(settings): added light/dark mode switcher
1 parent b98be73 commit 0304f6e

8 files changed

Lines changed: 182 additions & 16 deletions

File tree

‎mobile/androidApp/src/main/kotlin/app/myfaq/android/MainActivity.kt‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@ import android.os.Bundle
44
import androidx.activity.ComponentActivity
55
import androidx.activity.compose.setContent
66
import androidx.activity.enableEdgeToEdge
7+
import androidx.compose.runtime.CompositionLocalProvider
78

89
class MainActivity : ComponentActivity() {
910
override fun onCreate(savedInstanceState: Bundle?) {
1011
super.onCreate(savedInstanceState)
1112
enableEdgeToEdge()
13+
14+
val themeState = ThemeState.create(this)
15+
1216
setContent {
13-
MyFaqAppTheme {
14-
MyFaqNavHost()
17+
CompositionLocalProvider(LocalThemeState provides themeState) {
18+
MyFaqAppTheme(themeState = themeState) {
19+
MyFaqNavHost()
20+
}
1521
}
1622
}
1723
}

‎mobile/androidApp/src/main/kotlin/app/myfaq/android/Theme.kt‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,81 @@
11
package app.myfaq.android
22

3+
import android.content.Context
4+
import android.content.SharedPreferences
35
import androidx.compose.foundation.isSystemInDarkTheme
46
import androidx.compose.material3.MaterialTheme
57
import androidx.compose.material3.darkColorScheme
68
import androidx.compose.material3.lightColorScheme
79
import androidx.compose.runtime.Composable
10+
import androidx.compose.runtime.compositionLocalOf
11+
import androidx.compose.runtime.getValue
12+
import androidx.compose.runtime.mutableStateOf
13+
import androidx.compose.runtime.setValue
814

915
private val DarkColors = darkColorScheme()
1016
private val LightColors = lightColorScheme()
1117

18+
/** User-selectable theme mode, persisted via SharedPreferences. */
19+
enum class ThemeMode {
20+
SYSTEM,
21+
LIGHT,
22+
DARK,
23+
;
24+
25+
val label: String
26+
get() =
27+
when (this) {
28+
SYSTEM -> "System"
29+
LIGHT -> "Light"
30+
DARK -> "Dark"
31+
}
32+
}
33+
34+
/** Observable holder for the current theme preference. */
35+
class ThemeState(
36+
prefs: SharedPreferences,
37+
) {
38+
private val prefs = prefs
39+
40+
var mode: ThemeMode by mutableStateOf(load())
41+
private set
42+
43+
fun setMode(newMode: ThemeMode) {
44+
mode = newMode
45+
prefs.edit().putString(KEY, newMode.name).apply()
46+
}
47+
48+
private fun load(): ThemeMode =
49+
try {
50+
ThemeMode.valueOf(prefs.getString(KEY, ThemeMode.SYSTEM.name) ?: ThemeMode.SYSTEM.name)
51+
} catch (_: IllegalArgumentException) {
52+
ThemeMode.SYSTEM
53+
}
54+
55+
companion object {
56+
private const val KEY = "theme_mode"
57+
private const val PREFS_NAME = "app.myfaq.settings"
58+
59+
fun create(context: Context): ThemeState =
60+
ThemeState(context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE))
61+
}
62+
}
63+
64+
/** CompositionLocal so any descendant can read/change the theme. */
65+
val LocalThemeState = compositionLocalOf<ThemeState> { error("No ThemeState provided") }
66+
1267
@Composable
1368
fun MyFaqAppTheme(
14-
darkTheme: Boolean = isSystemInDarkTheme(),
69+
themeState: ThemeState,
1570
content: @Composable () -> Unit,
1671
) {
72+
val darkTheme =
73+
when (themeState.mode) {
74+
ThemeMode.LIGHT -> false
75+
ThemeMode.DARK -> true
76+
ThemeMode.SYSTEM -> isSystemInDarkTheme()
77+
}
78+
1779
MaterialTheme(
1880
colorScheme = if (darkTheme) DarkColors else LightColors,
1981
content = content,

‎mobile/androidApp/src/main/kotlin/app/myfaq/android/screens/SettingsScreen.kt‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ package app.myfaq.android.screens
22

33
import android.widget.Toast
44
import androidx.compose.foundation.clickable
5+
import androidx.compose.foundation.layout.Arrangement
56
import androidx.compose.foundation.layout.Column
7+
import androidx.compose.foundation.layout.Row
8+
import androidx.compose.foundation.layout.fillMaxWidth
69
import androidx.compose.foundation.layout.padding
710
import androidx.compose.material3.AlertDialog
811
import androidx.compose.material3.CenterAlignedTopAppBar
912
import androidx.compose.material3.ExperimentalMaterial3Api
1013
import androidx.compose.material3.HorizontalDivider
1114
import androidx.compose.material3.ListItem
1215
import androidx.compose.material3.MaterialTheme
16+
import androidx.compose.material3.RadioButton
1317
import androidx.compose.material3.Scaffold
1418
import androidx.compose.material3.Text
1519
import androidx.compose.material3.TextButton
@@ -19,8 +23,11 @@ import androidx.compose.runtime.getValue
1923
import androidx.compose.runtime.mutableStateOf
2024
import androidx.compose.runtime.remember
2125
import androidx.compose.runtime.setValue
26+
import androidx.compose.ui.Alignment
2227
import androidx.compose.ui.Modifier
2328
import androidx.compose.ui.platform.LocalContext
29+
import app.myfaq.android.LocalThemeState
30+
import app.myfaq.android.ThemeMode
2431
import app.myfaq.shared.data.ActiveInstanceManager
2532
import app.myfaq.shared.data.MyFaqDatabase
2633
import org.koin.compose.koinInject
@@ -62,6 +69,8 @@ fun SettingsScreen(
6269
}
6370
}
6471

72+
val themeState = LocalThemeState.current
73+
6574
Scaffold(
6675
topBar = {
6776
CenterAlignedTopAppBar(title = { Text("Settings") })
@@ -78,6 +87,37 @@ fun SettingsScreen(
7887
HorizontalDivider()
7988
}
8089

90+
// Appearance
91+
ListItem(
92+
overlineContent = { Text("Appearance") },
93+
headlineContent = {
94+
Row(
95+
modifier = Modifier.fillMaxWidth(),
96+
horizontalArrangement = Arrangement.SpaceBetween,
97+
) {
98+
ThemeMode.entries.forEach { mode ->
99+
Row(
100+
verticalAlignment = Alignment.CenterVertically,
101+
modifier =
102+
Modifier.clickable {
103+
themeState.setMode(mode)
104+
},
105+
) {
106+
RadioButton(
107+
selected = themeState.mode == mode,
108+
onClick = { themeState.setMode(mode) },
109+
)
110+
Text(
111+
text = mode.label,
112+
style = MaterialTheme.typography.bodyMedium,
113+
)
114+
}
115+
}
116+
}
117+
},
118+
)
119+
HorizontalDivider()
120+
81121
// Switch instance
82122
ListItem(
83123
headlineContent = { Text("Switch instance") },

‎mobile/iosApp/iosApp/Screens/SettingsScreen.swift‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Shared
33

44
struct SettingsScreen: View {
55
let onSwitchInstance: () -> Void
6+
@AppStorage("app_theme") private var themeMode: String = ThemeMode.system.rawValue
67
@State private var showCacheCleared = false
78
@State private var showDeleteConfirmation = false
89

@@ -23,6 +24,16 @@ struct SettingsScreen: View {
2324
}
2425
}
2526

27+
// Appearance
28+
Section("Appearance") {
29+
Picker("Theme", selection: $themeMode) {
30+
ForEach(ThemeMode.allCases, id: \.rawValue) { mode in
31+
Text(mode.label).tag(mode.rawValue)
32+
}
33+
}
34+
.pickerStyle(.segmented)
35+
}
36+
2637
Section {
2738
Button("Switch instance", action: onSwitchInstance)
2839

‎mobile/iosApp/iosApp/iosAppApp.swift‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,58 @@ import Shared
33

44
@main
55
struct MyFaqApp: App {
6+
@AppStorage("app_theme") private var themeMode: String = ThemeMode.system.rawValue
7+
68
init() {
79
IosPlatformModuleKt.doInitKoinIos()
810
}
911

1012
var body: some Scene {
1113
WindowGroup {
1214
ContentView()
15+
.preferredColorScheme(resolvedColorScheme)
16+
.environment(\.themeMode, currentThemeMode)
17+
}
18+
}
19+
20+
private var currentThemeMode: ThemeMode {
21+
ThemeMode(rawValue: themeMode) ?? .system
22+
}
23+
24+
private var resolvedColorScheme: ColorScheme? {
25+
switch currentThemeMode {
26+
case .system: return nil
27+
case .light: return .light
28+
case .dark: return .dark
29+
}
30+
}
31+
}
32+
33+
// MARK: - Theme mode
34+
35+
enum ThemeMode: String, CaseIterable {
36+
case system
37+
case light
38+
case dark
39+
40+
var label: String {
41+
switch self {
42+
case .system: return "System"
43+
case .light: return "Light"
44+
case .dark: return "Dark"
1345
}
1446
}
1547
}
48+
49+
// MARK: - Environment key
50+
51+
private struct ThemeModeKey: EnvironmentKey {
52+
static let defaultValue: ThemeMode = .system
53+
}
54+
55+
extension EnvironmentValues {
56+
var themeMode: ThemeMode {
57+
get { self[ThemeModeKey.self] }
58+
set { self[ThemeModeKey.self] = newValue }
59+
}
60+
}

‎mobile/iosApp/project.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ options:
1010

1111
settings:
1212
base:
13-
MARKETING_VERSION: "0.0.0"
13+
MARKETING_VERSION: "1.0.0"
1414
CURRENT_PROJECT_VERSION: 1
1515
DEVELOPMENT_TEAM: ""
1616
CODE_SIGN_STYLE: Automatic

‎mobile/shared/src/commonTest/kotlin/app/myfaq/shared/domain/HtmlUtilsTest.kt‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app.myfaq.shared.domain
22

33
import kotlin.test.Test
44
import kotlin.test.assertEquals
5+
import kotlin.test.assertTrue
56

67
class HtmlUtilsTest {
78
// ── stripHtml ──────────────────────────────────────────────
@@ -37,8 +38,8 @@ class HtmlUtilsTest {
3738
fun `handles list items`() {
3839
val html = "<ul><li>Item 1</li><li>Item 2</li></ul>"
3940
val result = HtmlUtils.stripHtml(html)
40-
assert(result.contains("Item 1"))
41-
assert(result.contains("Item 2"))
41+
assertTrue(result.contains("Item 1"))
42+
assertTrue(result.contains("Item 2"))
4243
}
4344

4445
@Test
@@ -77,11 +78,11 @@ class HtmlUtilsTest {
7778
<p>And a <a href="https://example.com">link</a>.</p>
7879
""".trimIndent()
7980
val result = HtmlUtils.stripHtml(html)
80-
assert(result.contains("FAQ answer"))
81-
assert(result.contains("Bullet 1"))
82-
assert(result.contains("Bullet 2"))
83-
assert(result.contains("link"))
84-
assert(!result.contains("<"))
81+
assertTrue(result.contains("FAQ answer"))
82+
assertTrue(result.contains("Bullet 1"))
83+
assertTrue(result.contains("Bullet 2"))
84+
assertTrue(result.contains("link"))
85+
assertTrue(!result.contains("<"))
8586
}
8687

8788
// ── decodeEntities ─────────────────────────────────────────
@@ -174,7 +175,7 @@ class HtmlUtilsTest {
174175
val html = "<p>${"A".repeat(200)}</p>"
175176
val result = HtmlUtils.preview(html, maxLength = 50)
176177
assertEquals(51, result.length) // 50 chars + ellipsis
177-
assert(result.endsWith("…"))
178+
assertTrue(result.endsWith("…"))
178179
}
179180

180181
@Test
@@ -186,9 +187,9 @@ class HtmlUtilsTest {
186187
@Test
187188
fun `preview replaces newlines with spaces`() {
188189
val result = HtmlUtils.preview("<p>Line 1</p><p>Line 2</p>")
189-
assert(!result.contains("\n"))
190-
assert(result.contains("Line 1"))
191-
assert(result.contains("Line 2"))
190+
assertTrue(!result.contains("\n"))
191+
assertTrue(result.contains("Line 1"))
192+
assertTrue(result.contains("Line 2"))
192193
}
193194

194195
@Test

‎mobile/shared/src/iosMain/kotlin/app/myfaq/shared/platform/SecureStore.ios.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package app.myfaq.shared.platform
22

3+
import kotlinx.cinterop.BetaInteropApi
34
import kotlinx.cinterop.ExperimentalForeignApi
45
import kotlinx.cinterop.alloc
56
import kotlinx.cinterop.memScoped
@@ -40,7 +41,7 @@ import platform.Security.kSecValueData
4041
* `kSecAttrAccessibleAfterFirstUnlock` so values survive reboots
4142
* but are unavailable until the user unlocks the device once.
4243
*/
43-
@OptIn(ExperimentalForeignApi::class)
44+
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
4445
actual class SecureStore {
4546
actual fun put(
4647
key: String,

0 commit comments

Comments
 (0)