first design draft

This commit is contained in:
Fabian Wolter
2026-02-01 18:14:18 +01:00
parent 2148e50795
commit a69cd0b110
18 changed files with 410 additions and 226 deletions

View File

@@ -61,4 +61,6 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.material3.adaptive.navigation3)
implementation(libs.kotlinx.serialization.core)
implementation(libs.bundles.voyager)
}

View File

@@ -1,49 +0,0 @@
package com.fabisahne.cloudsync
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fabisahne.cloudsync.ui.theme.CloudSyncTheme
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
import org.junit.Rule
import java.text.NumberFormat
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.fabisahne.cloudsync", appContext.packageName)
}
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun calculateTip_20PercentTip() {
composeTestRule.setContent {
CloudSyncTheme {
TipTimeLayout()
}
}
composeTestRule.onNodeWithText("Bill Amount")
.performTextInput("10")
val expected = NumberFormat.getCurrencyInstance().format(1.5)
composeTestRule.onNodeWithText("Tip Amount: $expected").assertExists(
"No node with this text was found."
)
}
}

View File

@@ -4,23 +4,22 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api
import cafe.adriel.voyager.navigator.Navigator
import com.fabisahne.cloudsync.ui.screens.HomeScreen
import com.fabisahne.cloudsync.ui.theme.CloudSyncTheme
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
CloudSyncTheme {
Icon(
Icons.Filled.Home,
contentDescription = "asdf",
)
Navigator(HomeScreen)
}
}
}
}

View File

@@ -0,0 +1,2 @@
package com.fabisahne.cloudsync.ui

View File

@@ -0,0 +1 @@
package com.fabisahne.cloudsync.ui

View File

@@ -1,71 +0,0 @@
package com.fabisahne.cloudsync.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavEntryDecorator
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
@Composable
fun rememberNavigationState(
startKey: NavKey,
topLevelKeys: Set<NavKey>,
): NavigationState {
val topLevelStack = rememberNavBackStack(startKey)
val subStacks = topLevelKeys.associateWith { key -> rememberNavBackStack(key) }
return remember(startKey, topLevelKeys) {
NavigationState(
startKey = startKey,
topLevelStack = topLevelStack,
subStacks = subStacks
)
}
}
class NavigationState(
val startKey: NavKey,
val topLevelStack: NavBackStack<NavKey>,
val subStacks: Map<NavKey, NavBackStack<NavKey>>
) {
val currentTopLevelKey: NavKey by derivedStateOf { topLevelStack.last() }
val topLevelKeys
get() = subStacks.keys
val currentSubStack: NavBackStack<NavKey>
get() = subStacks[currentTopLevelKey]
?: error("Sub stack for $currentTopLevelKey does not exist")
val currentKey: NavKey by derivedStateOf { currentSubStack.last() }
}
@Composable
fun NavigationState.toEntries(
entryProvider: (NavKey) -> NavEntry<NavKey>,
): SnapshotStateList<NavEntry<NavKey>> {
val decoratedEntries = subStacks.mapValues { (_, stack) ->
val decorators = listOf<NavEntryDecorator<NavKey>>(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
)
rememberDecoratedNavEntries(
backStack = stack,
entryDecorators = decorators,
entryProvider = entryProvider,
)
}
return topLevelStack
.flatMap { decoratedEntries[it] ?: emptyList() }
.toMutableStateList()
}

View File

@@ -1,45 +0,0 @@
package com.fabisahne.cloudsync.ui.navigation
import androidx.navigation3.runtime.NavKey
class Navigator(val state: NavigationState) {
fun navigate(key: NavKey) {
when (key) {
state.currentTopLevelKey -> clearSubStack()
in state.topLevelKeys -> goToTopLevel(key)
else -> goToKey(key)
}
}
fun goBack() {
when (state.currentKey) {
state.startKey -> error("You cannot go back from the start route")
state.currentTopLevelKey -> state.topLevelStack.removeLastOrNull()
else -> state.currentSubStack.removeLastOrNull()
}
}
private fun goToKey(key: NavKey) {
state.currentSubStack.apply {
remove(key)
add(key)
}
}
private fun goToTopLevel(key: NavKey) {
state.topLevelStack.apply {
if (key == state.startKey) {
clear()
} else {
remove(key)
}
add(key)
}
}
private fun clearSubStack() {
state.currentSubStack.run {
if (size > 1) subList(1, size).clear()
}
}
}

View File

@@ -0,0 +1,22 @@
package com.fabisahne.cloudsync.ui.navigation
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
import cafe.adriel.voyager.navigator.tab.Tab
@Composable
fun RowScope.TabNavigationItem(tab: Tab) {
val tabNavigator = LocalTabNavigator.current
NavigationBarItem(
selected = tabNavigator.current.key == tab.key,
onClick = { tabNavigator.current = tab },
label = { Text(tab.options.title) },
icon = { Icon(painter = tab.options.icon!!, contentDescription = tab.options.title) }
)
}

View File

@@ -0,0 +1,32 @@
package com.fabisahne.cloudsync.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.screen.Screen
data class FolderPairScreen(
val id: Long
) : Screen {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Folder Pair") }
)
},
content = { contentPadding ->
Surface(modifier = Modifier.padding(contentPadding)) {
Text("ASDF: $id")
}
}
)
}
}

View File

@@ -0,0 +1,62 @@
package com.fabisahne.cloudsync.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.CurrentTab
import cafe.adriel.voyager.navigator.tab.TabNavigator
import com.fabisahne.cloudsync.ui.navigation.TabNavigationItem
import com.fabisahne.cloudsync.ui.tabs.AccountTab
import com.fabisahne.cloudsync.ui.tabs.FolderPairTab
import com.fabisahne.cloudsync.ui.tabs.HistoryTab
import com.fabisahne.cloudsync.ui.tabs.SettingsTab
object HomeScreen : Screen {
private val TABS = listOf(
HistoryTab,
FolderPairTab,
AccountTab,
SettingsTab,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
TabNavigator(
HistoryTab
) { tabNavigator ->
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = tabNavigator.current.options.title) }
)
},
content = { padding ->
Surface(modifier = Modifier.padding(padding)) {
CurrentTab()
}
},
bottomBar = {
NavigationBar(windowInsets = NavigationBarDefaults.windowInsets) {
TABS.forEach {
TabNavigationItem(it)
}
}
}
)
}
}
}

View File

@@ -0,0 +1,33 @@
package com.fabisahne.cloudsync.ui.tabs
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
object AccountTab : Tab {
override val options: TabOptions
@Composable
get() {
val title = "Accounts"
val icon = rememberVectorPainter(Icons.Default.AccountBox)
return remember {
TabOptions(
index = 2u,
title = title,
icon = icon
)
}
}
@Composable
override fun Content() {
Text("Cloud Accounts")
}
}

View File

@@ -0,0 +1,59 @@
package com.fabisahne.cloudsync.ui.tabs
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.absolutePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.FolderCopy
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
object FolderPairTab : Tab {
override val options: TabOptions
@Composable
get() {
val title = "Folder Pairs"
val icon = rememberVectorPainter(Icons.Default.FolderCopy)
return remember {
TabOptions(
index = 1u,
title = title,
icon = icon
)
}
}
@Composable
override fun Content() {
// Text("Folder Pairs")
// FloatingActionButton(
//
// modifier = Modifier.absolute(),
// onClick = {}
// ) {
// Icon(Icons.Default.Add, "Add Pair")
// }
Scaffold(
floatingActionButton = {
FloatingActionButton(
onClick = {}
) {
Icon(Icons.Default.Add, "Add Pair")
}
}
) { paddingValues ->
Text("Pairs here", Modifier.padding(paddingValues))
}
}
}

View File

@@ -0,0 +1,141 @@
package com.fabisahne.cloudsync.ui.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.outlined.History
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.fabisahne.cloudsync.ui.screens.FolderPairScreen
import com.fabisahne.cloudsync.ui.theme.CloudSyncTheme
import java.time.LocalDate
object HistoryTab : Tab {
override val options: TabOptions
@Composable
get() {
val isSelected = LocalTabNavigator.current.current.key == key
val icon = if (isSelected)
Icons.Outlined.History
else
Icons.Default.History
return TabOptions(
index = 0u,
title = "History",
icon = rememberVectorPainter(icon),
)
}
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow.parent!!
LazyColumn(
contentPadding = PaddingValues(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(5) { index ->
HistoryItem(
"Account",
"Folder Pair $index",
LocalDate.now().minusDays(index.toLong()),
isError = index % 2 == 0,
onClick = { navigator.push(FolderPairScreen(index.toLong())) }
)
}
}
}
}
@Composable
fun HistoryItem(
account: String,
pairName: String,
date: LocalDate,
isError: Boolean = false,
onClick: () -> Unit = {},
) {
val (containerColor, contentColor) = if (isError)
MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
else
CardDefaults.cardColors().containerColor to CardDefaults.cardColors().contentColor
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(90.dp),
colors = CardDefaults.cardColors(
containerColor = containerColor,
contentColor = contentColor
),
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween
) {
Text("$account | $pairName", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
if (isError) "Error" else "Success",
style = MaterialTheme.typography.bodyMedium
)
Text(date.toString(), style = MaterialTheme.typography.bodyMedium)
}
}
}
}
@Preview
@Composable
fun HistoryCardPreviewLight() {
CloudSyncTheme {
HistoryItem(
"Account",
"Folder Pair",
LocalDate.now(),
)
}
}
@Preview
@Composable
fun HistoryCardPreviewDark() {
CloudSyncTheme(
darkTheme = true
) {
HistoryItem(
"Account",
"Folder Pair",
LocalDate.now()
)
}
}

View File

@@ -0,0 +1,33 @@
package com.fabisahne.cloudsync.ui.tabs
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
object SettingsTab : Tab {
override val options: TabOptions
@Composable
get() {
val title = "Settings"
val icon = rememberVectorPainter(Icons.Default.Settings)
return remember {
TabOptions(
index = 2u,
title = title,
icon = icon
)
}
}
@Composable
override fun Content() {
Text("Settings")
}
}

View File

@@ -1,11 +0,0 @@
package com.fabisahne.cloudsync.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@@ -1,30 +1,24 @@
package com.fabisahne.cloudsync.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
@Composable
fun CloudSyncTheme(
dynamicColor: Boolean = true,
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor -> {
val context = LocalContext.current
dynamicDarkColorScheme(context)
}
else -> DarkColorScheme
}
val colorScheme = if (darkTheme)
dynamicDarkColorScheme(context)
else
dynamicLightColorScheme(context)
MaterialTheme(
colorScheme = colorScheme,

View File

@@ -1,28 +0,0 @@
package com.fabisahne.cloudsync
import org.junit.Test
import org.junit.Assert.*
import java.text.NumberFormat
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
@Test
fun calculateTip_20Percent() {
val amount = 10.00
val tipPercent = 20.00
val expected = NumberFormat.getCurrencyInstance().format(2)
assertEquals(expected, calculateTip(amount, tipPercent))
}
}

View File

@@ -8,7 +8,7 @@ lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.2"
kotlin = "2.0.21"
composeBom = "2024.09.00"
compileSdk = "36"
voyager = "1.1.0-beta03"
nav3Core = "1.0.0"
lifeCycleViewmodelNav3 = "2.10.0"
@@ -40,7 +40,15 @@ androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecy
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization" }
[bundles]
voyager = ["voyager-navigator", "voyager-screenmodel", "voyager-tab-navigator", "voyager-transitions"]