geeknative 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE +202 -0
  2. package/bin/geeknative.js +12 -0
  3. package/package.json +42 -4
  4. package/src/cli.js +61 -0
  5. package/src/codegen/app.js +105 -0
  6. package/src/codegen/icons.js +87 -0
  7. package/src/codegen/index.js +44 -0
  8. package/src/codegen/strings.js +308 -0
  9. package/src/codegen/tokens.js +449 -0
  10. package/src/codegen/util.js +122 -0
  11. package/src/commands/check.js +148 -0
  12. package/src/commands/create.js +82 -0
  13. package/src/commands/doctor.js +108 -0
  14. package/src/commands/gen.js +44 -0
  15. package/src/commands/parity.js +134 -0
  16. package/src/commands/run.js +91 -0
  17. package/src/lib/exec.js +77 -0
  18. package/src/lib/fsutil.js +92 -0
  19. package/src/lib/log.js +37 -0
  20. package/src/lib/managed.js +80 -0
  21. package/src/lib/project.js +52 -0
  22. package/src/lib/toolchains.js +126 -0
  23. package/template/.claude/agents/android-engineer.md +15 -0
  24. package/template/.claude/agents/ios-engineer.md +15 -0
  25. package/template/.claude/agents/web-engineer.md +15 -0
  26. package/template/.claude/settings.json +65 -0
  27. package/template/.geeknative/framework.mk +41 -0
  28. package/template/.geeknative/hooks/after-edit.mjs +28 -0
  29. package/template/.geeknative/hooks/parity-guard.mjs +49 -0
  30. package/template/.geeknative/hooks/subagent-context.mjs +24 -0
  31. package/template/.geeknative/playbooks/bug.md +7 -0
  32. package/template/.geeknative/playbooks/design.md +10 -0
  33. package/template/.geeknative/playbooks/engineering.md +9 -0
  34. package/template/.geeknative/playbooks/feature.md +11 -0
  35. package/template/.geeknative/playbooks/platform-specific.md +8 -0
  36. package/template/.geeknative/playbooks/product.md +12 -0
  37. package/template/.geeknative/templates/feature.md +49 -0
  38. package/template/.gemini/settings.json +5 -0
  39. package/template/.github/dependabot.yml +18 -0
  40. package/template/AGENTS.md +97 -0
  41. package/template/CLAUDE.md +9 -0
  42. package/template/DESIGN.md +65 -0
  43. package/template/Makefile +6 -0
  44. package/template/PRODUCT.md +40 -0
  45. package/template/README.md +28 -0
  46. package/template/_gitignore +33 -0
  47. package/template/apps/android/AGENTS.md +66 -0
  48. package/template/apps/android/CLAUDE.md +1 -0
  49. package/template/apps/android/app/build.gradle.kts +68 -0
  50. package/template/apps/android/app/identity.properties +4 -0
  51. package/template/apps/android/app/src/main/AndroidManifest.xml +24 -0
  52. package/template/apps/android/app/src/main/kotlin/app/App.kt +15 -0
  53. package/template/apps/android/app/src/main/kotlin/app/MainActivity.kt +49 -0
  54. package/template/apps/android/app/src/main/kotlin/app/core/AppContainer.kt +27 -0
  55. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppCard.kt +30 -0
  56. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppTheme.kt +35 -0
  57. package/template/apps/android/app/src/main/kotlin/app/core/navigation/AppNavigation.kt +40 -0
  58. package/template/apps/android/app/src/main/kotlin/app/core/navigation/Route.kt +13 -0
  59. package/template/apps/android/app/src/main/kotlin/app/features/home/HomeScreen.kt +67 -0
  60. package/template/apps/android/app/src/main/kotlin/app/features/settings/Appearance.kt +22 -0
  61. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsRepository.kt +40 -0
  62. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsScreen.kt +113 -0
  63. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsViewModel.kt +28 -0
  64. package/template/apps/android/app/src/main/kotlin/app/generated/AppIdentity.kt +10 -0
  65. package/template/apps/android/app/src/main/kotlin/app/generated/Icons.kt +15 -0
  66. package/template/apps/android/app/src/main/kotlin/app/generated/Tokens.kt +144 -0
  67. package/template/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml +17 -0
  68. package/template/apps/android/app/src/main/res/mipmap/ic_launcher.xml +10 -0
  69. package/template/apps/android/app/src/main/res/mipmap/ic_launcher_round.xml +10 -0
  70. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
  71. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  72. package/template/apps/android/app/src/main/res/values/generated_colors.xml +15 -0
  73. package/template/apps/android/app/src/main/res/values/generated_strings.xml +16 -0
  74. package/template/apps/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
  75. package/template/apps/android/app/src/main/res/values/themes.xml +7 -0
  76. package/template/apps/android/app/src/main/res/values-night/generated_colors.xml +15 -0
  77. package/template/apps/android/app/src/main/res/values-night/themes.xml +6 -0
  78. package/template/apps/android/app/src/test/kotlin/app/features/settings/SettingsTest.kt +90 -0
  79. package/template/apps/android/build.gradle.kts +5 -0
  80. package/template/apps/android/gradle/gradle-daemon-jvm.properties +12 -0
  81. package/template/apps/android/gradle/libs.versions.toml +37 -0
  82. package/template/apps/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  83. package/template/apps/android/gradle/wrapper/gradle-wrapper.properties +9 -0
  84. package/template/apps/android/gradle.properties +7 -0
  85. package/template/apps/android/gradlew +248 -0
  86. package/template/apps/android/gradlew.bat +82 -0
  87. package/template/apps/android/settings.gradle.kts +29 -0
  88. package/template/apps/ios/AGENTS.md +70 -0
  89. package/template/apps/ios/App/AppMain.swift +13 -0
  90. package/template/apps/ios/App/Core/AppContainer.swift +22 -0
  91. package/template/apps/ios/App/Core/DesignSystem/AppCard.swift +15 -0
  92. package/template/apps/ios/App/Core/Navigation/Route.swift +4 -0
  93. package/template/apps/ios/App/Core/RootView.swift +32 -0
  94. package/template/apps/ios/App/Features/Home/HomeScreen.swift +35 -0
  95. package/template/apps/ios/App/Features/Settings/Appearance.swift +27 -0
  96. package/template/apps/ios/App/Features/Settings/SettingsRepository.swift +40 -0
  97. package/template/apps/ios/App/Features/Settings/SettingsScreen.swift +40 -0
  98. package/template/apps/ios/App/Features/Settings/SettingsViewModel.swift +23 -0
  99. package/template/apps/ios/App/Generated/AppIdentity.swift +9 -0
  100. package/template/apps/ios/App/Generated/Colors.xcassets/AccentColor.colorset/Contents.json +38 -0
  101. package/template/apps/ios/App/Generated/Colors.xcassets/Contents.json +6 -0
  102. package/template/apps/ios/App/Generated/Icons.swift +8 -0
  103. package/template/apps/ios/App/Generated/Localizable.xcstrings +127 -0
  104. package/template/apps/ios/App/Generated/Strings.swift +40 -0
  105. package/template/apps/ios/App/Generated/Tokens.swift +65 -0
  106. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  107. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  108. package/template/apps/ios/App/Resources/Assets.xcassets/Contents.json +6 -0
  109. package/template/apps/ios/App.xcodeproj/project.pbxproj +298 -0
  110. package/template/apps/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  111. package/template/apps/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +89 -0
  112. package/template/apps/ios/AppTests/SettingsTests.swift +54 -0
  113. package/template/apps/ios/CLAUDE.md +1 -0
  114. package/template/apps/ios/Config/App.xcconfig +23 -0
  115. package/template/apps/ios/Config/AppTests.xcconfig +10 -0
  116. package/template/apps/ios/Config/Base.xcconfig +25 -0
  117. package/template/apps/ios/Config/Debug.xcconfig +9 -0
  118. package/template/apps/ios/Config/Identity.xcconfig +5 -0
  119. package/template/apps/ios/Config/Release.xcconfig +6 -0
  120. package/template/apps/web/AGENTS.md +63 -0
  121. package/template/apps/web/CLAUDE.md +1 -0
  122. package/template/apps/web/package.json +38 -0
  123. package/template/apps/web/public/favicon.svg +6 -0
  124. package/template/apps/web/src/core/AppContainer.ts +18 -0
  125. package/template/apps/web/src/core/AppearanceEffect.tsx +31 -0
  126. package/template/apps/web/src/core/designsystem/AppCard.tsx +6 -0
  127. package/template/apps/web/src/features/home/HomeScreen.tsx +15 -0
  128. package/template/apps/web/src/features/home/home.test.tsx +22 -0
  129. package/template/apps/web/src/features/settings/Appearance.ts +29 -0
  130. package/template/apps/web/src/features/settings/LocalStorageSettingsRepository.ts +21 -0
  131. package/template/apps/web/src/features/settings/SettingsRepository.ts +7 -0
  132. package/template/apps/web/src/features/settings/SettingsScreen.tsx +44 -0
  133. package/template/apps/web/src/features/settings/settings.test.ts +45 -0
  134. package/template/apps/web/src/features/settings/useSettingsViewModel.ts +42 -0
  135. package/template/apps/web/src/generated/app.ts +9 -0
  136. package/template/apps/web/src/generated/icons.ts +9 -0
  137. package/template/apps/web/src/generated/strings.ts +88 -0
  138. package/template/apps/web/src/generated/tokens.css +86 -0
  139. package/template/apps/web/src/routeTree.gen.ts +86 -0
  140. package/template/apps/web/src/router.tsx +23 -0
  141. package/template/apps/web/src/routes/__root.tsx +72 -0
  142. package/template/apps/web/src/routes/index.tsx +6 -0
  143. package/template/apps/web/src/routes/settings.tsx +9 -0
  144. package/template/apps/web/src/styles.css +9 -0
  145. package/template/apps/web/tsconfig.json +26 -0
  146. package/template/apps/web/tsr.config.json +3 -0
  147. package/template/apps/web/vite.config.ts +9 -0
  148. package/template/apps/web/vitest.config.ts +9 -0
  149. package/template/contracts/app.json +7 -0
  150. package/template/contracts/icons.json +5 -0
  151. package/template/contracts/strings/en.json +13 -0
  152. package/template/contracts/tokens.json +40 -0
  153. package/template/package.json +17 -0
  154. package/template/spec/decisions.md +10 -0
  155. package/template/spec/features/home.md +39 -0
  156. package/template/spec/features/settings.md +50 -0
  157. package/template/spec/navigation.md +16 -0
  158. package/template/spec/parity.md +29 -0
  159. package/README.md +0 -3
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+
4
+ <application
5
+ android:name=".App"
6
+ android:allowBackup="true"
7
+ android:icon="@mipmap/ic_launcher"
8
+ android:label="@string/app_name"
9
+ android:roundIcon="@mipmap/ic_launcher_round"
10
+ android:supportsRtl="true"
11
+ android:theme="@style/Theme.App">
12
+
13
+ <activity
14
+ android:name=".MainActivity"
15
+ android:exported="true"
16
+ android:windowSoftInputMode="adjustResize">
17
+ <intent-filter>
18
+ <action android:name="android.intent.action.MAIN" />
19
+ <category android:name="android.intent.category.LAUNCHER" />
20
+ </intent-filter>
21
+ </activity>
22
+ </application>
23
+
24
+ </manifest>
@@ -0,0 +1,15 @@
1
+ package app
2
+
3
+ import android.app.Application
4
+ import app.core.AppContainer
5
+
6
+ class App : Application() {
7
+ /** The app's dependencies, created once at launch. */
8
+ lateinit var container: AppContainer
9
+ private set
10
+
11
+ override fun onCreate() {
12
+ super.onCreate()
13
+ container = AppContainer.live(this)
14
+ }
15
+ }
@@ -0,0 +1,49 @@
1
+ package app
2
+
3
+ import android.graphics.Color
4
+ import android.os.Bundle
5
+ import androidx.activity.ComponentActivity
6
+ import androidx.activity.SystemBarStyle
7
+ import androidx.activity.compose.setContent
8
+ import androidx.activity.enableEdgeToEdge
9
+ import androidx.compose.foundation.isSystemInDarkTheme
10
+ import androidx.compose.runtime.CompositionLocalProvider
11
+ import androidx.compose.runtime.DisposableEffect
12
+ import androidx.compose.runtime.getValue
13
+ import androidx.lifecycle.compose.collectAsStateWithLifecycle
14
+ import app.core.LocalAppContainer
15
+ import app.core.designsystem.AppTheme
16
+ import app.core.navigation.AppNavigation
17
+
18
+ class MainActivity : ComponentActivity() {
19
+ override fun onCreate(savedInstanceState: Bundle?) {
20
+ super.onCreate(savedInstanceState)
21
+ enableEdgeToEdge()
22
+ val container = (application as App).container
23
+
24
+ setContent {
25
+ // Null until the stored choice loads. Skip that first frame rather than flash the wrong theme.
26
+ val appearance by container.settingsRepository.appearance.collectAsStateWithLifecycle(initialValue = null)
27
+ val systemIsDark = isSystemInDarkTheme()
28
+
29
+ appearance?.let { chosen ->
30
+ val dark = chosen.isDark(systemIsDark)
31
+
32
+ // Keep the system bar icons readable when the app's appearance differs from the system's.
33
+ DisposableEffect(dark) {
34
+ enableEdgeToEdge(
35
+ statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
36
+ navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
37
+ )
38
+ onDispose {}
39
+ }
40
+
41
+ CompositionLocalProvider(LocalAppContainer provides container) {
42
+ AppTheme(darkTheme = dark) {
43
+ AppNavigation()
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,27 @@
1
+ package app.core
2
+
3
+ import android.content.Context
4
+ import androidx.compose.runtime.staticCompositionLocalOf
5
+ import androidx.datastore.preferences.preferencesDataStore
6
+ import app.features.settings.DataStoreSettingsRepository
7
+ import app.features.settings.InMemorySettingsRepository
8
+ import app.features.settings.SettingsRepository
9
+
10
+ private val Context.settingsDataStore by preferencesDataStore(name = "settings")
11
+
12
+ /**
13
+ * The app's dependencies, created once at launch. Composables read it from [LocalAppContainer].
14
+ * Swap an implementation here (for example, a backend-backed repository) without touching screens.
15
+ */
16
+ class AppContainer(val settingsRepository: SettingsRepository) {
17
+ companion object {
18
+ fun live(context: Context) = AppContainer(
19
+ settingsRepository = DataStoreSettingsRepository(context.applicationContext.settingsDataStore),
20
+ )
21
+
22
+ /** In-memory dependencies, for previews and tests. */
23
+ fun preview() = AppContainer(settingsRepository = InMemorySettingsRepository())
24
+ }
25
+ }
26
+
27
+ val LocalAppContainer = staticCompositionLocalOf<AppContainer> { error("AppContainer not provided") }
@@ -0,0 +1,30 @@
1
+ package app.core.designsystem
2
+
3
+ import androidx.compose.foundation.layout.Arrangement
4
+ import androidx.compose.foundation.layout.Column
5
+ import androidx.compose.foundation.layout.ColumnScope
6
+ import androidx.compose.foundation.layout.fillMaxWidth
7
+ import androidx.compose.foundation.layout.padding
8
+ import androidx.compose.foundation.shape.RoundedCornerShape
9
+ import androidx.compose.material3.Surface
10
+ import androidx.compose.runtime.Composable
11
+ import androidx.compose.ui.Modifier
12
+ import app.generated.AppRadius
13
+ import app.generated.AppSpacing
14
+
15
+ /** A card for grouped content on a screen (DESIGN.md: Card). */
16
+ @Composable
17
+ fun AppCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
18
+ Surface(
19
+ modifier = modifier.fillMaxWidth(),
20
+ shape = RoundedCornerShape(AppRadius.lg),
21
+ color = AppTheme.colors.surface,
22
+ contentColor = AppTheme.colors.onSurface,
23
+ ) {
24
+ Column(
25
+ modifier = Modifier.padding(AppSpacing.lg),
26
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.sm),
27
+ content = content,
28
+ )
29
+ }
30
+ }
@@ -0,0 +1,35 @@
1
+ package app.core.designsystem
2
+
3
+ import androidx.compose.material3.MaterialTheme
4
+ import androidx.compose.runtime.Composable
5
+ import androidx.compose.runtime.CompositionLocalProvider
6
+ import androidx.compose.runtime.ReadOnlyComposable
7
+ import androidx.compose.runtime.staticCompositionLocalOf
8
+ import app.generated.AppColorTokens
9
+ import app.generated.AppMaterialTypography
10
+ import app.generated.AppShapes
11
+ import app.generated.DarkAppColors
12
+ import app.generated.LightAppColors
13
+ import app.generated.toColorScheme
14
+
15
+ private val LocalAppColors = staticCompositionLocalOf { LightAppColors }
16
+
17
+ /** Applies the design tokens: [AppTheme.colors] for token colors, and Material 3 theming for stock components. */
18
+ @Composable
19
+ fun AppTheme(darkTheme: Boolean, content: @Composable () -> Unit) {
20
+ val colors = if (darkTheme) DarkAppColors else LightAppColors
21
+ CompositionLocalProvider(LocalAppColors provides colors) {
22
+ MaterialTheme(
23
+ colorScheme = colors.toColorScheme(dark = darkTheme),
24
+ typography = AppMaterialTypography,
25
+ shapes = AppShapes,
26
+ content = content,
27
+ )
28
+ }
29
+ }
30
+
31
+ object AppTheme {
32
+ /** The current color tokens, light or dark. */
33
+ val colors: AppColorTokens
34
+ @Composable @ReadOnlyComposable get() = LocalAppColors.current
35
+ }
@@ -0,0 +1,40 @@
1
+ package app.core.navigation
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.lifecycle.viewmodel.compose.viewModel
5
+ import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
6
+ import androidx.navigation3.runtime.entryProvider
7
+ import androidx.navigation3.runtime.rememberNavBackStack
8
+ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
9
+ import androidx.navigation3.ui.NavDisplay
10
+ import app.core.LocalAppContainer
11
+ import app.features.home.HomeScreen
12
+ import app.features.settings.SettingsScreen
13
+ import app.features.settings.SettingsViewModel
14
+
15
+ /** The navigation root: one back stack, starting at Home. */
16
+ @Composable
17
+ fun AppNavigation() {
18
+ val container = LocalAppContainer.current
19
+ val backStack = rememberNavBackStack(Route.Home)
20
+
21
+ NavDisplay(
22
+ backStack = backStack,
23
+ onBack = { backStack.removeLastOrNull() },
24
+ entryDecorators = listOf(
25
+ rememberSaveableStateHolderNavEntryDecorator(),
26
+ rememberViewModelStoreNavEntryDecorator(),
27
+ ),
28
+ entryProvider = entryProvider {
29
+ entry<Route.Home> {
30
+ HomeScreen(onOpenSettings = { backStack.add(Route.Settings) })
31
+ }
32
+ entry<Route.Settings> {
33
+ SettingsScreen(
34
+ viewModel = viewModel { SettingsViewModel(container.settingsRepository) },
35
+ onBack = { backStack.removeLastOrNull() },
36
+ )
37
+ }
38
+ },
39
+ )
40
+ }
@@ -0,0 +1,13 @@
1
+ package app.core.navigation
2
+
3
+ import androidx.navigation3.runtime.NavKey
4
+ import kotlinx.serialization.Serializable
5
+
6
+ /** Screens on the back stack. Names match spec/navigation.md. */
7
+ sealed interface Route : NavKey {
8
+ @Serializable
9
+ data object Home : Route
10
+
11
+ @Serializable
12
+ data object Settings : Route
13
+ }
@@ -0,0 +1,67 @@
1
+ package app.features.home
2
+
3
+ import androidx.compose.foundation.layout.Column
4
+ import androidx.compose.foundation.layout.fillMaxSize
5
+ import androidx.compose.foundation.layout.padding
6
+ import androidx.compose.foundation.rememberScrollState
7
+ import androidx.compose.foundation.verticalScroll
8
+ import androidx.compose.material3.ExperimentalMaterial3Api
9
+ import androidx.compose.material3.Icon
10
+ import androidx.compose.material3.IconButton
11
+ import androidx.compose.material3.Scaffold
12
+ import androidx.compose.material3.Text
13
+ import androidx.compose.material3.TopAppBar
14
+ import androidx.compose.runtime.Composable
15
+ import androidx.compose.ui.Modifier
16
+ import androidx.compose.ui.platform.testTag
17
+ import androidx.compose.ui.res.stringResource
18
+ import androidx.compose.ui.tooling.preview.Preview
19
+ import app.R
20
+ import app.core.designsystem.AppCard
21
+ import app.core.designsystem.AppTheme
22
+ import app.generated.AppIcons
23
+ import app.generated.AppIdentity
24
+ import app.generated.AppSpacing
25
+ import app.generated.AppTypography
26
+
27
+ @OptIn(ExperimentalMaterial3Api::class)
28
+ @Composable
29
+ fun HomeScreen(onOpenSettings: () -> Unit) {
30
+ Scaffold(
31
+ topBar = {
32
+ TopAppBar(
33
+ title = { Text(AppIdentity.NAME) },
34
+ actions = {
35
+ IconButton(onClick = onOpenSettings, modifier = Modifier.testTag("home.openSettings")) {
36
+ Icon(AppIcons.settings, contentDescription = stringResource(R.string.home_open_settings))
37
+ }
38
+ },
39
+ )
40
+ },
41
+ ) { padding ->
42
+ Column(
43
+ modifier = Modifier
44
+ .fillMaxSize()
45
+ .padding(padding)
46
+ .verticalScroll(rememberScrollState())
47
+ .padding(AppSpacing.lg),
48
+ ) {
49
+ AppCard {
50
+ Text(stringResource(R.string.home_welcome_title), style = AppTypography.title)
51
+ Text(
52
+ stringResource(R.string.home_welcome_body),
53
+ style = AppTypography.body,
54
+ color = AppTheme.colors.onSurfaceVariant,
55
+ )
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ @Preview
62
+ @Composable
63
+ private fun HomeScreenPreview() {
64
+ AppTheme(darkTheme = false) {
65
+ HomeScreen(onOpenSettings = {})
66
+ }
67
+ }
@@ -0,0 +1,22 @@
1
+ package app.features.settings
2
+
3
+ import androidx.annotation.StringRes
4
+ import app.R
5
+
6
+ /** The appearance setting. Stored as these exact [id]s on every platform. */
7
+ enum class Appearance(val id: String, @StringRes val label: Int) {
8
+ System("system", R.string.settings_appearance_system),
9
+ Light("light", R.string.settings_appearance_light),
10
+ Dark("dark", R.string.settings_appearance_dark);
11
+
12
+ /** Whether to render dark, given the system setting. */
13
+ fun isDark(systemIsDark: Boolean): Boolean = this == Dark || (this == System && systemIsDark)
14
+
15
+ companion object {
16
+ /** Storage key, shared with the iOS and web apps. */
17
+ const val STORAGE_KEY = "settings.appearance"
18
+
19
+ /** A missing or unknown stored value counts as [System]. */
20
+ fun fromId(id: String?): Appearance = entries.firstOrNull { it.id == id } ?: System
21
+ }
22
+ }
@@ -0,0 +1,40 @@
1
+ package app.features.settings
2
+
3
+ import androidx.datastore.core.DataStore
4
+ import androidx.datastore.preferences.core.Preferences
5
+ import androidx.datastore.preferences.core.edit
6
+ import androidx.datastore.preferences.core.stringPreferencesKey
7
+ import kotlinx.coroutines.flow.Flow
8
+ import kotlinx.coroutines.flow.MutableStateFlow
9
+ import kotlinx.coroutines.flow.map
10
+
11
+ /** Stores the user's preferences. Screens use whichever implementation AppContainer provides. */
12
+ interface SettingsRepository {
13
+ val appearance: Flow<Appearance>
14
+
15
+ suspend fun setAppearance(appearance: Appearance)
16
+ }
17
+
18
+ /** Keeps settings in Jetpack DataStore. */
19
+ class DataStoreSettingsRepository(private val dataStore: DataStore<Preferences>) : SettingsRepository {
20
+ override val appearance: Flow<Appearance> = dataStore.data.map { Appearance.fromId(it[appearanceKey]) }
21
+
22
+ override suspend fun setAppearance(appearance: Appearance) {
23
+ dataStore.edit { it[appearanceKey] = appearance.id }
24
+ }
25
+
26
+ private companion object {
27
+ val appearanceKey = stringPreferencesKey(Appearance.STORAGE_KEY)
28
+ }
29
+ }
30
+
31
+ /** Keeps settings in memory, for previews and tests. */
32
+ class InMemorySettingsRepository(initial: Appearance = Appearance.System) : SettingsRepository {
33
+ private val state = MutableStateFlow(initial)
34
+
35
+ override val appearance: Flow<Appearance> = state
36
+
37
+ override suspend fun setAppearance(appearance: Appearance) {
38
+ state.value = appearance
39
+ }
40
+ }
@@ -0,0 +1,113 @@
1
+ package app.features.settings
2
+
3
+ import androidx.compose.foundation.layout.Arrangement
4
+ import androidx.compose.foundation.layout.Column
5
+ import androidx.compose.foundation.layout.Row
6
+ import androidx.compose.foundation.layout.fillMaxSize
7
+ import androidx.compose.foundation.layout.fillMaxWidth
8
+ import androidx.compose.foundation.layout.heightIn
9
+ import androidx.compose.foundation.layout.padding
10
+ import androidx.compose.foundation.rememberScrollState
11
+ import androidx.compose.foundation.selection.selectable
12
+ import androidx.compose.foundation.selection.selectableGroup
13
+ import androidx.compose.foundation.verticalScroll
14
+ import androidx.compose.material3.ExperimentalMaterial3Api
15
+ import androidx.compose.material3.Icon
16
+ import androidx.compose.material3.IconButton
17
+ import androidx.compose.material3.RadioButton
18
+ import androidx.compose.material3.Scaffold
19
+ import androidx.compose.material3.Text
20
+ import androidx.compose.material3.TopAppBar
21
+ import androidx.compose.runtime.Composable
22
+ import androidx.compose.runtime.getValue
23
+ import androidx.compose.ui.Alignment
24
+ import androidx.compose.ui.Modifier
25
+ import androidx.compose.ui.platform.testTag
26
+ import androidx.compose.ui.res.stringResource
27
+ import androidx.compose.ui.semantics.Role
28
+ import androidx.compose.ui.tooling.preview.Preview
29
+ import androidx.compose.ui.unit.dp
30
+ import androidx.lifecycle.compose.collectAsStateWithLifecycle
31
+ import app.R
32
+ import app.core.designsystem.AppCard
33
+ import app.core.designsystem.AppTheme
34
+ import app.generated.AppIcons
35
+ import app.generated.AppSpacing
36
+ import app.generated.AppTypography
37
+
38
+ @Composable
39
+ fun SettingsScreen(viewModel: SettingsViewModel, onBack: () -> Unit) {
40
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
41
+ SettingsContent(state = state, onSelectAppearance = viewModel::selectAppearance, onBack = onBack)
42
+ }
43
+
44
+ @OptIn(ExperimentalMaterial3Api::class)
45
+ @Composable
46
+ private fun SettingsContent(state: SettingsUiState, onSelectAppearance: (Appearance) -> Unit, onBack: () -> Unit) {
47
+ Scaffold(
48
+ topBar = {
49
+ TopAppBar(
50
+ title = { Text(stringResource(R.string.settings_title)) },
51
+ navigationIcon = {
52
+ IconButton(onClick = onBack) {
53
+ Icon(AppIcons.back, contentDescription = stringResource(R.string.common_back))
54
+ }
55
+ },
56
+ )
57
+ },
58
+ ) { padding ->
59
+ Column(
60
+ modifier = Modifier
61
+ .fillMaxSize()
62
+ .padding(padding)
63
+ .verticalScroll(rememberScrollState())
64
+ .padding(AppSpacing.lg),
65
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.xl),
66
+ ) {
67
+ AppCard {
68
+ SectionTitle(stringResource(R.string.settings_appearance_title))
69
+ Column(Modifier.selectableGroup().testTag("settings.appearance")) {
70
+ state.appearanceOptions.forEach { option ->
71
+ Row(
72
+ modifier = Modifier
73
+ .fillMaxWidth()
74
+ .heightIn(min = 48.dp)
75
+ .selectable(
76
+ selected = option == state.appearance,
77
+ onClick = { onSelectAppearance(option) },
78
+ role = Role.RadioButton,
79
+ ),
80
+ verticalAlignment = Alignment.CenterVertically,
81
+ horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
82
+ ) {
83
+ RadioButton(selected = option == state.appearance, onClick = null)
84
+ Text(stringResource(option.label), style = AppTypography.body)
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ AppCard {
91
+ SectionTitle(stringResource(R.string.settings_about_title))
92
+ Text(
93
+ stringResource(R.string.settings_about_version, state.version, state.build),
94
+ style = AppTypography.body,
95
+ modifier = Modifier.testTag("settings.version"),
96
+ )
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ @Composable
103
+ private fun SectionTitle(text: String) {
104
+ Text(text, style = AppTypography.label, color = AppTheme.colors.onSurfaceVariant)
105
+ }
106
+
107
+ @Preview
108
+ @Composable
109
+ private fun SettingsScreenPreview() {
110
+ AppTheme(darkTheme = false) {
111
+ SettingsContent(state = SettingsUiState(), onSelectAppearance = {}, onBack = {})
112
+ }
113
+ }
@@ -0,0 +1,28 @@
1
+ package app.features.settings
2
+
3
+ import androidx.lifecycle.ViewModel
4
+ import androidx.lifecycle.viewModelScope
5
+ import app.generated.AppIdentity
6
+ import kotlinx.coroutines.flow.SharingStarted
7
+ import kotlinx.coroutines.flow.StateFlow
8
+ import kotlinx.coroutines.flow.map
9
+ import kotlinx.coroutines.flow.stateIn
10
+ import kotlinx.coroutines.launch
11
+
12
+ data class SettingsUiState(
13
+ val appearance: Appearance = Appearance.System,
14
+ val appearanceOptions: List<Appearance> = Appearance.entries,
15
+ val version: String = AppIdentity.VERSION,
16
+ val build: Int = AppIdentity.BUILD,
17
+ )
18
+
19
+ /** State and actions for the Settings screen. */
20
+ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel() {
21
+ val uiState: StateFlow<SettingsUiState> = repository.appearance
22
+ .map { SettingsUiState(appearance = it) }
23
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
24
+
25
+ fun selectAppearance(appearance: Appearance) {
26
+ viewModelScope.launch { repository.setAppearance(appearance) }
27
+ }
28
+ }
@@ -0,0 +1,10 @@
1
+ // GENERATED by geeknative from contracts/app.json. Do not edit: change the contract, then run `make gen`.
2
+ package app.generated
3
+
4
+ /** The app's name, identifier and version, shared with the iOS and web apps. */
5
+ object AppIdentity {
6
+ const val NAME = "GeekNative"
7
+ const val ID = "com.example.geeknative"
8
+ const val VERSION = "1.0.0"
9
+ const val BUILD = 1
10
+ }
@@ -0,0 +1,15 @@
1
+ // GENERATED by geeknative from contracts/icons.json. Do not edit: change the contract, then run `make gen`.
2
+ package app.generated
3
+
4
+ import androidx.compose.material.icons.Icons
5
+ import androidx.compose.material.icons.automirrored.filled.ArrowBack
6
+ import androidx.compose.material.icons.filled.Home
7
+ import androidx.compose.material.icons.filled.Settings
8
+ import androidx.compose.ui.graphics.vector.ImageVector
9
+
10
+ /** Icons as Material icons. Use with `Icon(AppIcons.settings, contentDescription = ...)`. */
11
+ object AppIcons {
12
+ val back: ImageVector get() = Icons.AutoMirrored.Filled.ArrowBack
13
+ val home: ImageVector get() = Icons.Filled.Home
14
+ val settings: ImageVector get() = Icons.Filled.Settings
15
+ }