Initial commit

This commit is contained in:
2026-07-22 14:08:23 +01:00
commit 01af2acb3d
39 changed files with 3990 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
*.iml
.gradle/
.idea/
local.properties
build/
captures/
.externalNativeBuild/
.cxx/
app/release/
keeply-release.jks
signing.properties

40
README.md Normal file
View File

@@ -0,0 +1,40 @@
# Keeply for Android
A native Android version of the Keeply pantry manager, built with Kotlin and Jetpack Compose. It talks directly to the same ASP.NET API as `new-pantry-management-frontend`; it is not a WebView or React Native wrapper.
## Included
- JWT sign-in, registration, encrypted token storage, session restore, and automatic token refresh
- Pantry summary, search, freshness/location filters, grouped product entries, and item CRUD
- Native Google barcode scanner with manual-entry fallback and barcode-first quick add
- Location CRUD
- Household creation/editing, member invites, and leave-household flow
- Role-gated site administrator user management
- Profile, email, name, and password settings
- Adaptive navigation for phones and tablets
## Open and run
Open this folder in Android Studio, allow Gradle sync to finish, and run the `app` configuration on an Android 6.0 (API 23) or newer device/emulator.
The production API is used by default:
```text
https://api.pantrymanager.kitchen
```
To use a different API, pass a Gradle property. Android emulators reach the host machine at `10.0.2.2`:
```powershell
.\gradlew.bat assembleDebug -PKEEPLY_API_BASE_URL=http://10.0.2.2:5000
```
Debug builds permit cleartext HTTP for local development. Release builds require HTTPS.
## Validate
```powershell
.\gradlew.bat testDebugUnitTest lintDebug assembleDebug
```
The debug APK is written to `app\build\outputs\apk\debug\app-debug.apk`.

98
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,98 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
val configuredApiUrl = providers.gradleProperty("KEEPLY_API_BASE_URL")
.orElse("https://api.pantrymanager.kitchen")
.get()
.trimEnd('/')
val signingProperties = Properties().apply {
val propertiesFile = rootProject.file("signing.properties")
if (propertiesFile.isFile) propertiesFile.inputStream().use(::load)
}
android {
namespace = "com.keeply.pantry"
compileSdk = 36
defaultConfig {
applicationId = "com.keeply.pantry"
minSdk = 23
targetSdk = 36
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
buildConfigField("String", "API_BASE_URL", "\"$configuredApiUrl\"")
manifestPlaceholders["allowCleartextTraffic"] = "false"
}
signingConfigs {
create("release") {
storeFile = rootProject.file(signingProperties.getProperty("storeFile"))
storePassword = signingProperties.getProperty("storePassword")
keyAlias = signingProperties.getProperty("keyAlias")
keyPassword = signingProperties.getProperty("keyPassword")
}
}
buildTypes {
debug {
manifestPlaceholders["allowCleartextTraffic"] = "true"
}
release {
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions.jvmTarget = "17"
buildFeatures {
compose = true
buildConfig = true
}
packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.05.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.16.0")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

1
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# Keeply uses platform JSON and does not require reflection-specific keep rules.

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Keeply"
android:usesCleartextTraffic="${allowCleartextTraffic}">
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui" />
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="unspecified">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,404 @@
package com.keeply.pantry
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.keeply.pantry.data.ApiClient
import com.keeply.pantry.data.ApiException
import com.keeply.pantry.data.AppDestination
import com.keeply.pantry.data.asIndividualEntries
import com.keeply.pantry.data.BarcodeLookup
import com.keeply.pantry.data.DataState
import com.keeply.pantry.data.Household
import com.keeply.pantry.data.InventoryItem
import com.keeply.pantry.data.InventoryItemRequest
import com.keeply.pantry.data.Location
import com.keeply.pantry.data.Notice
import com.keeply.pantry.data.TokenStore
import com.keeply.pantry.data.UpdateProfileRequest
import com.keeply.pantry.data.UpdateUserRequest
import com.keeply.pantry.data.User
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
sealed interface BarcodeResult {
data class Found(val product: BarcodeLookup, val suggestedName: String = product.title) : BarcodeResult
data object NotFound : BarcodeResult
}
class KeeplyViewModel(application: Application) : AndroidViewModel(application) {
private val tokenStore = TokenStore(application)
private val api = ApiClient(BuildConfig.API_BASE_URL, tokenStore)
var initializing by mutableStateOf(true)
private set
var user by mutableStateOf<User?>(null)
private set
var destination by mutableStateOf(AppDestination.Pantry)
private set
var working by mutableStateOf(false)
private set
var authError by mutableStateOf<String?>(null)
private set
var actionError by mutableStateOf<String?>(null)
private set
var notice by mutableStateOf<Notice?>(null)
private set
var inventory by mutableStateOf(DataState<List<InventoryItem>>())
private set
var locations by mutableStateOf(DataState<List<Location>>())
private set
var households by mutableStateOf(DataState<List<Household>>())
private set
var users by mutableStateOf(DataState<List<User>>())
private set
init {
restoreSession()
}
private fun restoreSession() {
if (tokenStore.accessToken.isNullOrBlank()) {
initializing = false
return
}
viewModelScope.launch {
try {
user = api.getProfile()
loadPantry()
} catch (_: Exception) {
tokenStore.clear()
} finally {
initializing = false
}
}
}
fun login(email: String, password: String) {
authError = null
working = true
viewModelScope.launch {
try {
val response = api.login(email.trim(), password)
val signedInUser = response.user
if (!response.success || response.accessToken.isNullOrBlank() ||
response.refreshToken.isNullOrBlank() || signedInUser == null
) {
throw IllegalStateException(response.message ?: "Unable to sign in.")
}
tokenStore.set(response.accessToken, response.refreshToken)
user = signedInUser
destination = AppDestination.Pantry
loadPantry()
} catch (error: Exception) {
authError = friendlyMessage(error, "Unable to sign in.")
} finally {
working = false
}
}
}
fun register(
email: String,
password: String,
confirmPassword: String,
firstName: String,
lastName: String,
) {
if (password != confirmPassword) {
authError = "The passwords do not match."
return
}
authError = null
working = true
viewModelScope.launch {
try {
val response = api.register(
email.trim(), password, confirmPassword, firstName.trim(), lastName.trim(),
)
val signedInUser = response.user
if (!response.success || response.accessToken.isNullOrBlank() ||
response.refreshToken.isNullOrBlank() || signedInUser == null
) {
throw IllegalStateException(response.message ?: "Unable to create your account.")
}
tokenStore.set(response.accessToken, response.refreshToken)
user = signedInUser
destination = AppDestination.Pantry
loadPantry()
} catch (error: Exception) {
authError = friendlyMessage(error, "Unable to create your account.")
} finally {
working = false
}
}
}
fun clearAuthError() {
authError = null
}
fun logout() {
viewModelScope.launch {
try {
api.logout()
} catch (_: Exception) {
// A local sign-out must still succeed if the API is unavailable.
} finally {
tokenStore.clear()
user = null
inventory = DataState()
locations = DataState()
households = DataState()
users = DataState()
}
}
}
fun navigate(to: AppDestination) {
if (to == AppDestination.Users && user?.isSiteAdmin != true) return
destination = to
when (to) {
AppDestination.Pantry -> loadPantry()
AppDestination.Locations -> loadLocations()
AppDestination.Households -> loadHouseholds()
AppDestination.Users -> loadUsers()
AppDestination.Profile -> Unit
}
}
fun loadPantry(force: Boolean = false) {
if (!force && inventory.data != null && locations.data != null) return
inventory = inventory.copy(loading = true, error = null)
locations = locations.copy(loading = true, error = null)
viewModelScope.launch {
try {
val itemRequest = async { api.getItems() }
val locationRequest = async { api.getLocations() }
inventory = DataState(data = itemRequest.await())
locations = DataState(data = locationRequest.await())
} catch (error: Exception) {
val message = friendlyMessage(error, "Your pantry could not be loaded.")
inventory = inventory.copy(loading = false, error = message)
locations = locations.copy(loading = false, error = message)
}
}
}
fun loadLocations(force: Boolean = false) {
if (!force && locations.data != null) return
locations = locations.copy(loading = true, error = null)
viewModelScope.launch {
locations = try {
DataState(data = api.getLocations())
} catch (error: Exception) {
DataState(error = friendlyMessage(error, "Locations could not be loaded."))
}
}
}
fun loadHouseholds(force: Boolean = false) {
if (!force && households.data != null) return
households = households.copy(loading = true, error = null)
viewModelScope.launch {
households = try {
DataState(data = api.getHouseholds())
} catch (error: Exception) {
DataState(error = friendlyMessage(error, "Households could not be loaded."))
}
}
}
fun loadUsers(force: Boolean = false) {
if (user?.isSiteAdmin != true || (!force && users.data != null)) return
users = users.copy(loading = true, error = null)
viewModelScope.launch {
users = try {
DataState(data = api.getUsers())
} catch (error: Exception) {
DataState(error = friendlyMessage(error, "Users could not be loaded."))
}
}
}
fun saveItem(item: InventoryItem?, request: InventoryItemRequest, onSuccess: () -> Unit) = action(
successMessage = if (item == null) "Item added to your pantry." else "Pantry item updated.",
block = {
if (item == null) api.createItem(request) else api.updateItem(item.id, request)
},
onSuccess = {
loadPantry(force = true)
onSuccess()
},
)
fun saveScannedItems(request: InventoryItemRequest, quantity: Int, onSuccess: () -> Unit) = action(
successMessage = if (quantity == 1) "Item added to your pantry." else "$quantity individual items added to your pantry.",
block = {
request.asIndividualEntries(quantity).forEach { api.createItem(it) }
},
onSuccess = {
loadPantry(force = true)
onSuccess()
},
)
fun deleteItem(item: InventoryItem, onSuccess: () -> Unit = {}) = action(
successMessage = "${item.title} was removed.",
block = { api.deleteItem(item.id) },
onSuccess = {
inventory = inventory.copy(data = inventory.data.orEmpty().filterNot { it.id == item.id })
onSuccess()
},
)
fun lookupBarcode(barcode: String, onResult: (Result<BarcodeResult>) -> Unit) {
working = true
actionError = null
viewModelScope.launch {
val result = runCatching {
val normalized = barcode.trim()
val existing = api.searchItems(normalized).firstOrNull {
it.barcode?.trim()?.equals(normalized, ignoreCase = true) == true
}
if (existing != null) {
BarcodeResult.Found(
BarcodeLookup(
barcode = existing.barcode ?: normalized,
title = existing.name,
size = existing.itemLookupSize,
imageUrl = existing.itemImageUrl,
),
existing.name,
)
} else {
try {
BarcodeResult.Found(api.lookupBarcode(normalized))
} catch (error: ApiException) {
if (error.status == 404) BarcodeResult.NotFound else throw error
}
}
}
working = false
result.exceptionOrNull()?.let { actionError = friendlyMessage(it, "We could not look up this barcode.") }
onResult(result)
}
}
fun saveLocation(location: Location?, name: String, description: String?, onSuccess: () -> Unit) = action(
successMessage = if (location == null) "Location added." else "Location updated.",
block = {
if (location == null) api.createLocation(name, description)
else api.updateLocation(location.id, name, description)
},
onSuccess = { saved ->
val current = locations.data.orEmpty()
locations = DataState(
data = if (current.any { it.id == saved.id }) current.map { if (it.id == saved.id) saved else it }
else current + saved,
)
onSuccess()
},
)
fun deleteLocation(location: Location, onSuccess: () -> Unit) = action(
successMessage = "${location.name} was removed.",
block = { api.deleteLocation(location.id) },
onSuccess = {
locations = locations.copy(data = locations.data.orEmpty().filterNot { it.id == location.id })
onSuccess()
},
)
fun saveHousehold(household: Household?, name: String, description: String?, onSuccess: () -> Unit) = action(
successMessage = if (household == null) "Household created." else "Household updated.",
block = {
if (household == null) api.createHousehold(name, description)
else api.updateHousehold(household.id, name, description)
},
onSuccess = { saved ->
upsertHousehold(saved)
onSuccess()
},
)
fun inviteMember(household: Household, email: String, onSuccess: () -> Unit) = action(
successMessage = "Member added to ${household.name}.",
block = { api.inviteHouseholdMember(household.id, email) },
onSuccess = { saved ->
upsertHousehold(saved)
onSuccess()
},
)
fun leaveHousehold(household: Household, onSuccess: () -> Unit) = action(
successMessage = "You left ${household.name}.",
block = { api.leaveHousehold(household.id) },
onSuccess = {
households = households.copy(data = households.data.orEmpty().filterNot { it.id == household.id })
onSuccess()
},
)
fun updateProfile(request: UpdateProfileRequest, successMessage: String, onSuccess: () -> Unit) = action(
successMessage = successMessage,
block = { api.updateProfile(request) },
onSuccess = { updated ->
user = updated
onSuccess()
},
)
fun updateUser(person: User, request: UpdateUserRequest, onSuccess: () -> Unit) = action(
successMessage = "${person.displayName} was updated.",
block = { api.updateUser(person.id, request) },
onSuccess = { updated ->
users = users.copy(data = users.data.orEmpty().map { if (it.id == updated.id) updated else it })
if (updated.id == user?.id) user = updated
onSuccess()
},
)
fun clearActionError() {
actionError = null
}
fun consumeNotice() {
notice = null
}
private fun upsertHousehold(saved: Household) {
val current = households.data.orEmpty()
households = DataState(
data = if (current.any { it.id == saved.id }) current.map { if (it.id == saved.id) saved else it }
else current + saved,
)
}
private fun <T> action(
successMessage: String,
block: suspend () -> T,
onSuccess: (T) -> Unit,
) {
working = true
actionError = null
viewModelScope.launch {
try {
val result = block()
onSuccess(result)
notice = Notice(message = successMessage)
} catch (error: Exception) {
actionError = friendlyMessage(error, "The change could not be saved.")
} finally {
working = false
}
}
}
}
private fun friendlyMessage(error: Throwable, fallback: String): String =
error.message?.takeIf { it.isNotBlank() } ?: fallback

View File

@@ -0,0 +1,29 @@
package com.keeply.pantry
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.ui.graphics.toArgb
import com.keeply.pantry.ui.KeeplyApp
import com.keeply.pantry.ui.theme.Canvas
import com.keeply.pantry.ui.theme.KeeplyTheme
class MainActivity : ComponentActivity() {
private val viewModel: KeeplyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(Canvas.toArgb(), Canvas.toArgb()),
navigationBarStyle = SystemBarStyle.light(Canvas.toArgb(), Canvas.toArgb()),
)
setContent {
KeeplyTheme {
KeeplyApp(viewModel)
}
}
}
}

View File

@@ -0,0 +1,311 @@
package com.keeply.pantry.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class ApiClient(
baseUrl: String,
private val tokenStore: TokenStore,
) {
private val baseUrl = baseUrl.trimEnd('/')
suspend fun login(email: String, password: String): AuthResponse =
requestObject("/api/auth/login", "POST", jsonOf("email" to email, "password" to password), false).toAuth()
suspend fun register(
email: String,
password: String,
confirmPassword: String,
firstName: String,
lastName: String,
): AuthResponse = requestObject(
"/api/auth/register",
"POST",
jsonOf(
"email" to email,
"password" to password,
"confirmPassword" to confirmPassword,
"firstName" to firstName,
"lastName" to lastName,
),
false,
).toAuth()
suspend fun logout() {
request("/api/auth/logout", "POST")
}
suspend fun getProfile(): User = requestObject("/api/profile").toUser()
suspend fun updateProfile(value: UpdateProfileRequest): User = requestObject(
"/api/profile",
"PUT",
JSONObject().apply {
value.email?.let { put("email", it) }
value.firstName?.let { put("firstName", it) }
value.lastName?.let { put("lastName", it) }
value.currentPassword?.let { put("currentPassword", it) }
value.newPassword?.let { put("newPassword", it) }
},
).toUser()
suspend fun getItems(): List<InventoryItem> =
requestArray("/api/inventoryitems").mapObjects { it.toInventoryItem() }
suspend fun searchItems(query: String): List<InventoryItem> =
requestArray("/api/search/items?q=${encode(query)}").mapObjects { it.toInventoryItem() }
suspend fun lookupBarcode(barcode: String): BarcodeLookup =
requestObject("/api/inventoryitems/barcode/${encode(barcode)}").toBarcodeLookup()
suspend fun createItem(value: InventoryItemRequest) {
request("/api/inventoryitems", "POST", value.toJson())
}
suspend fun updateItem(id: String, value: InventoryItemRequest): InventoryItem =
requestObject("/api/inventoryitems/${encode(id)}", "PUT", value.toJson()).toInventoryItem()
suspend fun deleteItem(id: String) {
request("/api/inventoryitems/${encode(id)}", "DELETE")
}
suspend fun getLocations(): List<Location> = requestArray("/api/locations").mapObjects { it.toLocation() }
suspend fun createLocation(name: String, description: String?): Location = requestObject(
"/api/locations", "POST", jsonOf("name" to name, "description" to description),
).toLocation()
suspend fun updateLocation(id: String, name: String, description: String?): Location = requestObject(
"/api/locations/${encode(id)}", "PUT", jsonOf("name" to name, "description" to description),
).toLocation()
suspend fun deleteLocation(id: String) {
request("/api/locations/${encode(id)}", "DELETE")
}
suspend fun getHouseholds(): List<Household> = requestArray("/api/households").mapObjects { it.toHousehold() }
suspend fun createHousehold(name: String, description: String?): Household = requestObject(
"/api/households", "POST", jsonOf("name" to name, "description" to description),
).toHousehold()
suspend fun updateHousehold(id: String, name: String, description: String?): Household = requestObject(
"/api/households/${encode(id)}", "PUT", jsonOf("name" to name, "description" to description),
).toHousehold()
suspend fun inviteHouseholdMember(id: String, email: String): Household = requestObject(
"/api/households/${encode(id)}/invite", "POST", jsonOf("email" to email),
).toHousehold()
suspend fun leaveHousehold(id: String) {
request("/api/households/${encode(id)}/leave", "DELETE")
}
suspend fun getUsers(): List<User> = requestArray("/api/users").mapObjects { it.toUser() }
suspend fun updateUser(id: String, value: UpdateUserRequest): User = requestObject(
"/api/users/${encode(id)}",
"PUT",
JSONObject().apply {
value.email?.let { put("email", it) }
value.firstName?.let { put("firstName", it) }
value.lastName?.let { put("lastName", it) }
value.password?.let { put("password", it) }
value.roles?.let { put("roles", JSONArray(it)) }
},
).toUser()
private suspend fun requestObject(
path: String,
method: String = "GET",
body: JSONObject? = null,
authenticated: Boolean = true,
): JSONObject = JSONObject(request(path, method, body, authenticated).ifBlank { "{}" })
private suspend fun requestArray(path: String): JSONArray = JSONArray(request(path))
private suspend fun request(
path: String,
method: String = "GET",
body: JSONObject? = null,
authenticated: Boolean = true,
retryOnUnauthorized: Boolean = true,
): String = withContext(Dispatchers.IO) {
val response = execute(path, method, body, authenticated)
if (response.status == HttpURLConnection.HTTP_UNAUTHORIZED && authenticated && retryOnUnauthorized) {
if (refreshAccessToken()) {
return@withContext request(path, method, body, authenticated, false)
}
}
if (response.status !in 200..299) throw ApiException(errorMessage(response.body), response.status)
response.body
}
private fun execute(path: String, method: String, body: JSONObject?, authenticated: Boolean): Response {
val connection = URI("$baseUrl$path").toURL().openConnection() as HttpURLConnection
connection.requestMethod = method
connection.connectTimeout = 15_000
connection.readTimeout = 20_000
connection.setRequestProperty("Accept", "application/json")
if (authenticated) tokenStore.accessToken?.let { connection.setRequestProperty("Authorization", "Bearer $it") }
if (body != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(body.toString().toByteArray(StandardCharsets.UTF_8)) }
}
return try {
val status = connection.responseCode
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
Response(status, stream?.bufferedReader()?.use { it.readText() }.orEmpty())
} finally {
connection.disconnect()
}
}
private fun refreshAccessToken(): Boolean {
val accessToken = tokenStore.accessToken ?: return false
val refreshToken = tokenStore.refreshToken ?: return false
val response = execute(
"/api/auth/refresh-token",
"POST",
jsonOf("accessToken" to accessToken, "refreshToken" to refreshToken),
false,
)
if (response.status !in 200..299) {
tokenStore.clear()
return false
}
val auth = runCatching { JSONObject(response.body).toAuth() }.getOrNull()
if (auth?.accessToken.isNullOrBlank() || auth?.refreshToken.isNullOrBlank()) {
tokenStore.clear()
return false
}
tokenStore.set(auth.accessToken!!, auth.refreshToken!!)
return true
}
private data class Response(val status: Int, val body: String)
}
private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8.toString())
private fun jsonOf(vararg values: Pair<String, Any?>): JSONObject = JSONObject().apply {
values.forEach { (key, value) -> put(key, value ?: JSONObject.NULL) }
}
private fun errorMessage(body: String): String {
if (body.isBlank()) return "Something went wrong. Please try again."
return runCatching {
val data = JSONObject(body)
data.stringOrNull("message")
?: data.stringOrNull("error")
?: data.stringOrNull("title")
?: data.optJSONObject("errors")?.let { errors ->
errors.keys().asSequence().mapNotNull { key -> errors.optJSONArray(key)?.optString(0) }.firstOrNull()
}
}.getOrNull() ?: "Something went wrong. Please try again."
}
private fun JSONObject.stringOrNull(key: String): String? =
if (!has(key) || isNull(key)) null else optString(key).takeIf { it.isNotBlank() }
private fun <T> JSONArray.mapObjects(block: (JSONObject) -> T): List<T> =
(0 until length()).map { block(getJSONObject(it)) }
private fun JSONObject.toAuth() = AuthResponse(
success = optBoolean("success"),
message = stringOrNull("message"),
accessToken = stringOrNull("accessToken"),
refreshToken = stringOrNull("refreshToken"),
user = optJSONObject("user")?.toUser(),
)
private fun JSONObject.toUser() = User(
id = optString("id"),
email = optString("email"),
firstName = stringOrNull("firstName"),
lastName = stringOrNull("lastName"),
roles = optJSONArray("roles")?.let { array -> (0 until array.length()).map { array.optString(it) } }.orEmpty(),
)
private fun JSONObject.toLocation() = Location(
id = optString("id"),
name = optString("name"),
description = stringOrNull("description"),
)
private fun JSONObject.toBarcodeLookup() = BarcodeLookup(
barcode = optString("barcode"),
title = optString("title"),
size = displayValue(opt("size")),
imageUrl = firstImageUrl(opt("images")),
)
private fun JSONObject.toInventoryItem() = InventoryItem(
id = optString("id"),
name = optString("name"),
expiryDate = stringOrNull("expiryDate"),
barcode = stringOrNull("barcode"),
useByDate = stringOrNull("useByDate"),
amount = if (has("amount") && !isNull("amount")) optDouble("amount") else null,
amountType = stringOrNull("amountType"),
itemLookupId = stringOrNull("itemLookupId"),
itemLookupTitle = stringOrNull("itemLookupTitle"),
itemLookupSize = displayValue(opt("itemLookupSize")),
itemImageUrl = firstImageUrl(opt("itemLookupImages")),
locationId = stringOrNull("locationId"),
location = optJSONObject("location")?.toLocation(),
)
private fun JSONObject.toHousehold() = Household(
id = optString("id"),
name = optString("name"),
description = stringOrNull("description"),
adminUserId = optString("adminUserId"),
adminEmail = optString("adminEmail"),
createdAt = optString("createdAt"),
isCurrentUserHouseholdAdmin = optBoolean("isCurrentUserHouseholdAdmin"),
members = optJSONArray("members")?.mapObjects { it.toMember() }.orEmpty(),
)
private fun JSONObject.toMember() = HouseholdMember(
userId = optString("userId"),
email = optString("email"),
firstName = stringOrNull("firstName"),
lastName = stringOrNull("lastName"),
joinedAt = optString("joinedAt"),
isHouseholdAdmin = optBoolean("isHouseholdAdmin"),
)
private fun InventoryItemRequest.toJson() = JSONObject().apply {
name?.let { put("name", it) }
expiryDate?.let { put("expiryDate", it) }
barcode?.let { put("barcode", it) }
useByDate?.let { put("useByDate", it) }
amount?.let { put("amount", it) }
amountType?.let { put("amountType", it) }
itemLookupId?.let { put("itemLookupId", it) }
locationId?.let { put("locationId", it) }
}
private fun displayValue(value: Any?): String? = when (value) {
null, JSONObject.NULL -> null
is String, is Number -> value.toString()
is JSONObject -> listOf("value", "display", "text").firstNotNullOfOrNull { value.stringOrNull(it) }
else -> null
}
private fun firstImageUrl(value: Any?): String? {
val first = (value as? JSONArray)?.opt(0) ?: return null
return when (first) {
is String -> first
is JSONObject -> first.stringOrNull("url") ?: first.stringOrNull("image_url")
else -> null
}
}

View File

@@ -0,0 +1,47 @@
package com.keeply.pantry.data
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit
import java.util.Locale
enum class ExpiryTone { Danger, Warning, Good, Neutral }
fun parseApiDate(value: String?): LocalDate? {
if (value.isNullOrBlank()) return null
return try {
LocalDate.parse(value.take(10))
} catch (_: DateTimeParseException) {
null
}
}
fun daysUntil(value: String?, today: LocalDate = LocalDate.now()): Long? =
parseApiDate(value)?.let { ChronoUnit.DAYS.between(today, it) }
fun expiryLabel(value: String?, today: LocalDate = LocalDate.now()): String = when (val days = daysUntil(value, today)) {
null -> "No expiry set"
in Long.MIN_VALUE..-1 -> "Expired ${-days}d ago"
0L -> "Expires today"
1L -> "Expires tomorrow"
else -> "Expires in $days days"
}
fun expiryTone(value: String?, today: LocalDate = LocalDate.now()): ExpiryTone = when (val days = daysUntil(value, today)) {
null -> ExpiryTone.Neutral
in Long.MIN_VALUE..-1 -> ExpiryTone.Danger
in 0..3 -> ExpiryTone.Warning
else -> ExpiryTone.Good
}
fun formatDate(value: String?): String = parseApiDate(value)?.format(
DateTimeFormatter.ofPattern("d MMM uuuu", Locale.UK),
) ?: "Not set"
fun toApiDate(value: String): String? = value.trim().takeIf { it.isNotEmpty() }?.let { "${it.take(10)}T00:00:00" }
fun formatAmount(value: Double?): String {
val amount = value ?: 1.0
return if (amount % 1.0 == 0.0) amount.toLong().toString() else amount.toString().trimEnd('0')
}

View File

@@ -0,0 +1,137 @@
package com.keeply.pantry.data
data class User(
val id: String,
val email: String,
val firstName: String? = null,
val lastName: String? = null,
val roles: List<String> = emptyList(),
) {
val isSiteAdmin: Boolean
get() = roles.any { it == "Site Admin" || it == "Admin" }
val displayName: String
get() = listOfNotNull(firstName, lastName).filter { it.isNotBlank() }.joinToString(" ")
.ifBlank { email }
val initials: String
get() = listOfNotNull(firstName?.firstOrNull(), lastName?.firstOrNull())
.joinToString("").ifBlank { email.firstOrNull()?.toString() ?: "K" }.uppercase()
}
data class AuthResponse(
val success: Boolean,
val message: String? = null,
val accessToken: String? = null,
val refreshToken: String? = null,
val user: User? = null,
)
data class Location(
val id: String,
val name: String,
val description: String? = null,
)
data class BarcodeLookup(
val barcode: String,
val title: String,
val size: String? = null,
val imageUrl: String? = null,
)
data class InventoryItem(
val id: String,
val name: String,
val expiryDate: String? = null,
val barcode: String? = null,
val useByDate: String? = null,
val amount: Double? = null,
val amountType: String? = null,
val itemLookupId: String? = null,
val itemLookupTitle: String? = null,
val itemLookupSize: String? = null,
val itemImageUrl: String? = null,
val locationId: String? = null,
val location: Location? = null,
) {
val title: String get() = itemLookupTitle?.takeIf { it.isNotBlank() } ?: name
}
data class InventoryItemRequest(
val name: String? = null,
val expiryDate: String? = null,
val barcode: String? = null,
val useByDate: String? = null,
val amount: Double? = null,
val amountType: String? = null,
val itemLookupId: String? = null,
val locationId: String? = null,
)
fun InventoryItemRequest.asIndividualEntries(quantity: Int): List<InventoryItemRequest> {
require(quantity > 0) { "Quantity must be at least 1." }
return List(quantity) { copy(amount = 1.0) }
}
data class HouseholdMember(
val userId: String,
val email: String,
val firstName: String? = null,
val lastName: String? = null,
val joinedAt: String,
val isHouseholdAdmin: Boolean,
) {
val displayName: String
get() = listOfNotNull(firstName, lastName).filter { it.isNotBlank() }.joinToString(" ")
.ifBlank { email }
val initials: String
get() = listOfNotNull(firstName?.firstOrNull(), lastName?.firstOrNull())
.joinToString("").ifBlank { email.firstOrNull()?.toString() ?: "K" }.uppercase()
}
data class Household(
val id: String,
val name: String,
val description: String? = null,
val adminUserId: String,
val adminEmail: String,
val createdAt: String,
val isCurrentUserHouseholdAdmin: Boolean,
val members: List<HouseholdMember>,
)
data class UpdateProfileRequest(
val email: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val currentPassword: String? = null,
val newPassword: String? = null,
)
data class UpdateUserRequest(
val email: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val password: String? = null,
val roles: List<String>? = null,
)
data class DataState<T>(
val data: T? = null,
val loading: Boolean = false,
val error: String? = null,
)
enum class AppDestination(val label: String) {
Pantry("My pantry"),
Locations("Locations"),
Households("Households"),
Users("Users"),
Profile("Profile & settings"),
}
data class Notice(val id: Long = System.nanoTime(), val message: String)
class ApiException(message: String, val status: Int) : Exception(message)

View File

@@ -0,0 +1,39 @@
package com.keeply.pantry.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class TokenStore(context: Context) {
private val preferences: SharedPreferences = runCatching {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"keeply_session",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}.getOrElse {
context.getSharedPreferences("keeply_session_fallback", Context.MODE_PRIVATE)
}
val accessToken: String? get() = preferences.getString(ACCESS_TOKEN, null)
val refreshToken: String? get() = preferences.getString(REFRESH_TOKEN, null)
fun set(accessToken: String, refreshToken: String) {
preferences.edit().putString(ACCESS_TOKEN, accessToken).putString(REFRESH_TOKEN, refreshToken).apply()
}
fun clear() {
preferences.edit().clear().apply()
}
private companion object {
const val ACCESS_TOKEN = "access_token"
const val REFRESH_TOKEN = "refresh_token"
}
}

View File

@@ -0,0 +1,261 @@
package com.keeply.pantry.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.People
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.keeply.pantry.KeeplyViewModel
import com.keeply.pantry.data.AppDestination
import com.keeply.pantry.ui.theme.Canvas
import com.keeply.pantry.ui.theme.Divider
import com.keeply.pantry.ui.theme.KeeplyGreen
import com.keeply.pantry.ui.theme.KeeplyGreenSoft
import com.keeply.pantry.ui.theme.Muted
import kotlinx.coroutines.launch
@Composable
fun KeeplyApp(viewModel: KeeplyViewModel) {
when {
viewModel.initializing -> InitialLoadingScreen()
viewModel.user == null -> AuthScreen(viewModel)
else -> SignedInApp(viewModel)
}
}
@Composable
private fun InitialLoadingScreen() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp)) {
KeeplyLogo()
CircularProgressIndicator(Modifier.size(27.dp), strokeWidth = 3.dp)
Text("Loading your pantry…", color = Muted, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun SignedInApp(viewModel: KeeplyViewModel) {
val snackbarHostState = remember { SnackbarHostState() }
val notice = viewModel.notice
LaunchedEffect(notice?.id) {
notice?.let {
snackbarHostState.showSnackbar(it.message)
viewModel.consumeNotice()
}
}
BoxWithConstraints(Modifier.fillMaxSize()) {
if (maxWidth >= 840.dp) {
Row(Modifier.fillMaxSize()) {
AppNavigationRail(viewModel)
AppScaffold(viewModel, snackbarHostState, showMenu = false)
}
} else {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(
modifier = Modifier.width(292.dp),
drawerContainerColor = Color.White,
) {
AppDrawerContent(
viewModel = viewModel,
onSelected = { scope.launch { drawerState.close() } },
)
}
},
) {
AppScaffold(
viewModel,
snackbarHostState,
showMenu = true,
onMenu = { scope.launch { drawerState.open() } },
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppScaffold(
viewModel: KeeplyViewModel,
snackbarHostState: SnackbarHostState,
showMenu: Boolean,
onMenu: () -> Unit = {},
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = Canvas,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { KeeplyLogo() },
navigationIcon = {
if (showMenu) IconButton(onClick = onMenu) { Icon(Icons.Outlined.Menu, "Open navigation") }
},
actions = {
TextButton(onClick = { viewModel.navigate(AppDestination.Profile) }) {
InitialsAvatar(viewModel.user?.initials.orEmpty())
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.White),
)
},
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
when (viewModel.destination) {
AppDestination.Pantry -> InventoryScreen(viewModel)
AppDestination.Locations -> LocationsScreen(viewModel)
AppDestination.Households -> HouseholdsScreen(viewModel)
AppDestination.Users -> UsersScreen(viewModel)
AppDestination.Profile -> ProfileScreen(viewModel)
}
}
}
}
@Composable
private fun AppDrawerContent(viewModel: KeeplyViewModel, onSelected: () -> Unit) {
val user = viewModel.user ?: return
Column(Modifier.fillMaxHeight().padding(horizontal = 14.dp, vertical = 20.dp)) {
KeeplyLogo()
Spacer(Modifier.padding(top = 26.dp))
SectionEyebrow("Workspace")
Spacer(Modifier.padding(top = 5.dp))
PrimaryDestinations.forEach { destination ->
DrawerDestination(destination, viewModel, onSelected)
}
if (user.isSiteAdmin) {
Spacer(Modifier.padding(top = 18.dp))
SectionEyebrow("Administration")
Spacer(Modifier.padding(top = 5.dp))
DrawerDestination(AppDestination.Users, viewModel, onSelected)
}
Spacer(Modifier.weight(1f))
HorizontalDivider(color = Divider)
NavigationDrawerItem(
label = {
Column {
Text(user.displayName, style = MaterialTheme.typography.labelLarge)
Text(if (user.isSiteAdmin) "Site administrator" else "Pantry member", color = Muted, style = MaterialTheme.typography.bodySmall)
}
},
selected = viewModel.destination == AppDestination.Profile,
onClick = { viewModel.navigate(AppDestination.Profile); onSelected() },
icon = { InitialsAvatar(user.initials) },
modifier = Modifier.padding(top = 12.dp),
)
NavigationDrawerItem(
label = { Text("Sign out") },
selected = false,
onClick = { viewModel.logout(); onSelected() },
icon = { Icon(Icons.Outlined.Logout, null) },
)
}
}
@Composable
private fun DrawerDestination(destination: AppDestination, viewModel: KeeplyViewModel, onSelected: () -> Unit) {
NavigationDrawerItem(
label = { Text(destination.label) },
selected = viewModel.destination == destination,
onClick = { viewModel.navigate(destination); onSelected() },
icon = { Icon(destination.icon(), null) },
colors = NavigationDrawerItemDefaults.colors(selectedContainerColor = KeeplyGreenSoft, selectedIconColor = KeeplyGreen),
shape = RoundedCornerShape(11.dp),
)
}
@Composable
private fun AppNavigationRail(viewModel: KeeplyViewModel) {
val destinations = buildList {
addAll(PrimaryDestinations)
if (viewModel.user?.isSiteAdmin == true) add(AppDestination.Users)
add(AppDestination.Profile)
}
NavigationRail(
modifier = Modifier.fillMaxHeight().width(94.dp),
containerColor = Color.White,
header = { Box(Modifier.padding(vertical = 18.dp)) { KeeplyLogo(compact = true) } },
) {
destinations.forEach { destination ->
NavigationRailItem(
selected = viewModel.destination == destination,
onClick = { viewModel.navigate(destination) },
icon = { Icon(destination.icon(), null) },
label = { Text(destination.label.substringBefore(' '), maxLines = 1) },
)
}
Spacer(Modifier.weight(1f))
NavigationRailItem(
selected = false,
onClick = viewModel::logout,
icon = { Icon(Icons.Outlined.Logout, null) },
label = { Text("Sign out") },
)
}
}
private val PrimaryDestinations = listOf(
AppDestination.Pantry,
AppDestination.Locations,
AppDestination.Households,
)
private fun AppDestination.icon(): ImageVector = when (this) {
AppDestination.Pantry -> Icons.Outlined.Inventory2
AppDestination.Locations -> Icons.Outlined.LocationOn
AppDestination.Households -> Icons.Outlined.Groups
AppDestination.Users -> Icons.Outlined.People
AppDestination.Profile -> Icons.Outlined.Person
}

View File

@@ -0,0 +1,208 @@
package com.keeply.pantry.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
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.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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.keeply.pantry.KeeplyViewModel
import com.keeply.pantry.ui.theme.Canvas
import com.keeply.pantry.ui.theme.Ink
import com.keeply.pantry.ui.theme.KeeplyGreenSoft
import com.keeply.pantry.ui.theme.Muted
@Composable
fun AuthScreen(viewModel: KeeplyViewModel) {
BoxWithConstraints(Modifier.fillMaxSize().background(Canvas)) {
if (maxWidth >= 760.dp) {
Row(Modifier.fillMaxSize()) {
AuthStory(Modifier.weight(0.9f).fillMaxHeight())
Box(Modifier.weight(1.1f).fillMaxHeight(), contentAlignment = Alignment.Center) {
AuthForm(viewModel, Modifier.padding(horizontal = 56.dp, vertical = 32.dp))
}
}
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
AuthForm(viewModel, Modifier.padding(horizontal = 22.dp, vertical = 32.dp))
}
}
}
}
@Composable
private fun AuthStory(modifier: Modifier = Modifier) {
Column(
modifier.background(Color(0xFF244C39)).padding(horizontal = 54.dp, vertical = 40.dp),
) {
KeeplyLogo(light = true)
Spacer(Modifier.weight(1f))
Text("A CALMER KITCHEN STARTS HERE", color = Color(0xFF9BC3A4), style = MaterialTheme.typography.labelSmall)
Spacer(Modifier.height(12.dp))
Text("Know what you have.\nUse it beautifully.", style = MaterialTheme.typography.displaySmall, color = Color.White)
Spacer(Modifier.height(20.dp))
Text(
"Keep your pantry organised, waste less food, and share the load with everyone at home.",
color = Color(0xFFBED0C3),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(Modifier.height(30.dp))
StoryBenefit(Icons.Outlined.Check, "See every item at a glance", "Clear expiry cues make planning simple.")
StoryBenefit(Icons.Outlined.QrCodeScanner, "Scan and store in seconds", "Product details are handled for you.")
StoryBenefit(Icons.Outlined.Shield, "Made for the whole household", "Manage people, places and shared routines.")
Spacer(Modifier.weight(1f))
Text("Food, thoughtfully kept", color = Color(0xFFA7CBAA), style = MaterialTheme.typography.labelMedium)
}
}
@Composable
private fun StoryBenefit(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, message: String) {
Row(Modifier.padding(vertical = 9.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(36.dp).background(Color.White.copy(alpha = 0.1f), CircleShape), contentAlignment = Alignment.Center) {
Icon(icon, null, tint = Color(0xFFDCEADE), modifier = Modifier.size(19.dp))
}
Spacer(Modifier.width(12.dp))
Column {
Text(title, color = Color.White, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodyMedium)
Text(message, color = Color(0xFFAAC0B0), style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun AuthForm(viewModel: KeeplyViewModel, modifier: Modifier = Modifier) {
var registerMode by remember { mutableStateOf(false) }
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
val valid = email.isNotBlank() && password.length >= 8 &&
(!registerMode || (firstName.isNotBlank() && lastName.isNotBlank() && confirmPassword.length >= 8))
Column(
modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(Modifier.fillMaxWidth()) { KeeplyLogo() }
Spacer(Modifier.height(42.dp))
Column(Modifier.fillMaxWidth()) {
SectionEyebrow(if (registerMode) "Your pantry awaits" else "Welcome back")
Spacer(Modifier.height(7.dp))
Text(if (registerMode) "Create your account" else "Sign in to Keeply", style = MaterialTheme.typography.headlineLarge, color = Ink)
Spacer(Modifier.height(7.dp))
Text(if (registerMode) "Set up your kitchen in a few moments." else "Pick up right where you left off.", color = Muted)
}
Spacer(Modifier.height(28.dp))
if (registerMode) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(firstName, { firstName = it }, label = { Text("First name") }, singleLine = true, modifier = Modifier.weight(1f))
OutlinedTextField(lastName, { lastName = it }, label = { Text("Last name") }, singleLine = true, modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(12.dp))
}
OutlinedTextField(
email,
{ email = it; viewModel.clearAuthError() },
label = { Text("Email address") },
placeholder = { Text("you@example.com") },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
password,
{ password = it; viewModel.clearAuthError() },
label = { Text("Password") },
placeholder = { Text("At least 8 characters") },
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, if (showPassword) "Hide password" else "Show password")
}
},
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = if (registerMode) ImeAction.Next else ImeAction.Done),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (registerMode) {
Spacer(Modifier.height(12.dp))
OutlinedTextField(
confirmPassword,
{ confirmPassword = it; viewModel.clearAuthError() },
label = { Text("Confirm password") },
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
singleLine = true,
supportingText = { Text("Use upper and lowercase letters and at least one number.") },
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(14.dp))
ErrorText(viewModel.authError)
Spacer(Modifier.height(16.dp))
Button(
onClick = {
if (registerMode) viewModel.register(email, password, confirmPassword, firstName, lastName)
else viewModel.login(email, password)
},
enabled = valid && !viewModel.working,
modifier = Modifier.fillMaxWidth().height(51.dp),
) {
if (viewModel.working) CircularProgressIndicator(Modifier.size(19.dp), color = Color.White, strokeWidth = 2.dp)
else Text(if (registerMode) "Create account" else "Sign in")
}
Spacer(Modifier.height(14.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(if (registerMode) "Already have an account?" else "New to Keeply?", color = Muted)
TextButton(onClick = {
registerMode = !registerMode
viewModel.clearAuthError()
}) { Text(if (registerMode) "Sign in" else "Create an account") }
}
Spacer(Modifier.height(16.dp))
Text("Private by design · Powered by your pantry API", color = Muted, style = MaterialTheme.typography.bodySmall)
}
}

View File

@@ -0,0 +1,297 @@
package com.keeply.pantry.ui
import android.app.DatePickerDialog
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.keeply.pantry.data.ExpiryTone
import com.keeply.pantry.ui.theme.Canvas
import com.keeply.pantry.ui.theme.Danger
import com.keeply.pantry.ui.theme.DangerSoft
import com.keeply.pantry.ui.theme.Divider as DividerColor
import com.keeply.pantry.ui.theme.Ink
import com.keeply.pantry.ui.theme.KeeplyGreen
import com.keeply.pantry.ui.theme.KeeplyGreenDark
import com.keeply.pantry.ui.theme.KeeplyGreenSoft
import com.keeply.pantry.ui.theme.Muted
import com.keeply.pantry.ui.theme.Warning
import com.keeply.pantry.ui.theme.WarningSoft
import java.time.LocalDate
@Composable
fun KeeplyLogo(compact: Boolean = false, light: Boolean = false) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Canvas(Modifier.size(31.dp, 28.dp)) {
val leafSize = androidx.compose.ui.geometry.Size(10.dp.toPx(), 23.dp.toPx())
rotate(-39f, Offset(7.dp.toPx(), 24.dp.toPx())) {
drawRoundRect(if (light) Color(0xFFDCEADE) else KeeplyGreen, Offset(2.dp.toPx(), 2.dp.toPx()), leafSize, androidx.compose.ui.geometry.CornerRadius(8.dp.toPx()))
}
rotate(-3f, Offset(15.dp.toPx(), 25.dp.toPx())) {
drawRoundRect(if (light) Color(0xFFA7CBAA) else Color(0xFF5B916D), Offset(10.dp.toPx(), 3.dp.toPx()), leafSize.copy(height = 22.dp.toPx()), androidx.compose.ui.geometry.CornerRadius(8.dp.toPx()))
}
rotate(39f, Offset(24.dp.toPx(), 24.dp.toPx())) {
drawRoundRect(if (light) Color(0xFF77A883) else Color(0xFF8BB594), Offset(19.dp.toPx(), 2.dp.toPx()), leafSize, androidx.compose.ui.geometry.CornerRadius(8.dp.toPx()))
}
}
if (!compact) Text(
"keeply",
color = if (light) Color.White else Ink,
fontSize = 23.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = (-0.8).sp,
)
}
}
@Composable
fun InitialsAvatar(initials: String, modifier: Modifier = Modifier, large: Boolean = false) {
Box(
modifier = modifier
.size(if (large) 72.dp else 40.dp)
.clip(CircleShape)
.background(KeeplyGreenSoft),
contentAlignment = Alignment.Center,
) {
Text(initials, color = KeeplyGreenDark, fontWeight = FontWeight.Bold, fontSize = if (large) 23.sp else 13.sp)
}
}
@Composable
fun SectionEyebrow(text: String) {
Text(
text.uppercase(),
color = KeeplyGreen,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.5.sp,
)
}
@Composable
fun PageHeader(eyebrow: String? = null, title: String, description: String? = null, action: (@Composable () -> Unit)? = null) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(Modifier.weight(1f)) {
eyebrow?.let { SectionEyebrow(it); Spacer(Modifier.height(5.dp)) }
Text(title, style = MaterialTheme.typography.headlineLarge, color = Ink)
description?.let {
Spacer(Modifier.height(5.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = Muted)
}
}
action?.let { Spacer(Modifier.width(12.dp)); it() }
}
}
@Composable
fun KeeplyCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
border = androidx.compose.foundation.BorderStroke(1.dp, DividerColor),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) { content() }
}
@Composable
fun LoadingPane(message: String = "Loading…") {
Column(
Modifier.fillMaxWidth().padding(vertical = 64.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
Text(message, color = Muted, style = MaterialTheme.typography.bodySmall)
}
}
@Composable
fun EmptyPane(title: String, message: String, actionLabel: String? = null, onAction: (() -> Unit)? = null) {
Column(
Modifier.fillMaxWidth().padding(vertical = 64.dp, horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Surface(shape = CircleShape, color = KeeplyGreenSoft, modifier = Modifier.size(56.dp)) {
Box(contentAlignment = Alignment.Center) { Icon(Icons.Outlined.ErrorOutline, null, tint = KeeplyGreen) }
}
Spacer(Modifier.height(14.dp))
Text(title, style = MaterialTheme.typography.titleMedium, color = Ink)
Spacer(Modifier.height(5.dp))
Text(message, color = Muted, style = MaterialTheme.typography.bodySmall)
if (actionLabel != null && onAction != null) {
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = onAction) { Text(actionLabel) }
}
}
}
@Composable
fun ErrorText(message: String?) {
if (!message.isNullOrBlank()) {
Surface(
color = DangerSoft,
contentColor = Danger,
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(message, Modifier.padding(12.dp), style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
fun ConfirmActionDialog(
title: String,
message: String,
confirmLabel: String,
working: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = { if (!working) onDismiss() },
title = { Text(title) },
text = { Text(message, color = Muted) },
confirmButton = {
Button(onClick = onConfirm, enabled = !working) {
if (working) CircularProgressIndicator(Modifier.size(17.dp), strokeWidth = 2.dp, color = Color.White)
else Text(confirmLabel)
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !working) { Text("Cancel") } },
)
}
@Composable
fun ChoiceField(
label: String,
value: String,
options: List<Pair<String, String>>,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val display = options.firstOrNull { it.first == value }?.second.orEmpty()
Column(modifier) {
Text(label, style = MaterialTheme.typography.labelMedium, color = Ink)
Spacer(Modifier.height(6.dp))
Surface(
modifier = Modifier.fillMaxWidth().clickable { expanded = true },
shape = RoundedCornerShape(10.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
color = Color.White,
) {
Row(Modifier.padding(horizontal = 15.dp, vertical = 15.dp), verticalAlignment = Alignment.CenterVertically) {
Text(display, Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
Icon(Icons.Outlined.ExpandMore, null, tint = Muted)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.second) },
onClick = { onSelected(option.first); expanded = false },
)
}
}
}
}
}
@Composable
fun DateField(label: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier) {
val context = androidx.compose.ui.platform.LocalContext.current
fun showPicker() {
val initial = runCatching { LocalDate.parse(value) }.getOrElse { LocalDate.now() }
DatePickerDialog(
context,
{ _, year, month, day -> onValueChange(LocalDate.of(year, month + 1, day).toString()) },
initial.year,
initial.monthValue - 1,
initial.dayOfMonth,
).show()
}
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = { Text("Not set") },
trailingIcon = { IconButton(onClick = ::showPicker) { Icon(Icons.Outlined.CalendarMonth, "Choose date") } },
singleLine = true,
modifier = modifier,
)
}
@Composable
fun StatusPill(label: String, tone: ExpiryTone) {
val background = when (tone) {
ExpiryTone.Danger -> DangerSoft
ExpiryTone.Warning -> WarningSoft
ExpiryTone.Good -> KeeplyGreenSoft
ExpiryTone.Neutral -> Canvas
}
val foreground = when (tone) {
ExpiryTone.Danger -> Danger
ExpiryTone.Warning -> Warning
ExpiryTone.Good -> KeeplyGreenDark
ExpiryTone.Neutral -> Muted
}
Surface(color = background, contentColor = foreground, shape = RoundedCornerShape(50)) {
Text(label, Modifier.padding(horizontal = 9.dp, vertical = 5.dp), style = MaterialTheme.typography.labelSmall)
}
}
@Composable
fun FieldDivider() {
androidx.compose.material3.HorizontalDivider(color = DividerColor)
}

View File

@@ -0,0 +1,279 @@
package com.keeply.pantry.ui
import androidx.compose.foundation.layout.Arrangement
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.height
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.MailOutline
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.keeply.pantry.KeeplyViewModel
import com.keeply.pantry.data.Household
import com.keeply.pantry.data.HouseholdMember
import com.keeply.pantry.data.formatDate
import com.keeply.pantry.ui.theme.Ink
import com.keeply.pantry.ui.theme.KeeplyGreen
import com.keeply.pantry.ui.theme.KeeplyGreenSoft
import com.keeply.pantry.ui.theme.Muted
import com.keeply.pantry.ui.theme.Warning
import com.keeply.pantry.ui.theme.WarningSoft
@Composable
fun HouseholdsScreen(viewModel: KeeplyViewModel) {
val households = viewModel.households.data.orEmpty()
val isSiteAdmin = viewModel.user?.isSiteAdmin == true
var editing by remember { mutableStateOf<Household?>(null) }
var editorOpen by remember { mutableStateOf(false) }
var inviting by remember { mutableStateOf<Household?>(null) }
var leaving by remember { mutableStateOf<Household?>(null) }
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 18.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 24.dp, bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(15.dp),
) {
item {
PageHeader(
eyebrow = "Pantries work better together",
title = "Households",
description = "Manage shared spaces and the people who keep them running.",
action = if (isSiteAdmin) {
{
Button(onClick = { editing = null; viewModel.clearActionError(); editorOpen = true }) {
Icon(Icons.Outlined.Add, null, Modifier.size(18.dp)); Spacer(Modifier.width(6.dp)); Text("New")
}
}
} else null,
)
}
if (!isSiteAdmin) {
item {
Surface(color = KeeplyGreenSoft, shape = RoundedCornerShape(13.dp), modifier = Modifier.fillMaxWidth()) {
Row(Modifier.padding(15.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.Shield, null, tint = KeeplyGreen)
Spacer(Modifier.width(11.dp))
Column {
Text("Your household access is managed safely", fontWeight = FontWeight.SemiBold)
Text("Household admins can update details and add existing Keeply users.", color = Muted, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
when {
viewModel.households.loading -> item { LoadingPane("Loading households…") }
viewModel.households.error != null -> item {
EmptyPane("Households are unavailable", viewModel.households.error.orEmpty(), "Try again") { viewModel.loadHouseholds(true) }
}
households.isEmpty() -> item {
EmptyPane(
"No households yet",
if (isSiteAdmin) "Create a household, then add the people who share it." else "A site administrator can create a household and add you to it.",
if (isSiteAdmin) "Create household" else null,
if (isSiteAdmin) ({ editing = null; editorOpen = true }) else null,
)
}
else -> items(households, key = { it.id }) { household ->
HouseholdCard(
household = household,
canManage = isSiteAdmin || household.isCurrentUserHouseholdAdmin,
onEdit = { editing = household; viewModel.clearActionError(); editorOpen = true },
onInvite = { inviting = household; viewModel.clearActionError() },
onLeave = { leaving = household; viewModel.clearActionError() },
)
}
}
}
if (editorOpen) {
HouseholdEditorDialog(
household = editing,
working = viewModel.working,
error = viewModel.actionError,
onSave = { name, description -> viewModel.saveHousehold(editing, name, description) { editorOpen = false } },
onDismiss = { editorOpen = false },
)
}
inviting?.let { household ->
InviteMemberDialog(
household = household,
working = viewModel.working,
error = viewModel.actionError,
onInvite = { email -> viewModel.inviteMember(household, email) { inviting = null } },
onDismiss = { inviting = null },
)
}
leaving?.let { household ->
ConfirmActionDialog(
title = "Leave this household?",
message = "You will lose access to ${household.name} and its shared search results.",
confirmLabel = "Leave household",
working = viewModel.working,
onConfirm = { viewModel.leaveHousehold(household) { leaving = null } },
onDismiss = { leaving = null },
)
}
}
@Composable
private fun HouseholdCard(
household: Household,
canManage: Boolean,
onEdit: () -> Unit,
onInvite: () -> Unit,
onLeave: () -> Unit,
) {
KeeplyCard {
Column {
Row(Modifier.fillMaxWidth().padding(17.dp), verticalAlignment = Alignment.Top) {
Surface(Modifier.size(48.dp), shape = RoundedCornerShape(13.dp), color = KeeplyGreenSoft) {
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) { Icon(Icons.Outlined.Groups, null, tint = KeeplyGreen) }
}
Spacer(Modifier.width(13.dp))
Column(Modifier.weight(1f)) {
SectionEyebrow("Established ${formatDate(household.createdAt)}")
Spacer(Modifier.height(4.dp))
Text(household.name, style = MaterialTheme.typography.titleLarge, color = Ink)
Text(household.description ?: "A shared place for a well-kept kitchen.", color = Muted, style = MaterialTheme.typography.bodySmall)
}
if (household.isCurrentUserHouseholdAdmin) {
Surface(color = WarningSoft, contentColor = Warning, shape = RoundedCornerShape(50)) {
Text("You manage this", Modifier.padding(horizontal = 8.dp, vertical = 5.dp), style = MaterialTheme.typography.labelSmall)
}
}
}
FieldDivider()
Row(Modifier.fillMaxWidth().padding(horizontal = 17.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
Text("${household.members.size} ${if (household.members.size == 1) "member" else "members"}", fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
if (canManage) TextButton(onClick = onInvite) { Icon(Icons.Outlined.MailOutline, null, Modifier.size(17.dp)); Spacer(Modifier.width(5.dp)); Text("Add member") }
}
Column(Modifier.padding(horizontal = 17.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
if (household.members.isEmpty()) Text("No members have been added yet.", color = Muted, style = MaterialTheme.typography.bodySmall)
household.members.forEach { MemberRow(it) }
}
Spacer(Modifier.height(13.dp))
FieldDivider()
Row(Modifier.fillMaxWidth().padding(horizontal = 17.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Text("Managed by ${household.adminEmail}", color = Muted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
if (canManage) OutlinedButton(onClick = onEdit) {
Icon(Icons.Outlined.Edit, null, Modifier.size(16.dp)); Spacer(Modifier.width(5.dp)); Text("Edit")
}
if (!household.isCurrentUserHouseholdAdmin) TextButton(onClick = onLeave) {
Icon(Icons.Outlined.Logout, null, Modifier.size(16.dp)); Spacer(Modifier.width(5.dp)); Text("Leave")
}
}
}
}
}
@Composable
private fun MemberRow(member: HouseholdMember) {
Surface(color = Color(0xFFFBFCFA), shape = RoundedCornerShape(11.dp), modifier = Modifier.fillMaxWidth()) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
InitialsAvatar(member.initials)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(member.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(member.email, color = Muted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (member.isHouseholdAdmin) Text("Household admin", color = Warning, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun HouseholdEditorDialog(
household: Household?,
working: Boolean,
error: String?,
onSave: (String, String?) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember(household?.id) { mutableStateOf(household?.name.orEmpty()) }
var description by remember(household?.id) { mutableStateOf(household?.description.orEmpty()) }
AlertDialog(
onDismissRequest = { if (!working) onDismiss() },
title = { Column { SectionEyebrow("Shared pantry"); Spacer(Modifier.height(4.dp)); Text(if (household == null) "Create a household" else "Edit household") } },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Household name") }, placeholder = { Text("e.g. The Morgan household") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(description, { description = it }, label = { Text("Description (optional)") }, minLines = 3, modifier = Modifier.fillMaxWidth())
ErrorText(error)
}
},
confirmButton = {
Button(onClick = { onSave(name.trim(), description.trim().ifBlank { null }) }, enabled = !working && name.isNotBlank()) {
if (working) CircularProgressIndicator(Modifier.size(17.dp), color = Color.White, strokeWidth = 2.dp)
else Text(if (household == null) "Create household" else "Save changes")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !working) { Text("Cancel") } },
)
}
@Composable
private fun InviteMemberDialog(
household: Household,
working: Boolean,
error: String?,
onInvite: (String) -> Unit,
onDismiss: () -> Unit,
) {
var email by remember(household.id) { mutableStateOf("") }
AlertDialog(
onDismissRequest = { if (!working) onDismiss() },
title = { Column { SectionEyebrow(household.name); Spacer(Modifier.height(4.dp)); Text("Add a household member") } },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Invite an existing Keeply user using the email address they use to sign in.", color = Muted)
OutlinedTextField(
email, { email = it }, label = { Text("Email address") }, singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
ErrorText(error)
}
},
confirmButton = {
Button(onClick = { onInvite(email.trim()) }, enabled = !working && email.isNotBlank()) {
if (working) CircularProgressIndicator(Modifier.size(17.dp), color = Color.White, strokeWidth = 2.dp) else Text("Add member")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !working) { Text("Cancel") } },
)
}

View File

@@ -0,0 +1,349 @@
package com.keeply.pantry.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material.icons.outlined.Remove
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.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import com.keeply.pantry.BarcodeResult
import com.keeply.pantry.KeeplyViewModel
import com.keeply.pantry.data.BarcodeLookup
import com.keeply.pantry.data.InventoryItem
import com.keeply.pantry.data.InventoryItemRequest
import com.keeply.pantry.data.Location
import com.keeply.pantry.data.toApiDate
import com.keeply.pantry.ui.theme.KeeplyGreenSoft
import com.keeply.pantry.ui.theme.Muted
private val Units = listOf(
"item" to "item",
"pack" to "pack",
"bottle" to "bottle",
"tin" to "tin",
"g" to "grams",
"kg" to "kilograms",
"ml" to "millilitres",
"litres" to "litres",
)
@Composable
fun InventoryItemDialog(
item: InventoryItem?,
locations: List<Location>,
working: Boolean,
error: String?,
onSave: (InventoryItemRequest) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember(item?.id) { mutableStateOf(item?.name.orEmpty()) }
var barcode by remember(item?.id) { mutableStateOf(item?.barcode.orEmpty()) }
var expiryDate by remember(item?.id) { mutableStateOf(item?.expiryDate?.take(10).orEmpty()) }
var useByDate by remember(item?.id) { mutableStateOf(item?.useByDate?.take(10).orEmpty()) }
var amount by remember(item?.id) { mutableStateOf(item?.amount?.toString() ?: "1") }
var amountType by remember(item?.id) { mutableStateOf(item?.amountType ?: "item") }
var locationId by remember(item?.id) { mutableStateOf(item?.locationId.orEmpty()) }
var localError by remember { mutableStateOf<String?>(null) }
fun save() {
if (name.isBlank() && barcode.isBlank()) {
localError = "Add a product name or barcode so the item can be identified."
return
}
val numericAmount = amount.toDoubleOrNull()
if (numericAmount == null || numericAmount <= 0) {
localError = "Enter a valid quantity."
return
}
localError = null
onSave(
InventoryItemRequest(
name = name.trim().ifBlank { null },
barcode = barcode.trim().ifBlank { null },
expiryDate = toApiDate(expiryDate),
useByDate = toApiDate(useByDate),
amount = numericAmount,
amountType = amountType,
locationId = locationId.ifBlank { null },
),
)
}
AlertDialog(
onDismissRequest = { if (!working) onDismiss() },
title = {
Column {
SectionEyebrow(if (item == null) "New item" else "Update details")
Spacer(Modifier.height(4.dp))
Text(if (item == null) "Add pantry item" else "Edit pantry item")
}
},
text = {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(name, { name = it; localError = null }, label = { Text("Product name") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
DateField("Expiry date", expiryDate, { expiryDate = it }, Modifier.weight(1f))
DateField("Use-by date", useByDate, { useByDate = it }, Modifier.weight(1f))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
amount,
{ amount = it; localError = null },
label = { Text("Quantity") },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(0.8f),
)
ChoiceField("Unit", amountType, Units, { amountType = it }, Modifier.weight(1.2f))
}
ChoiceField(
"Storage location",
locationId,
listOf("" to "No location") + locations.map { it.id to it.name },
{ locationId = it },
)
OutlinedTextField(
barcode,
{ barcode = it; localError = null },
label = { Text("Barcode (optional)") },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
ErrorText(localError ?: error)
}
},
confirmButton = {
Button(onClick = ::save, enabled = !working) {
if (working) CircularProgressIndicator(Modifier.size(17.dp), color = Color.White, strokeWidth = 2.dp)
else Text(if (item == null) "Add to pantry" else "Save changes")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !working) { Text("Cancel") } },
)
}
@Composable
fun BarcodeQuickAddDialog(
viewModel: KeeplyViewModel,
locations: List<Location>,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val scanner = remember(context) { GmsBarcodeScanning.getClient(context) }
var step by remember { mutableIntStateOf(0) }
var barcode by remember { mutableStateOf("") }
var lookup by remember { mutableStateOf<BarcodeLookup?>(null) }
var productMissing by remember { mutableStateOf(false) }
var productName by remember { mutableStateOf("") }
var expiryDate by remember { mutableStateOf("") }
var useByDate by remember { mutableStateOf("") }
var itemCount by remember { mutableStateOf("1") }
var amountType by remember { mutableStateOf("item") }
var locationId by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
fun scan() {
localError = null
scanner.startScan()
.addOnSuccessListener { result -> barcode = result.rawValue.orEmpty() }
.addOnFailureListener { localError = "Camera scanning was unavailable. Enter the barcode below instead." }
}
fun save() {
val quantity = itemCount.toIntOrNull()
if (quantity == null || quantity < 1) {
localError = "Enter a whole quantity of at least 1."
return
}
viewModel.saveScannedItems(
request = InventoryItemRequest(
name = productName.trim().ifBlank { null },
barcode = barcode.trim(),
expiryDate = toApiDate(expiryDate),
useByDate = toApiDate(useByDate),
amount = 1.0,
amountType = amountType,
locationId = locationId.ifBlank { null },
),
quantity = quantity,
onSuccess = onDismiss,
)
}
fun continueFlow() {
localError = null
when (step) {
0 -> {
if (barcode.isBlank()) {
localError = "Scan or enter a barcode first."
return
}
viewModel.lookupBarcode(barcode) { result ->
result.onSuccess {
when (it) {
is BarcodeResult.Found -> {
lookup = it.product
productMissing = false
}
BarcodeResult.NotFound -> {
lookup = null
productMissing = true
}
}
step = 1
}
}
}
1 -> if (productMissing) step = 2 else save()
else -> {
if (productName.isBlank()) localError = "Enter a product name so this item can be added." else save()
}
}
}
val title = when (step) { 0 -> "Scan an item"; 1 -> "Choose dates"; else -> "Add item details" }
val confirm = when {
viewModel.working && step == 0 -> "Finding product…"
viewModel.working -> "Adding item…"
step == 0 -> "Continue"
step == 1 && productMissing -> "Continue to details"
else -> "Add item"
}
AlertDialog(
onDismissRequest = { if (!viewModel.working) onDismiss() },
title = {
Column {
SectionEyebrow("Quick add · Step ${step + 1}")
Spacer(Modifier.height(4.dp))
Text(title)
}
},
text = {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(13.dp),
) {
when (step) {
0 -> {
androidx.compose.material3.Surface(color = KeeplyGreenSoft, shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp)) {
Icon(Icons.Outlined.QrCodeScanner, null, modifier = Modifier.size(35.dp))
Spacer(Modifier.height(8.dp))
Text(if (barcode.isBlank()) "Ready when you are" else "Barcode captured", fontWeight = FontWeight.Bold)
Text(if (barcode.isBlank()) "Use Android's native barcode scanner or type the number." else barcode, color = Muted)
}
}
OutlinedButton(onClick = ::scan, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Outlined.QrCodeScanner, null); Spacer(Modifier.width(8.dp)); Text(if (barcode.isBlank()) "Scan with camera" else "Scan again")
}
OutlinedTextField(
barcode,
{ barcode = it; localError = null },
label = { Text("Barcode number") },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
1 -> {
androidx.compose.material3.Surface(
color = if (productMissing) Color(0xFFFFF8EA) else KeeplyGreenSoft,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(14.dp)) {
Text(if (productMissing) "PRODUCT NOT FOUND" else "PRODUCT FOUND", style = MaterialTheme.typography.labelSmall)
Text(lookup?.title ?: "We'll ask for its details next.", fontWeight = FontWeight.Bold)
}
}
DateField("Expiry date (optional)", expiryDate, { expiryDate = it }, Modifier.fillMaxWidth())
DateField("Use-by date (optional)", useByDate, { useByDate = it }, Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
IconButton(
onClick = {
val currentCount = itemCount.toIntOrNull() ?: 1
itemCount = (currentCount - 1).coerceAtLeast(1).toString()
localError = null
},
enabled = (itemCount.toIntOrNull() ?: 1) > 1,
) { Icon(Icons.Outlined.Remove, "Decrease quantity") }
OutlinedTextField(
itemCount,
{ itemCount = it; localError = null },
label = { Text("Quantity") },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = {
val currentCount = itemCount.toIntOrNull() ?: 1
itemCount = (currentCount + 1).toString()
localError = null
},
) { Icon(Icons.Outlined.Add, "Increase quantity") }
}
Text("Each item will be created as a separate pantry entry.", color = Muted, style = MaterialTheme.typography.bodySmall)
}
else -> {
Text("We could not identify this barcode, so add the essentials.", color = Muted)
OutlinedTextField(productName, { productName = it; localError = null }, label = { Text("Product name") }, singleLine = true, modifier = Modifier.fillMaxWidth())
ChoiceField("Unit", amountType, Units, { amountType = it })
ChoiceField("Storage location", locationId, listOf("" to "Choose later") + locations.map { it.id to it.name }, { locationId = it })
}
}
ErrorText(localError ?: viewModel.actionError)
}
},
confirmButton = {
Button(onClick = ::continueFlow, enabled = !viewModel.working && (step != 0 || barcode.isNotBlank())) {
if (viewModel.working) CircularProgressIndicator(Modifier.size(17.dp), color = Color.White, strokeWidth = 2.dp)
else Text(confirm)
}
},
dismissButton = {
if (step == 0) TextButton(onClick = onDismiss, enabled = !viewModel.working) { Text("Cancel") }
else TextButton(onClick = { step--; localError = null }, enabled = !viewModel.working) {
Icon(Icons.Outlined.ArrowBack, null, Modifier.size(16.dp)); Spacer(Modifier.width(5.dp)); Text("Back")
}
},
)
}

View File

@@ -0,0 +1,325 @@
package com.keeply.pantry.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
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.height
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.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.CheckCircleOutline
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.keeply.pantry.KeeplyViewModel
import com.keeply.pantry.data.InventoryItem
import com.keeply.pantry.data.daysUntil
import com.keeply.pantry.data.expiryLabel
import com.keeply.pantry.data.expiryTone
import com.keeply.pantry.data.formatAmount
import com.keeply.pantry.data.formatDate
import com.keeply.pantry.ui.theme.Danger
import com.keeply.pantry.ui.theme.DangerSoft
import com.keeply.pantry.ui.theme.Ink
import com.keeply.pantry.ui.theme.KeeplyGreen
import com.keeply.pantry.ui.theme.KeeplyGreenSoft
import com.keeply.pantry.ui.theme.Muted
import com.keeply.pantry.ui.theme.Warning
import com.keeply.pantry.ui.theme.WarningSoft
private enum class PantryFilter(val label: String) {
All("All freshness"), Expired("Expired"), Expiring("Use soon"), Fresh("Fresh"), NoExpiry("No expiry")
}
private data class PantryGroup(val key: String, val entries: List<InventoryItem>)
@Composable
fun InventoryScreen(viewModel: KeeplyViewModel) {
val pantryItems = viewModel.inventory.data.orEmpty()
val locations = viewModel.locations.data.orEmpty()
var query by rememberSaveable { mutableStateOf("") }
var filter by rememberSaveable { mutableStateOf(PantryFilter.All) }
var locationId by rememberSaveable { mutableStateOf("") }
var editingItem by remember { mutableStateOf<InventoryItem?>(null) }
var editorOpen by remember { mutableStateOf(false) }
var scannerOpen by remember { mutableStateOf(false) }
var deletingItem by remember { mutableStateOf<InventoryItem?>(null) }
val filtered = remember(pantryItems, query, filter, locationId) {
val normalized = query.trim().lowercase()
pantryItems.filter { item ->
val days = daysUntil(item.expiryDate)
val matchesText = normalized.isBlank() || listOf(item.name, item.itemLookupTitle, item.barcode, item.location?.name)
.any { it?.lowercase()?.contains(normalized) == true }
val matchesLocation = locationId.isBlank() || item.locationId == locationId
val matchesStatus = when (filter) {
PantryFilter.All -> true
PantryFilter.Expired -> days != null && days < 0
PantryFilter.Expiring -> days != null && days in 0..7
PantryFilter.Fresh -> days != null && days > 7
PantryFilter.NoExpiry -> days == null
}
matchesText && matchesLocation && matchesStatus
}
}
val groups = remember(filtered) {
filtered.groupBy { item ->
item.itemLookupId?.let { "lookup:$it" } ?: "name:${item.title.trim().lowercase()}"
}.map { (key, values) ->
PantryGroup(key, values.sortedBy { it.expiryDate ?: "9999" })
}.sortedBy { it.entries.first().expiryDate ?: "9999" }
}
Box(Modifier.fillMaxSize()) {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 18.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 24.dp, bottom = 104.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
PageHeader(
title = "My pantry",
action = {
Button(onClick = { editingItem = null; viewModel.clearActionError(); editorOpen = true }) {
Icon(Icons.Outlined.Add, null, Modifier.size(18.dp)); Spacer(Modifier.width(6.dp)); Text("Add")
}
},
)
}
item { PantrySummary(pantryItems) }
item {
OutlinedTextField(
value = query,
onValueChange = { query = it },
leadingIcon = { Icon(Icons.Outlined.Search, null) },
placeholder = { Text("Search items, barcodes or locations…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
item {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(PantryFilter.entries) { option ->
FilterChip(selected = filter == option, onClick = { filter = option }, label = { Text(option.label) })
}
}
}
if (locations.isNotEmpty()) {
item {
ChoiceField(
label = "Storage location",
value = locationId,
options = listOf("" to "All locations") + locations.map { it.id to it.name },
onSelected = { locationId = it },
)
}
}
item {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("${filtered.size} ${if (filtered.size == 1) "item" else "items"}", fontWeight = FontWeight.SemiBold, color = Ink)
if (query.isNotBlank() || filter != PantryFilter.All || locationId.isNotBlank()) {
androidx.compose.material3.TextButton(onClick = { query = ""; filter = PantryFilter.All; locationId = "" }) { Text("Clear filters") }
}
}
}
when {
viewModel.inventory.loading -> item { LoadingPane("Loading your pantry…") }
viewModel.inventory.error != null -> item {
EmptyPane("We couldn't reach your pantry", viewModel.inventory.error.orEmpty(), "Try again") { viewModel.loadPantry(true) }
}
groups.isEmpty() -> item {
EmptyPane(
if (pantryItems.isEmpty()) "Your pantry is ready for its first item" else "No items match these filters",
if (pantryItems.isEmpty()) "Add an item manually or scan a barcode to get started." else "Try clearing a filter or searching for something else.",
if (pantryItems.isEmpty()) "Add first item" else "Clear filters",
) {
if (pantryItems.isEmpty()) editorOpen = true else { query = ""; filter = PantryFilter.All; locationId = "" }
}
}
else -> items(groups, key = { it.key }) { group ->
InventoryGroupCard(
group,
onEdit = { editingItem = it; viewModel.clearActionError(); editorOpen = true },
onDelete = { deletingItem = it; viewModel.clearActionError() },
)
}
}
}
ExtendedFloatingActionButton(
text = { Text("Scan barcode") },
icon = { Icon(Icons.Outlined.QrCodeScanner, null) },
onClick = { viewModel.clearActionError(); scannerOpen = true },
modifier = Modifier.align(Alignment.BottomStart).padding(start = 18.dp, bottom = 22.dp),
)
}
if (editorOpen) {
InventoryItemDialog(
item = editingItem,
locations = locations,
working = viewModel.working,
error = viewModel.actionError,
onSave = { request -> viewModel.saveItem(editingItem, request) { editorOpen = false } },
onDismiss = { editorOpen = false },
)
}
if (scannerOpen) {
BarcodeQuickAddDialog(
viewModel = viewModel,
locations = locations,
onDismiss = { scannerOpen = false },
)
}
deletingItem?.let { item ->
ConfirmActionDialog(
title = "Remove this item?",
message = "${item.title} will be permanently removed from your pantry.",
confirmLabel = "Remove",
working = viewModel.working,
onConfirm = { viewModel.deleteItem(item) { deletingItem = null } },
onDismiss = { deletingItem = null },
)
}
}
@Composable
private fun PantrySummary(items: List<InventoryItem>) {
val expired = items.count { (daysUntil(it.expiryDate) ?: 0) < 0 }
val expiring = items.count { daysUntil(it.expiryDate)?.let { days -> days in 0..7 } == true }
val fresh = items.size - expired - expiring
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
item { SummaryCard(Icons.Outlined.Inventory2, items.size, "Total items", KeeplyGreenSoft, KeeplyGreen) }
item { SummaryCard(Icons.Outlined.Schedule, expiring, "Use this week", WarningSoft, Warning) }
item { SummaryCard(Icons.Outlined.ErrorOutline, expired, "Expired", DangerSoft, Danger) }
item { SummaryCard(Icons.Outlined.CheckCircleOutline, fresh, "Fresh & ready", KeeplyGreenSoft, KeeplyGreen) }
}
}
@Composable
private fun SummaryCard(icon: ImageVector, value: Int, label: String, background: Color, foreground: Color) {
KeeplyCard {
Row(Modifier.padding(horizontal = 15.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(Modifier.size(38.dp), shape = RoundedCornerShape(10.dp), color = background) {
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) { Icon(icon, null, tint = foreground, modifier = Modifier.size(20.dp)) }
}
Spacer(Modifier.width(10.dp))
Column {
Text(value.toString(), style = MaterialTheme.typography.titleLarge, color = Ink)
Text(label, style = MaterialTheme.typography.bodySmall, color = Muted)
}
}
}
}
@Composable
private fun InventoryGroupCard(group: PantryGroup, onEdit: (InventoryItem) -> Unit, onDelete: (InventoryItem) -> Unit) {
if (group.entries.size == 1) {
InventoryItemRow(group.entries.first(), onEdit, onDelete)
return
}
var expanded by rememberSaveable(group.key) { mutableStateOf(false) }
val first = group.entries.first()
KeeplyCard {
Column {
Row(
Modifier.fillMaxWidth().clickable { expanded = !expanded }.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(Modifier.size(42.dp), shape = CircleShape, color = KeeplyGreenSoft) {
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) { Icon(Icons.Outlined.Inventory2, null, tint = KeeplyGreen) }
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(first.title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${group.entries.size} individual entries · ${group.entries.sumOf { it.amount ?: 1.0 }.let(::formatAmount)} ${first.amountType ?: "items"}", color = Muted, style = MaterialTheme.typography.bodySmall)
}
StatusPill(expiryLabel(first.expiryDate), expiryTone(first.expiryDate))
Icon(if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, null, tint = Muted)
}
if (expanded) {
group.entries.forEachIndexed { index, item ->
FieldDivider()
InventoryEntryRow(index + 1, item, onEdit, onDelete)
}
}
}
}
}
@Composable
private fun InventoryItemRow(item: InventoryItem, onEdit: (InventoryItem) -> Unit, onDelete: (InventoryItem) -> Unit) {
KeeplyCard {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(Modifier.size(44.dp), shape = CircleShape, color = KeeplyGreenSoft) {
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) { Icon(Icons.Outlined.Inventory2, null, tint = KeeplyGreen) }
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(item.title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${formatAmount(item.amount)} ${item.amountType ?: "item"} · ${item.location?.name ?: "Not placed"}", color = Muted, style = MaterialTheme.typography.bodySmall)
}
IconButton(onClick = { onEdit(item) }) { Icon(Icons.Outlined.Edit, "Edit ${item.title}") }
IconButton(onClick = { onDelete(item) }) { Icon(Icons.Outlined.DeleteOutline, "Delete ${item.title}", tint = Danger) }
}
Spacer(Modifier.height(11.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Expiry ${formatDate(item.expiryDate)}", color = Muted, style = MaterialTheme.typography.bodySmall)
StatusPill(expiryLabel(item.expiryDate), expiryTone(item.expiryDate))
}
}
}
}
@Composable
private fun InventoryEntryRow(number: Int, item: InventoryItem, onEdit: (InventoryItem) -> Unit, onDelete: (InventoryItem) -> Unit) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Entry $number", fontWeight = FontWeight.SemiBold)
Text("${formatAmount(item.amount)} ${item.amountType ?: "item"} · ${item.location?.name ?: "Not placed"} · ${formatDate(item.expiryDate)}", color = Muted, style = MaterialTheme.typography.bodySmall)
}
IconButton(onClick = { onEdit(item) }) { Icon(Icons.Outlined.Edit, "Edit entry") }
IconButton(onClick = { onDelete(item) }) { Icon(Icons.Outlined.DeleteOutline, "Delete entry", tint = Danger) }
}
}

View File

@@ -0,0 +1,166 @@
package com.keeply.pantry.ui
import androidx.compose.foundation.layout.Arrangement
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.height
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.LocationOn
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.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.keeply.pantry.KeeplyViewModel
import com.keeply.pantry.data.Location
import com.keeply.pantry.ui.theme.Danger
import com.keeply.pantry.ui.theme.Ink
import com.keeply.pantry.ui.theme.KeeplyGreen
import com.keeply.pantry.ui.theme.KeeplyGreenSoft
import com.keeply.pantry.ui.theme.Muted
@Composable
fun LocationsScreen(viewModel: KeeplyViewModel) {
val locations = viewModel.locations.data.orEmpty()
var editing by remember { mutableStateOf<Location?>(null) }
var editorOpen by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf<Location?>(null) }
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 18.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 24.dp, bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
PageHeader(
eyebrow = "A place for everything",
title = "Locations",
description = "Organise items by fridge, freezer, cupboards or anywhere else.",
action = {
Button(onClick = { editing = null; viewModel.clearActionError(); editorOpen = true }) {
Icon(Icons.Outlined.Add, null, Modifier.size(18.dp)); Spacer(Modifier.width(6.dp)); Text("Add")
}
},
)
}
when {
viewModel.locations.loading -> item { LoadingPane("Loading locations…") }
viewModel.locations.error != null -> item {
EmptyPane("Locations are unavailable", viewModel.locations.error.orEmpty(), "Try again") { viewModel.loadLocations(true) }
}
locations.isEmpty() -> item {
EmptyPane("Create your first location", "Locations make it easy to find every item in your kitchen.", "Add location") {
editing = null; editorOpen = true
}
}
else -> items(locations, key = { it.id }) { location ->
KeeplyCard {
Row(Modifier.fillMaxWidth().padding(17.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(Modifier.size(52.dp), shape = RoundedCornerShape(14.dp), color = KeeplyGreenSoft) {
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) {
Icon(Icons.Outlined.LocationOn, null, tint = KeeplyGreen, modifier = Modifier.size(27.dp))
}
}
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
SectionEyebrow("Storage area")
Spacer(Modifier.height(3.dp))
Text(location.name, style = MaterialTheme.typography.titleMedium, color = Ink)
Text(location.description ?: "A handy place for your pantry items.", color = Muted, style = MaterialTheme.typography.bodySmall)
}
IconButton(onClick = { editing = location; viewModel.clearActionError(); editorOpen = true }) {
Icon(Icons.Outlined.Edit, "Edit ${location.name}")
}
IconButton(onClick = { deleting = location; viewModel.clearActionError() }) {
Icon(Icons.Outlined.DeleteOutline, "Delete ${location.name}", tint = Danger)
}
}
}
}
}
}
if (editorOpen) {
LocationEditorDialog(
location = editing,
working = viewModel.working,
error = viewModel.actionError,
onSave = { name, description -> viewModel.saveLocation(editing, name, description) { editorOpen = false } },
onDismiss = { editorOpen = false },
)
}
deleting?.let { location ->
ConfirmActionDialog(
title = "Delete this location?",
message = "Items stored in ${location.name} may need to be moved first.",
confirmLabel = "Delete",
working = viewModel.working,
onConfirm = { viewModel.deleteLocation(location) { deleting = null } },
onDismiss = { deleting = null },
)
}
}
@Composable
private fun LocationEditorDialog(
location: Location?,
working: Boolean,
error: String?,
onSave: (String, String?) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember(location?.id) { mutableStateOf(location?.name.orEmpty()) }
var description by remember(location?.id) { mutableStateOf(location?.description.orEmpty()) }
AlertDialog(
onDismissRequest = { if (!working) onDismiss() },
title = {
Column {
SectionEyebrow("Pantry organisation")
Spacer(Modifier.height(4.dp))
Text(if (location == null) "Add a location" else "Edit location")
}
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Location name") }, placeholder = { Text("e.g. Kitchen cupboard") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(description, { description = it }, label = { Text("Description (optional)") }, minLines = 3, modifier = Modifier.fillMaxWidth())
ErrorText(error)
}
},
confirmButton = {
Button(onClick = { onSave(name.trim(), description.trim().ifBlank { null }) }, enabled = !working && name.isNotBlank()) {
if (working) CircularProgressIndicator(Modifier.size(17.dp), color = Color.White, strokeWidth = 2.dp)
else Text(if (location == null) "Add location" else "Save changes")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !working) { Text("Cancel") } },
)
}

View File

@@ -0,0 +1,183 @@
package com.keeply.pantry.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.height
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.material.icons.Icons
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.keeply.pantry.KeeplyViewModel
import com.keeply.pantry.data.UpdateProfileRequest
import com.keeply.pantry.ui.theme.Ink
import com.keeply.pantry.ui.theme.KeeplyGreen
import com.keeply.pantry.ui.theme.KeeplyGreenSoft
import com.keeply.pantry.ui.theme.Muted
@Composable
fun ProfileScreen(viewModel: KeeplyViewModel) {
val user = viewModel.user ?: return
var firstName by remember(user.id) { mutableStateOf(user.firstName.orEmpty()) }
var lastName by remember(user.id) { mutableStateOf(user.lastName.orEmpty()) }
var email by remember(user.id) { mutableStateOf(user.email) }
var currentPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 18.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 24.dp, bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
PageHeader(
eyebrow = "Your Keeply account",
title = "Profile & settings",
description = "Keep your personal details and sign-in information current.",
)
}
item {
KeeplyCard(Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
InitialsAvatar(user.initials, large = true)
Spacer(Modifier.height(12.dp))
Text(user.displayName, style = MaterialTheme.typography.titleLarge, color = Ink)
Text(user.email, color = Muted, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(11.dp))
Surface(color = KeeplyGreenSoft, shape = MaterialTheme.shapes.large) {
Row(Modifier.padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(if (user.isSiteAdmin) Icons.Outlined.Shield else Icons.Outlined.Person, null, Modifier.size(16.dp), tint = KeeplyGreen)
Spacer(Modifier.width(5.dp))
Text(if (user.isSiteAdmin) "Site administrator" else "Pantry member", color = KeeplyGreen, style = MaterialTheme.typography.labelMedium)
}
}
Spacer(Modifier.height(14.dp))
Text("Account ID · ${user.id}", color = Muted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
item {
SettingsCard(Icons.Outlined.Person, "Personal details", "Used across your household and pantry.") {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(firstName, { firstName = it; viewModel.clearActionError() }, label = { Text("First name") }, singleLine = true, modifier = Modifier.weight(1f))
OutlinedTextField(lastName, { lastName = it; viewModel.clearActionError() }, label = { Text("Last name") }, singleLine = true, modifier = Modifier.weight(1f))
}
OutlinedTextField(
email, { email = it; viewModel.clearActionError() }, label = { Text("Email address") }, singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Email), modifier = Modifier.fillMaxWidth(),
)
ErrorText(viewModel.actionError)
Button(
onClick = {
viewModel.updateProfile(
UpdateProfileRequest(email = email.trim(), firstName = firstName.trim(), lastName = lastName.trim()),
"Profile saved.",
) {}
},
enabled = !viewModel.working && firstName.isNotBlank() && lastName.isNotBlank() && email.isNotBlank(),
modifier = Modifier.align(Alignment.End),
) {
if (viewModel.working) CircularProgressIndicator(Modifier.size(17.dp), color = Color.White, strokeWidth = 2.dp)
else { Icon(Icons.Outlined.Save, null, Modifier.size(17.dp)); Spacer(Modifier.width(6.dp)); Text("Save details") }
}
}
}
item {
SettingsCard(Icons.Outlined.Key, "Change password", "Choose at least 8 characters with upper, lower and a number.") {
OutlinedTextField(
currentPassword,
{ currentPassword = it; viewModel.clearActionError() },
label = { Text("Current password") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
newPassword,
{ newPassword = it; viewModel.clearActionError() },
label = { Text("New password") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
ErrorText(viewModel.actionError)
Button(
onClick = {
viewModel.updateProfile(
UpdateProfileRequest(currentPassword = currentPassword, newPassword = newPassword),
"Password updated.",
) {
currentPassword = ""
newPassword = ""
}
},
enabled = !viewModel.working && currentPassword.isNotBlank() && newPassword.length >= 8,
modifier = Modifier.align(Alignment.End),
) { Text("Update password") }
}
}
item {
OutlinedButton(onClick = viewModel::logout, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Outlined.Logout, null); Spacer(Modifier.width(7.dp)); Text("Sign out")
}
}
}
}
@Composable
private fun SettingsCard(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
description: String,
content: @Composable ColumnScope.() -> Unit,
) {
KeeplyCard(Modifier.fillMaxWidth()) {
Column {
Row(Modifier.fillMaxWidth().padding(17.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(Modifier.size(39.dp), color = KeeplyGreenSoft, shape = MaterialTheme.shapes.medium) {
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) { Icon(icon, null, tint = KeeplyGreen, modifier = Modifier.size(20.dp)) }
}
Spacer(Modifier.width(11.dp))
Column {
Text(title, fontWeight = FontWeight.Bold)
Text(description, color = Muted, style = MaterialTheme.typography.bodySmall)
}
}
FieldDivider()
Column(Modifier.fillMaxWidth().padding(17.dp), verticalArrangement = Arrangement.spacedBy(13.dp), content = content)
}
}
}

View File

@@ -0,0 +1,221 @@
package com.keeply.pantry.ui
import androidx.compose.foundation.layout.Arrangement
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.height
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.People
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.keeply.pantry.KeeplyViewModel
import com.keeply.pantry.data.UpdateUserRequest
import com.keeply.pantry.data.User
import com.keeply.pantry.ui.theme.Ink
import com.keeply.pantry.ui.theme.KeeplyGreen
import com.keeply.pantry.ui.theme.KeeplyGreenSoft
import com.keeply.pantry.ui.theme.Muted
import com.keeply.pantry.ui.theme.WarningSoft
@Composable
fun UsersScreen(viewModel: KeeplyViewModel) {
if (viewModel.user?.isSiteAdmin != true) {
EmptyPane("Administrator access required", "This screen is only available to site administrators.")
return
}
val users = viewModel.users.data.orEmpty()
var query by rememberSaveable { mutableStateOf("") }
var editing by remember { mutableStateOf<User?>(null) }
val filtered = remember(users, query) {
val normalized = query.trim().lowercase()
if (normalized.isBlank()) users else users.filter { person ->
listOf(person.email, person.firstName, person.lastName).plus(person.roles)
.any { it?.lowercase()?.contains(normalized) == true }
}
}
val adminCount = users.count { it.isSiteAdmin }
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 18.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 24.dp, bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(13.dp),
) {
item {
PageHeader(
eyebrow = "Site administration",
title = "Users",
description = "Manage access, account details and administrator permissions.",
action = {
Surface(color = Color.White, shape = MaterialTheme.shapes.medium) {
Row(Modifier.padding(horizontal = 12.dp, vertical = 9.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Text("${users.size} users", color = Muted, style = MaterialTheme.typography.labelMedium)
Text("$adminCount admins", color = KeeplyGreen, style = MaterialTheme.typography.labelMedium)
}
}
},
)
}
item {
OutlinedTextField(
query,
{ query = it },
leadingIcon = { Icon(Icons.Outlined.Search, null) },
placeholder = { Text("Search people or email addresses…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
when {
viewModel.users.loading -> item { LoadingPane("Loading users…") }
viewModel.users.error != null -> item {
EmptyPane("Users are unavailable", viewModel.users.error.orEmpty(), "Try again") { viewModel.loadUsers(true) }
}
filtered.isEmpty() -> item {
EmptyPane("No users found", "Try a different name, email address or role.", "Clear search") { query = "" }
}
else -> items(filtered, key = { it.id }) { person ->
KeeplyCard {
Row(Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
InitialsAvatar(person.initials)
Spacer(Modifier.width(11.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(person.displayName, color = Ink, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (person.id == viewModel.user?.id) {
Spacer(Modifier.width(6.dp))
Surface(color = KeeplyGreenSoft, shape = MaterialTheme.shapes.small) {
Text("You", Modifier.padding(horizontal = 5.dp, vertical = 2.dp), color = KeeplyGreen, style = MaterialTheme.typography.labelSmall)
}
}
}
Text(person.email, color = Muted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Surface(color = if (person.isSiteAdmin) KeeplyGreenSoft else MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.large) {
Row(Modifier.padding(horizontal = 9.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(if (person.isSiteAdmin) Icons.Outlined.Shield else Icons.Outlined.People, null, Modifier.size(15.dp), tint = if (person.isSiteAdmin) KeeplyGreen else Muted)
Spacer(Modifier.width(5.dp))
Text(if (person.isSiteAdmin) "Site administrator" else "Member", style = MaterialTheme.typography.labelSmall)
}
}
Spacer(Modifier.width(6.dp))
OutlinedButton(onClick = { editing = person; viewModel.clearActionError() }) {
Icon(Icons.Outlined.Edit, null, Modifier.size(16.dp)); Spacer(Modifier.width(5.dp)); Text("Manage")
}
}
}
}
}
}
editing?.let { person ->
UserEditorDialog(
person = person,
currentUserId = viewModel.user?.id,
working = viewModel.working,
error = viewModel.actionError,
onSave = { request -> viewModel.updateUser(person, request) { editing = null } },
onDismiss = { editing = null },
)
}
}
@Composable
private fun UserEditorDialog(
person: User,
currentUserId: String?,
working: Boolean,
error: String?,
onSave: (UpdateUserRequest) -> Unit,
onDismiss: () -> Unit,
) {
var firstName by remember(person.id) { mutableStateOf(person.firstName.orEmpty()) }
var lastName by remember(person.id) { mutableStateOf(person.lastName.orEmpty()) }
var email by remember(person.id) { mutableStateOf(person.email) }
var password by remember(person.id) { mutableStateOf("") }
var siteAdmin by remember(person.id) { mutableStateOf(person.isSiteAdmin) }
AlertDialog(
onDismissRequest = { if (!working) onDismiss() },
title = { Column { SectionEyebrow(if (person.id == currentUserId) "Your administrator account" else "Site administration"); Spacer(Modifier.height(4.dp)); Text("Manage user") } },
text = {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
InitialsAvatar(person.initials)
Spacer(Modifier.width(10.dp))
Column { Text(person.displayName, fontWeight = FontWeight.Bold); Text(person.email, color = Muted, style = MaterialTheme.typography.bodySmall) }
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(firstName, { firstName = it }, label = { Text("First name") }, singleLine = true, modifier = Modifier.weight(1f))
OutlinedTextField(lastName, { lastName = it }, label = { Text("Last name") }, singleLine = true, modifier = Modifier.weight(1f))
}
OutlinedTextField(email, { email = it }, label = { Text("Email address") }, singleLine = true, keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Email), modifier = Modifier.fillMaxWidth())
OutlinedTextField(password, { password = it }, label = { Text("Set a new password (optional)") }, singleLine = true, keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth())
Surface(color = KeeplyGreenSoft, shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.padding(13.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.Shield, null, tint = KeeplyGreen)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text("Site administrator", fontWeight = FontWeight.SemiBold)
Text("Can manage every user and create households.", color = Muted, style = MaterialTheme.typography.bodySmall)
}
Switch(siteAdmin, { siteAdmin = it })
}
}
if (person.id == currentUserId && !siteAdmin) {
Surface(color = WarningSoft, shape = MaterialTheme.shapes.medium) {
Text("Removing your own administrator role will hide this screen after your next sign-in.", Modifier.padding(11.dp), style = MaterialTheme.typography.bodySmall)
}
}
ErrorText(error)
}
},
confirmButton = {
Button(
onClick = {
onSave(UpdateUserRequest(email.trim(), firstName.trim(), lastName.trim(), password.ifBlank { null }, if (siteAdmin) listOf("Site Admin") else emptyList()))
},
enabled = !working && firstName.isNotBlank() && lastName.isNotBlank() && email.isNotBlank() && (password.isBlank() || password.length >= 8),
) {
if (working) CircularProgressIndicator(Modifier.size(17.dp), color = Color.White, strokeWidth = 2.dp) else Text("Save user")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !working) { Text("Cancel") } },
)
}

View File

@@ -0,0 +1,73 @@
package com.keeply.pantry.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Ink = Color(0xFF24332A)
val Muted = Color(0xFF738078)
val Canvas = Color(0xFFF7F7F2)
val Surface = Color(0xFFFFFFFF)
val KeeplyGreen = Color(0xFF2F6D4F)
val KeeplyGreenDark = Color(0xFF24563E)
val KeeplyGreenSoft = Color(0xFFE9F2EC)
val Danger = Color(0xFFAE4B45)
val DangerSoft = Color(0xFFFAEAE7)
val Warning = Color(0xFFAD7026)
val WarningSoft = Color(0xFFFBF0DC)
val BlueSoft = Color(0xFFE4EFF3)
val Divider = Color(0xFFE4E7E1)
private val KeeplyColorScheme = lightColorScheme(
primary = KeeplyGreen,
onPrimary = Color.White,
primaryContainer = KeeplyGreenSoft,
onPrimaryContainer = KeeplyGreenDark,
secondary = Color(0xFF5B7664),
onSecondary = Color.White,
secondaryContainer = Color(0xFFDFE9DF),
onSecondaryContainer = Ink,
error = Danger,
errorContainer = DangerSoft,
onErrorContainer = Danger,
background = Canvas,
onBackground = Ink,
surface = Surface,
onSurface = Ink,
surfaceVariant = Color(0xFFF1F3EF),
onSurfaceVariant = Muted,
outline = Color(0xFFCCD8CF),
outlineVariant = Divider,
)
private val KeeplyTypography = Typography(
displaySmall = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, fontSize = 38.sp, lineHeight = 43.sp),
headlineLarge = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 38.sp),
headlineMedium = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, fontSize = 25.sp, lineHeight = 31.sp),
headlineSmall = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 26.sp),
titleLarge = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, fontSize = 20.sp),
titleMedium = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, fontSize = 16.sp),
titleSmall = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, fontSize = 14.sp),
bodyLarge = TextStyle(fontFamily = FontFamily.SansSerif, fontSize = 16.sp, lineHeight = 23.sp),
bodyMedium = TextStyle(fontFamily = FontFamily.SansSerif, fontSize = 14.sp, lineHeight = 20.sp),
bodySmall = TextStyle(fontFamily = FontFamily.SansSerif, fontSize = 12.sp, lineHeight = 17.sp),
labelLarge = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, fontSize = 14.sp),
labelMedium = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, fontSize = 12.sp),
labelSmall = TextStyle(fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold, fontSize = 10.sp),
)
@Composable
fun KeeplyTheme(content: @Composable () -> Unit) {
// Keeply deliberately keeps the calm cream and green palette in both system modes.
MaterialTheme(
colorScheme = KeeplyColorScheme,
typography = KeeplyTypography,
content = content,
)
}

View File

@@ -0,0 +1,3 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#244C39" />
</shape>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#DCEADE" android:pathData="M52,72 C36,68 27,54 28,36 C43,38 54,48 56,63 C56,67 55,70 52,72 Z" />
<path android:fillColor="#A7CBAA" android:pathData="M54,72 C48,58 51,40 63,28 C73,42 72,58 62,70 C60,72 57,73 54,72 Z" />
<path android:fillColor="#77A883" android:pathData="M59,73 C62,57 74,47 90,45 C89,62 78,74 63,77 C61,77 60,75 59,73 Z" />
</vector>

View File

@@ -0,0 +1,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#244C39" android:pathData="M0,0h108v108h-108z" />
<path android:fillColor="#DCEADE" android:pathData="M52,72 C36,68 27,54 28,36 C43,38 54,48 56,63 C56,67 55,70 52,72 Z" />
<path android:fillColor="#A7CBAA" android:pathData="M54,72 C48,58 51,40 63,28 C73,42 72,58 62,70 C60,72 57,73 54,72 Z" />
<path android:fillColor="#77A883" android:pathData="M59,73 C62,57 74,47 90,45 C89,62 78,74 63,77 C61,77 60,75 59,73 Z" />
</vector>

View File

@@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#244C39" android:pathData="M54,0A54,54 0,1 0,54 108A54,54 0,1 0,54 0" />
<path android:fillColor="#DCEADE" android:pathData="M52,72 C36,68 27,54 28,36 C43,38 54,48 56,63 C56,67 55,70 52,72 Z" />
<path android:fillColor="#A7CBAA" android:pathData="M54,72 C48,58 51,40 63,28 C73,42 72,58 62,70 C60,72 57,73 54,72 Z" />
<path android:fillColor="#77A883" android:pathData="M59,73 C62,57 74,47 90,45 C89,62 78,74 63,77 C61,77 60,75 59,73 Z" />
</vector>

View File

@@ -0,0 +1,4 @@
<resources>
<color name="keeply_green">#2F6D4F</color>
<color name="keeply_canvas">#F7F7F2</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Keeply</string>
</resources>

View File

@@ -0,0 +1,9 @@
<resources>
<style name="Theme.Keeply" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">@color/keeply_canvas</item>
<item name="android:navigationBarColor">@color/keeply_canvas</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>

View File

@@ -0,0 +1,36 @@
package com.keeply.pantry
import com.keeply.pantry.data.ExpiryTone
import com.keeply.pantry.data.daysUntil
import com.keeply.pantry.data.expiryLabel
import com.keeply.pantry.data.expiryTone
import com.keeply.pantry.data.formatAmount
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.LocalDate
class DateUtilsTest {
private val today = LocalDate.of(2026, 7, 22)
@Test
fun expiryLabelsMatchPantryRules() {
assertEquals("Expired 2d ago", expiryLabel("2026-07-20T00:00:00", today))
assertEquals("Expires today", expiryLabel("2026-07-22T00:00:00", today))
assertEquals("Expires tomorrow", expiryLabel("2026-07-23T00:00:00", today))
assertEquals("No expiry set", expiryLabel(null, today))
}
@Test
fun expiryTonesUseThreeDayWarningWindow() {
assertEquals(ExpiryTone.Danger, expiryTone("2026-07-21", today))
assertEquals(ExpiryTone.Warning, expiryTone("2026-07-25", today))
assertEquals(ExpiryTone.Good, expiryTone("2026-07-26", today))
assertEquals(3L, daysUntil("2026-07-25", today))
}
@Test
fun quantitiesDoNotShowUnnecessaryDecimalPlaces() {
assertEquals("1", formatAmount(1.0))
assertEquals("1.5", formatAmount(1.5))
}
}

View File

@@ -0,0 +1,19 @@
package com.keeply.pantry
import com.keeply.pantry.data.InventoryItemRequest
import com.keeply.pantry.data.asIndividualEntries
import org.junit.Assert.assertEquals
import org.junit.Test
class InventoryItemRequestTest {
@Test
fun barcodeQuantityCreatesSeparateSingleItemRequests() {
val request = InventoryItemRequest(barcode = "5012345678900", amount = 3.0, amountType = "item")
val entries = request.asIndividualEntries(3)
assertEquals(3, entries.size)
assertEquals(listOf(1.0, 1.0, 1.0), entries.map { it.amount })
assertEquals(listOf("5012345678900", "5012345678900", "5012345678900"), entries.map { it.barcode })
}
}

5
build.gradle.kts Normal file
View File

@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.13.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false
}

4
gradle.properties Normal file
View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Normal file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

18
settings.gradle.kts Normal file
View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Keeply"
include(":app")