Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • jonas.broeckmann/kaffeekasse
1 result
Select Git revision
Show changes
Commits on Source (3)
Showing
with 699 additions and 513 deletions
......@@ -17,6 +17,8 @@ import io.ktor.http.Parameters
import io.ktor.http.URLBuilder
import io.ktor.http.Url
import io.ktor.http.parameters
import io.ktor.http.parseServerSetCookieHeader
import io.ktor.http.renderSetCookieHeader
import io.ktor.serialization.kotlinx.json.json
import it.skrape.core.htmlDocument
import it.skrape.selects.Doc
......@@ -74,20 +76,46 @@ class PortalClient(
private val portal = object : Module(this, "portal") {
val currentSession = MutableStateFlow(PortalAPIResponse.Session())
suspend fun updateSession(): Unit = withContext(computationScope) {
val currentSession = MutableStateFlow(Session())
suspend fun updateSession(restore: String? = null): Unit = withContext(computationScope) {
if (restore != null) {
debug { "Restoring cookie: $restore" }
try {
val cookie = parseServerSetCookieHeader(restore)
cookiesStorage.addCookie(baseUrl, cookie)
debug { "Restored cookie: $cookie" }
} catch (e: Throwable) {
error(e) { "Failed to restore cookie" }
}
}
debug { "Updating session" }
val response = requestAPI(updateSession = false).decodeAs<PortalAPIResponse>()
debug { "Session: ${response.session}" }
currentSession.value = response.session
val serializedCookie = cookiesStorage.get(baseUrl)
.firstOrNull { it.name == SessionCookieName }
?.let { renderSetCookieHeader(it) }
debug { "Session: ${response.session}; Cookie: $serializedCookie" }
currentSession.value = Session(
response.session,
serializedCookie
)
}
private val SessionCookieName = "PORTALSESSID"
}
val session get() = portal.currentSession.asStateFlow()
data class Session(
val response: PortalAPIResponse.Session = PortalAPIResponse.Session(),
val data: String? = null
)
suspend fun forceUpdateSession(restore: String? = null) = portal.updateSession(restore)
suspend fun login(login: Login) {
require(login.isValid) { "Invalid login" }
if (session.value.username == login.username) return
if (session.value.response.username == login.username) return
debug { "Logging in as ${login.username}" }
post(
portal.moduleUrl,
......@@ -98,7 +126,7 @@ class PortalClient(
},
updateSession = true
)
debug { "Logged in: ${session.value.isLoggedIn}" }
debug { "Logged in: ${session.value.response.isLoggedIn}" }
}
suspend fun logoutAll() {
......@@ -182,3 +210,5 @@ class PortalClient(
protected val computationScope get() = client.computationScope
}
}
......@@ -2,7 +2,10 @@ package net.novagamestudios.kaffeekasse.model.session
sealed interface Session : java.io.Serializable {
data object Empty : Session
val data: String?
data object Empty : Session {
override val data: String? get() = null
}
interface WithDevice : Session {
val device: Device
}
......@@ -14,13 +17,14 @@ sealed interface Session : java.io.Serializable {
companion object {
operator fun invoke(
device: Device?,
user: User?
user: User?,
data: String? = null
): Session {
val realUser = user?.takeIf { it.isRealUser }
return when {
device != null && realUser != null -> SessionWithDeviceAndRealUser(device, realUser)
device != null -> SessionWithDevice(device)
realUser != null -> SessionWithRealUser(realUser)
device != null && realUser != null -> SessionWithDeviceAndRealUser(device, realUser, data)
device != null -> SessionWithDevice(device, data)
realUser != null -> SessionWithRealUser(realUser, data)
else -> Empty
}
}
......@@ -37,14 +41,17 @@ private val User.isRealUser get() = !isKaffeekasse
private data class SessionWithDevice(
override val device: Device
override val device: Device,
override val data: String?
) : Session.WithDevice
private data class SessionWithRealUser(
override val realUser: User
override val realUser: User,
override val data: String?
) : Session.WithRealUser
private data class SessionWithDeviceAndRealUser(
override val device: Device,
override val realUser: User
override val realUser: User,
override val data: String?
) : Session.WithDeviceAndUser
......@@ -57,6 +57,15 @@ class Credentials(
}
suspend fun sessionData(): String? {
return settingsStore.value.savedSessionData
}
suspend fun storeSessionData(data: String?) {
settingsStore.update { it.copy(savedSessionData = data) }
}
context (Context)
suspend fun userLogin(): Login? {
getDebugUserLogin()?.let { return it }
......
......@@ -51,6 +51,10 @@ class LoginRepository(
val errors = _errors.asStateFlow()
suspend fun forceUpdateSession() = action {
portal.forceUpdateSession()
}
val autoLogin by lazy { settings.values.mapState { it.autoLogin } }
private var autoLoginAttemptAvailable = true
......@@ -77,6 +81,14 @@ class LoginRepository(
debug { "Auto login enabled: ${autoLogin.value}" }
if (!autoLogin.value) return@action null
val sessionData = credentialsRepository.sessionData()
debug { "Auto login session data: $sessionData" }
if (sessionData != null) {
portal.forceUpdateSession(sessionData)
if (portal.session.value !is Session.Empty) return@action null
}
val login = with(activityContext) {
credentialsRepository.userLogin()
?.takeIf { it.isValid }
......@@ -88,12 +100,13 @@ class LoginRepository(
autoLoginAttemptAvailable = false
info { "Trying to auto login user: ${login.username}" }
performUserLogin(login)
credentialsRepository.storeSessionData(portal.session.value.data)
return@action login
}
suspend fun login(login: Login, autoLogin: Boolean, activityContext: Context) {
suspend fun login(login: Login, autoLogin: Boolean, activityContext: Context) = action {
val cleanedLogin = login.copy(username = login.username.trim())
if (!performUserLogin(cleanedLogin)) return
if (!performUserLogin(cleanedLogin)) return@action
with(activityContext) {
updateAutoLogin(cleanedLogin, autoLogin)
}
......@@ -103,15 +116,19 @@ class LoginRepository(
context (Context)
private suspend fun updateAutoLogin(login: Login, autoLogin: Boolean) {
settings.update { it.copy(autoLogin = autoLogin) }
if (autoLogin) when (val result = credentialsRepository.storeUserLogin(login)) {
Credentials.StoreResult.Success -> { }
Credentials.StoreResult.Cancelled -> { }
Credentials.StoreResult.Unsupported -> {
toastShort("Credential storage not supported")
settings.tryUpdate { it.copy(autoLogin = false) }
}
is Credentials.StoreResult.Error -> {
toastShort("Failed to store credentials: ${result.message}")
if (autoLogin) {
credentialsRepository.storeSessionData(portal.session.value.data)
when (val result = credentialsRepository.storeUserLogin(login)) {
Credentials.StoreResult.Success -> { }
Credentials.StoreResult.Cancelled -> { }
Credentials.StoreResult.Unsupported -> {
toastShort("Credential storage not supported")
settings.tryUpdate { it.copy(autoLogin = false) }
}
is Credentials.StoreResult.Error -> {
toastShort("Failed to store credentials: ${result.message}")
}
}
}
}
......@@ -149,6 +166,7 @@ class LoginRepository(
suspend fun logoutUser() = action {
credentialsRepository.storeSessionData(null)
portal.logoutUser()
}
......
......@@ -25,6 +25,7 @@ data class Settings(
val fullscreen: Boolean = false,
val animationsEnabled: Boolean = true,
val autoLogin: Boolean = false,
val savedSessionData: String? = null,
val deviceCredentials: DeviceCredentials? = null,
val developerMode: Boolean = false,
val userSettings: Map<String, UserSettings> = emptyMap()
......
......@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import net.novagamestudios.common_utils.Logger
import net.novagamestudios.common_utils.debug
import net.novagamestudios.common_utils.info
import net.novagamestudios.common_utils.warn
import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseAPI
......@@ -31,12 +32,20 @@ class PortalRepository(
client.session,
kaffeekasse.currentDevice
) { session, device ->
val response = session.response
Session(
device = device,
user = if (session.isLoggedIn) User(session.username, session.displayName) else null
user = if (response.isLoggedIn) User(response.username, response.displayName) else null,
data = session.data
)
}.stateIn(coroutineScope, SharingStarted.Eagerly, Session.Empty)
suspend fun forceUpdateSession(restore: String? = null) {
info { "Updating session" }
client.forceUpdateSession(restore)
debug { "Session updated" }
}
suspend fun loginUser(login: Login) {
if (session.value is Session.WithRealUser) throw IllegalStateException("User already logged in")
client.login(login)
......
......@@ -6,11 +6,10 @@ import cafe.adriel.voyager.navigator.Navigator
import net.novagamestudios.common_utils.Logger
import net.novagamestudios.kaffeekasse.model.session.Session
import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
import net.novagamestudios.kaffeekasse.ui.AppInfoTopBarAction
import net.novagamestudios.kaffeekasse.ui.AppModuleSelection
import net.novagamestudios.kaffeekasse.ui.login.LogoutTopBarAction
import net.novagamestudios.kaffeekasse.ui.navigation.HiwiTrackerNavigation
import net.novagamestudios.kaffeekasse.ui.navigation.ScaffoldContentWithModel
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
class HiwiTrackerModuleScreenModel private constructor(
......@@ -25,15 +24,7 @@ class HiwiTrackerModuleScreenModel private constructor(
}
}
@Composable
fun HiwiTrackerTopBarTitle(navigator: Navigator) {
when (navigator.lastItem) {
is HiwiTrackerNavigation.OverviewScreen -> AppModuleSelection()
}
}
@Composable
fun HiwiTrackerTopBarActions(model: HiwiTrackerModuleScreenModel) {
AppInfoTopBarAction()
LogoutTopBarAction(model.session)
class HiwiTrackerModuleContent(provider: ScreenModelProvider<HiwiTrackerModuleScreenModel>) : ScaffoldContentWithModel<HiwiTrackerModuleScreenModel>(provider) {
@Composable
override fun Content(navigator: Navigator) { }
}
......@@ -64,6 +64,7 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.navigator.Navigator
import io.woong.compose.grid.SimpleGridCells
import io.woong.compose.grid.VerticalGrid
import kotlinx.coroutines.flow.collectLatest
......@@ -95,15 +96,21 @@ import net.novagamestudios.kaffeekasse.model.session.Session
import net.novagamestudios.kaffeekasse.repositories.MutableSettingsStore
import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
import net.novagamestudios.kaffeekasse.repositories.i11.HiwiTrackerRepository
import net.novagamestudios.kaffeekasse.ui.AppInfoTopBarAction
import net.novagamestudios.kaffeekasse.ui.AppModuleSelection
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen
import net.novagamestudios.kaffeekasse.ui.login.LogoutTopBarAction
import net.novagamestudios.kaffeekasse.ui.navigation.HiwiTrackerNavigation
import net.novagamestudios.kaffeekasse.ui.navigation.ScaffoldContentWithModel
import net.novagamestudios.kaffeekasse.ui.theme.disabled
import net.novagamestudios.kaffeekasse.ui.util.HorizontalKeyedPager
import net.novagamestudios.kaffeekasse.ui.util.HorizontalPagedLayout
import net.novagamestudios.kaffeekasse.ui.util.KeyedPagerState
import net.novagamestudios.kaffeekasse.ui.util.PullToRefreshBox
import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
import net.novagamestudios.kaffeekasse.ui.util.onClickComingSoon
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
import net.novagamestudios.kaffeekasse.ui.util.synchronizePagerState
import net.novagamestudios.kaffeekasse.util.richdata.RichData
import net.novagamestudios.kaffeekasse.util.richdata.RichDataState
......@@ -206,11 +213,30 @@ class OverviewScreenModel private constructor(
}
class OverviewContent(provider: ScreenModelProvider<OverviewScreenModel>) : ScaffoldContentWithModel<OverviewScreenModel>(provider) {
@Composable
override fun Content(navigator: Navigator) = Overview(model)
@Composable
override fun TopAppBarTitle(navigator: Navigator) {
AppModuleSelection()
}
@Composable
override fun TopAppBarActions(navigator: Navigator) {
AppInfoTopBarAction()
LogoutTopBarAction(model.session)
}
@Composable
override fun backNavigationHandler(navigator: Navigator): BackNavigationHandler {
return BackNavigationHandler.default()
}
}
@Composable
fun Overview(
private fun Overview(
model: OverviewScreenModel,
modifier: Modifier = Modifier
) = PullToRefreshBox(
......
......@@ -24,6 +24,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.navigator.Navigator
import net.novagamestudios.common_utils.Logger
import net.novagamestudios.common_utils.compose.components.BoxCenter
import net.novagamestudios.common_utils.compose.components.CircularProgressIndicator
......@@ -35,11 +36,15 @@ import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
import net.novagamestudios.kaffeekasse.ui.AppInfoTopBarAction
import net.novagamestudios.kaffeekasse.ui.login.LogoutTopBarAction
import net.novagamestudios.kaffeekasse.ui.navigation.AppSubpageTitle
import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation
import net.novagamestudios.kaffeekasse.ui.navigation.ScaffoldContentWithModel
import net.novagamestudios.kaffeekasse.ui.util.FailureRetryScreen
import net.novagamestudios.kaffeekasse.ui.util.PullToRefreshBox
import net.novagamestudios.kaffeekasse.ui.util.RichDataContent
import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateHere
class AccountScreenModel private constructor(
......@@ -58,9 +63,30 @@ class AccountScreenModel private constructor(
}
}
class AccountContent(provider: ScreenModelProvider<AccountScreenModel>) : ScaffoldContentWithModel<AccountScreenModel>(provider) {
@Composable
override fun Content(navigator: Navigator) = Account(model)
@Composable
override fun TopAppBarTitle(navigator: Navigator) {
AppSubpageTitle("Konto")
}
@Composable
override fun TopAppBarActions(navigator: Navigator) {
AppInfoTopBarAction()
LogoutTopBarAction(model.session)
}
@Composable
override fun backNavigationHandler(navigator: Navigator): BackNavigationHandler {
return BackNavigationHandler.default()
}
}
@Composable
fun Account(
private fun Account(
model: AccountScreenModel,
modifier: Modifier = Modifier
) = PullToRefreshBox(
......@@ -166,10 +192,3 @@ private fun AccountDetails(
Spacer(Modifier.height(64.dp))
}
}
@Composable
fun AccountTopBarActions(model: AccountScreenModel) {
AppInfoTopBarAction()
LogoutTopBarAction(model.session)
}
......@@ -14,55 +14,42 @@ import net.novagamestudios.common_utils.Logger
import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCartProvider
import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart
import net.novagamestudios.kaffeekasse.model.kaffeekasse.isNotEmpty
import net.novagamestudios.kaffeekasse.model.session.Session
import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.ManualBillScreenModel.Companion.backNavigationHandler
import net.novagamestudios.kaffeekasse.ui.navigation.AppSubpageTitle
import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation
import net.novagamestudios.kaffeekasse.ui.navigation.ScaffoldContentWithModel
import net.novagamestudios.kaffeekasse.ui.theme.ifAnimationsEnabled
import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
class KaffeekasseModuleScreenModel private constructor(
session: Session.WithRealUser,
val cart: MutableCart
) : ScreenModel, Logger {
val accountName = when (session) {
is Session.WithDevice -> session.realUser.displayName ?: "Unknown User"
else -> null
}
companion object : ScreenModelFactory<KaffeekasseNavigation.Tab, KaffeekasseModuleScreenModel> {
context (RepositoryProvider)
override fun create(screen: KaffeekasseNavigation.Tab) = KaffeekasseModuleScreenModel(
session = screen.session,
cart = kaffeekasseCartProvider[screen.session.realUser]
)
}
}
class KaffeekasseModuleContent(provider: ScreenModelProvider<KaffeekasseModuleScreenModel>) : ScaffoldContentWithModel<KaffeekasseModuleScreenModel>(provider) {
@Composable
override fun Content(navigator: Navigator) { }
@Composable
fun KaffeekasseTopBarTitle(
model: KaffeekasseModuleScreenModel,
navigator: Navigator
) {
when (val screen = navigator.lastItem) {
is KaffeekasseNavigation.ManualBillScreen -> ManualBillTopBarTitle(screen.model, navigator)
is KaffeekasseNavigation.AccountScreen -> AppSubpageTitle("Konto")
is KaffeekasseNavigation.TransactionsScreen -> AppSubpageTitle("Übersicht")
@Composable
override fun TopAppBarNavigationIcon(navigator: Navigator) {
CartEmptyNavigation(model, backNavigationHandler(navigator))
}
}
@Composable
fun KaffeekasseTopBarNavigation(
private fun CartEmptyNavigation(
model: KaffeekasseModuleScreenModel,
navigator: Navigator
backNavigationHandler: BackNavigationHandler
) {
val backNavigationHandler = model.backNavigationHandler(navigator)
AnimatedVisibility(
visible = !backNavigationHandler.canNavigateBack() && model.cart.isNotEmpty(),
enter = expandHorizontally().ifAnimationsEnabled(),
......@@ -74,28 +61,3 @@ fun KaffeekasseTopBarNavigation(
}
}
@Composable
fun KaffeekasseTopBarActions(
navigator: Navigator
) {
when (val screen = navigator.lastItem) {
is KaffeekasseNavigation.ManualBillScreen -> ManualBillTopBarActions(screen.model)
is KaffeekasseNavigation.AccountScreen -> AccountTopBarActions(screen.model)
is KaffeekasseNavigation.TransactionsScreen -> TransactionsTopBarActions(screen.model)
}
}
@Composable
fun KaffeekasseModuleScreenModel.backNavigationHandler(navigator: Navigator): BackNavigationHandler {
return when (val screen = navigator.lastItem) {
is KaffeekasseNavigation.ManualBillScreen -> screen.model.backNavigationHandler(navigator)
is KaffeekasseNavigation.AccountScreen -> BackNavigationHandler.default()
is KaffeekasseNavigation.TransactionsScreen -> BackNavigationHandler.default()
else -> BackNavigationHandler.Disabled
}
}
......@@ -35,9 +35,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.currentOrThrow
import net.novagamestudios.common_utils.Logger
import net.novagamestudios.common_utils.compose.components.CircularProgressIndicator
import net.novagamestudios.common_utils.compose.components.RowCenter
......@@ -51,6 +49,7 @@ import net.novagamestudios.kaffeekasse.model.session.deviceOrNull
import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
import net.novagamestudios.kaffeekasse.ui.AppModuleSelection
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.ManualBillScreenModel.Companion.backNavigationHandler
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CategorizedItems
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CategorizedItemsState
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.Checkout
......@@ -60,6 +59,7 @@ import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CustomItemsStat
import net.novagamestudios.kaffeekasse.ui.login.LogoutTopBarAction
import net.novagamestudios.kaffeekasse.ui.navigation.AppSubpageTitle
import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation
import net.novagamestudios.kaffeekasse.ui.navigation.ScaffoldContentWithModel
import net.novagamestudios.kaffeekasse.ui.util.FailureRetryScreen
import net.novagamestudios.kaffeekasse.ui.util.RichDataContent
import net.novagamestudios.kaffeekasse.ui.util.TopBarSearchAction
......@@ -68,6 +68,7 @@ import net.novagamestudios.kaffeekasse.ui.util.TopBarSearchFieldState
import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler.Companion.then
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateHere
......@@ -144,7 +145,7 @@ class ManualBillScreenModel private constructor(
get() = currentPagerIndex - 1
set(value) { currentPagerIndex = value + 1 }
@Suppress("unused")
@Suppress("unused", "RedundantSuspendModifier")
suspend fun autoScrollToDeviceItemGroup() {
val initialItemGroupIndex = session.deviceOrNull?.itemTypeId
?.let { id -> itemGroups.indexOfFirst { it.id == id }.takeIf { it >= 0 } }
......@@ -164,7 +165,7 @@ class ManualBillScreenModel private constructor(
)
@Composable
fun ManualBillScreenModel.backNavigationHandler(navigator: Navigator): BackNavigationHandler {
internal fun ManualBillScreenModel.backNavigationHandler(): BackNavigationHandler {
val categorizedItems = if (currentPagerIndex == 0) {
searchItems
} else {
......@@ -190,11 +191,41 @@ class ManualBillScreenModel private constructor(
}
class ManualBillContent(provider: ScreenModelProvider<ManualBillScreenModel>) : ScaffoldContentWithModel<ManualBillScreenModel>(provider) {
@Composable
override fun Content(navigator: Navigator) {
DynamicManualBill(model)
}
@Composable
override fun TopAppBarTitle(navigator: Navigator) {
RowCenter {
if (!model.searchState.show) {
val name = model.accountName
if (name != null) AppSubpageTitle("$name")
else AppModuleSelection()
}
TopBarSearchField(model.searchState)
}
}
@Composable
override fun TopAppBarActions(navigator: Navigator) {
TopBarSearchAction(model.searchState)
AccountButton(model, navigator)
TransactionsButton(model, navigator)
LogoutTopBarAction(model.session)
}
@Composable
override fun backNavigationHandler(navigator: Navigator): BackNavigationHandler {
return model.backNavigationHandler()
}
}
@Composable
fun DynamicManualBill(
private fun DynamicManualBill(
model: ManualBillScreenModel,
modifier: Modifier = Modifier,
postTabsContent: (@Composable () -> Unit)? = null
......@@ -331,35 +362,25 @@ private fun ItemGroupTab(
@Composable
fun ManualBillTopBarTitle(
private fun AccountButton(
model: ManualBillScreenModel,
navigator: Navigator
) = RowCenter {
if (!model.searchState.show) {
val name = model.accountName
if (name != null) AppSubpageTitle("$name")
else AppModuleSelection()
navigator: Navigator,
modifier: Modifier = Modifier
) {
IconButton(onClick = { navigator.push(KaffeekasseNavigation.AccountScreen(model.session)) }, modifier) {
Icon(Icons.Default.Person, "Konto")
}
TopBarSearchField(model.searchState)
}
@Composable
fun ManualBillTopBarActions(
model: ManualBillScreenModel
private fun TransactionsButton(
model: ManualBillScreenModel,
navigator: Navigator,
modifier: Modifier = Modifier
) {
TopBarSearchAction(model.searchState)
val navigator = LocalNavigator.currentOrThrow
IconButton(onClick = {
navigator.push(KaffeekasseNavigation.AccountScreen(model.session))
}) {
Icon(Icons.Default.Person, "Konto")
}
IconButton(onClick = {
navigator.push(KaffeekasseNavigation.TransactionsScreen(model.session))
}) {
IconButton(onClick = { navigator.push(KaffeekasseNavigation.TransactionsScreen(model.session)) }, modifier) {
Icon(Icons.Default.Receipt, "Übersicht")
}
LogoutTopBarAction(model.session)
}
......
......@@ -38,6 +38,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.navigator.Navigator
import net.novagamestudios.common_utils.Logger
import net.novagamestudios.kaffeekasse.App
import net.novagamestudios.kaffeekasse.data.category
......@@ -47,12 +48,16 @@ import net.novagamestudios.kaffeekasse.model.session.Session
import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.CategoryIcon
import net.novagamestudios.kaffeekasse.ui.navigation.AppSubpageTitle
import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation
import net.novagamestudios.kaffeekasse.ui.navigation.ScaffoldContentWithModel
import net.novagamestudios.kaffeekasse.ui.util.FailureRetryScreen
import net.novagamestudios.kaffeekasse.ui.util.PullToRefreshBox
import net.novagamestudios.kaffeekasse.ui.util.RichDataContent
import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
import net.novagamestudios.kaffeekasse.ui.util.openInBrowser
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateHere
import java.time.LocalDate
import java.time.format.DateTimeFormatter
......@@ -82,8 +87,34 @@ class TransactionsScreenModel private constructor(
}
class TransactionsContent(provider: ScreenModelProvider<TransactionsScreenModel>) : ScaffoldContentWithModel<TransactionsScreenModel>(provider) {
@Composable
override fun Content(navigator: Navigator) = Transactions(model)
@Composable
override fun TopAppBarTitle(navigator: Navigator) {
AppSubpageTitle("Übersicht")
}
@Composable
override fun TopAppBarActions(navigator: Navigator) {
if (model.showCharts) {
TransactionsChartsFilter(model.chartsState)
} else {
OpenInBrowserButton(model)
}
ListChartsToggleButton(model)
}
@Composable
override fun backNavigationHandler(navigator: Navigator): BackNavigationHandler {
return BackNavigationHandler.default()
}
}
@Composable
fun Transactions(
private fun Transactions(
model: TransactionsScreenModel,
modifier: Modifier = Modifier
) = PullToRefreshBox(
......@@ -126,17 +157,11 @@ fun Transactions(
}
@Composable
fun TransactionsTopBarActions(
model: TransactionsScreenModel
private fun ListChartsToggleButton(
model: TransactionsScreenModel,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
when (model.showCharts) {
true -> TransactionsChartsFilter(model.chartsState)
false -> IconButton(onClick = { model.openInBrowser(context) }) {
Icon(Icons.Default.OpenInBrowser, "Open in browser")
}
}
IconButton(onClick = { model.showCharts = !model.showCharts }) {
IconButton(onClick = { model.showCharts = !model.showCharts }, modifier) {
when (model.showCharts) {
true -> Icon(Icons.AutoMirrored.Filled.List, "List")
false -> Icon(Icons.Default.BarChart, "Charts")
......@@ -144,6 +169,16 @@ fun TransactionsTopBarActions(
}
}
@Composable
private fun OpenInBrowserButton(
model: TransactionsScreenModel,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
IconButton(onClick = { model.openInBrowser(context) }, modifier) {
Icon(Icons.Default.OpenInBrowser, "Open in browser")
}
}
@Composable
private fun TransactionsList(
......
package net.novagamestudios.kaffeekasse.ui.login
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.ScreenLockLandscape
import androidx.compose.material.icons.filled.SmartScreen
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.navigator.Navigator
import kotlinx.coroutines.launch
import net.novagamestudios.common_utils.Logger
import net.novagamestudios.common_utils.compose.components.BoxCenter
import net.novagamestudios.common_utils.compose.components.CircularLoadingBox
import net.novagamestudios.kaffeekasse.app
import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials
import net.novagamestudios.kaffeekasse.model.credentials.isValid
import net.novagamestudios.kaffeekasse.model.session.Session
import net.novagamestudios.kaffeekasse.repositories.LoginRepository
import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository
import net.novagamestudios.kaffeekasse.ui.AppInfoTopBarAction
import net.novagamestudios.kaffeekasse.ui.FullscreenIconButton
import net.novagamestudios.kaffeekasse.ui.login.UserSelectionScreenModel.Companion.backNavigationHandler
import net.novagamestudios.kaffeekasse.ui.navigation.AppScaffoldNavigatorDefaultContent
import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation
import net.novagamestudios.kaffeekasse.ui.navigation.ScaffoldContentWithModel
import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
import net.novagamestudios.kaffeekasse.ui.util.rememberSerializableState
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.collectAsStateHere
......@@ -64,15 +41,7 @@ class LoginScreenModel private constructor(
val isLoading by loginRepository.isPerformingAction.collectAsStateHere()
var showLoginDeviceDialog by mutableStateOf(false)
fun loginDevice(deviceCredentials: DeviceCredentials) {
screenModelScope.launch {
loginRepository.loginDevice(deviceCredentials)
showLoginDeviceDialog = false
}
}
val loginDeviceState = LoginDeviceState(screenModelScope, loginRepository)
companion object : ScreenModelFactory<LoginNavigation.Companion, LoginScreenModel> {
context (RepositoryProvider)
......@@ -83,158 +52,47 @@ class LoginScreenModel private constructor(
}
}
@Composable
fun LoginAdditional(
model: LoginScreenModel,
navigator: Navigator
) {
val session = model.session
when (navigator.lastItem) {
is LoginNavigation.FormScreen -> {
if (session is Session.WithDevice) navigator.replace(LoginNavigation.UserSelectionScreen(session.device))
}
is LoginNavigation.UserSelectionScreen -> {
if (session !is Session.WithDevice) navigator.replace(LoginNavigation.FormScreen)
}
}
if (model.showLoginDeviceDialog) LoginDeviceDialog(model)
}
@Composable
private fun LoginDeviceDialog(
model: LoginScreenModel,
modifier: Modifier = Modifier
) {
val inputState = rememberSerializableState {
mutableStateOf(DeviceCredentials.Empty)
}
val input by inputState
AlertDialog(
onDismissRequest = { model.showLoginDeviceDialog = false },
confirmButton = {
Button(
onClick = { model.loginDevice(input) },
enabled = !model.isLoading && input.isValid
) {
Text("Einloggen")
}
},
modifier,
dismissButton = {
TextButton(
onClick = { model.showLoginDeviceDialog = false },
enabled = !model.isLoading
) {
Text("Abbrechen")
}
},
text = {
CircularLoadingBox(loading = model.isLoading) {
LoginDeviceForm(
inputState = inputState,
onDone = { model.loginDevice(input) }
)
class LoginContent(provider: ScreenModelProvider<LoginScreenModel>) : ScaffoldContentWithModel<LoginScreenModel>(provider) {
@Composable
override fun Content(navigator: Navigator) {
if (model.isLoading) {
BoxCenter(Modifier.fillMaxSize()) {
CircularProgressIndicator()
}
} else {
navigator.AppScaffoldNavigatorDefaultContent("login-navigator-content")
}
)
}
@Composable
private fun LoginDeviceForm(
inputState: MutableState<DeviceCredentials>,
onDone: () -> Unit,
modifier: Modifier = Modifier
) {
var input by inputState
Column(
modifier.width(IntrinsicSize.Min),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val focusManager = LocalFocusManager.current
OutlinedTextField(
value = input.deviceId,
onValueChange = { input = input.copy(deviceId = it.uppercase()) },
label = { Text("Geräte-Id") },
placeholder = { Text("XX:XX:XX:XX:XX:XX") },
leadingIcon = { Icon(Icons.Default.SmartScreen, "Geräte-Id") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
singleLine = true
)
OutlinedTextField(
value = input.apiKey,
onValueChange = { input = input.copy(apiKey = it) },
label = { Text("API-Schlüssel") },
leadingIcon = { Icon(Icons.Default.Key, "API-Schlüssel") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { onDone() }),
singleLine = true
)
Additional(model, navigator)
}
}
@Composable
fun LoginTopBarTitle(
model: LoginScreenModel,
navigator: Navigator
) {
when (val screen = navigator.lastItem) {
is LoginNavigation.UserSelectionScreen -> {
UserSelectionTopBarTitle(screen.model, navigator)
}
@Composable
override fun backNavigationHandler(navigator: Navigator): BackNavigationHandler {
return default(navigator) { backNavigationHandler(it) } ?: BackNavigationHandler.default()
}
}
@Composable
fun LoginTopBarActions(
model: LoginScreenModel,
navigator: Navigator
) {
when (val screen = navigator.lastItem) {
private fun Additional(model: LoginScreenModel, navigator: Navigator) {
when (navigator.lastItem) {
is LoginNavigation.FormScreen -> {
AppInfoTopBarAction()
IconButton(
onClick = { model.showLoginDeviceDialog = true },
enabled = !model.isLoading
) {
Icon(Icons.Default.ScreenLockLandscape, "Gerät einloggen")
}
val session = model.session
if (session is Session.WithDevice) navigator.replace(LoginNavigation.UserSelectionScreen(session.device))
}
is LoginNavigation.UserSelectionScreen -> {
UserSelectionTopBarActions(screen.model, navigator)
val session = model.session
if (session !is Session.WithDevice) navigator.replace(LoginNavigation.FormScreen)
}
}
FullscreenIconButton()
}
@Composable
fun LoginScreenModel.backNavigationHandler(navigator: Navigator): BackNavigationHandler {
return when (val screen = navigator.lastItem) {
is LoginNavigation.UserSelectionScreen -> screen.model.backNavigationHandler(navigator)
else -> BackNavigationHandler.default()
}
LoginDeviceAdditional(model.loginDeviceState)
}
@Composable
fun LogoutTopBarAction(session: Session) {
val app = app()
val loginRepository = app.loginRepository
fun LogoutTopBarAction(session: Session) = with(app()) {
val isLoading by loginRepository.isPerformingAction.collectAsState()
CircularLoadingBox(loading = isLoading) {
IconButton(
onClick = { app.launch { loginRepository.logoutUser() } },
onClick = { launch { loginRepository.logoutUser() } },
enabled = session !is Session.Empty
) {
Icon(
......
package net.novagamestudios.kaffeekasse.ui.login
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.PhonelinkErase
import androidx.compose.material.icons.filled.ScreenLockLandscape
import androidx.compose.material.icons.filled.SmartScreen
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.novagamestudios.common_utils.compose.components.CircularLoadingBox
import net.novagamestudios.common_utils.compose.state.collectAsStateIn
import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials
import net.novagamestudios.kaffeekasse.model.credentials.isValid
import net.novagamestudios.kaffeekasse.repositories.LoginRepository
import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation
import net.novagamestudios.kaffeekasse.ui.util.navigation.nearestScreen
import net.novagamestudios.kaffeekasse.ui.util.rememberSerializableState
class LoginDeviceState(
private val coroutineScope: CoroutineScope,
private val loginRepository: LoginRepository
) {
val isLoading by loginRepository.isPerformingAction.collectAsStateIn(coroutineScope)
var showDialog by mutableStateOf(false)
fun login(deviceCredentials: DeviceCredentials) {
coroutineScope.launch {
loginRepository.loginDevice(deviceCredentials)
showDialog = false
}
}
fun logout() {
coroutineScope.launch {
loginRepository.logoutDevice()
}
}
}
@Composable
private fun findState(): LoginDeviceState {
return nearestScreen<LoginNavigation.Companion>().model.loginDeviceState
}
@Composable
fun LoginDeviceAdditional(
state: LoginDeviceState,
modifier: Modifier = Modifier
) {
if (state.showDialog) LoginDeviceDialog(
onDismiss = { state.showDialog = false },
onLogin = { state.login(it) },
isLoading = state.isLoading,
modifier
)
}
@Composable
private fun LoginDeviceDialog(
onDismiss: () -> Unit,
onLogin: (DeviceCredentials) -> Unit,
isLoading: Boolean,
modifier: Modifier = Modifier
) {
val inputState = rememberSerializableState {
mutableStateOf(DeviceCredentials.Empty)
}
val input by inputState
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
Button(
onClick = { onLogin(input) },
enabled = !isLoading && input.isValid
) {
Text("Einloggen")
}
},
modifier,
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isLoading
) {
Text("Abbrechen")
}
},
text = {
CircularLoadingBox(loading = isLoading) {
LoginDeviceForm(
inputState = inputState,
onDone = { onLogin(input) }
)
}
}
)
}
@Composable
private fun LoginDeviceForm(
inputState: MutableState<DeviceCredentials>,
onDone: () -> Unit,
modifier: Modifier = Modifier
) {
var input by inputState
Column(
modifier.width(IntrinsicSize.Min),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val focusManager = LocalFocusManager.current
OutlinedTextField(
value = input.deviceId,
onValueChange = { input = input.copy(deviceId = it.uppercase()) },
label = { Text("Geräte-Id") },
placeholder = { Text("XX:XX:XX:XX:XX:XX") },
leadingIcon = { Icon(Icons.Default.SmartScreen, "Geräte-Id") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
singleLine = true
)
OutlinedTextField(
value = input.apiKey,
onValueChange = { input = input.copy(apiKey = it) },
label = { Text("API-Schlüssel") },
leadingIcon = { Icon(Icons.Default.Key, "API-Schlüssel") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { onDone() }),
singleLine = true
)
}
}
@Composable
fun LoginDeviceButton(
modifier: Modifier = Modifier
) {
val state = findState()
IconButton(
onClick = { state.showDialog = true },
modifier,
enabled = !state.isLoading
) {
Icon(Icons.Default.ScreenLockLandscape, "Gerät einloggen")
}
}
@Composable
fun LogoutDeviceButton(
modifier: Modifier = Modifier
) {
val state = findState()
var confirmLogout by remember { mutableStateOf(false) }
IconButton(
onClick = { confirmLogout = true },
modifier,
enabled = !state.isLoading
) {
Icon(Icons.Default.PhonelinkErase, "Gerät ausloggen")
}
if (confirmLogout) ConfirmLogoutDeviceDialog(
onDismiss = { confirmLogout = false },
onConfirm = { state.logout() }
)
}
@Composable
private fun ConfirmLogoutDeviceDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier
) = AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
Button(onClick = onConfirm) { Text("Ausloggen") }
},
modifier,
dismissButton = {
TextButton(onClick = onDismiss) { Text("Abbrechen") }
},
text = {
Text("Möchtest du das Gerät wirklich ausloggen?")
}
)
......@@ -36,15 +36,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.composed
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
......@@ -54,6 +47,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.navigator.Navigator
import kotlinx.coroutines.launch
import net.novagamestudios.common_utils.Logger
import net.novagamestudios.common_utils.compose.components.BoxCenter
......@@ -63,10 +57,15 @@ import net.novagamestudios.kaffeekasse.repositories.LoginRepository
import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
import net.novagamestudios.kaffeekasse.repositories.SettingsRepository
import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository
import net.novagamestudios.kaffeekasse.ui.AppInfoTopBarAction
import net.novagamestudios.kaffeekasse.ui.FullscreenIconButton
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen
import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
import net.novagamestudios.kaffeekasse.ui.navigation.ScaffoldContentWithModel
import net.novagamestudios.kaffeekasse.ui.util.autofill
import net.novagamestudios.kaffeekasse.ui.util.rememberSerializableState
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.collectAsStateHere
......@@ -107,9 +106,20 @@ class LoginFormScreenModel private constructor(
}
}
class LoginFormContent(provider: ScreenModelProvider<LoginFormScreenModel>) : ScaffoldContentWithModel<LoginFormScreenModel>(provider) {
@Composable
override fun Content(navigator: Navigator) = LoginForm(model)
@Composable
override fun TopAppBarActions(navigator: Navigator) {
AppInfoTopBarAction()
LoginDeviceButton()
FullscreenIconButton()
}
}
@Composable
fun LoginForm(
private fun LoginForm(
model: LoginFormScreenModel,
modifier: Modifier = Modifier
) = BoxCenter(modifier) {
......@@ -217,24 +227,3 @@ fun LoginForm(
}
}
fun Modifier.autofill(
autofillTypes: List<AutofillType>,
onFill: (String) -> Unit
) = composed {
val autofill = LocalAutofill.current
val autofillNode = AutofillNode(
autofillTypes = autofillTypes,
onFill = onFill
)
LocalAutofillTree.current += autofillNode
this
.onGloballyPositioned { autofillNode.boundingBox = it.boundsInWindow() }
.onFocusChanged {
autofill?.run {
if (it.isFocused) requestAutofillForNode(autofillNode)
else cancelAutofillForNode(autofillNode)
}
}
}
......@@ -26,13 +26,11 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Password
import androidx.compose.material.icons.filled.PhonelinkErase
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
......@@ -86,8 +84,10 @@ import net.novagamestudios.kaffeekasse.repositories.LoginRepository
import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
import net.novagamestudios.kaffeekasse.ui.AppInfoTopBarAction
import net.novagamestudios.kaffeekasse.ui.FullscreenIconButton
import net.novagamestudios.kaffeekasse.ui.handleSession
import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation
import net.novagamestudios.kaffeekasse.ui.navigation.ScaffoldContentWithModel
import net.novagamestudios.kaffeekasse.ui.util.AlphabetSelectionChar
import net.novagamestudios.kaffeekasse.ui.util.FailureRetryScreen
import net.novagamestudios.kaffeekasse.ui.util.HorizontalSelectionBar
......@@ -101,7 +101,7 @@ import net.novagamestudios.kaffeekasse.ui.util.VerticalSelectionBar
import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
import net.novagamestudios.kaffeekasse.ui.util.rememberPullToRefreshState
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.collectAsStateHere
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
import net.novagamestudios.kaffeekasse.util.richdata.asRichDataFlow
import net.novagamestudios.kaffeekasse.util.richdata.combineRich
import net.novagamestudios.kaffeekasse.util.richdata.stateIn
......@@ -113,8 +113,6 @@ class UserSelectionScreenModel private constructor(
val loginRepository: LoginRepository,
kaffeekasse: KaffeekasseRepository
) : ScreenModel, Logger {
val isPerformingLoginAction by loginRepository.isPerformingAction.collectAsStateHere()
val users = kaffeekasse.basicUserInfoList
val searchState = TopBarSearchFieldState()
......@@ -171,12 +169,6 @@ class UserSelectionScreenModel private constructor(
loginUser(dialog.user, dialog.auth)
}
fun logoutDevice() {
screenModelScope.launch {
loginRepository.logoutDevice()
}
}
companion object : ScreenModelFactory<LoginNavigation.UserSelectionScreen, UserSelectionScreenModel> {
context (RepositoryProvider)
override fun create(screen: LoginNavigation.UserSelectionScreen) = UserSelectionScreenModel(
......@@ -184,21 +176,49 @@ class UserSelectionScreenModel private constructor(
loginRepository = loginRepository,
kaffeekasse = kaffeekasseRepository,
)
}
}
class UserSelectionContent(provider: ScreenModelProvider<UserSelectionScreenModel>) : ScaffoldContentWithModel<UserSelectionScreenModel>(provider) {
@Composable
override fun Content(navigator: Navigator) = UserSelection(model)
@Composable
fun UserSelectionScreenModel.backNavigationHandler(navigator: Navigator): BackNavigationHandler {
return remember { searchState.backNavigation }
@Composable
override fun TopAppBarTitle(navigator: Navigator) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (model.searchState.show) {
TopBarSearchField(model.searchState)
} else {
Image(
painterResource(R.drawable.logo_edited),
"Kaffeekasse",
Modifier.size(32.dp)
)
DeviceInfo(model.device, Modifier.padding(16.dp))
}
}
}
}
@Composable
override fun TopAppBarActions(navigator: Navigator) {
TopBarSearchAction(model.searchState, alwaysShow = true)
AppInfoTopBarAction()
LogoutDeviceButton()
FullscreenIconButton()
}
@Composable
override fun backNavigationHandler(navigator: Navigator): BackNavigationHandler {
val model = model
return remember { model.searchState.backNavigation }
}
}
@Composable
fun UserSelection(
private fun UserSelection(
model: UserSelectionScreenModel,
modifier: Modifier = Modifier
) {
......@@ -264,42 +284,6 @@ fun UserSelection(
Toasts(model.toasts)
}
/*
@Composable
private fun UserSearchBar(
query: String,
onQueryChange: (String) -> Unit,
onSearch: () -> Unit,
modifier: Modifier = Modifier
) = DockedSearchBar(
inputField = {
val focusRequester = remember { FocusRequester() }
SearchBarDefaults.InputField(
query = query,
onQueryChange = onQueryChange,
onSearch = { onSearch() },
expanded = false,
onExpandedChange = { },
Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
placeholder = { Text("Search users") },
leadingIcon = { Icon(Icons.Default.Search, "Search") },
trailingIcon = {
if (query.isNotEmpty()) IconButton(
onClick = { onQueryChange("") }
) {
Icon(Icons.Default.Close, "Clear search")
}
},
)
},
expanded = false,
onExpandedChange = { },
modifier,
content = { }
)
*/
@Composable
private fun UserGridWithSelectionBar(
......@@ -536,47 +520,6 @@ private fun UserAuthDialog(
)
@Composable
fun UserSelectionTopBarTitle(
model: UserSelectionScreenModel,
navigator: Navigator
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (model.searchState.show) {
TopBarSearchField(model.searchState)
} else {
Image(
painterResource(R.drawable.logo_edited),
"Kaffeekasse",
Modifier.size(32.dp)
)
DeviceInfo(model.device, Modifier.padding(16.dp))
}
}
}
@Composable
fun UserSelectionTopBarActions(
model: UserSelectionScreenModel,
navigator: Navigator
) {
TopBarSearchAction(model.searchState, alwaysShow = true)
AppInfoTopBarAction()
var confirmLogout by remember { mutableStateOf(false) }
IconButton(
onClick = { confirmLogout = true },
enabled = !model.isPerformingLoginAction
) {
Icon(Icons.Default.PhonelinkErase, "Gerät ausloggen")
}
if (confirmLogout) ConfirmLogoutDeviceDialog(
onDismiss = { confirmLogout = false },
onConfirm = { model.logoutDevice() }
)
}
@Composable
private fun DeviceInfo(
device: Device,
......@@ -598,30 +541,3 @@ private fun DeviceInfo(
}
}
@Composable
private fun ConfirmLogoutDeviceDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier
) = AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
Button(
onClick = onConfirm
) {
Text("Ausloggen")
}
},
modifier,
dismissButton = {
TextButton(
onClick = onDismiss
) {
Text("Abbrechen")
}
},
text = {
Text("Möchtest du das Gerät wirklich ausloggen?")
}
)
......@@ -29,6 +29,29 @@ import net.novagamestudios.kaffeekasse.ui.theme.ifAnimationsEnabled
import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
import net.novagamestudios.kaffeekasse.ui.util.navigation.debugNavigation
@Composable
fun AppScaffoldNavigator(
key: String,
initialRoute: List<Screen>,
scaffoldContent: ScaffoldContent,
title: @Composable (Navigator) -> Unit = { scaffoldContent.TopAppBarTitle(it) },
navigationIcon: @Composable (Navigator) -> Unit = { scaffoldContent.TopAppBarNavigationIcon(it) },
actions: @Composable RowScope.(Navigator) -> Unit = { scaffoldContent.TopAppBarActions(it) },
topAppBarScrollBehavior: TopAppBarScrollBehavior? = TopAppBarDefaults.enterAlwaysScrollBehavior(),
backNavigationHandlerProvider: @Composable (Navigator) -> BackNavigationHandler = { scaffoldContent.backNavigationHandler(it) },
content: @Composable Navigator.() -> Unit = { AppScaffoldNavigatorDefaultContent("$key-content") }
) = AppScaffoldNavigator(
key = key,
initialRoute = initialRoute,
title = title,
navigationIcon = navigationIcon,
actions = actions,
topAppBarScrollBehavior = topAppBarScrollBehavior,
backNavigationHandlerProvider = backNavigationHandlerProvider,
content = content
)
@Composable
fun AppScaffoldNavigator(
key: String,
......
......@@ -2,7 +2,6 @@ package net.novagamestudios.kaffeekasse.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
......@@ -12,90 +11,69 @@ import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabNavigator
import cafe.adriel.voyager.navigator.tab.TabOptions
import net.novagamestudios.common_utils.compose.components.BoxCenter
import net.novagamestudios.kaffeekasse.model.session.Device
import net.novagamestudios.kaffeekasse.model.session.Session
import net.novagamestudios.kaffeekasse.model.session.deviceOrNull
import net.novagamestudios.kaffeekasse.ui.AppModulesScreenModel
import net.novagamestudios.kaffeekasse.ui.getValue
import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerModuleContent
import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerModuleScreenModel
import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerTopBarActions
import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerTopBarTitle
import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.Overview
import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.OverviewContent
import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.OverviewScreenModel
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.Account
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.AccountContent
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.AccountScreenModel
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.DynamicManualBill
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseModuleContent
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseModuleScreenModel
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseTopBarActions
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseTopBarNavigation
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseTopBarTitle
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.ManualBillContent
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.ManualBillScreenModel
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.Transactions
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.TransactionsContent
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.TransactionsScreenModel
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.backNavigationHandler
import net.novagamestudios.kaffeekasse.ui.login.LoginAdditional
import net.novagamestudios.kaffeekasse.ui.login.LoginForm
import net.novagamestudios.kaffeekasse.ui.login.LoginContent
import net.novagamestudios.kaffeekasse.ui.login.LoginFormContent
import net.novagamestudios.kaffeekasse.ui.login.LoginFormScreenModel
import net.novagamestudios.kaffeekasse.ui.login.LoginScreenModel
import net.novagamestudios.kaffeekasse.ui.login.LoginTopBarActions
import net.novagamestudios.kaffeekasse.ui.login.LoginTopBarTitle
import net.novagamestudios.kaffeekasse.ui.login.UserSelection
import net.novagamestudios.kaffeekasse.ui.login.UserSelectionContent
import net.novagamestudios.kaffeekasse.ui.login.UserSelectionScreenModel
import net.novagamestudios.kaffeekasse.ui.login.backNavigationHandler
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenProvidingModel
sealed interface LoginNavigation {
companion object : LoginNavigation, ScreenProvidingModel<LoginScreenModel> {
companion object : LoginNavigation, ScreenProvidingModel<LoginScreenModel>, ScaffoldScreen {
@get:Composable override val model by LoginScreenModel
private val initialScreen: Screen @Composable get() = model
.session
.deviceOrNull
?.let { UserSelectionScreen(it) }
?: FormScreen
override val scaffoldContent = LoginContent(this)
@Composable override fun Content() = AppScaffoldNavigator(
key = "login-navigator",
initialRoute = listOf(initialScreen),
title = { LoginTopBarTitle(model, it) },
actions = { LoginTopBarActions(model, it) },
title = { scaffoldContent.TopAppBarTitle(it) },
actions = { scaffoldContent.TopAppBarActions(it) },
topAppBarScrollBehavior = null,
content = {
if (model.isLoading) {
BoxCenter(Modifier.fillMaxSize()) {
CircularProgressIndicator()
}
} else {
AppScaffoldNavigatorDefaultContent("login-navigator-content")
}
LoginAdditional(model, this)
},
backNavigationHandlerProvider = { model.backNavigationHandler(it) }
content = { scaffoldContent.Content(this) },
backNavigationHandlerProvider = { scaffoldContent.backNavigationHandler(it) }
)
}
data object FormScreen : LoginNavigation, ScreenProvidingModel<LoginFormScreenModel> {
data object FormScreen : LoginNavigation, ScreenProvidingModel<LoginFormScreenModel>, ScaffoldScreen {
@get:Composable override val model by LoginFormScreenModel
@Composable override fun Content() = LoginForm(model)
override val scaffoldContent = LoginFormContent(this)
}
data class UserSelectionScreen(
val device: Device
) : LoginNavigation, ScreenProvidingModel<UserSelectionScreenModel> {
) : LoginNavigation, ScreenProvidingModel<UserSelectionScreenModel>, ScaffoldScreen {
@get:Composable override val model by UserSelectionScreenModel
@Composable override fun Content() = UserSelection(model)
override val scaffoldContent = UserSelectionContent(this)
}
}
sealed interface ModuleTab : Tab {
val session: Session.WithRealUser
}
......@@ -132,38 +110,36 @@ sealed interface KaffeekasseNavigation {
data class Tab(
override val session: Session.WithRealUser
) : ModuleTab, KaffeekasseNavigation, ScreenModelProvider<KaffeekasseModuleScreenModel> {
) : ModuleTab, KaffeekasseNavigation, ScreenModelProvider<KaffeekasseModuleScreenModel>, ScaffoldScreen {
@get:Composable override val model by KaffeekasseModuleScreenModel
override val scaffoldContent = KaffeekasseModuleContent(this)
@Composable override fun Content() = AppScaffoldNavigator(
key = "kaffeekasse-navigator",
initialRoute = listOf(ManualBillScreen(session)),
title = { KaffeekasseTopBarTitle(model, it) },
navigationIcon = { KaffeekasseTopBarNavigation(model, it) },
actions = { KaffeekasseTopBarActions(it) },
backNavigationHandlerProvider = { model.backNavigationHandler(it) }
scaffoldContent = scaffoldContent
)
override val options @Composable get() = remember { TabOptions(1u, "Kaffeekasse") }
}
data class ManualBillScreen(
val session: Session.WithRealUser
) : KaffeekasseNavigation, ScreenProvidingModel<ManualBillScreenModel> {
) : KaffeekasseNavigation, ScreenProvidingModel<ManualBillScreenModel>, ScaffoldScreen {
@get:Composable override val model by ManualBillScreenModel
@Composable override fun Content() = DynamicManualBill(model)
override val scaffoldContent = ManualBillContent(this)
}
data class AccountScreen(
val session: Session.WithRealUser
) : KaffeekasseNavigation, ScreenProvidingModel<AccountScreenModel> {
) : KaffeekasseNavigation, ScreenProvidingModel<AccountScreenModel>, ScaffoldScreen {
@get:Composable override val model by AccountScreenModel
@Composable override fun Content() = Account(model)
override val scaffoldContent = AccountContent(this)
}
data class TransactionsScreen(
val session: Session.WithRealUser
) : KaffeekasseNavigation, ScreenProvidingModel<TransactionsScreenModel> {
) : KaffeekasseNavigation, ScreenProvidingModel<TransactionsScreenModel>, ScaffoldScreen {
@get:Composable override val model by TransactionsScreenModel
@Composable override fun Content() = Transactions(model)
override val scaffoldContent = TransactionsContent(this)
}
}
......@@ -172,21 +148,21 @@ sealed interface HiwiTrackerNavigation {
data class Tab(
override val session: Session.WithRealUser
) : ModuleTab, HiwiTrackerNavigation, ScreenModelProvider<HiwiTrackerModuleScreenModel> {
) : ModuleTab, HiwiTrackerNavigation, ScreenModelProvider<HiwiTrackerModuleScreenModel>, ScaffoldScreen {
@get:Composable override val model by HiwiTrackerModuleScreenModel
override val scaffoldContent = HiwiTrackerModuleContent(this)
@Composable override fun Content() = AppScaffoldNavigator(
key = "hiwitracker-navigator",
initialRoute = listOf(OverviewScreen(session)),
title = { HiwiTrackerTopBarTitle(it) },
actions = { HiwiTrackerTopBarActions(model) }
scaffoldContent = scaffoldContent
)
override val options @Composable get() = remember { TabOptions(2u, "Hiwi Tracker") }
}
data class OverviewScreen(
val session: Session.WithRealUser
) : HiwiTrackerNavigation, ScreenProvidingModel<OverviewScreenModel> {
) : HiwiTrackerNavigation, ScreenProvidingModel<OverviewScreenModel>, ScaffoldScreen {
@get:Composable override val model by OverviewScreenModel
@Composable override fun Content() = Overview(model)
override val scaffoldContent = OverviewContent(this)
}
}
package net.novagamestudios.kaffeekasse.ui.navigation
import androidx.compose.runtime.Composable
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.currentOrThrow
import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
abstract class ScaffoldContent {
@Composable
abstract fun Content(navigator: Navigator)
@Composable
open fun TopAppBarNavigationIcon(navigator: Navigator): Unit = default(navigator) { TopAppBarNavigationIcon(it) } ?: Unit
@Composable
open fun TopAppBarTitle(navigator: Navigator): Unit = default(navigator) { TopAppBarTitle(it) } ?: Unit
@Composable
open fun TopAppBarActions(navigator: Navigator): Unit = default(navigator) { TopAppBarActions(it) } ?: Unit
@Composable
open fun backNavigationHandler(navigator: Navigator): BackNavigationHandler = default(navigator) { backNavigationHandler(it) } ?: BackNavigationHandler.Disabled
@Composable
protected inline fun <R> default(navigator: Navigator, block: ScaffoldContent.(Navigator) -> R): R? {
val screen = navigator.lastItem
if (screen !is ScaffoldScreen) return null
val scaffoldContent = screen.scaffoldContent
if (scaffoldContent == this) return null
return scaffoldContent.block(navigator)
}
}
abstract class ScaffoldContentWithModel<out T : ScreenModel>(
provider: ScreenModelProvider<T>
) : ScaffoldContent(), ScreenModelProvider<T> by provider
interface ScaffoldScreen : Screen {
val scaffoldContent: ScaffoldContent
@Composable
override fun Content() = scaffoldContent.Content(LocalNavigator.currentOrThrow)
}
package net.novagamestudios.kaffeekasse.ui.util
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.composed
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
fun Modifier.autofill(
autofillTypes: List<AutofillType>,
onFill: (String) -> Unit
) = composed {
val autofill = LocalAutofill.current
val autofillNode = AutofillNode(
autofillTypes = autofillTypes,
onFill = onFill
)
LocalAutofillTree.current += autofillNode
this
.onGloballyPositioned { autofillNode.boundingBox = it.boundsInWindow() }
.onFocusChanged {
autofill?.run {
if (it.isFocused) requestAutofillForNode(autofillNode)
else cancelAutofillForNode(autofillNode)
}
}
}
\ No newline at end of file