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.
- package/LICENSE +202 -0
- package/bin/geeknative.js +12 -0
- package/package.json +42 -4
- package/src/cli.js +61 -0
- package/src/codegen/app.js +105 -0
- package/src/codegen/icons.js +87 -0
- package/src/codegen/index.js +44 -0
- package/src/codegen/strings.js +308 -0
- package/src/codegen/tokens.js +449 -0
- package/src/codegen/util.js +122 -0
- package/src/commands/check.js +148 -0
- package/src/commands/create.js +82 -0
- package/src/commands/doctor.js +108 -0
- package/src/commands/gen.js +44 -0
- package/src/commands/parity.js +134 -0
- package/src/commands/run.js +91 -0
- package/src/lib/exec.js +77 -0
- package/src/lib/fsutil.js +92 -0
- package/src/lib/log.js +37 -0
- package/src/lib/managed.js +80 -0
- package/src/lib/project.js +52 -0
- package/src/lib/toolchains.js +126 -0
- package/template/.claude/agents/android-engineer.md +15 -0
- package/template/.claude/agents/ios-engineer.md +15 -0
- package/template/.claude/agents/web-engineer.md +15 -0
- package/template/.claude/settings.json +65 -0
- package/template/.geeknative/framework.mk +41 -0
- package/template/.geeknative/hooks/after-edit.mjs +28 -0
- package/template/.geeknative/hooks/parity-guard.mjs +49 -0
- package/template/.geeknative/hooks/subagent-context.mjs +24 -0
- package/template/.geeknative/playbooks/bug.md +7 -0
- package/template/.geeknative/playbooks/design.md +10 -0
- package/template/.geeknative/playbooks/engineering.md +9 -0
- package/template/.geeknative/playbooks/feature.md +11 -0
- package/template/.geeknative/playbooks/platform-specific.md +8 -0
- package/template/.geeknative/playbooks/product.md +12 -0
- package/template/.geeknative/templates/feature.md +49 -0
- package/template/.gemini/settings.json +5 -0
- package/template/.github/dependabot.yml +18 -0
- package/template/AGENTS.md +97 -0
- package/template/CLAUDE.md +9 -0
- package/template/DESIGN.md +65 -0
- package/template/Makefile +6 -0
- package/template/PRODUCT.md +40 -0
- package/template/README.md +28 -0
- package/template/_gitignore +33 -0
- package/template/apps/android/AGENTS.md +66 -0
- package/template/apps/android/CLAUDE.md +1 -0
- package/template/apps/android/app/build.gradle.kts +68 -0
- package/template/apps/android/app/identity.properties +4 -0
- package/template/apps/android/app/src/main/AndroidManifest.xml +24 -0
- package/template/apps/android/app/src/main/kotlin/app/App.kt +15 -0
- package/template/apps/android/app/src/main/kotlin/app/MainActivity.kt +49 -0
- package/template/apps/android/app/src/main/kotlin/app/core/AppContainer.kt +27 -0
- package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppCard.kt +30 -0
- package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppTheme.kt +35 -0
- package/template/apps/android/app/src/main/kotlin/app/core/navigation/AppNavigation.kt +40 -0
- package/template/apps/android/app/src/main/kotlin/app/core/navigation/Route.kt +13 -0
- package/template/apps/android/app/src/main/kotlin/app/features/home/HomeScreen.kt +67 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/Appearance.kt +22 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsRepository.kt +40 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsScreen.kt +113 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsViewModel.kt +28 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/AppIdentity.kt +10 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/Icons.kt +15 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/Tokens.kt +144 -0
- package/template/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml +17 -0
- package/template/apps/android/app/src/main/res/mipmap/ic_launcher.xml +10 -0
- package/template/apps/android/app/src/main/res/mipmap/ic_launcher_round.xml +10 -0
- package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
- package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- package/template/apps/android/app/src/main/res/values/generated_colors.xml +15 -0
- package/template/apps/android/app/src/main/res/values/generated_strings.xml +16 -0
- package/template/apps/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
- package/template/apps/android/app/src/main/res/values/themes.xml +7 -0
- package/template/apps/android/app/src/main/res/values-night/generated_colors.xml +15 -0
- package/template/apps/android/app/src/main/res/values-night/themes.xml +6 -0
- package/template/apps/android/app/src/test/kotlin/app/features/settings/SettingsTest.kt +90 -0
- package/template/apps/android/build.gradle.kts +5 -0
- package/template/apps/android/gradle/gradle-daemon-jvm.properties +12 -0
- package/template/apps/android/gradle/libs.versions.toml +37 -0
- package/template/apps/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/template/apps/android/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/template/apps/android/gradle.properties +7 -0
- package/template/apps/android/gradlew +248 -0
- package/template/apps/android/gradlew.bat +82 -0
- package/template/apps/android/settings.gradle.kts +29 -0
- package/template/apps/ios/AGENTS.md +70 -0
- package/template/apps/ios/App/AppMain.swift +13 -0
- package/template/apps/ios/App/Core/AppContainer.swift +22 -0
- package/template/apps/ios/App/Core/DesignSystem/AppCard.swift +15 -0
- package/template/apps/ios/App/Core/Navigation/Route.swift +4 -0
- package/template/apps/ios/App/Core/RootView.swift +32 -0
- package/template/apps/ios/App/Features/Home/HomeScreen.swift +35 -0
- package/template/apps/ios/App/Features/Settings/Appearance.swift +27 -0
- package/template/apps/ios/App/Features/Settings/SettingsRepository.swift +40 -0
- package/template/apps/ios/App/Features/Settings/SettingsScreen.swift +40 -0
- package/template/apps/ios/App/Features/Settings/SettingsViewModel.swift +23 -0
- package/template/apps/ios/App/Generated/AppIdentity.swift +9 -0
- package/template/apps/ios/App/Generated/Colors.xcassets/AccentColor.colorset/Contents.json +38 -0
- package/template/apps/ios/App/Generated/Colors.xcassets/Contents.json +6 -0
- package/template/apps/ios/App/Generated/Icons.swift +8 -0
- package/template/apps/ios/App/Generated/Localizable.xcstrings +127 -0
- package/template/apps/ios/App/Generated/Strings.swift +40 -0
- package/template/apps/ios/App/Generated/Tokens.swift +65 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/Contents.json +6 -0
- package/template/apps/ios/App.xcodeproj/project.pbxproj +298 -0
- package/template/apps/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/template/apps/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +89 -0
- package/template/apps/ios/AppTests/SettingsTests.swift +54 -0
- package/template/apps/ios/CLAUDE.md +1 -0
- package/template/apps/ios/Config/App.xcconfig +23 -0
- package/template/apps/ios/Config/AppTests.xcconfig +10 -0
- package/template/apps/ios/Config/Base.xcconfig +25 -0
- package/template/apps/ios/Config/Debug.xcconfig +9 -0
- package/template/apps/ios/Config/Identity.xcconfig +5 -0
- package/template/apps/ios/Config/Release.xcconfig +6 -0
- package/template/apps/web/AGENTS.md +63 -0
- package/template/apps/web/CLAUDE.md +1 -0
- package/template/apps/web/package.json +38 -0
- package/template/apps/web/public/favicon.svg +6 -0
- package/template/apps/web/src/core/AppContainer.ts +18 -0
- package/template/apps/web/src/core/AppearanceEffect.tsx +31 -0
- package/template/apps/web/src/core/designsystem/AppCard.tsx +6 -0
- package/template/apps/web/src/features/home/HomeScreen.tsx +15 -0
- package/template/apps/web/src/features/home/home.test.tsx +22 -0
- package/template/apps/web/src/features/settings/Appearance.ts +29 -0
- package/template/apps/web/src/features/settings/LocalStorageSettingsRepository.ts +21 -0
- package/template/apps/web/src/features/settings/SettingsRepository.ts +7 -0
- package/template/apps/web/src/features/settings/SettingsScreen.tsx +44 -0
- package/template/apps/web/src/features/settings/settings.test.ts +45 -0
- package/template/apps/web/src/features/settings/useSettingsViewModel.ts +42 -0
- package/template/apps/web/src/generated/app.ts +9 -0
- package/template/apps/web/src/generated/icons.ts +9 -0
- package/template/apps/web/src/generated/strings.ts +88 -0
- package/template/apps/web/src/generated/tokens.css +86 -0
- package/template/apps/web/src/routeTree.gen.ts +86 -0
- package/template/apps/web/src/router.tsx +23 -0
- package/template/apps/web/src/routes/__root.tsx +72 -0
- package/template/apps/web/src/routes/index.tsx +6 -0
- package/template/apps/web/src/routes/settings.tsx +9 -0
- package/template/apps/web/src/styles.css +9 -0
- package/template/apps/web/tsconfig.json +26 -0
- package/template/apps/web/tsr.config.json +3 -0
- package/template/apps/web/vite.config.ts +9 -0
- package/template/apps/web/vitest.config.ts +9 -0
- package/template/contracts/app.json +7 -0
- package/template/contracts/icons.json +5 -0
- package/template/contracts/strings/en.json +13 -0
- package/template/contracts/tokens.json +40 -0
- package/template/package.json +17 -0
- package/template/spec/decisions.md +10 -0
- package/template/spec/features/home.md +39 -0
- package/template/spec/features/settings.md +50 -0
- package/template/spec/navigation.md +16 -0
- package/template/spec/parity.md +29 -0
- 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
|
+
}
|