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
  • main
1 result

Target

Select target project
  • justJanne/meteroid
1 result
Select Git revision
  • main
1 result
Show changes
Showing
with 920 additions and 312 deletions
......@@ -26,13 +26,18 @@ package de.chaosdorf.meteroid.ui.drinks
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.paddingFromBaseline
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
......@@ -40,32 +45,40 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import de.chaosdorf.meteroid.model.Drink
import de.chaosdorf.meteroid.sample.SampleDrinkProvider
import de.chaosdorf.meteroid.ui.PriceBadge
@Preview(widthDp = 120, showBackground = true)
@Composable
fun DrinkTile(
@PreviewParameter(SampleDrinkProvider::class) item: Drink
@PreviewParameter(SampleDrinkProvider::class) item: Drink,
onPurchase: (Drink) -> Unit = {}
) {
val thumbPainter = rememberAsyncImagePainter(
item.logoUrl
)
val drinkPainter = rememberAsyncImagePainter(
item.logoUrl.replace("/thumb/", "/original/"),
item.originalLogoUrl,
error = thumbPainter
)
Column(
modifier = Modifier.padding(4.dp)
modifier = Modifier
.height(IntrinsicSize.Max)
.alpha(if (item.active) 1.0f else 0.67f)
.clip(RoundedCornerShape(8.dp))
.clickable { onPurchase(item) }
.padding(8.dp)
) {
Box {
Image(
......@@ -73,39 +86,38 @@ fun DrinkTile(
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.aspectRatio(1.0f)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.aspectRatio(1.0f)
.padding(8.dp)
)
Text(
String.format("%.02f €", item.price),
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.SemiBold,
PriceBadge(
item.price,
modifier = Modifier
.padding(vertical = 12.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.primary)
.align(Alignment.BottomEnd)
.padding(horizontal = 8.dp)
.paddingFromBaseline(bottom = 12.dp)
)
}
Spacer(Modifier.height(4.dp))
Text(
item.name,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 4.dp)
style = MaterialTheme.typography.labelLarge,
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.align(Alignment.CenterHorizontally)) {
Text(
String.format("%.02fl · %.02f€/l", item.volume, item.price / item.volume),
modifier = Modifier
.padding(horizontal = 4.dp)
.fillMaxWidth()
) {
Text(
String.format("%.02f l", item.volume),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
.padding(horizontal = 8.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
}
......
......@@ -32,30 +32,66 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavOptions
import de.chaosdorf.meteroid.ui.navigation.HomeSections
import de.chaosdorf.meteroid.ui.navigation.MeteroidBottomBar
import de.chaosdorf.meteroid.ui.navigation.MeteroidTopBar
import de.chaosdorf.meteroid.ui.navigation.Routes
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.time.Month
@Composable
fun MoneyListScreen(
viewModel: MoneyListViewModel,
onNavigate: (String) -> Unit = {}
onNavigate: (String, NavOptions) -> Unit
) {
val onBack = remember {
{
onNavigate(
Routes.Users.List,
NavOptions.Builder().setPopUpTo(Routes.Users.List, false).build()
)
}
}
val account by viewModel.account.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(account) {
val offline = viewModel.checkOffline(account?.server)
snackbarHostState.currentSnackbarData?.dismiss()
if (offline) {
snackbarHostState.showSnackbar(
message = "Unable to connect to server",
duration = SnackbarDuration.Indefinite
)
}
}
Scaffold(
topBar = { MeteroidTopBar(account, onNavigate) },
topBar = { MeteroidTopBar(account, onNavigate, viewModel::togglePin) },
bottomBar = {
MeteroidBottomBar(
currentRoute = HomeSections.DEPOSIT,
historyEnabled = account?.second?.audit == true,
historyEnabled = account?.user?.audit == true,
wrappedEnabled = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
.month.let { it == Month.NOVEMBER || it == Month.DECEMBER },
navigateTo = onNavigate
)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { paddingValues: PaddingValues ->
Column {
......@@ -66,7 +102,7 @@ fun MoneyListScreen(
horizontalArrangement = Arrangement.SpaceBetween,
) {
items(viewModel.money) { monetaryAmount ->
MoneyTile(monetaryAmount)
MoneyTile(monetaryAmount) { viewModel.deposit(it, onBack) }
}
}
}
......
......@@ -29,30 +29,59 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.chaosdorf.meteroid.R
import de.chaosdorf.meteroid.model.AccountInfo
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.meteroid.model.User
import de.chaosdorf.meteroid.sync.AccountProvider
import de.chaosdorf.meteroid.sync.SyncManager
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
enum class MonetaryAmount(val amount: Double, @DrawableRes val image: Int) {
MONEY_50(0.50, R.drawable.euro_50),
MONEY_100(1.00, R.drawable.euro_100),
MONEY_200(2.00, R.drawable.euro_200),
MONEY_500(5.00, R.drawable.euro_500),
MONEY_1000(10.00, R.drawable.euro_1000),
MONEY_2000(20.00, R.drawable.euro_2000),
MONEY_5000(50.00, R.drawable.euro_5000),
enum class MonetaryAmount(val amount: BigDecimal, @DrawableRes val image: Int) {
MONEY_50(0.50.toBigDecimal(), R.drawable.euro_50),
MONEY_100(1.00.toBigDecimal(), R.drawable.euro_100),
MONEY_200(2.00.toBigDecimal(), R.drawable.euro_200),
MONEY_500(5.00.toBigDecimal(), R.drawable.euro_500),
MONEY_1000(10.00.toBigDecimal(), R.drawable.euro_1000),
MONEY_2000(20.00.toBigDecimal(), R.drawable.euro_2000),
MONEY_5000(50.00.toBigDecimal(), R.drawable.euro_5000),
}
@HiltViewModel
class MoneyListViewModel @Inject constructor(
accountProvider: AccountProvider
private val accountProvider: AccountProvider,
private val syncManager: SyncManager
) : ViewModel() {
val account: StateFlow<Pair<Server, User?>?> = accountProvider.account
val account: StateFlow<AccountInfo?> = accountProvider.account
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val money: List<MonetaryAmount> = MonetaryAmount.entries
fun deposit(item: MonetaryAmount, onBack: () -> Unit) {
account.value?.let { account ->
viewModelScope.launch {
syncManager.deposit(account, item.amount)
if (!account.pinned) {
onBack()
}
}
}
}
fun togglePin() {
account.value?.let { account ->
account.user?.let { user ->
viewModelScope.launch {
accountProvider.togglePin(account.server.serverId, user.userId)
}
}
}
}
suspend fun checkOffline(server: Server?): Boolean =
if (server == null) true
else syncManager.checkOffline(server)
}
......@@ -26,52 +26,47 @@ package de.chaosdorf.meteroid.ui.money
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.paddingFromBaseline
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.chaosdorf.meteroid.ui.PriceBadge
@Composable
fun MoneyTile(
item: MonetaryAmount
item: MonetaryAmount,
onDeposit: (MonetaryAmount) -> Unit = {}
) {
Column(
modifier = Modifier.padding(4.dp)
Box(
modifier = Modifier
.height(IntrinsicSize.Max)
.clip(RoundedCornerShape(8.dp))
.clickable { onDeposit(item) }
) {
Box {
Image(
painterResource(item.image),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.aspectRatio(1.0f)
.padding(8.dp)
)
Text(
String.format("%.02f €", item.amount),
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.SemiBold,
PriceBadge(
item.amount,
modifier = Modifier
.padding(vertical = 12.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.primary)
.align(Alignment.BottomEnd)
.padding(horizontal = 8.dp)
.paddingFromBaseline(bottom = 24.dp)
)
}
}
}
......@@ -25,18 +25,25 @@
package de.chaosdorf.meteroid.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Celebration
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.LocalAtm
import androidx.compose.material.icons.twotone.Celebration
import androidx.compose.material.icons.twotone.History
import androidx.compose.material.icons.twotone.LocalAtm
import androidx.compose.ui.graphics.vector.ImageVector
import de.chaosdorf.meteroid.icons.MeteroidIcons
import de.chaosdorf.meteroid.icons.outlined.WaterFull
import de.chaosdorf.meteroid.icons.twotone.WaterFull
enum class HomeSections(
override val title: String,
override val icon: ImageVector,
override val iconActive: ImageVector,
override val route: String
) : MeteroidNavSection {
PURCHASE("Drinks", MeteroidIcons.TwoTone.WaterFull, Routes.Home.Purchase),
DEPOSIT("Money", Icons.TwoTone.LocalAtm, Routes.Home.Deposit),
HISTORY("History", Icons.TwoTone.History, Routes.Home.History);
PURCHASE("Drinks", MeteroidIcons.Outlined.WaterFull, MeteroidIcons.TwoTone.WaterFull, Routes.Home.Purchase),
DEPOSIT("Money", Icons.Outlined.LocalAtm, Icons.TwoTone.LocalAtm, Routes.Home.Deposit),
HISTORY("History", Icons.Outlined.History, Icons.TwoTone.History, Routes.Home.History),
WRAPPED("Wrapped", Icons.Outlined.Celebration, Icons.TwoTone.Celebration, Routes.Home.Wrapped);
}
......@@ -30,24 +30,40 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavOptions
@Composable
fun <T : MeteroidNavSection> MeteroidBottomBar(
currentRoute: T,
navigateTo: (String) -> Unit,
navigateTo: (String, NavOptions) -> Unit,
historyEnabled: Boolean,
wrappedEnabled: Boolean,
modifier: Modifier = Modifier
) {
NavigationBar {
for (route in HomeSections.entries) {
if (wrappedEnabled || route != HomeSections.WRAPPED) {
NavigationBarItem(
icon = { Icon(route.icon, contentDescription = route.title) },
icon = {
Icon(
if (route == currentRoute) route.iconActive else route.icon,
contentDescription = route.title
)
},
label = { Text(route.title) },
selected = route == currentRoute,
onClick = { navigateTo(route.route) },
onClick = {
navigateTo(
route.route,
NavOptions.Builder()
.setPopUpTo(Routes.Home.Root, true)
.build()
)
},
modifier = modifier,
enabled = route != HomeSections.HISTORY || historyEnabled
)
}
}
}
}
......@@ -29,5 +29,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
interface MeteroidNavSection {
val title: String
val icon: ImageVector
val iconActive: ImageVector
val route: String
}
......@@ -25,77 +25,109 @@
package de.chaosdorf.meteroid.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material.icons.twotone.PushPin
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavOptions
import coil.compose.AsyncImage
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.meteroid.model.User
import de.chaosdorf.meteroid.model.AccountInfo
import de.chaosdorf.meteroid.ui.PriceBadge
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.internal.toCanonicalHost
@Composable
fun MeteroidTopBar(
account: Pair<Server, User?>?,
onNavigate: (String) -> Unit = {}
account: AccountInfo?,
onNavigate: (String, NavOptions) -> Unit,
onTogglePin: () -> Unit,
) {
TopAppBar(
title = {
Text(
account?.second?.name
?: account?.first?.name
?: "Meteroid"
)
},
navigationIcon = {
Surface(
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 6.dp,
tonalElevation = 6.dp,
shape = RoundedCornerShape(32.dp),
onClick = {
onNavigate(Routes.Users.List, NavOptions.Builder().build())
}
) {
Row(modifier = Modifier.padding(8.dp)) {
AsyncImage(
account?.second?.gravatarUrl(),
account?.user?.gravatarUrl,
contentDescription = "User List",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.border(1.dp, Color.White, CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.background(MaterialTheme.colorScheme.tertiary)
)
},
actions = {
account?.second?.let { user ->
val (foreground, background) =
if (user.balance < 0)
Pair(MaterialTheme.colorScheme.onError, MaterialTheme.colorScheme.error)
else
Pair(MaterialTheme.colorScheme.onPrimary, MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.align(Alignment.CenterVertically)) {
if (account != null) {
if (account.user != null) {
Text(
String.format("%.02f €", user.balance),
color = foreground,
fontSize = 14.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.padding(end = 20.dp)
.clip(RoundedCornerShape(16.dp))
.background(background)
.padding(horizontal = 8.dp)
account.user!!.name,
fontWeight = FontWeight.SemiBold
)
Text(
account.server.url.toHttpUrl().host,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.67f),
fontWeight = FontWeight.Medium
)
} else {
Text(
account.server.url.toHttpUrl().host,
fontWeight = FontWeight.SemiBold
)
}
},
} else {
Text(
"Meteroid",
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(
Modifier
.weight(1.0f)
.width(16.dp))
IconButton(onClick = onTogglePin) {
Icon(
if (account?.pinned == true) Icons.Filled.PushPin
else Icons.Outlined.PushPin,
contentDescription = null
)
}
Spacer(Modifier.width(8.dp))
account?.user?.let { user ->
PriceBadge(
user.balance,
modifier = Modifier
.shadow(4.dp)
.clickable { onNavigate(Routes.Users.Root) }
.align(Alignment.CenterVertically)
.padding(end = 12.dp)
)
}
}
}
}
......@@ -44,5 +44,6 @@ object Routes {
const val Deposit = "$Root/deposit"
const val Purchase = "$Root/purchase"
const val History = "$Root/history"
const val Wrapped = "$Root/wrapped"
}
}
......@@ -24,47 +24,141 @@
package de.chaosdorf.meteroid.ui.servers
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import de.chaosdorf.meteroid.R
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl
@Composable
fun AddServerScreen(
viewModel: AddServerViewModel = viewModel(),
isFirst: Boolean = false,
onBack: () -> Unit = {},
onAdd: () -> Unit = {}
) {
val scope = rememberCoroutineScope()
val url by viewModel.url.collectAsState()
val server by viewModel.server.collectAsState()
Column {
Scaffold(
topBar = {
TopAppBar(
title = {
Row {
Image(
painterResource(R.mipmap.ic_launcher),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp)
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.align(Alignment.CenterVertically)) {
Text(
"Meteroid",
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium
)
}
}
},
navigationIcon = {
if (!isFirst) {
IconButton(onClick = { onBack() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
}
}
},
modifier = Modifier.shadow(4.dp)
)
},
) { paddingValues ->
Column(
Modifier
.padding(paddingValues)
.padding(16.dp, 8.dp)) {
TextField(
label = { Text("Server URL") },
value = url,
onValueChange = { viewModel.url.value = it }
onValueChange = { viewModel.url.value = it },
modifier = Modifier.fillMaxWidth()
)
server?.let { server ->
Card(
modifier = Modifier.padding(vertical = 8.dp)
) {
Row(modifier = Modifier.padding(16.dp, 8.dp)) {
AsyncImage(
server.logoUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp)
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.align(Alignment.CenterVertically)) {
Text(
server.name!!,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium
)
Text(
server.url.toHttpUrl().host,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.67f),
fontWeight = FontWeight.Medium,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(
Modifier
.width(16.dp)
.weight(1.0f))
Button(onClick = {
IconButton(onClick = {
scope.launch {
viewModel.addServer()
onAdd()
}
}) {
Text("Add Server")
Icon(Icons.Default.Add, contentDescription = "Add Server")
}
}
}
}
server?.let { server ->
Text(server.url)
Text(server.name ?: "null1")
AsyncImage(model = server.logoUrl, contentDescription = null)
}
}
}
......@@ -27,12 +27,11 @@ package de.chaosdorf.meteroid.ui.servers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.chaosdorf.mete.v1.MeteApiV1Factory
import de.chaosdorf.mete.model.MeteApiFactory
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.meteroid.model.ServerId
import de.chaosdorf.meteroid.model.ServerRepository
import de.chaosdorf.meteroid.util.findBestIcon
import de.chaosdorf.meteroid.util.resolve
import de.chaosdorf.meteroid.util.newServer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
......@@ -44,36 +43,20 @@ import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel
class AddServerViewModel @Inject constructor(
private val factory: MeteApiFactory,
private val repository: ServerRepository
) : ViewModel() {
val url = MutableStateFlow("")
private suspend fun buildServer(
id: ServerId,
url: String
): Server? = try {
val api = MeteApiV1Factory.newInstance(url)
val manifest = api.getManifest()
val icon = manifest?.findBestIcon()
Server(
id,
manifest?.name,
url,
icon?.resolve(url)
)
} catch (_: Exception) {
null
}
val server: StateFlow<Server?> = url
.debounce(300.milliseconds)
.mapLatest { buildServer(ServerId(-1), it) }
.mapLatest { factory.newServer(ServerId(-1), it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
suspend fun addServer() {
val highestServerId = repository.getAll().maxOfOrNull { it.serverId.value } ?: 0
val serverId = ServerId(highestServerId + 1)
val server = buildServer(serverId, url.value)
val server = factory.newServer(serverId, url.value)
if (server != null) {
repository.save(server)
}
......
......@@ -24,24 +24,37 @@
package de.chaosdorf.meteroid.ui.servers
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import de.chaosdorf.meteroid.R
import de.chaosdorf.meteroid.model.ServerId
import okhttp3.HttpUrl.Companion.toHttpUrl
@Preview
@Composable
......@@ -55,7 +68,24 @@ fun ServerListScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Meteroid") },
title = {
Row {
Image(
painterResource(R.mipmap.ic_launcher),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp)
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.align(Alignment.CenterVertically)) {
Text(
"Meteroid",
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium
)
}
}
},
modifier = Modifier.shadow(4.dp)
)
}
......@@ -65,7 +95,15 @@ fun ServerListScreen(
items(servers) { server ->
ListItem(
headlineContent = { Text(server.name ?: server.url) },
supportingContent = { if (server.name != null) Text(server.url) },
supportingContent = { if (server.name != null) Text(server.url.toHttpUrl().host) },
leadingContent = {
AsyncImage(
server.logoUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp)
)
},
modifier = Modifier.clickable { onSelect(server.serverId) }
)
}
......
......@@ -22,7 +22,7 @@
* THE SOFTWARE.
*/
package de.chaosdorf.meteroid.ui.purchases
package de.chaosdorf.meteroid.ui.transactions
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
......@@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachMoney
import androidx.compose.material.icons.filled.QuestionMark
......@@ -45,32 +44,33 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter
import de.chaosdorf.meteroid.model.Drink
import de.chaosdorf.meteroid.model.Purchase
import de.chaosdorf.meteroid.model.Transaction
import de.chaosdorf.meteroid.ui.PriceBadge
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toLocalDateTime
import java.math.BigDecimal
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun PurchaseListItem(
purchase: Purchase,
fun TransactionListItem(
transaction: Transaction,
drink: Drink?
) {
val timestamp = purchase.createdAt.toLocalDateTime(TimeZone.currentSystemDefault())
val timestamp = transaction.timestamp.toLocalDateTime(TimeZone.currentSystemDefault())
val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
ListItem(
headlineContent = {
val label =
if (drink != null) drink.name
else if (purchase.difference > 0.0) "Deposit"
else "Unknown"
val label = when {
drink != null -> drink.name
transaction.difference > BigDecimal.ZERO -> "Deposit"
else -> "Unknown"
}
Text(label)
},
supportingContent = {
......@@ -89,7 +89,7 @@ fun PurchaseListItem(
drink.logoUrl
)
val originalPainter = rememberAsyncImagePainter(
drink.logoUrl.replace("/thumb/", "/original/"),
drink.originalLogoUrl,
error = thumbPainter
)
......@@ -101,7 +101,7 @@ fun PurchaseListItem(
.align(Alignment.Center)
.fillMaxSize()
)
} else if (purchase.difference > 0) {
} else if (transaction.difference > BigDecimal.ZERO) {
Icon(
Icons.Default.AttachMoney,
contentDescription = null,
......@@ -117,22 +117,9 @@ fun PurchaseListItem(
}
},
trailingContent = {
val (foreground, background) =
if (purchase.difference < 0)
Pair(MaterialTheme.colorScheme.onError, MaterialTheme.colorScheme.error)
else
Pair(MaterialTheme.colorScheme.onPrimary, MaterialTheme.colorScheme.primary)
Text(
String.format("%.02f €", purchase.difference),
color = foreground,
fontSize = 14.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(background)
.padding(horizontal = 8.dp)
PriceBadge(
transaction.difference,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
)
......
......@@ -22,51 +22,67 @@
* THE SOFTWARE.
*/
package de.chaosdorf.meteroid.ui.purchases
package de.chaosdorf.meteroid.ui.transactions
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import de.chaosdorf.meteroid.sync.SyncHandler
import androidx.compose.runtime.remember
import androidx.navigation.NavOptions
import de.chaosdorf.meteroid.ui.navigation.HomeSections
import de.chaosdorf.meteroid.ui.navigation.MeteroidBottomBar
import de.chaosdorf.meteroid.ui.navigation.MeteroidTopBar
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.time.Month
@Composable
fun PurchaseListScreen(
viewModel: PurchaseViewModel,
onNavigate: (route: String) -> Unit = {}
fun TransactionListScreen(
viewModel: TransactionViewModel,
onNavigate: (String, NavOptions) -> Unit
) {
val account by viewModel.account.collectAsState()
val purchases by viewModel.purchases.collectAsState()
val syncState by viewModel.syncState.collectAsState()
val transactions by viewModel.transactions.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(account) {
val offline = viewModel.checkOffline(account?.server)
snackbarHostState.currentSnackbarData?.dismiss()
if (offline) {
snackbarHostState.showSnackbar(
message = "Unable to connect to server",
duration = SnackbarDuration.Indefinite
)
}
}
Scaffold(
topBar = { MeteroidTopBar(account, onNavigate) },
topBar = { MeteroidTopBar(account, onNavigate, viewModel::togglePin) },
bottomBar = {
MeteroidBottomBar(
currentRoute = HomeSections.HISTORY,
historyEnabled = account?.second?.audit == true,
historyEnabled = account?.user?.audit == true,
wrappedEnabled = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
.month.let { it == Month.NOVEMBER || it == Month.DECEMBER },
navigateTo = onNavigate
)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { paddingValues: PaddingValues ->
Column {
if (syncState == SyncHandler.State.Loading) {
LinearProgressIndicator()
}
LazyColumn(modifier = Modifier.padding(paddingValues)) {
items(purchases) { (purchase, drink) ->
PurchaseListItem(purchase, drink)
}
LazyColumn(contentPadding = paddingValues) {
items(transactions) { (transaction, drink) ->
TransactionListItem(transaction, drink)
}
}
}
......
......@@ -22,20 +22,19 @@
* THE SOFTWARE.
*/
package de.chaosdorf.meteroid.ui.purchases
package de.chaosdorf.meteroid.ui.transactions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.chaosdorf.meteroid.model.Drink
import de.chaosdorf.meteroid.model.AccountInfo
import de.chaosdorf.meteroid.model.DrinkRepository
import de.chaosdorf.meteroid.model.Purchase
import de.chaosdorf.meteroid.model.PurchaseRepository
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.meteroid.model.User
import de.chaosdorf.meteroid.model.TransactionRepository
import de.chaosdorf.meteroid.sync.AccountProvider
import de.chaosdorf.meteroid.sync.PurchaseSyncHandler
import de.chaosdorf.meteroid.sync.SyncHandler
import de.chaosdorf.meteroid.sync.SyncManager
import de.chaosdorf.meteroid.sync.TransactionSyncHandler
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
......@@ -43,57 +42,75 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
import kotlin.time.Duration.Companion.minutes
@HiltViewModel
class PurchaseViewModel @Inject constructor(
accountProvider: AccountProvider,
repository: PurchaseRepository,
class TransactionViewModel @Inject constructor(
private val accountProvider: AccountProvider,
repository: TransactionRepository,
drinkRepository: DrinkRepository,
syncHandler: PurchaseSyncHandler
private val syncManager: SyncManager
) : ViewModel() {
val account: StateFlow<Pair<Server, User?>?> = accountProvider.account
val account: StateFlow<AccountInfo?> = accountProvider.account
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val purchases: StateFlow<List<Pair<Purchase, Drink?>>> = accountProvider.account
val transactions: StateFlow<List<TransactionInfo>> = accountProvider.account
.flatMapLatest { account ->
account?.let { (server, user) ->
user?.let { user ->
account?.let { (server, maybeUser) ->
maybeUser?.let { user ->
combine(
repository.getAllFlow(server.serverId, user.userId),
drinkRepository.getAllFlow(server.serverId)
) { purchases, drinks ->
purchases.map { purchase ->
Pair(purchase, drinks.firstOrNull { drink -> drink.drinkId == purchase.drinkId })
) { transactions, drinks ->
transactions.map { transaction ->
TransactionInfo(
transaction,
drinks.firstOrNull { drink -> drink.drinkId == transaction.drinkId }
)
}
}
}
} ?: flowOf(emptyList())
}.mapLatest { list ->
list.mergeAdjecentDeposits()
.filter { it.second != null || it.first.difference != 0.0 }
.filter { it.drink != null || it.transaction.difference != BigDecimal.ZERO }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
val syncState: StateFlow<SyncHandler.State> = syncHandler.state
fun togglePin() {
account.value?.let { account ->
account.user?.let { user ->
viewModelScope.launch {
accountProvider.togglePin(account.server.serverId, user.userId)
}
}
}
}
fun List<Pair<Purchase, Drink?>>.mergeAdjecentDeposits(): List<Pair<Purchase, Drink?>> {
val result = mutableListOf<Pair<Purchase, Drink?>>()
suspend fun checkOffline(server: Server?): Boolean =
if (server == null) true
else syncManager.checkOffline(server)
}
fun List<TransactionInfo>.mergeAdjecentDeposits(): List<TransactionInfo> {
val result = mutableListOf<TransactionInfo>()
for (entry in this) {
val previous = result.lastOrNull()
if (previous != null
&& previous.first.difference > 0
&& entry.first.difference > 0
&& previous.second == null
&& entry.second == null
&& entry.first.createdAt.minus(previous.first.createdAt) < 5.minutes
&& previous.transaction.difference > BigDecimal.ZERO
&& entry.transaction.difference > BigDecimal.ZERO
&& previous.drink == null
&& entry.drink == null
&& entry.transaction.timestamp.minus(previous.transaction.timestamp) < 5.minutes
) {
result.removeLast()
result.add(
Pair(
entry.first.copy(difference = entry.first.difference + previous.first.difference),
null
entry.copy(
transaction = entry.transaction.copy(
difference = entry.transaction.difference + previous.transaction.difference
)
)
)
} else {
......
......@@ -22,19 +22,12 @@
* THE SOFTWARE.
*/
package de.chaosdorf.mete.v1
package de.chaosdorf.meteroid.ui.transactions
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import de.chaosdorf.meteroid.model.Drink
import de.chaosdorf.meteroid.model.Transaction
@Serializable
data class AuditResponseV1(
@SerialName("payments_sum")
val payments: Double,
@SerialName("deposits_sum")
val deposits: Double,
@SerialName("sum")
val total: Double,
@SerialName("audits")
val entries: List<AuditEntryModelV1>
data class TransactionInfo(
val transaction: Transaction,
val drink: Drink?
)
......@@ -24,17 +24,21 @@
package de.chaosdorf.meteroid.ui.users
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
......@@ -42,15 +46,16 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import de.chaosdorf.mete.UserId
import de.chaosdorf.meteroid.sync.SyncHandler
import de.chaosdorf.mete.model.UserId
import okhttp3.HttpUrl.Companion.toHttpUrl
@Composable
fun UserListScreen(
......@@ -61,28 +66,55 @@ fun UserListScreen(
) {
val server by viewModel.account.collectAsState()
val users by viewModel.users.collectAsState()
val syncState by viewModel.syncState.collectAsState()
val pinnedUsers by viewModel.pinnedUsers.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = {
Row {
AsyncImage(
server?.server?.logoUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp)
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.align(Alignment.CenterVertically)) {
if (server?.server != null) {
if (server?.server?.name != null) {
Text(
server!!.server.name!!,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium
)
Text(
server!!.server.url.toHttpUrl().host,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.67f),
fontWeight = FontWeight.Medium,
style = MaterialTheme.typography.bodyMedium
)
} else {
Text(
server?.first?.name
?: "Meteroid"
server!!.server.url,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium
)
}
} else {
Text(
"Meteroid",
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
},
navigationIcon = {
AsyncImage(
server?.first?.logoUrl,
contentDescription = "User List",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.border(1.dp, Color.White, CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
)
IconButton(onClick = { onBack() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
}
},
modifier = Modifier
.shadow(4.dp)
......@@ -90,24 +122,40 @@ fun UserListScreen(
)
}
) { paddingValues ->
Column {
if (syncState == SyncHandler.State.Loading) {
LinearProgressIndicator()
}
LazyColumn(modifier = Modifier.padding(paddingValues)) {
items(users) { user ->
ListItem(
headlineContent = { Text(user.name) },
supportingContent = { Text(user.email) },
modifier = Modifier.clickable { onSelect(user.userId) }
LazyVerticalGrid(
GridCells.Adaptive(80.dp),
modifier = Modifier.padding(horizontal = 8.dp),
contentPadding = paddingValues
) {
if (pinnedUsers.isNotEmpty()) {
item("pinned", span = { GridItemSpan(maxLineSpan) }) {
Text(
"Pinned",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 16.dp, bottom = 4.dp)
)
}
item {
ListItem(
headlineContent = { Text("Add User") },
modifier = Modifier.clickable { onAdd() }
items(pinnedUsers) { user ->
UserTile(user, onSelect)
}
}
for (character in 'A'..'Z') {
val group = users.filter { it.name.startsWith(character, ignoreCase = true) }
if (group.isNotEmpty()) {
item(character.toString(), span = { GridItemSpan(maxLineSpan) }) {
Text(
"$character",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 16.dp, bottom = 4.dp)
)
}
items(group) { user ->
UserTile(user, onSelect)
}
}
}
}
}
......
......@@ -27,34 +27,47 @@ package de.chaosdorf.meteroid.ui.users
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.chaosdorf.meteroid.model.Server
import de.chaosdorf.meteroid.model.AccountInfo
import de.chaosdorf.meteroid.model.PinnedUserRepository
import de.chaosdorf.meteroid.model.ServerId
import de.chaosdorf.meteroid.model.User
import de.chaosdorf.meteroid.model.UserRepository
import de.chaosdorf.meteroid.sync.AccountProvider
import de.chaosdorf.meteroid.sync.SyncHandler
import de.chaosdorf.meteroid.sync.UserSyncHandler
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class UserListViewModel @Inject constructor(
accountProvider: AccountProvider,
repository: UserRepository,
syncHandler: UserSyncHandler
userRepository: UserRepository,
pinnedUserRepository: PinnedUserRepository,
) : ViewModel() {
val account: StateFlow<Pair<Server, User?>?> = accountProvider.account
val account: StateFlow<AccountInfo?> = accountProvider.account
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val users: StateFlow<List<User>> = accountProvider.account
.flatMapLatest { account ->
account?.let { (server, _) ->
repository.getAllFlow(server.serverId)
userRepository.getAllFlow(server.serverId)
} ?: flowOf(emptyList())
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
val syncState: StateFlow<SyncHandler.State> = syncHandler.state
val pinnedUsers = accountProvider.account
.flatMapLatest { account ->
account?.let { (server, _) ->
combine(
userRepository.getAllFlow(server.serverId),
pinnedUserRepository.getAllFlow(server.serverId)
) { users, pinned ->
users.filter { pinned.contains(it.userId) }
}
} ?: flowOf(emptyList())
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
}
/*
* The MIT License (MIT)
*
* Copyright (c) 2013-2023 Chaosdorf e.V.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package de.chaosdorf.meteroid.ui.users
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.paddingFromBaseline
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import de.chaosdorf.mete.model.UserId
import de.chaosdorf.meteroid.model.Drink
import de.chaosdorf.meteroid.model.User
import de.chaosdorf.meteroid.sample.SampleDrinkProvider
import de.chaosdorf.meteroid.ui.PriceBadge
@Composable
fun UserTile(
item: User,
onSelect: (UserId) -> Unit = {}
) {
val avatarPainter = rememberAsyncImagePainter(
item.gravatarUrl
)
Column(
modifier = Modifier
.height(IntrinsicSize.Max)
.alpha(if (item.active) 1.0f else 0.67f)
.clip(RoundedCornerShape(8.dp))
.clickable { onSelect(item.userId) }
.padding(8.dp)
) {
Box {
Image(
avatarPainter,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.aspectRatio(1.0f)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
)
}
Spacer(Modifier.height(4.dp))
Text(
item.name,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.labelLarge,
)
}
}
/*
* The MIT License (MIT)
*
* Copyright (c) 2013-2023 Chaosdorf e.V.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package de.chaosdorf.meteroid.ui.wrapped
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavOptions
import coil.compose.rememberAsyncImagePainter
import de.chaosdorf.meteroid.R
import de.chaosdorf.meteroid.ui.navigation.HomeSections
import de.chaosdorf.meteroid.ui.navigation.MeteroidBottomBar
import de.chaosdorf.meteroid.ui.navigation.MeteroidTopBar
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.time.Month
import java.time.format.TextStyle
@Composable
fun WrappedScreen(
viewModel: WrappedViewModel,
onNavigate: (String, NavOptions) -> Unit
) {
val account by viewModel.account.collectAsState()
val slides by viewModel.slides.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(account) {
val offline = viewModel.checkOffline(account?.server)
snackbarHostState.currentSnackbarData?.dismiss()
if (offline) {
snackbarHostState.showSnackbar(
message = "Unable to connect to server",
duration = SnackbarDuration.Indefinite
)
}
}
Scaffold(
topBar = { MeteroidTopBar(account, onNavigate, viewModel::togglePin) },
bottomBar = {
MeteroidBottomBar(
currentRoute = HomeSections.WRAPPED,
historyEnabled = account?.user?.audit == true,
wrappedEnabled = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
.month.let { it == Month.NOVEMBER || it == Month.DECEMBER },
navigateTo = onNavigate
)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { paddingValues: PaddingValues ->
LazyColumn(contentPadding = paddingValues) {
items(slides) { slide ->
when (slide) {
is WrappedSlide.MostBoughtDrink ->
ListItem(
headlineContent = {
Text("Your favorite drink is ${slide.drink.name}")
},
supportingContent = {
Text("At least you enjoyed it ${slide.count} times this year.")
},
leadingContent = {
val thumbPainter = rememberAsyncImagePainter(
slide.drink.logoUrl
)
val drinkPainter = rememberAsyncImagePainter(
slide.drink.originalLogoUrl,
error = thumbPainter
)
Image(
drinkPainter,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(72.dp)
)
}
)
is WrappedSlide.Caffeine ->
ListItem(
headlineContent = {
Text("You consumed ${slide.total} mg of caffeine this year.")
},
supportingContent = {
slide.wouldKill?.let { animal ->
Text("This could kill a medium-weight ${animal.name}. Glad you're still here.")
}
},
leadingContent = {
val painter = painterResource(
when (slide.wouldKill) {
WrappedSlide.Caffeine.Animal.Squirrel -> R.drawable.wrapped_squirrel
WrappedSlide.Caffeine.Animal.Rat -> R.drawable.wrapped_rat
WrappedSlide.Caffeine.Animal.Cat -> R.drawable.wrapped_cat
WrappedSlide.Caffeine.Animal.Koala -> R.drawable.wrapped_koala
WrappedSlide.Caffeine.Animal.Lynx -> R.drawable.wrapped_lynx
WrappedSlide.Caffeine.Animal.Jaguar -> R.drawable.wrapped_jaguar
WrappedSlide.Caffeine.Animal.Reindeer -> R.drawable.wrapped_reindeer
WrappedSlide.Caffeine.Animal.Gorilla -> R.drawable.wrapped_gorilla
WrappedSlide.Caffeine.Animal.Lion -> R.drawable.wrapped_lion
WrappedSlide.Caffeine.Animal.Bear -> R.drawable.wrapped_bear
WrappedSlide.Caffeine.Animal.Moose -> R.drawable.wrapped_moose
else -> R.drawable.wrapped_coffee_beans
}
)
Image(
painter,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(72.dp)
)
}
)
is WrappedSlide.MostActive ->
ListItem(
headlineContent = {
Text(
"You were most active on ${
slide.weekday.getDisplayName(
TextStyle.FULL,
LocalConfiguration.current.locale
)
}s at ${slide.hour} o'clock."
)
},
leadingContent = {
val painter = painterResource(R.drawable.wrapped_clock)
Image(
painter,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.size(72.dp)
)
}
)
}
}
}
}
}