Revert "Add Ladill Mini native Android and iOS apps."

This reverts commit ea6afb80a4.
This commit is contained in:
isaacclad
2026-06-11 22:32:29 +00:00
parent ea6afb80a4
commit 1dba54c55b
172 changed files with 0 additions and 12114 deletions
-16
View File
@@ -1,16 +0,0 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
*.apk
*.aab
keystore.properties
*.jks
*.keystore
/app/build
.kotlin
-125
View File
@@ -1,125 +0,0 @@
import java.util.Properties
import org.gradle.api.GradleException
val keystoreProperties = Properties().apply {
val file = rootProject.file("keystore.properties")
if (file.exists()) {
file.inputStream().use(::load)
}
}
fun signingProperty(name: String): String? =
keystoreProperties.getProperty(name)
?: providers.gradleProperty(name).orNull
?: providers.environmentVariable(name).orNull
val releaseStoreFile = signingProperty("LADILL_UPLOAD_STORE_FILE") ?: signingProperty("storeFile")
val releaseStorePassword = signingProperty("LADILL_UPLOAD_STORE_PASSWORD") ?: signingProperty("storePassword")
val releaseKeyAlias = signingProperty("LADILL_UPLOAD_KEY_ALIAS") ?: signingProperty("keyAlias")
val releaseKeyPassword = signingProperty("LADILL_UPLOAD_KEY_PASSWORD") ?: signingProperty("keyPassword")
val hasReleaseSigning = listOf(releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword).all { !it.isNullOrBlank() }
val requestedReleaseBuild = gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) }
if (requestedReleaseBuild && !hasReleaseSigning) {
throw GradleException(
"Release signing is not configured. Create apps/android/keystore.properties with storeFile, storePassword, keyAlias, and keyPassword, or provide LADILL_UPLOAD_STORE_FILE, LADILL_UPLOAD_STORE_PASSWORD, LADILL_UPLOAD_KEY_ALIAS, and LADILL_UPLOAD_KEY_PASSWORD.",
)
}
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
android {
namespace = "com.ladill.mini"
compileSdk = 35
defaultConfig {
applicationId = "com.ladill.mini"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = rootProject.file(releaseStoreFile!!)
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
buildTypes {
debug {
buildConfigField("String", "BASE_URL", "\"https://mini.ladill.com/api/v1/\"")
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
buildConfigField("String", "BASE_URL", "\"https://mini.ladill.com/api/v1/\"")
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.splashscreen)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.biometric)
implementation(libs.kotlinx.coroutines.android)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)
implementation(libs.coil.compose)
implementation(libs.coil.svg)
implementation(libs.zxing.core)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging.ktx)
debugImplementation(libs.androidx.compose.ui.tooling)
}
-91
View File
@@ -1,91 +0,0 @@
{
"project_info": {
"project_number": "578991696856",
"firebase_url": "https://laddile-30129-default-rtdb.firebaseio.com",
"project_id": "laddile-30129",
"storage_bucket": "laddile-30129.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:578991696856:android:e5239a3350aef0e069e0ad",
"android_client_info": {
"package_name": "com.climp.me"
}
},
"oauth_client": [
{
"client_id": "578991696856-amffvm0esftuar2hegn0k7tp8jampf4t.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.climp.me",
"certificate_hash": "57bb2009ae16fbdee470878df06bd4df5d7069d4"
}
},
{
"client_id": "578991696856-mtbgssqga5gfd8k6578hlhde39s9jct4.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCJcn0V8qZky_oFx-xe-k4iUbLYNdBB3Lc"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "578991696856-6s72642bttvg6787rk7ldv8mrkhba844.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "578991696856-5gkpjsrre8uhfnpanagmj9cgp87drjso.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.zipxapp.com"
}
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:578991696856:android:724fa8fce0bcaf5d69e0ad",
"android_client_info": {
"package_name": "com.ladill.mini"
}
},
"oauth_client": [
{
"client_id": "578991696856-mtbgssqga5gfd8k6578hlhde39s9jct4.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCJcn0V8qZky_oFx-xe-k4iUbLYNdBB3Lc"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "578991696856-6s72642bttvg6787rk7ldv8mrkhba844.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "578991696856-5gkpjsrre8uhfnpanagmj9cgp87drjso.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.zipxapp.com"
}
}
]
}
}
}
],
"configuration_version": "1"
}
-18
View File
@@ -1,18 +0,0 @@
# Retrofit / Gson models are accessed reflectively.
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.ladill.mini.data.model.** { *; }
# Retrofit
-keepclasseswithmembers class * {
@retrofit2.http.* <methods>;
}
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn retrofit2.**
# Firebase Cloud Messaging
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }
-dontwarn com.google.firebase.**
-dontwarn com.google.android.gms.**
@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission
android:name="android.permission.USE_FINGERPRINT"
android:maxSdkVersion="27" />
<application
android:name=".LadillMiniApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.LadillMini">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="mini.ladill.com" />
</intent-filter>
</activity>
<service
android:name=".push.LadillMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -1,14 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1545_8743)">
<path d="M12.29 5.3999C12.67 5.7399 12.99 6.0699 13.23 6.3299C13.3959 6.51365 13.4877 6.75238 13.4877 6.9999C13.4877 7.24742 13.3959 7.48615 13.23 7.6699C12.18 8.7999 9.79004 10.9999 7.00004 10.9999H6.60004" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.87003 10.13C2.71166 9.48125 1.665 8.65067 0.770029 7.67C0.604189 7.48625 0.51239 7.24752 0.51239 7C0.51239 6.75248 0.604189 6.51375 0.770029 6.33C1.82003 5.2 4.21003 3 7.00003 3C8.0998 3.02299 9.17614 3.32216 10.13 3.87" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.5 1.5L1.5 12.5" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.59 8.41C5.21441 8.03665 5.00223 7.52958 5 7C5 6.46957 5.21071 5.96086 5.58579 5.58579C5.96086 5.21071 6.46957 5 7 5C7.52958 5.00223 8.03665 5.21441 8.41 5.59" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.74 8C8.56198 8.3043 8.3067 8.55614 8 8.73" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1545_8743">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,4 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.23 6.33C13.3959 6.51375 13.4877 6.75248 13.4877 7C13.4877 7.24752 13.3959 7.48625 13.23 7.67C12.18 8.8 9.79003 11 7.00003 11C4.21003 11 1.82003 8.8 0.770029 7.67C0.604189 7.48625 0.51239 7.24752 0.51239 7C0.51239 6.75248 0.604189 6.51375 0.770029 6.33C1.82003 5.2 4.21003 3 7.00003 3C9.79003 3 12.18 5.2 13.23 6.33Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 9C8.10457 9 9 8.10457 9 7C9 5.89543 8.10457 5 7 5C5.89543 5 5 5.89543 5 7C5 8.10457 5.89543 9 7 9Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 677 B

@@ -1,13 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1545_10637)">
<path d="M7 13.5C10.5899 13.5 13.5 10.5899 13.5 7C13.5 3.41015 10.5899 0.5 7 0.5C3.41015 0.5 0.5 3.41015 0.5 7C0.5 10.5899 3.41015 13.5 7 13.5Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.5 5.5C5.5 5.20333 5.58797 4.91332 5.7528 4.66665C5.91762 4.41997 6.15189 4.22771 6.42597 4.11418C6.70006 4.00065 7.00166 3.97094 7.29264 4.02882C7.58361 4.0867 7.85088 4.22956 8.06066 4.43934C8.27044 4.64912 8.4133 4.91639 8.47118 5.20737C8.52906 5.49834 8.49935 5.79994 8.38582 6.07403C8.27229 6.34812 8.08003 6.58238 7.83336 6.74721C7.58668 6.91203 7.29667 7 7 7V8" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 10.5C6.86193 10.5 6.75 10.3881 6.75 10.25C6.75 10.1119 6.86193 10 7 10" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 10.5C7.13807 10.5 7.25 10.3881 7.25 10.25C7.25 10.1119 7.13807 10 7 10" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1545_10637">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,11 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1545_10396)">
<path d="M0.5 7L7 0.5L13.5 7" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.5 8.5V13.5H11.5V8.5" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1545_10396">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 446 B

@@ -1,12 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1545_10577)">
<path d="M6.5 7H13.5" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.5 5L13.5 7L11.5 9" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.7 11.49C10.802 12.4259 9.6446 13.0719 8.37656 13.3446C7.10852 13.6174 5.78787 13.5046 4.58447 13.0207C3.38108 12.5367 2.34998 11.7039 1.6238 10.6291C0.897627 9.55443 0.509583 8.28704 0.509583 6.98999C0.509583 5.69294 0.897627 4.42555 1.6238 3.35084C2.34998 2.27613 3.38108 1.44325 4.58447 0.959333C5.78787 0.475413 7.10852 0.362584 8.37656 0.635359C9.6446 0.908135 10.802 1.55404 11.7 2.48999" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1545_10577">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 910 B

@@ -1,12 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1545_11430)">
<path d="M12.5 2.25H1.5C0.947715 2.25 0.5 2.69772 0.5 3.25V10.75C0.5 11.3023 0.947715 11.75 1.5 11.75H12.5C13.0523 11.75 13.5 11.3023 13.5 10.75V3.25C13.5 2.69772 13.0523 2.25 12.5 2.25Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.5 5.75H13.5" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 9.25H11" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1545_11430">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 685 B

@@ -1,12 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1545_11393)">
<path d="M10 1.75H1.5C0.947715 1.75 0.5 2.19772 0.5 2.75V8.75C0.5 9.30228 0.947715 9.75 1.5 9.75H10C10.5523 9.75 11 9.30228 11 8.75V2.75C11 2.19772 10.5523 1.75 10 1.75Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.75 7.25C6.57843 7.25 7.25 6.57843 7.25 5.75C7.25 4.92157 6.57843 4.25 5.75 4.25C4.92157 4.25 4.25 4.92157 4.25 5.75C4.25 6.57843 4.92157 7.25 5.75 7.25Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.5 12.25H12.5C12.7652 12.25 13.0196 12.1446 13.2071 11.9571C13.3946 11.7696 13.5 11.5152 13.5 11.25V6.25" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1545_11393">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 903 B

@@ -1,11 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1545_11250)">
<path d="M7 0.5C8.13778 0.5 9.22896 0.951981 10.0335 1.75651C10.838 2.56104 11.29 3.65222 11.29 4.79C11.29 9.56 13.03 10.5 13.5 10.5H0.5C0.98 10.5 2.71 9.55 2.71 4.79C2.71 3.65222 3.16198 2.56104 3.96651 1.75651C4.77104 0.951981 5.86222 0.5 7 0.5V0.5Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.5 12.3301C5.58644 12.6621 5.7806 12.9561 6.05205 13.1659C6.32351 13.3757 6.6569 13.4896 7 13.4896C7.3431 13.4896 7.67649 13.3757 7.94795 13.1659C8.2194 12.9561 8.41356 12.6621 8.5 12.3301" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1545_11250">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 837 B

@@ -1,12 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1545_10597)">
<path d="M7.36 13.43C7.12832 13.5194 6.87168 13.5194 6.64 13.43V13.43C4.83069 12.7206 3.27713 11.4824 2.18188 9.877C1.08664 8.2716 0.500536 6.37342 0.5 4.43V1.5C0.5 1.23478 0.605357 0.98043 0.792893 0.792893C0.98043 0.605357 1.23478 0.5 1.5 0.5H12.5C12.7652 0.5 13.0196 0.605357 13.2071 0.792893C13.3946 0.98043 13.5 1.23478 13.5 1.5V4.42C13.5015 6.36513 12.9163 8.26549 11.8209 9.87287C10.7256 11.4803 9.17089 12.7199 7.36 13.43V13.43Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 5.5H5.5C4.94772 5.5 4.5 5.94772 4.5 6.5V8.5C4.5 9.05228 4.94772 9.5 5.5 9.5H8.5C9.05228 9.5 9.5 9.05228 9.5 8.5V6.5C9.5 5.94772 9.05228 5.5 8.5 5.5Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 5.5V4.5C8.5 4.30302 8.4612 4.10796 8.38582 3.92597C8.31044 3.74399 8.19995 3.57863 8.06066 3.43934C7.92137 3.30005 7.75601 3.18956 7.57403 3.11418C7.39204 3.0388 7.19698 3 7 3C6.80302 3 6.60796 3.0388 6.42597 3.11418C6.24399 3.18956 6.07863 3.30005 5.93934 3.43934C5.80005 3.57863 5.68956 3.74399 5.61418 3.92597C5.5388 4.10796 5.5 4.30302 5.5 4.5V5.5" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1545_10597">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,12 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1545_10427)">
<path d="M7 8C8.38071 8 9.5 6.88071 9.5 5.5C9.5 4.11929 8.38071 3 7 3C5.61929 3 4.5 4.11929 4.5 5.5C4.5 6.88071 5.61929 8 7 8Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.72998 11.9001C3.17622 11.1676 3.80339 10.5622 4.55119 10.1422C5.29899 9.72211 6.14227 9.50146 6.99998 9.50146C7.85769 9.50146 8.70097 9.72211 9.44877 10.1422C10.1966 10.5622 10.8237 11.1676 11.27 11.9001" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 13.5C10.5899 13.5 13.5 10.5899 13.5 7C13.5 3.41015 10.5899 0.5 7 0.5C3.41015 0.5 0.5 3.41015 0.5 7C0.5 10.5899 3.41015 13.5 7 13.5Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1545_10427">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 939 B

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#1f2937" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 3.75 9.375v-4.5ZM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5ZM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0 1 13.5 9.375v-4.5Z"/>
<path d="M13.5 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 0 1-1.125-1.125v-4.5Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 785 B

@@ -1,3 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.22998 2.25L5.65998 1.14C5.7325 0.952064 5.86009 0.790411 6.02603 0.676212C6.19197 0.562014 6.38854 0.500595 6.58998 0.5H7.40998C7.61142 0.500595 7.80799 0.562014 7.97393 0.676212C8.13988 0.790411 8.26746 0.952064 8.33998 1.14L8.76998 2.25L10.23 3.09L11.41 2.91C11.6065 2.88333 11.8064 2.91567 11.9845 3.00292C12.1626 3.09017 12.3107 3.22838 12.41 3.4L12.81 4.1C12.9125 4.27435 12.9597 4.47568 12.9454 4.67742C12.9311 4.87916 12.856 5.07183 12.73 5.23L12 6.16V7.84L12.75 8.77C12.876 8.92817 12.9511 9.12084 12.9654 9.32258C12.9797 9.52432 12.9325 9.72565 12.83 9.9L12.43 10.6C12.3307 10.7716 12.1826 10.9098 12.0045 10.9971C11.8264 11.0843 11.6265 11.1167 11.43 11.09L10.25 10.91L8.78998 11.75L8.35998 12.86C8.28746 13.0479 8.15988 13.2096 7.99393 13.3238C7.82799 13.438 7.63142 13.4994 7.42998 13.5H6.58998C6.38854 13.4994 6.19197 13.438 6.02603 13.3238C5.86009 13.2096 5.7325 13.0479 5.65998 12.86L5.22998 11.75L3.76998 10.91L2.58998 11.09C2.3935 11.1167 2.19352 11.0843 2.01546 10.9971C1.83741 10.9098 1.6893 10.7716 1.58998 10.6L1.18998 9.9C1.08748 9.72565 1.04026 9.52432 1.05454 9.32258C1.06882 9.12084 1.14394 8.92817 1.26998 8.77L1.99998 7.84V6.16L1.24998 5.23C1.12394 5.07183 1.04882 4.87916 1.03454 4.67742C1.02026 4.47568 1.06748 4.27435 1.16998 4.1L1.56998 3.4C1.6693 3.22838 1.81741 3.09017 1.99546 3.00292C2.17352 2.91567 2.3735 2.88333 2.56998 2.91L3.74998 3.09L5.22998 2.25ZM4.99998 7C4.99998 7.39556 5.11728 7.78224 5.33704 8.11114C5.5568 8.44004 5.86916 8.69638 6.23461 8.84776C6.60007 8.99913 7.0022 9.03874 7.39016 8.96157C7.77812 8.8844 8.13449 8.69392 8.41419 8.41421C8.6939 8.13451 8.88438 7.77814 8.96155 7.39018C9.03872 7.00222 8.99912 6.60009 8.84774 6.23463C8.69636 5.86918 8.44002 5.55682 8.11112 5.33706C7.78222 5.1173 7.39554 5 6.99998 5C6.46955 5 5.96084 5.21071 5.58577 5.58579C5.21069 5.96086 4.99998 6.46957 4.99998 7V7Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

@@ -1,10 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1545_10624)">
<path d="M2.5 7.5H3.5C3.76522 7.5 4.01957 7.60536 4.20711 7.79289C4.39464 7.98043 4.5 8.23478 4.5 8.5V12.5C4.5 12.7652 4.39464 13.0196 4.20711 13.2071C4.01957 13.3946 3.76522 13.5 3.5 13.5H1.5C1.23478 13.5 0.98043 13.3946 0.792893 13.2071C0.605357 13.0196 0.5 12.7652 0.5 12.5V7C0.5 5.27609 1.18482 3.62279 2.40381 2.40381C3.62279 1.18482 5.27609 0.5 7 0.5C8.72391 0.5 10.3772 1.18482 11.5962 2.40381C12.8152 3.62279 13.5 5.27609 13.5 7V12.5C13.5 12.7652 13.3946 13.0196 13.2071 13.2071C13.0196 13.3946 12.7652 13.5 12.5 13.5H10.5C10.2348 13.5 9.98043 13.3946 9.79289 13.2071C9.60536 13.0196 9.5 12.7652 9.5 12.5V8.5C9.5 8.23478 9.60536 7.98043 9.79289 7.79289C9.98043 7.60536 10.2348 7.5 10.5 7.5H11.5" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1545_10624">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1021 B

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
<defs>
<style>
.cls-1 {
fill: #eec434;
}
.cls-1, .cls-2, .cls-3 {
stroke-width: 0px;
}
.cls-2 {
fill: #ff3441;
}
.cls-3 {
fill: #00a749;
}
</style>
</defs>
<path class="cls-2" d="M-.3,122.7v225.3c61,0,110.7-49.7,110.7-110.7V12C49.4,12-.3,61.7-.3,122.7Z"/>
<path class="cls-3" d="M249.6,348V122.7c0-61,49.7-110.7,110.7-110.7v225.3c0,61-49.7,110.7-110.7,110.7h0Z"/>
<path class="cls-1" d="M124.6,12v225.3c0,61,49.7,110.7,110.7,110.7V122.7c0-61-49.7-110.7-110.7-110.7h0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 727 B

@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 387.6 76.2">
<defs>
<style>
.cls-1 {
fill: #eec434;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
stroke-width: 0px;
}
.cls-2 {
fill: #000;
}
.cls-3 {
fill: #ff3441;
}
.cls-4 {
fill: #00a749;
}
</style>
</defs>
<g>
<path class="cls-2" d="M137.4,53.9v8.6h-34.8V12.2h8.6v41.8h26.2Z"/>
<path class="cls-2" d="M141.1,44.5c0-2.7.5-5.3,1.5-7.7s2.4-4.4,4.2-6.2,3.9-3.1,6.2-4.2c2.4-1,4.9-1.5,7.7-1.5s5.3.5,7.7,1.5,4.4,2.4,6.2,4.2c1.8,1.8,3.1,3.8,4.2,6.2,1,2.4,1.5,4.9,1.5,7.7v18.1h-8.4v-2c-3.3,2.4-7.1,3.5-11.2,3.5s-5.7-.5-8.1-1.5-4.5-2.4-6.2-4.2-3-3.9-4-6.2c-.9-2.4-1.4-4.9-1.4-7.7,0,0,.1,0,.1,0ZM149.5,44.5c0,1.5.3,3,.9,4.4s1.4,2.6,2.4,3.6,2.2,1.8,3.6,2.4c1.4.6,2.8.9,4.4.9s3-.3,4.4-.8c1.4-.6,2.6-1.3,3.6-2.3s1.8-2.2,2.4-3.6c.6-1.4.9-2.9.9-4.5s-.3-3-.9-4.4c-.6-1.4-1.4-2.6-2.4-3.6s-2.2-1.8-3.6-2.4c-1.4-.6-2.8-.9-4.4-.9s-3,.3-4.4.9-2.6,1.4-3.6,2.4-1.8,2.2-2.4,3.6c-.6,1.4-.9,2.8-.9,4.4h0Z"/>
<path class="cls-2" d="M224.2,44.5c0,2.8-.5,5.4-1.5,7.7-1,2.4-2.4,4.4-4.2,6.2s-3.9,3.1-6.2,4.1-4.9,1.5-7.7,1.5-5.3-.5-7.7-1.5-4.4-2.4-6.2-4.2-3.1-3.8-4.2-6.2c-1-2.4-1.5-4.9-1.5-7.7s.5-5.3,1.5-7.7,2.4-4.4,4.2-6.2,3.8-3.1,6.2-4.2c2.4-1,4.9-1.5,7.7-1.5s2.1.1,3.2.4c1.1.2,2.1.6,3.1.9,1,.4,1.9.8,2.8,1.3.9.5,1.6,1,2.2,1.6V12.2h8.4v32.3h-.1ZM215.8,44.5c0-1.5-.3-3-.9-4.4-.6-1.4-1.4-2.6-2.4-3.6s-2.2-1.8-3.6-2.4c-1.4-.6-2.8-.9-4.4-.9s-3,.3-4.4.9c-1.4.6-2.6,1.4-3.6,2.4s-1.8,2.2-2.4,3.6c-.6,1.4-.9,2.8-.9,4.4s.3,3,.9,4.4c.6,1.4,1.4,2.6,2.4,3.6s2.2,1.8,3.6,2.4c1.4.6,2.8.9,4.4.9s3-.3,4.4-.9c1.4-.6,2.6-1.4,3.6-2.4s1.8-2.2,2.4-3.6c.6-1.4.9-2.8.9-4.4Z"/>
<path class="cls-2" d="M228.7,17.4c0-1.5.5-2.8,1.5-3.8s2.2-1.5,3.7-1.5,2.8.5,3.8,1.5,1.5,2.3,1.5,3.8-.5,2.7-1.5,3.7-2.3,1.5-3.8,1.5-2.7-.5-3.7-1.5-1.5-2.2-1.5-3.7ZM229.7,26.6h8.4v36h-8.4V26.6Z"/>
<path class="cls-2" d="M243.8,12.2h8.4v50.4h-8.4V12.2Z"/>
<path class="cls-2" d="M257.9,12.2h8.4v50.4h-8.4V12.2Z"/>
</g>
<path class="cls-3" d="M0,25.3v50.9c13.8,0,25-11.2,25-25V.2C11.2.2,0,11.5,0,25.3Z"/>
<path class="cls-4" d="M56.5,76.2V25.3C56.5,11.5,67.7.2,81.5.2v50.9c0,13.8-11.2,25-25,25h0Z"/>
<path class="cls-1" d="M28.3.2v50.9c0,13.8,11.2,25,25,25V25.3C53.3,11.5,42.1.2,28.3.2h0Z"/>
<g>
<path class="cls-2" d="M324.8,12.9h5v50.4h-5V21.4l-16.2,22-16.1-21.9v41.8h-5V12.9h5l16.1,22s16.2-22,16.2-22Z"/>
<path class="cls-2" d="M334.2,15.9c0-.9.3-1.6.9-2.2.6-.6,1.3-.9,2.2-.9s1.6.3,2.2.9c.6.6.9,1.3.9,2.2s-.3,1.6-.9,2.2-1.3.9-2.2.9-1.6-.3-2.2-.9-.9-1.3-.9-2.2ZM334.7,27.3h5v36h-5V27.3Z"/>
<path class="cls-2" d="M372,41.7c0-1.5-.3-3-.9-4.3s-1.4-2.5-2.4-3.5-2.2-1.8-3.5-2.4-2.8-.9-4.2-.9-2.9.3-4.2.9c-1.3.6-2.5,1.5-3.5,2.5s-1.8,2.2-2.4,3.6c-.6,1.3-.9,2.7-.9,4.1v21.6h-5V27.3h5v3.3c.5-.8,1.1-1.5,1.9-2.1s1.7-1.2,2.7-1.7,2-.8,3.1-1,2.2-.4,3.3-.4c2.2,0,4.3.4,6.3,1.3s3.7,2,5.1,3.5c1.5,1.5,2.6,3.2,3.5,5.2s1.3,4.1,1.3,6.3v21.6h-5.1v-21.6h0Z"/>
<path class="cls-2" d="M381.5,15.9c0-.9.3-1.6.9-2.2.6-.6,1.3-.9,2.2-.9s1.6.3,2.2.9c.6.6.9,1.3.9,2.2s-.3,1.6-.9,2.2-1.3.9-2.2.9-1.6-.3-2.2-.9-.9-1.3-.9-2.2ZM382,27.3h5v36h-5V27.3Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

@@ -1,72 +0,0 @@
package com.ladill.mini
import android.app.Application
import coil.Coil
import coil.ImageLoader
import coil.decode.SvgDecoder
import com.ladill.mini.data.api.ApiClient
import com.ladill.mini.data.repository.AuthRepository
import com.ladill.mini.data.repository.MiniRepository
import com.ladill.mini.data.repository.NotificationRepository
import com.ladill.mini.push.PushTokenRegistrar
import com.ladill.mini.util.PinManager
import com.ladill.mini.util.SessionManager
import com.ladill.mini.util.TokenStore
import okhttp3.OkHttpClient
/**
* Manual DI container — mirrors the ClimpMe Android pattern: construct the
* session/token/api/repositories once and expose them as `lateinit` singletons
* the screens read via `application as LadillMiniApp`.
*/
class LadillMiniApp : Application() {
lateinit var sessionManager: SessionManager
private set
lateinit var tokenStore: TokenStore
private set
lateinit var apiClient: ApiClient
private set
lateinit var authRepository: AuthRepository
private set
lateinit var miniRepository: MiniRepository
private set
lateinit var notificationRepository: NotificationRepository
private set
lateinit var pinManager: PinManager
private set
override fun onCreate() {
super.onCreate()
sessionManager = SessionManager(applicationContext)
tokenStore = TokenStore(sessionManager.getToken())
// Coil loads bundled brand SVGs and the authenticated QR preview PNGs, so
// it shares a bearer-injecting client and the SVG decoder.
val imageClient = OkHttpClient.Builder()
.addInterceptor { chain ->
val builder = chain.request().newBuilder().header("Accept", "image/*")
tokenStore.token?.let { builder.header("Authorization", "Bearer $it") }
chain.proceed(builder.build())
}
.build()
Coil.setImageLoader(
ImageLoader.Builder(this)
.okHttpClient(imageClient)
.components { add(SvgDecoder.Factory()) }
.build(),
)
apiClient = ApiClient(tokenStore)
authRepository = AuthRepository(applicationContext, sessionManager, tokenStore, apiClient)
miniRepository = MiniRepository(apiClient)
notificationRepository = NotificationRepository(apiClient)
pinManager = PinManager(applicationContext)
if (authRepository.isLoggedIn()) {
PushTokenRegistrar.registerIfLoggedIn(this)
}
}
}
@@ -1,239 +0,0 @@
package com.ladill.mini
import android.Manifest
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.ladill.mini.navigation.Route
import com.ladill.mini.ui.security.PinLockContent
import com.ladill.mini.ui.security.PinSetupContent
import com.ladill.mini.ui.account.BillingScreen
import com.ladill.mini.ui.account.ProfileScreen
import com.ladill.mini.ui.account.SecurityScreen
import com.ladill.mini.ui.account.SettingsScreen
import com.ladill.mini.ui.account.SupportScreen
import com.ladill.mini.ui.account.WalletScreen
import com.ladill.mini.ui.auth.LoginScreen
import com.ladill.mini.ui.auth.RegisterScreen
import com.ladill.mini.ui.home.HomeScreen
import com.ladill.mini.ui.landing.LandingScreen
import com.ladill.mini.ui.splash.SplashScreen
import com.ladill.mini.push.PushTokenRegistrar
import com.ladill.mini.ui.theme.LadillMiniTheme
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
// Light-mode app: force dark system-bar icons on the light background.
val lightBars = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
enableEdgeToEdge(statusBarStyle = lightBars, navigationBarStyle = lightBars)
super.onCreate(savedInstanceState)
val app = application as LadillMiniApp
setContent {
LadillMiniTheme {
val notificationPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted && app.authRepository.isLoggedIn()) {
PushTokenRegistrar.registerIfLoggedIn(app)
}
}
androidx.compose.runtime.LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} else if (app.authRepository.isLoggedIn()) {
PushTokenRegistrar.registerIfLoggedIn(app)
}
}
AppLock(app) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Route.SPLASH) {
composable(Route.SPLASH) {
SplashScreen(
isLoggedIn = app.authRepository.isLoggedIn(),
hasPin = app.pinManager.hasPin(),
toHome = {
navController.navigate(Route.HOME) {
popUpTo(Route.SPLASH) { inclusive = true }
}
},
toPinLock = {
navController.navigate(Route.PIN_LOCK) {
popUpTo(Route.SPLASH) { inclusive = true }
}
},
toLanding = {
navController.navigate(Route.LANDING) {
popUpTo(Route.SPLASH) { inclusive = true }
}
},
)
}
composable(Route.PIN_LOCK) {
PinLockContent(
verify = { app.pinManager.verifyPin(it) },
onUnlocked = {
navController.navigate(Route.HOME) {
popUpTo(Route.PIN_LOCK) { inclusive = true }
}
},
onBiometric = null,
biometricEnabled = app.pinManager.isBiometricEnabled(),
)
}
composable(Route.PIN_SETUP) {
PinSetupContent(onPinSet = { code ->
app.pinManager.savePin(code)
navController.navigate(Route.HOME) {
popUpTo(Route.PIN_SETUP) { inclusive = true }
}
})
}
composable(Route.LANDING) {
LandingScreen(
onSignIn = { navController.navigate(Route.LOGIN) },
onCreateAccount = { navController.navigate(Route.REGISTER) },
)
}
composable(Route.LOGIN) {
LoginScreen(
onLoggedIn = {
navController.navigate(Route.HOME) {
popUpTo(Route.LOGIN) { inclusive = true }
}
},
onCreateAccount = { navController.navigate(Route.REGISTER) },
onBack = { navController.popBackStack() },
)
}
composable(Route.REGISTER) {
RegisterScreen(
onRegistered = {
navController.navigate(Route.HOME) {
popUpTo(Route.LOGIN) { inclusive = true }
}
},
onBack = { navController.popBackStack() },
onSignIn = {
navController.navigate(Route.LOGIN) {
popUpTo(Route.REGISTER) { inclusive = true }
}
},
)
}
composable(Route.HOME) {
HomeScreen(
onOpenProfile = { navController.navigate(Route.PROFILE) },
onOpenSettings = { navController.navigate(Route.SETTINGS) },
onOpenSecurity = { navController.navigate(Route.SECURITY) },
onOpenWallet = { navController.navigate(Route.WALLET) },
onOpenBilling = { navController.navigate(Route.BILLING) },
onOpenSupport = { navController.navigate(Route.SUPPORT) },
onLoggedOut = {
navController.navigate(Route.LANDING) {
popUpTo(0) { inclusive = true }
}
},
)
}
composable(Route.PROFILE) {
ProfileScreen(onBack = { navController.popBackStack() })
}
composable(Route.SETTINGS) {
SettingsScreen(onBack = { navController.popBackStack() })
}
composable(Route.SECURITY) {
SecurityScreen(onBack = { navController.popBackStack() })
}
composable(Route.WALLET) {
WalletScreen(onBack = { navController.popBackStack() })
}
composable(Route.BILLING) {
BillingScreen(onBack = { navController.popBackStack() })
}
composable(Route.SUPPORT) {
SupportScreen(onBack = { navController.popBackStack() })
}
}
}
}
}
}
}
/**
* Re-locks over app content when the user returns from the background. Cold-start
* unlock is handled by the [Route.PIN_LOCK] screen (matches ClimpMe). Biometric
* auto-prompt lives in [PinLockContent] via LaunchedEffect, not lifecycle resume.
*/
@androidx.compose.runtime.Composable
private fun AppLock(app: LadillMiniApp, content: @androidx.compose.runtime.Composable () -> Unit) {
val pin = app.pinManager
var locked by remember { mutableStateOf(false) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP &&
app.authRepository.isLoggedIn() && pin.hasPin()
) {
locked = true
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
content()
if (locked) {
Surface(modifier = Modifier.fillMaxSize(), color = androidx.compose.material3.MaterialTheme.colorScheme.background) {
PinLockContent(
verify = { pin.verifyPin(it) },
onUnlocked = { locked = false },
onBiometric = null,
biometricEnabled = pin.isBiometricEnabled(),
)
}
}
}
@@ -1,50 +0,0 @@
package com.ladill.mini.data.api
import com.google.gson.FieldNamingPolicy
import com.google.gson.GsonBuilder
import com.ladill.mini.BuildConfig
import com.ladill.mini.util.TokenStore
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Builds the Retrofit [ApiService]. A bearer interceptor reads the live token
* from [TokenStore] on every request, so login/logout take effect immediately
* without rebuilding the client.
*/
class ApiClient(tokenStore: TokenStore) {
val service: ApiService
init {
val gson = GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create()
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
}
val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
val builder = chain.request().newBuilder()
.header("Accept", "application/json")
tokenStore.token?.let { builder.header("Authorization", "Bearer $it") }
chain.proceed(builder.build())
}
.addInterceptor(logging)
.build()
service = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(ApiService::class.java)
}
}
@@ -1,155 +0,0 @@
package com.ladill.mini.data.api
import com.ladill.mini.data.model.CreatePaymentQrRequest
import com.ladill.mini.data.model.AccountSettings
import com.ladill.mini.data.model.AfiaChatRequest
import com.ladill.mini.data.model.AfiaChatResponse
import com.ladill.mini.data.model.AvatarResult
import com.ladill.mini.data.model.Bank
import com.ladill.mini.data.model.ChangePasswordRequest
import com.ladill.mini.data.model.CheckoutUrl
import com.ladill.mini.data.model.Envelope
import com.ladill.mini.data.model.LoginRequest
import com.ladill.mini.data.model.LoginResult
import com.ladill.mini.data.model.MessageResponse
import com.ladill.mini.data.model.NotificationPrefs
import com.ladill.mini.data.model.NotificationsListResponse
import com.ladill.mini.data.model.PushTokenRequest
import com.ladill.mini.data.model.UnreadCountEnvelope
import com.ladill.mini.data.model.Overview
import com.ladill.mini.data.model.PagedEnvelope
import com.ladill.mini.data.model.Payment
import com.ladill.mini.data.model.PaymentQr
import com.ladill.mini.data.model.PayoutAccountWrapper
import com.ladill.mini.data.model.Payouts
import com.ladill.mini.data.model.ProfileInfo
import com.ladill.mini.data.model.CreateTicketRequest
import com.ladill.mini.data.model.RegisterRequest
import com.ladill.mini.data.model.SupportTicket
import com.ladill.mini.data.model.TopupRequest
import com.ladill.mini.data.model.UpdatePaymentQrRequest
import com.ladill.mini.data.model.UpdatePayoutAccountRequest
import com.ladill.mini.data.model.UpdateProfileRequest
import com.ladill.mini.data.model.UpdateSettingsRequest
import com.ladill.mini.data.model.User
import com.ladill.mini.data.model.WalletInfo
import com.ladill.mini.data.model.Withdrawal
import com.ladill.mini.data.model.WithdrawRequest
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.HTTP
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
interface ApiService {
@POST("auth/login")
suspend fun login(@Body body: LoginRequest): Envelope<LoginResult>
@POST("auth/register")
suspend fun register(@Body body: RegisterRequest): Envelope<LoginResult>
@POST("auth/logout")
suspend fun logout()
@GET("me")
suspend fun me(): Envelope<User>
@GET("mini/overview")
suspend fun overview(): Envelope<Overview>
@POST("mini/afia/chat")
suspend fun afiaChat(@Body body: AfiaChatRequest): AfiaChatResponse
@GET("mini/payment-qrs")
suspend fun paymentQrs(): Envelope<List<PaymentQr>>
@POST("mini/payment-qrs")
suspend fun createPaymentQr(@Body body: CreatePaymentQrRequest): Envelope<PaymentQr>
@GET("mini/payment-qrs/{id}")
suspend fun paymentQr(@Path("id") id: Long): Envelope<PaymentQr>
@PATCH("mini/payment-qrs/{id}")
suspend fun updatePaymentQr(@Path("id") id: Long, @Body body: UpdatePaymentQrRequest): Envelope<PaymentQr>
@DELETE("mini/payment-qrs/{id}")
suspend fun deletePaymentQr(@Path("id") id: Long)
@GET("mini/payments")
suspend fun payments(@Query("q") query: String? = null): PagedEnvelope<Payment>
@GET("mini/payouts")
suspend fun payouts(): Envelope<Payouts>
@GET("mini/account/settings")
suspend fun accountSettings(): Envelope<AccountSettings>
@PUT("mini/account/settings")
suspend fun updateSettings(@Body body: UpdateSettingsRequest): Envelope<NotificationPrefs>
@PUT("mini/account/profile")
suspend fun updateProfile(@Body body: UpdateProfileRequest): Envelope<ProfileInfo>
@Multipart
@POST("mini/account/avatar")
suspend fun uploadAvatar(@Part avatar: MultipartBody.Part): Envelope<AvatarResult>
@POST("mini/account/change-password")
suspend fun changePassword(@Body body: ChangePasswordRequest): Envelope<MessageResponse>
@GET("mini/wallet")
suspend fun wallet(): Envelope<WalletInfo>
@POST("mini/wallet/topup")
suspend fun walletTopup(@Body body: TopupRequest): Envelope<CheckoutUrl>
@GET("mini/wallet/banks")
suspend fun banks(@Query("type") type: String): Envelope<List<Bank>>
@GET("mini/wallet/payout-account")
suspend fun payoutAccount(): Envelope<PayoutAccountWrapper>
@PUT("mini/wallet/payout-account")
suspend fun updatePayoutAccount(@Body body: UpdatePayoutAccountRequest): Envelope<PayoutAccountWrapper>
@GET("mini/wallet/withdrawals")
suspend fun withdrawals(): Envelope<List<Withdrawal>>
@POST("mini/wallet/withdraw")
suspend fun withdraw(@Body body: WithdrawRequest): Envelope<Withdrawal>
@GET("mini/support/tickets")
suspend fun supportTickets(): Envelope<List<SupportTicket>>
@POST("mini/support/tickets")
suspend fun createSupportTicket(@Body body: CreateTicketRequest): Envelope<SupportTicket>
@GET("mini/support/tickets/{id}")
suspend fun supportTicket(@Path("id") id: Long): Envelope<SupportTicket>
@GET("mini/notifications")
suspend fun notifications(): NotificationsListResponse
@GET("mini/notifications/unread-count")
suspend fun unreadNotificationCount(): Envelope<UnreadCountEnvelope>
@POST("mini/notifications/{id}/read")
suspend fun markNotificationRead(@Path("id") id: String)
@POST("mini/notifications/mark-all-read")
suspend fun markAllNotificationsRead()
@POST("mini/push-token")
suspend fun registerPushToken(@Body body: PushTokenRequest)
@HTTP(method = "DELETE", path = "mini/push-token", hasBody = true)
suspend fun unregisterPushToken(@Body body: PushTokenRequest)
}
@@ -1,74 +0,0 @@
package com.ladill.mini.data.model
/**
* Countries + regions offered at sign-up. Mirrors the web register form
* (RegisteredUserController::countries / statesByCountry on the monolith) so the
* native form submits values the central API accepts.
*/
data class Country(
val code: String,
val name: String,
val dialCode: String,
val states: List<String>,
)
object Countries {
val all: List<Country> = listOf(
Country(
"GH", "Ghana", "233",
listOf(
"Ahafo", "Ashanti", "Bono", "Bono East", "Central", "Eastern", "Greater Accra",
"North East", "Northern", "Oti", "Savannah", "Upper East", "Upper West", "Volta",
"Western", "Western North",
),
),
Country(
"NG", "Nigeria", "234",
listOf(
"Abia", "Abuja FCT", "Adamawa", "Akwa Ibom", "Anambra", "Bauchi", "Bayelsa", "Benue",
"Borno", "Cross River", "Delta", "Ebonyi", "Edo", "Ekiti", "Enugu", "Gombe", "Imo",
"Jigawa", "Kaduna", "Kano", "Katsina", "Kebbi", "Kogi", "Kwara", "Lagos", "Nasarawa",
"Niger", "Ogun", "Ondo", "Osun", "Oyo", "Plateau", "Rivers", "Sokoto", "Taraba",
"Yobe", "Zamfara",
),
),
Country(
"KE", "Kenya", "254",
listOf(
"Baringo", "Bomet", "Bungoma", "Busia", "Elgeyo-Marakwet", "Embu", "Garissa",
"Homa Bay", "Isiolo", "Kajiado", "Kakamega", "Kericho", "Kiambu", "Kilifi",
"Kirinyaga", "Kisii", "Kisumu", "Kitui", "Kwale", "Laikipia", "Lamu", "Machakos",
"Makueni", "Mandera", "Marsabit", "Meru", "Migori", "Mombasa", "Muranga", "Nairobi",
"Nakuru", "Nandi", "Narok", "Nyamira", "Nyandarua", "Nyeri", "Samburu", "Siaya",
"Taita-Taveta", "Tana River", "Tharaka-Nithi", "Trans Nzoia", "Turkana",
"Uasin Gishu", "Vihiga", "Wajir", "West Pokot",
),
),
Country(
"ZA", "South Africa", "27",
listOf(
"Eastern Cape", "Free State", "Gauteng", "KwaZulu-Natal", "Limpopo", "Mpumalanga",
"North West", "Northern Cape", "Western Cape",
),
),
Country(
"GB", "United Kingdom", "44",
listOf("England", "Northern Ireland", "Scotland", "Wales"),
),
Country(
"US", "United States", "1",
listOf(
"Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut",
"Delaware", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa",
"Kansas", "Kentucky", "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan",
"Minnesota", "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada",
"New Hampshire", "New Jersey", "New Mexico", "New York", "North Carolina",
"North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania", "Rhode Island",
"South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont",
"Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming",
),
),
)
val default: Country get() = all.first()
}
@@ -1,271 +0,0 @@
package com.ladill.mini.data.model
/**
* API DTOs for the Ladill Mini backend (routes/api.php, /api/v1).
*
* Gson is configured with LOWER_CASE_WITH_UNDERSCORES in ApiClient, so these
* camelCase fields map straight onto the snake_case JSON keys.
*/
// Generic response envelopes
data class Envelope<T>(val data: T)
data class PagedEnvelope<T>(val data: List<T>, val meta: PageMeta?)
data class PageMeta(
val currentPage: Int = 1,
val lastPage: Int = 1,
val perPage: Int = 25,
val total: Int = 0,
)
// Auth
data class LoginRequest(
val email: String,
val password: String,
val deviceName: String = "Ladill Mini Android",
)
data class LoginResult(val token: String, val user: User)
data class RegisterRequest(
val name: String,
val email: String,
val password: String,
val passwordConfirmation: String,
val company: String?,
val address: String,
val city: String,
val state: String,
val country: String,
val zipcode: String,
val phoneCc: String,
val phone: String,
val mobileCc: String?,
val mobile: String?,
val terms: Boolean,
val deviceName: String = "Ladill Mini Android",
)
data class User(
val id: Long,
val publicId: String?,
val name: String?,
val email: String?,
val avatarUrl: String?,
val actingAccount: Account?,
)
data class Account(
val id: Long,
val publicId: String?,
val name: String?,
val email: String?,
)
// Overview
data class Overview(
val currency: String = "GHS",
val todayTakingsMinor: Long = 0,
val todayCount: Int = 0,
val paymentQrCount: Int = 0,
val walletBalanceMinor: Long = 0,
val recentPayments: List<Payment> = emptyList(),
)
// Payment
data class Payment(
val id: Long,
val reference: String?,
val amountMinor: Long = 0,
val merchantAmountMinor: Long = 0,
val platformFeeMinor: Long = 0,
val currency: String = "GHS",
val status: String = "paid",
val payerName: String?,
val payerEmail: String?,
val payerPhone: String?,
val payerNote: String?,
val qrCodeId: Long?,
val qrLabel: String?,
val paidAt: String?,
val createdAt: String?,
)
// Payment QR
data class PaymentQr(
val id: Long,
val label: String,
val businessName: String?,
val branchLabel: String?,
val currency: String = "GHS",
val shortCode: String?,
val publicUrl: String?,
val isActive: Boolean = true,
val scansTotal: Int = 0,
val previewUrl: String?,
val createdAt: String?,
val updatedAt: String?,
)
data class CreatePaymentQrRequest(
val label: String,
val businessName: String,
val branchLabel: String?,
)
data class UpdatePaymentQrRequest(
val label: String? = null,
val businessName: String? = null,
val branchLabel: String? = null,
val isActive: Boolean? = null,
)
// Payouts
data class Payouts(
val currency: String = "GHS",
val totalRevenueMinor: Long = 0,
val walletBalanceMinor: Long = 0,
val accountWalletUrl: String?,
)
// Account settings
data class AccountSettings(val profile: ProfileInfo, val notifications: NotificationPrefs)
data class ProfileInfo(
val name: String?,
val email: String?,
val phone: String? = null,
val phoneCc: String? = null,
)
data class NotificationPrefs(
val notifyEmail: String? = null,
val productUpdates: Boolean = true,
val notifyRegistrations: Boolean = true,
val notifyPayouts: Boolean = true,
)
data class UpdateSettingsRequest(
val notifyEmail: String?,
val productUpdates: Boolean,
val notifyRegistrations: Boolean,
val notifyPayouts: Boolean,
)
data class UpdateProfileRequest(val name: String, val phoneCc: String?, val phone: String?)
data class AvatarResult(val avatarUrl: String?)
data class ChangePasswordRequest(
val currentPassword: String,
val password: String,
val passwordConfirmation: String,
)
data class MessageResponse(val message: String?)
// Afia AI assistant
data class AfiaChatRequest(
val message: String,
val history: List<AfiaChatTurn> = emptyList(),
)
data class AfiaChatTurn(val role: String, val text: String)
data class AfiaChatResponse(val reply: String? = null, val message: String? = null)
// Wallet
data class WalletInfo(
val currency: String = "GHS",
val balanceMinor: Long = 0,
val spentMinor: Long = 0,
val creditedMinor: Long = 0,
)
data class TopupRequest(val amount: Double)
data class CheckoutUrl(val checkoutUrl: String)
// Withdrawals & payout account
data class PayoutAccountWrapper(val payoutAccount: PayoutAccount?)
data class PayoutAccount(
val accountType: String? = null,
val accountName: String? = null,
val accountNumber: String? = null,
val bankCode: String? = null,
val bankName: String? = null,
val currency: String? = "GHS",
)
data class UpdatePayoutAccountRequest(
val accountType: String,
val accountName: String,
val accountNumber: String,
val bankCode: String,
val bankName: String,
val currency: String = "GHS",
)
data class Bank(val name: String, val code: String)
data class WithdrawRequest(val amount: Double)
data class Withdrawal(
val id: Long = 0,
val amountGhs: Double = 0.0,
val status: String = "pending",
val createdAt: String? = null,
)
// Support tickets
data class SupportTicket(
val id: Long = 0,
val ticketRef: String? = null,
val subject: String = "",
val status: String = "open",
val priority: String = "normal",
val hasReply: Boolean = false,
val createdAt: String? = null,
val message: String? = null,
val reply: String? = null,
val repliedAt: String? = null,
)
data class CreateTicketRequest(
val subject: String,
val message: String,
val priority: String = "normal",
)
// Notifications & push
data class NotificationsListResponse(
val data: List<AppNotification> = emptyList(),
val unreadCount: Int = 0,
)
data class NotificationsResponse(
val data: List<AppNotification> = emptyList(),
val unreadCount: Int = 0,
)
data class AppNotification(
val id: String,
val type: String? = null,
val title: String,
val message: String,
val icon: String? = null,
val milestone: String? = null,
val url: String? = null,
val readAt: String? = null,
val createdAt: String? = null,
)
data class UnreadCountEnvelope(val unreadCount: Int = 0)
data class PushTokenRequest(
val token: String,
val platform: String = "android",
val deviceName: String = "Ladill Mini Android",
)
@@ -1,37 +0,0 @@
package com.ladill.mini.data.repository
import com.ladill.mini.data.api.ApiClient
import com.ladill.mini.data.model.NotificationsResponse
import com.ladill.mini.data.model.PushTokenRequest
class NotificationRepository(private val api: ApiClient) {
suspend fun getNotifications(): Result<NotificationsResponse> = runCatching {
val body = api.service.notifications()
NotificationsResponse(data = body.data, unreadCount = body.unreadCount)
}
suspend fun unreadCount(): Result<Int> = runCatching {
api.service.unreadNotificationCount().data.unreadCount
}
suspend fun markAllRead(): Result<Unit> = runCatching {
api.service.markAllNotificationsRead()
Unit
}
suspend fun markRead(id: String): Result<Unit> = runCatching {
api.service.markNotificationRead(id)
Unit
}
suspend fun registerPushToken(token: String): Result<Unit> = runCatching {
api.service.registerPushToken(PushTokenRequest(token = token))
Unit
}
suspend fun unregisterPushToken(token: String): Result<Unit> = runCatching {
api.service.unregisterPushToken(PushTokenRequest(token = token))
Unit
}
}
@@ -1,195 +0,0 @@
package com.ladill.mini.data.repository
import com.google.gson.JsonParser
import com.ladill.mini.data.api.ApiClient
import com.ladill.mini.data.model.AccountSettings
import com.ladill.mini.data.model.ChangePasswordRequest
import com.ladill.mini.data.model.CreatePaymentQrRequest
import com.ladill.mini.data.model.CreateTicketRequest
import com.ladill.mini.data.model.LoginRequest
import com.ladill.mini.data.model.LoginResult
import com.ladill.mini.data.model.NotificationPrefs
import com.ladill.mini.data.model.Overview
import com.ladill.mini.data.model.Payment
import com.ladill.mini.data.model.PaymentQr
import com.ladill.mini.data.model.PayoutAccount
import com.ladill.mini.data.model.Payouts
import com.ladill.mini.data.model.ProfileInfo
import com.ladill.mini.data.model.RegisterRequest
import com.ladill.mini.data.model.SupportTicket
import com.ladill.mini.data.model.TopupRequest
import com.ladill.mini.data.model.UpdatePaymentQrRequest
import com.ladill.mini.data.model.UpdatePayoutAccountRequest
import com.ladill.mini.data.model.UpdateProfileRequest
import com.ladill.mini.data.model.UpdateSettingsRequest
import com.ladill.mini.data.model.WalletInfo
import com.ladill.mini.data.model.Withdrawal
import com.ladill.mini.data.model.WithdrawRequest
import com.ladill.mini.push.PushTokenRegistrar
import com.ladill.mini.util.SessionManager
import com.ladill.mini.util.TokenStore
import android.content.Context
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
/** Turn an exception into a user-facing message, pulling Laravel's `message`/`errors`. */
fun Throwable.userMessage(): String = when (this) {
is HttpException -> parseHttpError(this)
is java.io.IOException -> "No connection. Check your network and try again."
else -> message ?: "Something went wrong. Please try again."
}
private fun parseHttpError(e: HttpException): String {
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
if (!raw.isNullOrBlank()) {
runCatching {
val obj = JsonParser.parseString(raw).asJsonObject
obj.get("errors")?.asJsonObject?.entrySet()?.firstOrNull()
?.value?.asJsonArray?.firstOrNull()?.asString
?.let { return it }
obj.get("message")?.asString?.let { return it }
}
}
return when (e.code()) {
401 -> "Your session expired. Please sign in again."
403 -> "You don't have permission to do that."
else -> "Request failed (${e.code()})."
}
}
class AuthRepository(
private val context: Context,
private val session: SessionManager,
private val tokenStore: TokenStore,
private val api: ApiClient,
) {
fun isLoggedIn(): Boolean = !session.getToken().isNullOrBlank()
fun cachedName(): String? = session.getName()
fun cachedEmail(): String? = session.getEmail()
fun cachedAvatarUrl(): String? = session.getAvatarUrl()
suspend fun login(email: String, password: String): Result<LoginResult> = runCatching {
val result = api.service.login(LoginRequest(email = email.trim(), password = password)).data
session.saveSession(result.token, result.user.name, result.user.email, result.user.avatarUrl)
tokenStore.update(result.token)
PushTokenRegistrar.registerIfLoggedIn(context)
result
}
suspend fun register(request: RegisterRequest): Result<LoginResult> = runCatching {
val result = api.service.register(request).data
session.saveSession(result.token, result.user.name, result.user.email, result.user.avatarUrl)
tokenStore.update(result.token)
PushTokenRegistrar.registerIfLoggedIn(context)
result
}
suspend fun logout() {
PushTokenRegistrar.unregister(context)
runCatching { api.service.logout() }
session.clear()
tokenStore.update(null)
}
}
class MiniRepository(private val api: ApiClient) {
suspend fun overview(): Result<Overview> = runCatching { api.service.overview().data }
suspend fun afiaChat(message: String, history: List<Pair<String, String>>): Result<String> = runCatching {
val response = api.service.afiaChat(
com.ladill.mini.data.model.AfiaChatRequest(
message = message,
history = history.map { (role, text) ->
com.ladill.mini.data.model.AfiaChatTurn(role, text)
},
),
)
response.reply?.takeIf { it.isNotBlank() }
?: response.message?.takeIf { it.isNotBlank() }
?: "Sorry, I could not respond."
}
suspend fun paymentQrs(): Result<List<PaymentQr>> = runCatching { api.service.paymentQrs().data }
suspend fun paymentQr(id: Long): Result<PaymentQr> = runCatching { api.service.paymentQr(id).data }
suspend fun createPaymentQr(label: String, businessName: String, branchLabel: String?): Result<PaymentQr> =
runCatching {
api.service.createPaymentQr(
CreatePaymentQrRequest(label.trim(), businessName.trim(), branchLabel?.trim()?.ifBlank { null }),
).data
}
suspend fun setActive(id: Long, active: Boolean): Result<PaymentQr> = runCatching {
api.service.updatePaymentQr(id, UpdatePaymentQrRequest(isActive = active)).data
}
suspend fun deletePaymentQr(id: Long): Result<Unit> = runCatching { api.service.deletePaymentQr(id) }
suspend fun payments(query: String? = null): Result<List<Payment>> =
runCatching { api.service.payments(query?.ifBlank { null }).data }
suspend fun payouts(): Result<Payouts> = runCatching { api.service.payouts().data }
// Account settings
suspend fun accountSettings(): Result<AccountSettings> = runCatching { api.service.accountSettings().data }
suspend fun updateSettings(prefs: NotificationPrefs): Result<NotificationPrefs> = runCatching {
api.service.updateSettings(
UpdateSettingsRequest(
notifyEmail = prefs.notifyEmail?.trim()?.ifBlank { null },
productUpdates = prefs.productUpdates,
notifyRegistrations = prefs.notifyRegistrations,
notifyPayouts = prefs.notifyPayouts,
),
).data
}
suspend fun updateProfile(name: String, phoneCc: String?, phone: String?): Result<ProfileInfo> = runCatching {
api.service.updateProfile(
UpdateProfileRequest(name.trim(), phoneCc?.trim()?.ifBlank { null }, phone?.trim()?.ifBlank { null }),
).data
}
suspend fun changePassword(current: String, password: String, confirm: String): Result<Unit> = runCatching {
api.service.changePassword(ChangePasswordRequest(current, password, confirm))
Unit
}
suspend fun uploadAvatar(bytes: ByteArray, filename: String, mime: String): Result<String?> = runCatching {
val body = bytes.toRequestBody(mime.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("avatar", filename, body)
api.service.uploadAvatar(part).data.avatarUrl
}
// Wallet
suspend fun wallet(): Result<WalletInfo> = runCatching { api.service.wallet().data }
suspend fun topup(amount: Double): Result<String> = runCatching {
api.service.walletTopup(TopupRequest(amount)).data.checkoutUrl
}
suspend fun banks(type: String): Result<List<com.ladill.mini.data.model.Bank>> = runCatching { api.service.banks(type).data }
suspend fun payoutAccount(): Result<PayoutAccount?> = runCatching { api.service.payoutAccount().data.payoutAccount }
suspend fun savePayoutAccount(req: UpdatePayoutAccountRequest): Result<PayoutAccount?> = runCatching {
api.service.updatePayoutAccount(req).data.payoutAccount
}
suspend fun withdrawals(): Result<List<Withdrawal>> = runCatching { api.service.withdrawals().data }
suspend fun withdraw(amount: Double): Result<Withdrawal> = runCatching { api.service.withdraw(WithdrawRequest(amount)).data }
// Support tickets
suspend fun supportTickets(): Result<List<SupportTicket>> = runCatching { api.service.supportTickets().data }
suspend fun supportTicket(id: Long): Result<SupportTicket> = runCatching { api.service.supportTicket(id).data }
suspend fun createTicket(subject: String, message: String, priority: String): Result<SupportTicket> = runCatching {
api.service.createSupportTicket(CreateTicketRequest(subject.trim(), message.trim(), priority)).data
}
}
@@ -1,17 +0,0 @@
package com.ladill.mini.navigation
object Route {
const val SPLASH = "splash"
const val LANDING = "landing"
const val LOGIN = "login"
const val REGISTER = "register"
const val PIN_LOCK = "pin_lock"
const val PIN_SETUP = "pin_setup"
const val HOME = "home"
const val PROFILE = "profile"
const val SETTINGS = "settings"
const val SECURITY = "security"
const val WALLET = "wallet"
const val BILLING = "billing"
const val SUPPORT = "support"
}
@@ -1,24 +0,0 @@
package com.ladill.mini.push
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
class LadillMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
PushTokenRegistrar.onTokenRefresh(applicationContext, token)
}
override fun onMessageReceived(message: RemoteMessage) {
val title = message.notification?.title ?: message.data["title"] ?: return
val body = message.notification?.body ?: message.data["body"] ?: return
val milestone = message.data["milestone"]
PushNotifications.show(
context = applicationContext,
title = title,
body = body,
milestone = milestone,
)
}
}
@@ -1,79 +0,0 @@
package com.ladill.mini.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.ladill.mini.MainActivity
import com.ladill.mini.R
object PushNotifications {
const val CHANNEL_PAYMENTS = "payments"
const val CHANNEL_GENERAL = "general"
fun ensureChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_PAYMENTS,
"Payments",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Instant alerts when customers pay you"
enableVibration(true)
},
)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_GENERAL,
"Account updates",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Payouts and account activity"
},
)
}
fun show(
context: Context,
title: String,
body: String,
milestone: String?,
notificationId: Int = (System.currentTimeMillis() % Int.MAX_VALUE).toInt(),
) {
ensureChannels(context)
val channel = if (milestone == "payment_received") CHANNEL_PAYMENTS else CHANNEL_GENERAL
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("open_notifications", true)
}
val pending = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, channel)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pending)
.build()
NotificationManagerCompat.from(context).notify(notificationId, notification)
}
}
@@ -1,74 +0,0 @@
package com.ladill.mini.push
import android.content.Context
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import com.ladill.mini.LadillMiniApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
object PushTokenRegistrar {
private const val TAG = "PushTokenRegistrar"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun registerIfLoggedIn(context: Context) {
if (!isFirebaseAvailable()) return
val app = context.applicationContext as? LadillMiniApp ?: return
if (!app.authRepository.isLoggedIn()) return
PushNotifications.ensureChannels(context)
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w(TAG, "FCM token fetch failed", task.exception)
return@addOnCompleteListener
}
val token = task.result ?: return@addOnCompleteListener
uploadToken(app, token)
}
}
fun onTokenRefresh(context: Context, token: String) {
val app = context.applicationContext as? LadillMiniApp ?: return
if (!app.authRepository.isLoggedIn()) return
uploadToken(app, token)
}
fun unregister(context: Context, onDone: (() -> Unit)? = null) {
if (!isFirebaseAvailable()) {
onDone?.invoke()
return
}
val app = context.applicationContext as? LadillMiniApp ?: run {
onDone?.invoke()
return
}
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
val token = task.result
scope.launch {
if (token != null) {
app.notificationRepository.unregisterPushToken(token)
}
onDone?.invoke()
}
}
}
private fun uploadToken(app: LadillMiniApp, token: String) {
scope.launch {
app.notificationRepository.registerPushToken(token)
.onFailure { e -> Log.w(TAG, "Push token upload failed: ${e.message}") }
}
}
private fun isFirebaseAvailable(): Boolean = runCatching {
FirebaseMessaging.getInstance()
true
}.getOrDefault(false)
}
@@ -1,228 +0,0 @@
package com.ladill.mini.ui.account
import android.app.Application
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.PaddingValues
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Payments
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ladill.mini.data.model.AppNotification
import com.ladill.mini.ui.components.AppIcon
import com.ladill.mini.ui.notifications.NotificationsViewModel
import com.ladill.mini.ui.theme.LadillIndigo
/** Shared back-arrow + title header for menu destination screens. */
@Composable
fun MenuScaffold(
title: String,
onBack: () -> Unit,
content: @Composable () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MaterialTheme.colorScheme.onSurface)
}
Text(title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface)
}
content()
}
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun NotificationsSheet(onDismiss: () -> Unit, onUnreadChanged: (Int) -> Unit = {}) {
val app = LocalContext.current.applicationContext as Application
val vm: NotificationsViewModel = viewModel(factory = NotificationsViewModel.Factory(app))
val state by vm.state.collectAsStateWithLifecycle()
androidx.compose.runtime.LaunchedEffect(state.unreadCount) {
onUnreadChanged(state.unreadCount)
}
androidx.compose.material3.ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = androidx.compose.material3.rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.weight(1f)) {
Text("Notifications", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface)
if (state.unreadCount > 0) {
Text(
"${state.unreadCount} unread",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (state.unreadCount > 0 && !state.loading) {
TextButton(onClick = vm::markAllRead, enabled = !state.markingAll) {
Text("Mark all read", color = LadillIndigo)
}
}
}
Spacer(Modifier.height(12.dp))
when {
state.loading -> Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = LadillIndigo)
}
state.error != null -> Box(Modifier.fillMaxWidth().padding(vertical = 32.dp), contentAlignment = Alignment.Center) {
Text(state.error ?: "Error", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = vm::refresh,
modifier = Modifier.fillMaxWidth().height(420.dp),
) {
if (state.notifications.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
AppIcon(name = "notifications", size = 40.dp, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(12.dp))
Text("You're all caught up", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
Text(
"Payment alerts and account updates will show up here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
} else {
LazyColumn(
contentPadding = PaddingValues(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
items(state.notifications, key = { it.id }) { notification ->
NotificationRow(
notification = notification,
onClick = { vm.onNotificationTap(notification) },
)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
}
}
}
}
}
}
}
}
@Composable
private fun NotificationRow(notification: AppNotification, onClick: () -> Unit) {
val unread = notification.readAt == null
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
) {
Surface(
shape = CircleShape,
color = if (unread) LadillIndigo.copy(alpha = 0.12f) else MaterialTheme.colorScheme.background,
modifier = Modifier.size(40.dp),
) {
Box(contentAlignment = Alignment.Center) {
val icon = when (notification.milestone) {
"payment_received" -> Icons.Outlined.Payments
"withdrawal_submitted", "wallet_credited" -> Icons.Outlined.AccountBalanceWallet
else -> Icons.Outlined.Notifications
}
Icon(
imageVector = icon,
contentDescription = null,
tint = if (unread) LadillIndigo else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
Spacer(Modifier.size(12.dp))
Column(Modifier.weight(1f)) {
Text(
notification.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
notification.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
if (unread) {
Box(
modifier = Modifier
.padding(start = 8.dp, top = 6.dp)
.size(8.dp)
.clip(CircleShape)
.background(LadillIndigo),
)
}
}
}
@@ -1,187 +0,0 @@
package com.ladill.mini.ui.account
import android.app.Application
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ladill.mini.LadillMiniApp
import com.ladill.mini.data.repository.userMessage
import com.ladill.mini.ui.components.LadillButton
import com.ladill.mini.ui.components.LadillCard
import com.ladill.mini.ui.components.UserAvatar
import com.ladill.mini.ui.theme.LadillIndigo
import com.ladill.mini.ui.theme.LadillMoney
import com.ladill.mini.ui.theme.White
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ProfileUiState(
val name: String = "",
val email: String = "",
val phone: String = "",
val avatarUrl: String? = null,
val saving: Boolean = false,
val uploading: Boolean = false,
val message: String? = null,
)
class ProfileViewModel(app: Application) : AndroidViewModel(app) {
private val application = app as LadillMiniApp
private val repo = application.miniRepository
private val session = application.sessionManager
private val _state = MutableStateFlow(
ProfileUiState(
name = session.getName() ?: "",
email = session.getEmail() ?: "",
avatarUrl = session.getAvatarUrl(),
),
)
val state: StateFlow<ProfileUiState> = _state.asStateFlow()
/** Refresh from the server so the saved phone (held on the central account) shows. */
fun load() = viewModelScope.launch {
repo.accountSettings().onSuccess { s ->
_state.update {
it.copy(
name = s.profile.name ?: it.name,
email = s.profile.email ?: it.email,
phone = s.profile.phone ?: it.phone,
)
}
}
}
fun onName(v: String) = _state.update { it.copy(name = v, message = null) }
fun onPhone(v: String) = _state.update { it.copy(phone = v, message = null) }
fun saveProfile() = viewModelScope.launch {
val s = _state.value
if (s.name.isBlank() || s.saving) return@launch
_state.update { it.copy(saving = true, message = null) }
repo.updateProfile(s.name, null, s.phone)
.onSuccess {
session.saveName(s.name)
_state.update { it.copy(saving = false, message = "Profile saved") }
}
.onFailure { e -> _state.update { it.copy(saving = false, message = e.userMessage()) } }
}
fun uploadAvatar(bytes: ByteArray, filename: String, mime: String) = viewModelScope.launch {
_state.update { it.copy(uploading = true, message = null) }
repo.uploadAvatar(bytes, filename, mime)
.onSuccess { url ->
session.saveAvatarUrl(url)
_state.update { it.copy(uploading = false, avatarUrl = url, message = "Photo updated") }
}
.onFailure { e -> _state.update { it.copy(uploading = false, message = e.userMessage()) } }
}
}
@Composable
fun ProfileScreen(onBack: () -> Unit, viewModel: ProfileViewModel = viewModel()) {
val s by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) { viewModel.load() }
val picker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? ->
if (uri != null) {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
if (bytes != null) {
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
val ext = when {
mime.contains("png") -> "png"
mime.contains("webp") -> "webp"
else -> "jpg"
}
viewModel.uploadAvatar(bytes, "avatar.$ext", mime)
}
}
}
MenuScaffold("Profile", onBack) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(contentAlignment = Alignment.BottomEnd) {
UserAvatar(name = s.name, photoUrl = s.avatarUrl, size = 96.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(LadillIndigo)
.clickable {
picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
) {
if (s.uploading) {
CircularProgressIndicator(color = White, strokeWidth = 2.dp, modifier = Modifier.size(16.dp))
} else {
Icon(Icons.Filled.PhotoCamera, contentDescription = "Change photo", tint = White, modifier = Modifier.size(18.dp))
}
}
}
Spacer(Modifier.height(20.dp))
LadillCard {
com.ladill.mini.ui.components.LadillTextField(s.name, viewModel::onName, "Full name", enabled = !s.saving)
Spacer(Modifier.height(12.dp))
com.ladill.mini.ui.components.LadillTextField(s.email, {}, "Email", enabled = false)
Spacer(Modifier.height(12.dp))
com.ladill.mini.ui.components.LadillTextField(s.phone, viewModel::onPhone, "Phone", placeholder = "24 123 4567", keyboardType = KeyboardType.Phone, enabled = !s.saving)
Spacer(Modifier.height(16.dp))
LadillButton(
text = if (s.saving) "Saving…" else "Save profile",
onClick = viewModel::saveProfile,
loading = s.saving,
enabled = s.name.isNotBlank(),
showArrow = false,
)
s.message?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = LadillMoney)
}
}
}
}
}
@@ -1,327 +0,0 @@
package com.ladill.mini.ui.account
import android.app.Application
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ladill.mini.LadillMiniApp
import com.ladill.mini.data.repository.userMessage
import com.ladill.mini.ui.common.ErrorState
import com.ladill.mini.ui.common.LoadingState
import com.ladill.mini.ui.components.LadillButton
import com.ladill.mini.ui.components.LadillCard
import com.ladill.mini.ui.components.LadillSecondaryButton
import com.ladill.mini.ui.components.LadillTextField
import com.ladill.mini.ui.components.UserAvatar
import com.ladill.mini.ui.security.PinSetupContent
import com.ladill.mini.ui.theme.LadillIndigo
import com.ladill.mini.util.BiometricAuth
import com.ladill.mini.util.BiometricUnlockStatus
import com.ladill.mini.ui.theme.LadillMoney
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SettingsUiState(
val loading: Boolean = true,
val loadError: String? = null,
val name: String = "",
val email: String = "",
val notifyEmail: String = "",
val productUpdates: Boolean = true,
val notifyRegistrations: Boolean = true,
val notifyPayouts: Boolean = true,
val currentPassword: String = "",
val newPassword: String = "",
val confirmPassword: String = "",
val savingProfile: Boolean = false,
val savingNotifications: Boolean = false,
val savingPassword: Boolean = false,
val profileMsg: String? = null,
val notificationsMsg: String? = null,
val passwordMsg: String? = null,
val passwordError: String? = null,
)
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val application = app as LadillMiniApp
private val repo = application.miniRepository
private val _state = MutableStateFlow(SettingsUiState())
val state: StateFlow<SettingsUiState> = _state.asStateFlow()
fun load() = viewModelScope.launch {
_state.update { it.copy(loading = true, loadError = null) }
repo.accountSettings()
.onSuccess { s ->
_state.update {
it.copy(
loading = false,
name = s.profile.name ?: "",
email = s.profile.email ?: "",
notifyEmail = s.notifications.notifyEmail ?: (s.profile.email ?: ""),
productUpdates = s.notifications.productUpdates,
notifyRegistrations = s.notifications.notifyRegistrations,
notifyPayouts = s.notifications.notifyPayouts,
)
}
}
.onFailure { e -> _state.update { it.copy(loading = false, loadError = e.userMessage()) } }
}
fun onName(v: String) = _state.update { it.copy(name = v, profileMsg = null) }
fun onNotifyEmail(v: String) = _state.update { it.copy(notifyEmail = v, notificationsMsg = null) }
fun onProductUpdates(v: Boolean) = _state.update { it.copy(productUpdates = v, notificationsMsg = null) }
fun onNotifyRegistrations(v: Boolean) = _state.update { it.copy(notifyRegistrations = v, notificationsMsg = null) }
fun onNotifyPayouts(v: Boolean) = _state.update { it.copy(notifyPayouts = v, notificationsMsg = null) }
fun onCurrentPassword(v: String) = _state.update { it.copy(currentPassword = v, passwordError = null, passwordMsg = null) }
fun onNewPassword(v: String) = _state.update { it.copy(newPassword = v, passwordError = null, passwordMsg = null) }
fun onConfirmPassword(v: String) = _state.update { it.copy(confirmPassword = v, passwordError = null, passwordMsg = null) }
fun saveProfile() = viewModelScope.launch {
val s = _state.value
if (s.name.isBlank() || s.savingProfile) return@launch
_state.update { it.copy(savingProfile = true, profileMsg = null) }
repo.updateProfile(s.name, null, null)
.onSuccess { _state.update { it.copy(savingProfile = false, profileMsg = "Profile saved") } }
.onFailure { e -> _state.update { it.copy(savingProfile = false, profileMsg = e.userMessage()) } }
}
fun saveNotifications() = viewModelScope.launch {
val s = _state.value
if (s.savingNotifications) return@launch
_state.update { it.copy(savingNotifications = true, notificationsMsg = null) }
repo.updateSettings(
com.ladill.mini.data.model.NotificationPrefs(
notifyEmail = s.notifyEmail,
productUpdates = s.productUpdates,
notifyRegistrations = s.notifyRegistrations,
notifyPayouts = s.notifyPayouts,
),
)
.onSuccess { _state.update { it.copy(savingNotifications = false, notificationsMsg = "Preferences saved") } }
.onFailure { e -> _state.update { it.copy(savingNotifications = false, notificationsMsg = e.userMessage()) } }
}
fun changePassword() = viewModelScope.launch {
val s = _state.value
if (s.savingPassword) return@launch
if (s.newPassword.length < 8) {
_state.update { it.copy(passwordError = "New password must be at least 8 characters.") }
return@launch
}
if (s.newPassword != s.confirmPassword) {
_state.update { it.copy(passwordError = "Passwords do not match.") }
return@launch
}
_state.update { it.copy(savingPassword = true, passwordError = null, passwordMsg = null) }
repo.changePassword(s.currentPassword, s.newPassword, s.confirmPassword)
.onSuccess {
_state.update {
it.copy(savingPassword = false, passwordMsg = "Password updated", currentPassword = "", newPassword = "", confirmPassword = "")
}
}
.onFailure { e -> _state.update { it.copy(savingPassword = false, passwordError = e.userMessage()) } }
}
fun logout(onDone: () -> Unit) = viewModelScope.launch {
application.authRepository.logout()
onDone()
}
}
@Composable
fun SettingsScreen(
onBack: () -> Unit,
viewModel: SettingsViewModel = viewModel(),
) {
val s by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.load() }
MenuScaffold("Settings", onBack) {
when {
s.loading -> LoadingState()
s.loadError != null -> ErrorState(s.loadError!!, onRetry = { viewModel.load() })
else -> Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
SectionTitle("Notifications")
LadillCard {
LadillTextField(s.notifyEmail, viewModel::onNotifyEmail, "Notifications email", keyboardType = KeyboardType.Email, enabled = !s.savingNotifications)
Spacer(Modifier.height(8.dp))
ToggleRow("New payments", s.notifyRegistrations, viewModel::onNotifyRegistrations)
ToggleRow("Payout updates", s.notifyPayouts, viewModel::onNotifyPayouts)
ToggleRow("Product updates", s.productUpdates, viewModel::onProductUpdates)
Spacer(Modifier.height(12.dp))
LadillButton(
text = if (s.savingNotifications) "Saving…" else "Save preferences",
onClick = viewModel::saveNotifications,
loading = s.savingNotifications,
showArrow = false,
)
s.notificationsMsg?.let { StatusLine(it) }
}
Spacer(Modifier.height(24.dp))
}
}
}
}
/** Security page — password change + PIN/biometric app lock. */
@Composable
fun SecurityScreen(
onBack: () -> Unit,
viewModel: SettingsViewModel = viewModel(),
) {
val s by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val pin = remember { (context.applicationContext as LadillMiniApp).pinManager }
var hasPin by remember { mutableStateOf(pin.hasPin()) }
var biometricEnabled by remember { mutableStateOf(pin.isBiometricEnabled()) }
var showSetup by remember { mutableStateOf(false) }
MenuScaffold("Security", onBack) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
SectionTitle("Change password")
LadillCard {
LadillTextField(s.currentPassword, viewModel::onCurrentPassword, "Current password", isPassword = true, enabled = !s.savingPassword)
Spacer(Modifier.height(10.dp))
LadillTextField(s.newPassword, viewModel::onNewPassword, "New password", isPassword = true, enabled = !s.savingPassword)
Spacer(Modifier.height(10.dp))
LadillTextField(
s.confirmPassword, viewModel::onConfirmPassword, "Confirm new password",
isPassword = true, enabled = !s.savingPassword, error = s.passwordError,
)
Spacer(Modifier.height(14.dp))
LadillButton(
text = if (s.savingPassword) "Updating…" else "Update password",
onClick = viewModel::changePassword,
loading = s.savingPassword,
enabled = s.currentPassword.isNotBlank() && s.newPassword.isNotBlank() && s.confirmPassword.isNotBlank(),
showArrow = false,
)
s.passwordMsg?.let { StatusLine(it) }
}
SectionTitle("App lock")
LadillCard {
Text(
if (hasPin) "A 4-digit PIN is set. Ladill Mini locks when you leave the app." else "Set a 4-digit PIN to lock the app.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
if (!hasPin) {
LadillButton(text = "Set up PIN", onClick = { showSetup = true }, showArrow = false)
} else {
val bioStatus = remember { BiometricAuth.status(context, true) }
ToggleRow("Unlock with fingerprint / face", biometricEnabled && bioStatus == BiometricUnlockStatus.Ready) {
biometricEnabled = it
pin.setBiometricEnabled(it)
}
when (bioStatus) {
BiometricUnlockStatus.NoneEnrolled -> StatusLine("No fingerprint or face enrolled. Add one in device settings.")
BiometricUnlockStatus.Unavailable -> StatusLine("Biometrics aren't available on this device.")
else -> {}
}
Spacer(Modifier.height(8.dp))
LadillSecondaryButton(text = "Change PIN", onClick = { showSetup = true })
Spacer(Modifier.height(8.dp))
LadillSecondaryButton(text = "Turn off app lock", onClick = {
pin.clearPin()
hasPin = false
})
}
}
Spacer(Modifier.height(24.dp))
}
}
if (showSetup) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Box(modifier = Modifier.fillMaxSize()) {
PinSetupContent(onPinSet = { code ->
pin.savePin(code)
hasPin = true
showSetup = false
})
IconButton(onClick = { showSetup = false }, modifier = Modifier.statusBarsPadding().padding(4.dp)) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Cancel", tint = MaterialTheme.colorScheme.onSurface)
}
}
}
}
}
@Composable
private fun SectionTitle(text: String) {
Spacer(Modifier.height(22.dp))
Text(text, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 10.dp))
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
Switch(
checked = checked,
onCheckedChange = onChange,
colors = SwitchDefaults.colors(checkedTrackColor = LadillIndigo),
)
}
}
@Composable
private fun StatusLine(text: String) {
Spacer(Modifier.height(8.dp))
Text(text, style = MaterialTheme.typography.bodySmall, color = LadillMoney)
}
@@ -1,248 +0,0 @@
package com.ladill.mini.ui.account
import android.app.Application
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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 androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ladill.mini.LadillMiniApp
import com.ladill.mini.data.model.SupportTicket
import com.ladill.mini.data.repository.userMessage
import com.ladill.mini.ui.common.EmptyState
import com.ladill.mini.ui.common.ErrorState
import com.ladill.mini.ui.common.LoadingState
import com.ladill.mini.ui.common.UiState
import com.ladill.mini.ui.components.LadillButton
import com.ladill.mini.ui.components.LadillCard
import com.ladill.mini.ui.components.LadillTextField
import com.ladill.mini.ui.components.StatusPill
import com.ladill.mini.util.formatDate
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SupportUiState(
val tickets: UiState<List<SupportTicket>> = UiState.Loading,
val showCreate: Boolean = false,
val subject: String = "",
val message: String = "",
val creating: Boolean = false,
val createError: String? = null,
val open: SupportTicket? = null,
)
class SupportViewModel(app: Application) : AndroidViewModel(app) {
private val repo = (app as LadillMiniApp).miniRepository
private val _state = MutableStateFlow(SupportUiState())
val state: StateFlow<SupportUiState> = _state.asStateFlow()
fun load() = viewModelScope.launch {
_state.update { it.copy(tickets = UiState.Loading) }
repo.supportTickets()
.onSuccess { list -> _state.update { it.copy(tickets = UiState.Success(list)) } }
.onFailure { e -> _state.update { it.copy(tickets = UiState.Error(e.userMessage())) } }
}
fun openCreate() = _state.update { it.copy(showCreate = true, subject = "", message = "", createError = null) }
fun dismissCreate() = _state.update { it.copy(showCreate = false) }
fun onSubject(v: String) = _state.update { it.copy(subject = v, createError = null) }
fun onMessage(v: String) = _state.update { it.copy(message = v, createError = null) }
fun create() = viewModelScope.launch {
val s = _state.value
if (s.subject.isBlank() || s.message.isBlank() || s.creating) return@launch
_state.update { it.copy(creating = true, createError = null) }
repo.createTicket(s.subject, s.message, "normal")
.onSuccess {
_state.update { it.copy(creating = false, showCreate = false) }
load()
}
.onFailure { e -> _state.update { it.copy(creating = false, createError = e.userMessage()) } }
}
fun openTicket(id: Long) = viewModelScope.launch {
repo.supportTicket(id).onSuccess { t -> _state.update { it.copy(open = t) } }
}
fun closeTicket() = _state.update { it.copy(open = null) }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SupportScreen(onBack: () -> Unit, viewModel: SupportViewModel = viewModel()) {
val s by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.load() }
MenuScaffold("Support", onBack) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
Text(
"Open a ticket and our team will reply. You'll see responses here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(14.dp))
LadillButton(text = "New ticket", onClick = viewModel::openCreate, showArrow = false)
Spacer(Modifier.height(16.dp))
when (val t = s.tickets) {
is UiState.Loading -> LoadingState()
is UiState.Error -> ErrorState(t.message, onRetry = { viewModel.load() })
is UiState.Success -> {
if (t.data.isEmpty()) {
EmptyState("No tickets yet", "Tap New ticket to reach our support team.")
} else {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
t.data.forEach { ticket ->
TicketRow(ticket) { viewModel.openTicket(ticket.id) }
Spacer(Modifier.height(10.dp))
}
Spacer(Modifier.height(24.dp))
}
}
}
}
}
}
if (s.showCreate) {
CreateTicketSheet(s, viewModel)
}
s.open?.let { TicketDetailSheet(it, viewModel::closeTicket) }
}
@Composable
private fun TicketRow(ticket: SupportTicket, onClick: () -> Unit) {
LadillCard(contentPadding = 16) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(modifier = Modifier.weight(1f)) {
Text(ticket.subject, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
val meta = listOfNotNull(ticket.ticketRef, formatDate(ticket.createdAt).ifBlank { null }).joinToString(" · ")
if (meta.isNotBlank()) {
Text(meta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
}
}
TicketStatusPill(ticket.status)
}
}
}
@Composable
private fun TicketStatusPill(status: String) {
val (label, fg, bg) = when (status) {
"open" -> Triple("Open", Color(0xFFB45309), Color(0xFFFEF3C7))
"resolved", "closed" -> Triple("Resolved", Color(0xFF047857), Color(0xFFD1FAE5))
"pending" -> Triple("Awaiting you", Color(0xFF4338CA), Color(0xFFEEF2FF))
else -> Triple(status.replaceFirstChar { it.uppercase() }, Color(0xFF64748B), Color(0xFFE2E8F0))
}
StatusPill(text = label, fg = fg, bg = bg)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreateTicketSheet(s: SupportUiState, vm: SupportViewModel) {
ModalBottomSheet(
onDismissRequest = vm::dismissCreate,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp).imePadding()) {
Text("New ticket", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.height(16.dp))
LadillTextField(s.subject, vm::onSubject, "Subject", placeholder = "What do you need help with?", enabled = !s.creating)
Spacer(Modifier.height(12.dp))
Text("Message", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 6.dp))
OutlinedTextField(
value = s.message,
onValueChange = vm::onMessage,
modifier = Modifier.fillMaxWidth().height(140.dp),
enabled = !s.creating,
shape = RoundedCornerShape(14.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = com.ladill.mini.ui.theme.LadillIndigo,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
),
)
s.createError?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.height(18.dp))
LadillButton(
text = if (s.creating) "Sending…" else "Submit ticket",
onClick = vm::create,
loading = s.creating,
enabled = s.subject.isNotBlank() && s.message.isNotBlank(),
)
Spacer(Modifier.height(8.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TicketDetailSheet(ticket: SupportTicket, onDismiss: () -> Unit) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(),
containerColor = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(ticket.subject, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
TicketStatusPill(ticket.status)
}
ticket.ticketRef?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
}
Spacer(Modifier.height(16.dp))
Bubble("You", ticket.message ?: "", false)
if (!ticket.reply.isNullOrBlank()) {
Spacer(Modifier.height(10.dp))
Bubble("Support", ticket.reply, true)
} else {
Spacer(Modifier.height(12.dp))
Text("Awaiting a reply from our team.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.height(8.dp))
}
}
}
@Composable
private fun Bubble(author: String, body: String, support: Boolean) {
LadillCard(contentPadding = 14) {
Text(author, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = if (support) com.ladill.mini.ui.theme.LadillIndigo else MaterialTheme.colorScheme.onSurfaceVariant)
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 4.dp))
}
}
@@ -1,393 +0,0 @@
package com.ladill.mini.ui.account
import android.app.Application
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ladill.mini.LadillMiniApp
import com.ladill.mini.data.model.Bank
import com.ladill.mini.data.model.PayoutAccount
import com.ladill.mini.data.model.UpdatePayoutAccountRequest
import com.ladill.mini.data.model.WalletInfo
import com.ladill.mini.data.model.Withdrawal
import com.ladill.mini.data.repository.userMessage
import com.ladill.mini.ui.common.ErrorState
import com.ladill.mini.ui.common.LoadingState
import com.ladill.mini.ui.common.UiState
import com.ladill.mini.ui.components.LadillButton
import com.ladill.mini.ui.components.LadillCard
import com.ladill.mini.ui.components.LadillSecondaryButton
import com.ladill.mini.ui.components.LadillTextField
import com.ladill.mini.ui.components.StatCard
import com.ladill.mini.ui.components.StatusPill
import com.ladill.mini.ui.theme.LadillIndigo
import com.ladill.mini.ui.theme.LadillIndigoSoft
import com.ladill.mini.ui.theme.LadillMoney
import com.ladill.mini.util.formatMoney
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class WalletUiState(
val wallet: UiState<WalletInfo> = UiState.Loading,
val payout: PayoutAccount? = null,
val withdrawals: List<Withdrawal> = emptyList(),
val withdrawAmount: String = "",
val withdrawing: Boolean = false,
val withdrawError: String? = null,
val withdrawMsg: String? = null,
val showPayoutSheet: Boolean = false,
val pType: String = "mobile_money",
val pName: String = "",
val pNumber: String = "",
val pBankName: String = "",
val pBankCode: String = "",
val banks: List<Bank> = emptyList(),
val loadingBanks: Boolean = false,
val savingPayout: Boolean = false,
val payoutError: String? = null,
)
class WalletViewModel(app: Application) : AndroidViewModel(app) {
private val repo = (app as LadillMiniApp).miniRepository
private val _state = MutableStateFlow(WalletUiState())
val state: StateFlow<WalletUiState> = _state.asStateFlow()
fun load() = viewModelScope.launch {
_state.update { it.copy(wallet = UiState.Loading) }
repo.wallet()
.onSuccess { w -> _state.update { it.copy(wallet = UiState.Success(w)) } }
.onFailure { e -> _state.update { it.copy(wallet = UiState.Error(e.userMessage())) } }
repo.payoutAccount().onSuccess { p -> _state.update { it.copy(payout = p) } }
repo.withdrawals().onSuccess { list -> _state.update { it.copy(withdrawals = list) } }
}
fun onWithdrawAmount(v: String) = _state.update { it.copy(withdrawAmount = v.filter { c -> c.isDigit() || c == '.' }, withdrawError = null, withdrawMsg = null) }
fun withdraw() = viewModelScope.launch {
val amount = _state.value.withdrawAmount.toDoubleOrNull()
if (amount == null || amount < 1) {
_state.update { it.copy(withdrawError = "Enter an amount of at least GHS 1.") }
return@launch
}
_state.update { it.copy(withdrawing = true, withdrawError = null, withdrawMsg = null) }
repo.withdraw(amount)
.onSuccess {
_state.update { it.copy(withdrawing = false, withdrawAmount = "", withdrawMsg = "Withdrawal of ${formatMoney((amount * 100).toLong())} requested") }
load()
}
.onFailure { e -> _state.update { it.copy(withdrawing = false, withdrawError = e.userMessage()) } }
}
fun openPayoutSheet() {
_state.update {
it.copy(
showPayoutSheet = true,
payoutError = null,
pType = it.payout?.accountType ?: "mobile_money",
pName = it.payout?.accountName ?: "",
pNumber = it.payout?.accountNumber ?: "",
pBankName = it.payout?.bankName ?: "",
pBankCode = it.payout?.bankCode ?: "",
)
}
loadBanks(_state.value.pType)
}
fun closePayoutSheet() = _state.update { it.copy(showPayoutSheet = false) }
fun onPType(v: String) {
_state.update { it.copy(pType = v, pBankName = "", pBankCode = "", payoutError = null) }
loadBanks(v)
}
fun onPName(v: String) = _state.update { it.copy(pName = v, payoutError = null) }
fun onPNumber(v: String) = _state.update { it.copy(pNumber = v, payoutError = null) }
fun onBankSelected(b: Bank) = _state.update { it.copy(pBankName = b.name, pBankCode = b.code, payoutError = null) }
private fun loadBanks(type: String) = viewModelScope.launch {
_state.update { it.copy(loadingBanks = true) }
repo.banks(type)
.onSuccess { list -> _state.update { it.copy(banks = list, loadingBanks = false) } }
.onFailure { _state.update { it.copy(banks = emptyList(), loadingBanks = false) } }
}
fun savePayout() = viewModelScope.launch {
val s = _state.value
if (s.pName.isBlank() || s.pNumber.isBlank() || s.pBankCode.isBlank()) {
_state.update { it.copy(payoutError = "Select a provider and fill in your account details.") }
return@launch
}
_state.update { it.copy(savingPayout = true, payoutError = null) }
repo.savePayoutAccount(UpdatePayoutAccountRequest(s.pType, s.pName, s.pNumber, s.pBankCode, s.pBankName, "GHS"))
.onSuccess { p -> _state.update { it.copy(savingPayout = false, showPayoutSheet = false, payout = p) } }
.onFailure { e -> _state.update { it.copy(savingPayout = false, payoutError = e.userMessage()) } }
}
}
@Composable
fun BillingScreen(onBack: () -> Unit, viewModel: WalletViewModel = viewModel()) {
val s by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.load() }
MenuScaffold("Billing & usage", onBack) {
Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp)) {
when (val w = s.wallet) {
is UiState.Loading -> LoadingState()
is UiState.Error -> ErrorState(w.message, onRetry = { viewModel.load() })
is UiState.Success -> {
val data = w.data
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
StatCard("Collected", formatMoney(data.creditedMinor, data.currency), modifier = Modifier.weight(1f))
StatCard("Platform fees", formatMoney(data.spentMinor, data.currency), modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(12.dp))
LadillCard {
Text("Usage", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
Text(
"Ladill Mini deducts a small platform fee from each payment you collect. Your balance is what's left to withdraw.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
fun WalletScreen(onBack: () -> Unit, viewModel: WalletViewModel = viewModel()) {
val s by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.load() }
if (s.showPayoutSheet) PayoutSheet(s, viewModel)
MenuScaffold("Wallet", onBack) {
Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp)) {
when (val w = s.wallet) {
is UiState.Loading -> LoadingState()
is UiState.Error -> ErrorState(w.message, onRetry = { viewModel.load() })
is UiState.Success -> {
val data = w.data
StatCard("Wallet balance", formatMoney(data.balanceMinor, data.currency), highlight = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
StatCard("Collected", formatMoney(data.creditedMinor, data.currency), modifier = Modifier.weight(1f))
StatCard("Fees", formatMoney(data.spentMinor, data.currency), modifier = Modifier.weight(1f))
}
Section("Withdraw")
WithdrawSection(s, viewModel)
if (s.withdrawals.isNotEmpty()) {
Section("Recent withdrawals")
s.withdrawals.forEach { WithdrawalRow(it) }
}
}
}
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun WithdrawSection(s: WalletUiState, vm: WalletViewModel) {
LadillCard {
if (s.payout == null) {
Text("Add a payout account to withdraw your balance.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(12.dp))
LadillButton(text = "Add payout account", onClick = vm::openPayoutSheet, showArrow = false)
} else {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(s.payout.bankName ?: "Payout account", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
Text(maskAccount(s.payout.accountNumber), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text("Change", style = MaterialTheme.typography.bodyMedium, color = LadillIndigo, modifier = Modifier.clickable(onClick = vm::openPayoutSheet))
}
Spacer(Modifier.height(14.dp))
LadillTextField(s.withdrawAmount, vm::onWithdrawAmount, "Amount (GHS)", placeholder = "100", keyboardType = KeyboardType.Decimal, enabled = !s.withdrawing, error = s.withdrawError)
Spacer(Modifier.height(14.dp))
LadillButton(
text = if (s.withdrawing) "Requesting…" else "Withdraw",
onClick = vm::withdraw,
loading = s.withdrawing,
enabled = s.withdrawAmount.isNotBlank(),
)
s.withdrawMsg?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = LadillMoney)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PayoutSheet(s: WalletUiState, vm: WalletViewModel) {
ModalBottomSheet(
onDismissRequest = vm::closePayoutSheet,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 24.dp).imePadding()) {
Text("Payout account", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TypeChip("Mobile money", s.pType == "mobile_money") { vm.onPType("mobile_money") }
TypeChip("Bank", s.pType == "bank_account") { vm.onPType("bank_account") }
}
Spacer(Modifier.height(14.dp))
BankDropdown(
label = if (s.pType == "mobile_money") "Network" else "Bank",
selected = s.pBankName,
banks = s.banks,
loading = s.loadingBanks,
enabled = !s.savingPayout,
onSelect = vm::onBankSelected,
)
Spacer(Modifier.height(12.dp))
LadillTextField(s.pName, vm::onPName, "Account name", enabled = !s.savingPayout)
Spacer(Modifier.height(10.dp))
LadillTextField(
s.pNumber, vm::onPNumber,
if (s.pType == "mobile_money") "Mobile number" else "Account number",
keyboardType = KeyboardType.Number, enabled = !s.savingPayout, error = s.payoutError,
)
Spacer(Modifier.height(18.dp))
LadillButton(
text = if (s.savingPayout) "Saving…" else "Save payout account",
onClick = vm::savePayout,
loading = s.savingPayout,
enabled = s.pName.isNotBlank() && s.pNumber.isNotBlank() && s.pBankCode.isNotBlank(),
showArrow = false,
)
Spacer(Modifier.height(8.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BankDropdown(
label: String,
selected: String,
banks: List<Bank>,
loading: Boolean,
enabled: Boolean,
onSelect: (Bank) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 6.dp))
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { if (enabled && !loading) expanded = it }) {
OutlinedTextField(
value = if (loading) "Loading…" else selected,
onValueChange = {},
readOnly = true,
enabled = enabled,
placeholder = { Text("Select $label") },
trailingIcon = { Icon(Icons.Filled.ArrowDropDown, contentDescription = null) },
shape = RoundedCornerShape(14.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = LadillIndigo,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
),
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
banks.forEach { bank ->
DropdownMenuItem(text = { Text(bank.name) }, onClick = { onSelect(bank); expanded = false })
}
}
}
}
}
@Composable
private fun TypeChip(label: String, selected: Boolean, onClick: () -> Unit) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = if (selected) LadillIndigo else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.border(1.dp, if (selected) LadillIndigo else MaterialTheme.colorScheme.outline, RoundedCornerShape(999.dp))
.background(if (selected) LadillIndigoSoft else Color.Transparent, RoundedCornerShape(999.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
@Composable
private fun WithdrawalRow(w: Withdrawal) {
LadillCard(contentPadding = 14) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(formatMoney((w.amountGhs * 100).toLong()), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
val (label, fg, bg) = when (w.status) {
"completed" -> Triple("Completed", Color(0xFF047857), Color(0xFFD1FAE5))
"processing" -> Triple("Processing", Color(0xFF4338CA), Color(0xFFEEF2FF))
"failed" -> Triple("Failed", Color(0xFFB91C1C), Color(0xFFFEE2E2))
else -> Triple("Pending", Color(0xFFB45309), Color(0xFFFEF3C7))
}
StatusPill(text = label, fg = fg, bg = bg)
}
}
}
private fun maskAccount(number: String?): String {
val n = number?.trim().orEmpty()
return if (n.length <= 4) n else "•••• ${n.takeLast(4)}"
}
@Composable
private fun Section(title: String) {
Spacer(Modifier.height(22.dp))
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 10.dp))
}
@@ -1,286 +0,0 @@
package com.ladill.mini.ui.afia
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ladill.mini.ui.theme.LadillBrandGreen
import com.ladill.mini.ui.theme.LadillIndigo
import com.ladill.mini.ui.theme.LadillIndigoText
import com.ladill.mini.ui.theme.White
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AfiaSheet(
onDismiss: () -> Unit,
viewModel: AfiaViewModel = viewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LaunchedEffect(state.messages.size, state.loading) {
val last = state.messages.lastIndex + if (state.loading) 1 else 0
if (last >= 0) listState.animateScrollToItem(last)
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 420.dp, max = 640.dp)
.navigationBarsPadding()
.imePadding(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 4.dp),
) {
AfiaOrb(modifier = Modifier.size(36.dp))
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text("Afia", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text("Mini assistant", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
itemsIndexed(state.messages) { _, message ->
ChatBubble(message)
}
if (state.loading) {
item {
Row(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
}
}
}
}
}
if (state.messages.size <= 1) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
viewModel.suggestions.forEach { suggestion ->
SuggestionChip(text = suggestion, enabled = !state.loading) {
viewModel.useSuggestion(suggestion)
}
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
OutlinedTextField(
value = state.input,
onValueChange = viewModel::onInput,
modifier = Modifier.weight(1f),
enabled = !state.loading,
placeholder = { Text("Message Afia…") },
singleLine = true,
shape = RoundedCornerShape(14.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = LadillIndigo,
cursorColor = LadillIndigo,
),
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = viewModel::send,
enabled = !state.loading && state.input.isNotBlank(),
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
Brush.horizontalGradient(
colors = listOf(LadillIndigo, LadillBrandGreen),
),
),
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = White)
}
}
Text(
"Afia can make mistakes — verify important details.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
)
}
}
}
@Composable
fun AfiaTopBarButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(36.dp)
.clip(RoundedCornerShape(12.dp))
.background(Brush.horizontalGradient(listOf(LadillIndigo, Color(0xFF2563EB), LadillBrandGreen)))
.clickable(onClick = onClick),
) {
Icon(Icons.Filled.AutoAwesome, contentDescription = "AI assistant", tint = White, modifier = Modifier.size(20.dp))
}
}
@Composable
private fun AfiaOrb(modifier: Modifier = Modifier) {
Box(contentAlignment = Alignment.Center, modifier = modifier) {
Box(
modifier = Modifier
.matchParentSize()
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(Color(0xFFA5B4FC), LadillIndigo, Color(0xFF3730A3)),
),
),
)
Box(
modifier = Modifier
.size(10.dp)
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(LadillBrandGreen)
.border(2.dp, White, CircleShape),
)
}
}
@Composable
private fun ChatBubble(message: AfiaMessage) {
val isUser = message.role == "user"
val textColor = if (isUser) White else MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Text(
text = formatAfiaMarkdown(message.text, textColor),
style = MaterialTheme.typography.bodyMedium,
color = textColor,
modifier = Modifier
.fillMaxWidth(0.88f)
.clip(
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (isUser) 16.dp else 4.dp,
bottomEnd = if (isUser) 4.dp else 16.dp,
),
)
.background(if (isUser) LadillIndigo else MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
}
/** Renders `**bold**` segments — matches the web Afia chat formatter. */
private fun formatAfiaMarkdown(text: String, color: Color): AnnotatedString = buildAnnotatedString {
val bold = Regex("\\*\\*([^*]+?)\\*\\*")
var cursor = 0
for (match in bold.findAll(text)) {
if (match.range.first > cursor) {
append(text.substring(cursor, match.range.first))
}
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = color)) {
append(match.groupValues[1])
}
cursor = match.range.last + 1
}
if (cursor < text.length) append(text.substring(cursor))
}
@Composable
private fun SuggestionChip(text: String, enabled: Boolean, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surface)
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text("", color = LadillIndigoText, style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.width(8.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
}
}
@@ -1,80 +0,0 @@
package com.ladill.mini.ui.afia
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.ladill.mini.LadillMiniApp
import com.ladill.mini.data.repository.userMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AfiaMessage(val role: String, val text: String)
data class AfiaUiState(
val messages: List<AfiaMessage> = listOf(
AfiaMessage("assistant", "Hi, I'm Afia 👋 Ask me about payment QRs — creating one, accepting payments, payouts, or the 5% platform fee…"),
),
val input: String = "",
val loading: Boolean = false,
)
class AfiaViewModel(app: Application) : AndroidViewModel(app) {
private val repo = (app as LadillMiniApp).miniRepository
private val _state = MutableStateFlow(AfiaUiState())
val state: StateFlow<AfiaUiState> = _state.asStateFlow()
val suggestions = SUGGESTIONS
fun onInput(value: String) = _state.update { it.copy(input = value) }
fun useSuggestion(text: String) {
_state.update { it.copy(input = text) }
send()
}
fun send() = viewModelScope.launch {
val text = _state.value.input.trim()
if (text.isEmpty() || _state.value.loading) return@launch
val history = _state.value.messages.map { it.role to it.text }
_state.update {
it.copy(
messages = it.messages + AfiaMessage("user", text),
input = "",
loading = true,
)
}
repo.afiaChat(text, history)
.onSuccess { reply ->
_state.update {
it.copy(
messages = it.messages + AfiaMessage("assistant", reply),
loading = false,
)
}
}
.onFailure { e ->
_state.update {
it.copy(
messages = it.messages + AfiaMessage("assistant", e.userMessage()),
loading = false,
)
}
}
}
private companion object {
val SUGGESTIONS = listOf(
"How do I create a payment QR?",
"How do customers pay me?",
"When do payouts reach my wallet?",
"What is the platform fee?",
)
}
}
@@ -1,50 +0,0 @@
package com.ladill.mini.ui.auth
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.ladill.mini.LadillMiniApp
import com.ladill.mini.data.repository.userMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class LoginUiState(
val email: String = "",
val password: String = "",
val loading: Boolean = false,
val error: String? = null,
)
class AuthViewModel(app: Application) : AndroidViewModel(app) {
private val repo = (app as LadillMiniApp).authRepository
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
fun onEmail(value: String) = _state.update { it.copy(email = value, error = null) }
fun onPassword(value: String) = _state.update { it.copy(password = value, error = null) }
val canSubmit: Boolean
get() = _state.value.email.isNotBlank() && _state.value.password.isNotBlank()
fun login(onSuccess: () -> Unit) {
val current = _state.value
if (current.loading || current.email.isBlank() || current.password.isBlank()) return
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
repo.login(current.email, current.password)
.onSuccess {
_state.update { it.copy(loading = false) }
onSuccess()
}
.onFailure { e ->
_state.update { it.copy(loading = false, error = e.userMessage()) }
}
}
}
}
@@ -1,166 +0,0 @@
package com.ladill.mini.ui.auth
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ladill.mini.ui.components.LadillButton
import com.ladill.mini.ui.components.LadillTextField
import com.ladill.mini.ui.theme.LadillIndigo
@Composable
fun LoginScreen(
onLoggedIn: () -> Unit,
onCreateAccount: () -> Unit,
onBack: () -> Unit,
viewModel: AuthViewModel = viewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var step by remember { mutableIntStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
.imePadding(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
) {
IconButton(onClick = { if (step > 0) step-- else onBack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MaterialTheme.colorScheme.onSurface)
}
}
StepBar(current = step, total = 2, modifier = Modifier.padding(horizontal = 24.dp))
AnimatedContent(
targetState = step,
transitionSpec = {
if (targetState > initialState) {
(slideInHorizontally { it / 2 } + fadeIn()) togetherWith (slideOutHorizontally { -it / 2 } + fadeOut())
} else {
(slideInHorizontally { -it / 2 } + fadeIn()) togetherWith (slideOutHorizontally { it / 2 } + fadeOut())
}
},
label = "login_step",
modifier = Modifier.weight(1f),
) { currentStep ->
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)) {
when (currentStep) {
0 -> {
Text("Welcome back", style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
Text(
"Enter your email to continue",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
Spacer(Modifier.height(28.dp))
LadillTextField(
value = state.email,
onValueChange = viewModel::onEmail,
label = "Email",
placeholder = "you@example.com",
keyboardType = KeyboardType.Email,
enabled = !state.loading,
)
Spacer(Modifier.height(28.dp))
LadillButton(text = "Continue", onClick = { step = 1 }, enabled = state.email.isNotBlank())
Spacer(Modifier.height(16.dp))
Text(
"New to Ladill? Create an account",
style = MaterialTheme.typography.bodyMedium,
color = LadillIndigo,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().clickable(onClick = onCreateAccount).padding(vertical = 8.dp),
)
}
else -> {
Text("Enter your password", style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
Text(
"Signing in as",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
Spacer(Modifier.height(8.dp))
Box(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(20.dp))
.padding(horizontal = 14.dp, vertical = 8.dp)
.widthIn(max = 280.dp),
) {
Text(state.email, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
}
Spacer(Modifier.height(24.dp))
LadillTextField(
value = state.password,
onValueChange = viewModel::onPassword,
label = "Password",
placeholder = "Your password",
keyboardType = KeyboardType.Password,
isPassword = true,
enabled = !state.loading,
error = state.error,
)
Spacer(Modifier.height(28.dp))
LadillButton(
text = if (state.loading) "Signing in…" else "Sign in",
onClick = { viewModel.login(onLoggedIn) },
loading = state.loading,
enabled = viewModel.canSubmit,
)
}
}
}
}
}
}
}
@@ -1,268 +0,0 @@
package com.ladill.mini.ui.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ladill.mini.data.model.Countries
import com.ladill.mini.data.model.Country
import com.ladill.mini.ui.components.LadillButton
import com.ladill.mini.ui.components.LadillTextField
import com.ladill.mini.ui.theme.LadillIndigo
private const val TOTAL_STEPS = 3
@Composable
fun RegisterScreen(
onRegistered: () -> Unit,
onBack: () -> Unit,
onSignIn: () -> Unit,
viewModel: RegisterViewModel = viewModel(),
) {
val s by viewModel.state.collectAsStateWithLifecycle()
var step by remember { mutableIntStateOf(0) }
val disabled = s.saving
val stepValid = when (step) {
0 -> s.name.isNotBlank() && s.email.isNotBlank() && s.password.length >= 8 && s.passwordConfirmation == s.password
1 -> s.phone.isNotBlank()
else -> s.address.isNotBlank() && s.city.isNotBlank() && s.state.isNotBlank() && s.zipcode.isNotBlank() && s.terms
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
.imePadding(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
) {
IconButton(onClick = { if (step > 0) step-- else onBack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MaterialTheme.colorScheme.onSurface)
}
}
StepBar(
current = step,
total = TOTAL_STEPS,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().weight(1f),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
StepHeader(step)
Spacer(Modifier.height(24.dp))
when (step) {
0 -> StepDetails(s, viewModel, disabled)
1 -> StepContact(s, viewModel, disabled)
else -> StepAddress(s, viewModel, disabled)
}
}
}
Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) {
val isLast = step == TOTAL_STEPS - 1
LadillButton(
text = when {
isLast && s.saving -> "Creating account…"
isLast -> "Create account"
else -> "Continue"
},
onClick = { if (isLast) viewModel.register(onRegistered) else step++ },
loading = isLast && s.saving,
enabled = stepValid,
)
if (step == 0) {
Text(
text = "Already have an account? Sign in",
style = MaterialTheme.typography.bodyMedium,
color = LadillIndigo,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !disabled, onClick = onSignIn)
.padding(vertical = 10.dp),
)
} else {
Spacer(Modifier.height(4.dp))
}
}
}
}
@Composable
private fun StepHeader(step: Int) {
val (title, subtitle) = when (step) {
0 -> "Your details" to "Tell us who you are and set a password."
1 -> "Contact" to "How customers and Ladill can reach you."
else -> "Business address" to "Where your business is based."
}
Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center)
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp),
)
}
@Composable
private fun StepDetails(s: RegisterUiState, vm: RegisterViewModel, disabled: Boolean) {
LadillTextField(s.name, vm::onName, "Full name", placeholder = "Ama Mensah", enabled = !disabled)
Gap()
LadillTextField(s.email, vm::onEmail, "Email", placeholder = "you@example.com", keyboardType = KeyboardType.Email, enabled = !disabled)
Gap()
LadillTextField(s.password, vm::onPassword, "Password", placeholder = "At least 8 characters", keyboardType = KeyboardType.Password, isPassword = true, enabled = !disabled)
Gap()
LadillTextField(
s.passwordConfirmation, vm::onPasswordConfirmation, "Confirm password",
keyboardType = KeyboardType.Password, isPassword = true, enabled = !disabled,
error = if (s.passwordConfirmation.isNotBlank() && s.passwordConfirmation != s.password) "Passwords do not match." else s.error,
)
}
@Composable
private fun StepContact(s: RegisterUiState, vm: RegisterViewModel, disabled: Boolean) {
CountryDropdown("Country", s.country.name, Countries.all, { it.name }, !disabled, vm::onCountry)
Gap()
LadillTextField(
s.phone, vm::onPhone, "Phone (+${s.country.dialCode})",
placeholder = "24 123 4567", keyboardType = KeyboardType.Phone, enabled = !disabled,
)
Gap()
LadillTextField(s.company, vm::onCompany, "Company (optional)", enabled = !disabled, error = s.error)
}
@Composable
private fun StepAddress(s: RegisterUiState, vm: RegisterViewModel, disabled: Boolean) {
LadillTextField(s.address, vm::onAddress, "Address", enabled = !disabled)
Gap()
LadillTextField(s.city, vm::onCity, "City", enabled = !disabled)
Gap()
CountryDropdown("Region / State", s.state.ifBlank { "Select region" }, s.country.states, { it }, !disabled, vm::onState)
Gap()
LadillTextField(s.zipcode, vm::onZipcode, "Postal / ZIP code", enabled = !disabled)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = s.terms,
onCheckedChange = vm::onTerms,
enabled = !disabled,
colors = CheckboxDefaults.colors(checkedColor = LadillIndigo),
)
Text(
"I agree to the Ladill Terms of Service and Privacy Policy.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable(enabled = !disabled) { vm.onTerms(!s.terms) },
)
}
s.error?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center)
}
}
@Composable
private fun Gap() = Spacer(Modifier.height(14.dp))
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> CountryDropdown(
label: String,
selectedText: String,
options: List<T>,
optionLabel: (T) -> String,
enabled: Boolean,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 6.dp))
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { if (enabled) expanded = it }) {
OutlinedTextField(
value = selectedText,
onValueChange = {},
readOnly = true,
enabled = enabled,
trailingIcon = { Icon(Icons.Filled.ArrowDropDown, contentDescription = null) },
shape = RoundedCornerShape(14.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = LadillIndigo,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
),
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(optionLabel(option)) },
onClick = {
onSelect(option)
expanded = false
},
)
}
}
}
}
}
@@ -1,103 +0,0 @@
package com.ladill.mini.ui.auth
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.ladill.mini.LadillMiniApp
import com.ladill.mini.data.model.Countries
import com.ladill.mini.data.model.Country
import com.ladill.mini.data.model.RegisterRequest
import com.ladill.mini.data.repository.userMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class RegisterUiState(
val name: String = "",
val email: String = "",
val password: String = "",
val passwordConfirmation: String = "",
val company: String = "",
val address: String = "",
val city: String = "",
val country: Country = Countries.default,
val state: String = "",
val zipcode: String = "",
val phone: String = "",
val terms: Boolean = false,
val saving: Boolean = false,
val error: String? = null,
)
class RegisterViewModel(app: Application) : AndroidViewModel(app) {
private val repo = (app as LadillMiniApp).authRepository
private val _state = MutableStateFlow(RegisterUiState())
val state: StateFlow<RegisterUiState> = _state.asStateFlow()
fun onName(v: String) = _state.update { it.copy(name = v, error = null) }
fun onEmail(v: String) = _state.update { it.copy(email = v, error = null) }
fun onPassword(v: String) = _state.update { it.copy(password = v, error = null) }
fun onPasswordConfirmation(v: String) = _state.update { it.copy(passwordConfirmation = v, error = null) }
fun onCompany(v: String) = _state.update { it.copy(company = v, error = null) }
fun onAddress(v: String) = _state.update { it.copy(address = v, error = null) }
fun onCity(v: String) = _state.update { it.copy(city = v, error = null) }
fun onZipcode(v: String) = _state.update { it.copy(zipcode = v, error = null) }
fun onPhone(v: String) = _state.update { it.copy(phone = v, error = null) }
fun onTerms(v: Boolean) = _state.update { it.copy(terms = v, error = null) }
// Reset region to the first one in the newly chosen country.
fun onCountry(c: Country) = _state.update { it.copy(country = c, state = "", error = null) }
fun onState(v: String) = _state.update { it.copy(state = v, error = null) }
val canSubmit: Boolean
get() = with(_state.value) {
name.isNotBlank() && email.isNotBlank() && password.length >= 8 &&
passwordConfirmation == password && address.isNotBlank() && city.isNotBlank() &&
state.isNotBlank() && zipcode.isNotBlank() && phone.isNotBlank() && terms
}
fun register(onSuccess: () -> Unit) {
val s = _state.value
if (s.saving) return
if (s.password != s.passwordConfirmation) {
_state.update { it.copy(error = "Passwords do not match.") }
return
}
if (!s.terms) {
_state.update { it.copy(error = "Please accept the terms to continue.") }
return
}
_state.update { it.copy(saving = true, error = null) }
viewModelScope.launch {
repo.register(
RegisterRequest(
name = s.name.trim(),
email = s.email.trim(),
password = s.password,
passwordConfirmation = s.passwordConfirmation,
company = s.company.trim().ifBlank { null },
address = s.address.trim(),
city = s.city.trim(),
state = s.state,
country = s.country.code,
zipcode = s.zipcode.trim(),
phoneCc = s.country.dialCode,
phone = s.phone.trim(),
mobileCc = null,
mobile = null,
terms = true,
),
).onSuccess {
_state.update { it.copy(saving = false) }
onSuccess()
}.onFailure { e ->
_state.update { it.copy(saving = false, error = e.userMessage()) }
}
}
}
}
@@ -1,30 +0,0 @@
package com.ladill.mini.ui.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.ladill.mini.ui.theme.LadillIndigo
/** Segmented progress bar for the multistep sign-up. Benchmarked on ClimpMe. */
@Composable
fun StepBar(current: Int, total: Int, modifier: Modifier = Modifier) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier) {
repeat(total) { i ->
Box(
modifier = Modifier
.weight(1f)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (i <= current) LadillIndigo else MaterialTheme.colorScheme.surfaceVariant),
)
}
}
}
@@ -1,67 +0,0 @@
package com.ladill.mini.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.ladill.mini.ui.components.LadillSecondaryButton
@Composable
fun LoadingState(modifier: Modifier = Modifier) {
Box(modifier.fillMaxWidth().padding(48.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
strokeWidth = 2.dp,
modifier = Modifier.size(28.dp),
)
}
}
@Composable
fun ErrorState(message: String, onRetry: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
LadillSecondaryButton(text = "Try again", onClick = onRetry, modifier = Modifier.fillMaxWidth())
}
}
@Composable
fun EmptyState(title: String, subtitle: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth().padding(40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@@ -1,8 +0,0 @@
package com.ladill.mini.ui.common
/** Minimal async-data wrapper used across the Mini tabs. */
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Error(val message: String) : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
}
@@ -1,37 +0,0 @@
package com.ladill.mini.ui.components
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
/**
* Tinted SVG icon loaded from assets/icons (the ClimpMe icon set). Uses the
* global Coil ImageLoader (SVG decoder registered in LadillMiniApp).
*/
@Composable
fun AppIcon(
name: String,
modifier: Modifier = Modifier,
size: Dp = 20.dp,
tint: Color = MaterialTheme.colorScheme.onSurface,
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("file:///android_asset/icons/$name.svg")
.build(),
contentDescription = null,
modifier = modifier.size(size),
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(tint, BlendMode.SrcIn),
)
}
@@ -1,93 +0,0 @@
package com.ladill.mini.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.unit.dp
import com.ladill.mini.ui.theme.LadillIndigoSoft
import com.ladill.mini.ui.theme.LadillIndigoText
/** Web `rounded-2xl border border-slate-200 bg-white` surface. */
@Composable
fun LadillCard(
modifier: Modifier = Modifier,
contentPadding: Int = 20,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Column(modifier = Modifier.padding(contentPadding.dp), content = content)
}
}
/**
* Compact stat tile — uppercase tracked label over a large value, matching the
* Overview dashboard cards. `highlight` paints the indigo wallet-balance variant.
*/
@Composable
fun StatCard(
label: String,
value: String,
modifier: Modifier = Modifier,
highlight: Boolean = false,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(20.dp),
color = if (highlight) LadillIndigoSoft else MaterialTheme.colorScheme.surface,
border = BorderStroke(
1.dp,
if (highlight) LadillIndigoSoft else MaterialTheme.colorScheme.outline,
),
) {
Column(modifier = Modifier.padding(18.dp)) {
Text(
text = label.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = if (highlight) LadillIndigoText else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
/** Small status pill (active / inactive, paid / pending). */
@Composable
fun StatusPill(
text: String,
fg: Color,
bg: Color,
modifier: Modifier = Modifier,
) {
Surface(shape = RoundedCornerShape(999.dp), color = bg, modifier = modifier) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = fg,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp),
)
}
}
@@ -1,134 +0,0 @@
package com.ladill.mini.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowOutward
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ladill.mini.ui.theme.LadillIndigo
import com.ladill.mini.ui.theme.LadillPrimaryGradient
import com.ladill.mini.ui.theme.White
/**
* Primary CTA — a gradient (indigo -> blue) pill with a trailing arrow circle,
* mirroring the web `.btn-primary`. Full-width by default.
*/
@Composable
fun LadillButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
loading: Boolean = false,
enabled: Boolean = true,
showArrow: Boolean = true,
) {
val interaction = remember { MutableInteractionSource() }
val active = enabled && !loading
Box(
modifier = modifier
.fillMaxWidth()
.height(56.dp)
.clip(CircleShape)
.alpha(if (active) 1f else 0.5f)
.background(LadillPrimaryGradient)
.clickable(
interactionSource = interaction,
indication = null,
enabled = active,
onClick = onClick,
)
.padding(start = 22.dp, end = 6.dp),
contentAlignment = Alignment.Center,
) {
if (loading) {
CircularProgressIndicator(
color = White,
strokeWidth = 2.dp,
modifier = Modifier.size(20.dp),
)
} else {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = White,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(end = if (showArrow) 44.dp else 16.dp),
)
if (showArrow) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.align(Alignment.CenterEnd)
.size(44.dp)
.clip(CircleShape)
.background(White),
) {
Icon(
imageVector = Icons.Filled.ArrowOutward,
contentDescription = null,
tint = LadillIndigo,
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
/** Secondary / ghost pill — outlined, used for non-primary actions. */
@Composable
fun LadillSecondaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val interaction = remember { MutableInteractionSource() }
Box(
modifier = modifier
.fillMaxWidth()
.height(52.dp)
.clip(CircleShape)
.alpha(if (enabled) 1f else 0.5f)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(interaction, null, enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -1,45 +0,0 @@
package com.ladill.mini.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.ladill.mini.R
/**
* Full Ladill Mini wordmark, loaded from the bundled brand SVG
* (assets/ladillmini-logo.svg) via Coil's SVG decoder. The source artwork is
* ~387x76, so width tracks height by that ratio.
*/
@Composable
fun LadillMiniWordmark(
modifier: Modifier = Modifier,
height: Dp = 28.dp,
) {
AsyncImage(
model = "file:///android_asset/ladillmini-logo.svg",
contentDescription = "Ladill Mini",
modifier = modifier
.height(height)
.width(height * (387.6f / 76.2f)),
)
}
/** Compact tri-colour brand mark (the Ladill "L" tiles). */
@Composable
fun LadillMiniMark(
modifier: Modifier = Modifier,
size: Dp = 40.dp,
) {
Image(
painter = painterResource(R.drawable.ladill_mini_mark),
contentDescription = "Ladill Mini",
modifier = modifier.size(size),
)
}
@@ -1,93 +0,0 @@
package com.ladill.mini.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
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.Modifier
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 androidx.compose.foundation.text.KeyboardOptions
import com.ladill.mini.ui.theme.LadillIndigo
/**
* Labelled input matching the web `@tailwindcss/forms` look — rounded, soft
* border, indigo focus ring.
*/
@Composable
fun LadillTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
placeholder: String? = null,
keyboardType: KeyboardType = KeyboardType.Text,
isPassword: Boolean = false,
singleLine: Boolean = true,
enabled: Boolean = true,
error: String? = null,
) {
var revealed by remember { mutableStateOf(false) }
val obscured = isPassword && !revealed
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 6.dp),
)
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
singleLine = singleLine,
isError = error != null,
placeholder = placeholder?.let { { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) } },
visualTransformation = if (obscured) PasswordVisualTransformation() else VisualTransformation.None,
trailingIcon = if (isPassword) {
{
IconButton(onClick = { revealed = !revealed }) {
AppIcon(
name = if (revealed) "eyeball-hide" else "eyeball",
size = 20.dp,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
null
},
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
shape = RoundedCornerShape(14.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = LadillIndigo,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
cursorColor = LadillIndigo,
),
)
if (error != null) {
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
)
}
}
}
@@ -1,59 +0,0 @@
package com.ladill.mini.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.ladill.mini.ui.theme.LadillInk
import com.ladill.mini.ui.theme.White
/**
* Renders a QR code on-device from [content] with ZXing. Offline and
* auth-free — the printed/scanned link is the QR's encoded payload.
*/
@Composable
fun QrImage(
content: String,
modifier: Modifier = Modifier,
size: Dp = 220.dp,
) {
val dark = LadillInk.toArgb()
val light = White.toArgb()
val bitmap = remember(content) {
runCatching {
val px = 600
val hints = mapOf(
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M,
EncodeHintType.MARGIN to 1,
)
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, px, px, hints)
val bmp = createBitmap(px, px)
for (x in 0 until px) {
for (y in 0 until px) {
bmp[x, y] = if (matrix[x, y]) dark else light
}
}
bmp
}.getOrNull()
}
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR code",
modifier = modifier.size(size),
)
}
}
@@ -1,70 +0,0 @@
package com.ladill.mini.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.ladill.mini.ui.theme.LadillBlue
import com.ladill.mini.ui.theme.LadillIndigo
import com.ladill.mini.ui.theme.White
/**
* Circular avatar. Shows the uploaded photo when [photoUrl] is set, otherwise
* the user's initial on the Ladill gradient.
*/
@Composable
fun UserAvatar(
name: String?,
modifier: Modifier = Modifier,
photoUrl: String? = null,
size: Dp = 40.dp,
onClick: (() -> Unit)? = null,
) {
val shaped = modifier
.size(size)
.clip(CircleShape)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
if (!photoUrl.isNullOrBlank()) {
AsyncImage(
model = photoUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = shaped,
)
return
}
val initial = name?.trim()?.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
Box(
contentAlignment = Alignment.Center,
modifier = shaped.background(
Brush.linearGradient(
colors = listOf(LadillIndigo, LadillBlue),
start = Offset(0f, 0f),
end = Offset(size.value, size.value),
),
),
) {
Text(
text = initial,
color = White,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
}
}
@@ -1,350 +0,0 @@
package com.ladill.mini.ui.home
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.ui.draw.clip
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.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.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.border
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ladill.mini.ui.account.NotificationsSheet
import com.ladill.mini.ui.afia.AfiaSheet
import com.ladill.mini.ui.afia.AfiaTopBarButton
import com.ladill.mini.ui.components.AppIcon
import com.ladill.mini.ui.components.UserAvatar
import com.ladill.mini.ui.qr.CreatePaymentQrSheet
import com.ladill.mini.ui.qr.PaymentQrDetailSheet
import com.ladill.mini.ui.theme.LadillIndigo
import com.ladill.mini.ui.theme.LadillIndigoSoft
private enum class HomeTab { Overview, Qrs, Payments, Payouts }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onOpenProfile: () -> Unit,
onOpenSettings: () -> Unit,
onOpenSecurity: () -> Unit,
onOpenWallet: () -> Unit,
onOpenBilling: () -> Unit,
onOpenSupport: () -> Unit,
onLoggedOut: () -> Unit,
viewModel: HomeViewModel = viewModel(),
) {
var selected by remember { mutableIntStateOf(0) }
var showMenu by remember { mutableStateOf(false) }
var showCreateSheet by remember { mutableStateOf(false) }
var showNotifications by remember { mutableStateOf(false) }
var showAfia by remember { mutableStateOf(false) }
var selectedQrId by remember { mutableStateOf<Long?>(null) }
val tabs = remember { HomeTab.entries.toList() }
val unreadCount by viewModel.unreadNotifications.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
HomeTopBar(
name = viewModel.accountName,
avatarUrl = viewModel.accountAvatarUrl,
unreadCount = unreadCount,
onAvatarClick = { showMenu = true },
onAfia = { showAfia = true },
onNotifications = { showNotifications = true },
)
Box(modifier = Modifier.weight(1f)) {
when (tabs[selected]) {
HomeTab.Overview -> OverviewTab(
viewModel = viewModel,
onCreateQr = { showCreateSheet = true },
onSeeAllPayments = { selected = HomeTab.Payments.ordinal },
onOpenWallet = onOpenWallet,
)
HomeTab.Qrs -> PaymentQrsTab(viewModel = viewModel, onCreateQr = { showCreateSheet = true }, onOpenQr = { selectedQrId = it })
HomeTab.Payments -> PaymentsTab(viewModel = viewModel)
HomeTab.Payouts -> PayoutsTab(viewModel = viewModel, onOpenWallet = onOpenWallet)
}
}
HomeBottomBar(selected = selected, onSelect = { selected = it })
}
if (showMenu) {
UserMenuSheet(
name = viewModel.accountName,
email = viewModel.accountEmail,
avatarUrl = viewModel.accountAvatarUrl,
onDismiss = { showMenu = false },
onProfile = { showMenu = false; onOpenProfile() },
onSettings = { showMenu = false; onOpenSettings() },
onSecurity = { showMenu = false; onOpenSecurity() },
onWallet = { showMenu = false; onOpenWallet() },
onBilling = { showMenu = false; onOpenBilling() },
onSupport = { showMenu = false; onOpenSupport() },
onSignOut = { showMenu = false; viewModel.logout(onLoggedOut) },
)
}
if (showCreateSheet) {
CreatePaymentQrSheet(
onDismiss = { showCreateSheet = false },
onCreated = { id ->
showCreateSheet = false
selectedQrId = id
},
)
}
selectedQrId?.let { qrId ->
PaymentQrDetailSheet(
qrId = qrId,
onDismiss = { selectedQrId = null },
onDeleted = {
selectedQrId = null
viewModel.loadQrs()
viewModel.loadOverview()
},
)
}
if (showNotifications) {
NotificationsSheet(
onDismiss = {
showNotifications = false
viewModel.loadUnreadCount()
},
onUnreadChanged = viewModel::setUnreadCount,
)
}
if (showAfia) {
AfiaSheet(onDismiss = { showAfia = false })
}
}
@Composable
private fun HomeTopBar(
name: String?,
avatarUrl: String?,
unreadCount: Int,
onAvatarClick: () -> Unit,
onAfia: () -> Unit,
onNotifications: () -> Unit,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
UserAvatar(name = name, photoUrl = avatarUrl, size = 40.dp, onClick = onAvatarClick)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
AfiaTopBarButton(onClick = onAfia)
Box {
IconButton(onClick = onNotifications) {
AppIcon(name = "notifications", size = 24.dp, tint = MaterialTheme.colorScheme.onSurface)
}
if (unreadCount > 0) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 6.dp, end = 6.dp)
.size(16.dp)
.clip(RoundedCornerShape(8.dp))
.background(LadillIndigo),
) {
Text(
text = if (unreadCount > 9) "9+" else unreadCount.toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
}
}
@Composable
private fun HomeBottomBar(selected: Int, onSelect: (Int) -> Unit) {
val items = listOf(
BottomItem("Home", "home"),
BottomItem("QRs", "qr"),
BottomItem("Payments", "money"),
BottomItem("Payouts", "money-owed"),
)
Column {
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = 1.dp)
NavigationBar(containerColor = MaterialTheme.colorScheme.surface, tonalElevation = 0.dp) {
items.forEachIndexed { index, item ->
val sel = selected == index
val tint = if (sel) LadillIndigo else MaterialTheme.colorScheme.onSurfaceVariant
NavigationBarItem(
selected = sel,
onClick = { onSelect(index) },
icon = { AppIcon(name = item.iconAsset, size = 22.dp, tint = tint) },
label = { Text(item.label, style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = LadillIndigo,
selectedTextColor = LadillIndigo,
indicatorColor = LadillIndigoSoft,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
}
}
}
private data class BottomItem(val label: String, val iconAsset: String)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UserMenuSheet(
name: String?,
email: String?,
avatarUrl: String?,
onDismiss: () -> Unit,
onProfile: () -> Unit,
onSettings: () -> Unit,
onSecurity: () -> Unit,
onWallet: () -> Unit,
onBilling: () -> Unit,
onSupport: () -> Unit,
onSignOut: () -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(),
containerColor = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
) {
UserAvatar(name = name, photoUrl = avatarUrl, size = 44.dp)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name ?: "Ladill account",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
email?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
Column(modifier = Modifier.padding(horizontal = 6.dp)) {
MenuSectionHeader("Personal")
MenuSheetItem("Profile", "Photo, name & phone", "profile", onProfile)
MenuSheetItem("Settings", "Notification preferences", "setting", onSettings)
MenuSectionHeader("Security and access")
MenuSheetItem("Security", "Password & app lock", "password", onSecurity)
MenuSectionHeader("Billing and usage")
MenuSheetItem("Wallet", "Balance, top-up & withdrawals", "money-owed", onWallet)
MenuSheetItem("Billing", "Usage & platform fees", "money", onBilling)
MenuSectionHeader("Help")
MenuSheetItem("Support", "Get help from our team", "support", onSupport)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline, modifier = Modifier.padding(vertical = 4.dp))
Column(modifier = Modifier.padding(horizontal = 6.dp)) {
MenuSheetItem("Sign out", null, "logout", onSignOut)
}
Spacer(Modifier.height(8.dp))
}
}
}
@Composable
private fun MenuSectionHeader(text: String) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp, top = 14.dp, bottom = 4.dp),
)
}
@Composable
private fun MenuSheetItem(label: String, description: String?, iconAsset: String, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
MenuIconBox(iconAsset)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface)
description?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun MenuIconBox(iconAsset: String) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.background(MaterialTheme.colorScheme.background, RoundedCornerShape(10.dp))
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(10.dp)),
) {
AppIcon(name = iconAsset, size = 18.dp, tint = MaterialTheme.colorScheme.onSurface)
}
}
@@ -1,119 +0,0 @@
package com.ladill.mini.ui.home
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.ladill.mini.LadillMiniApp
import com.ladill.mini.data.model.Overview
import com.ladill.mini.data.model.Payment
import com.ladill.mini.data.model.PaymentQr
import com.ladill.mini.data.model.Payouts
import com.ladill.mini.data.repository.userMessage
import com.ladill.mini.ui.common.UiState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class HomeViewModel(app: Application) : AndroidViewModel(app) {
private val application = app as LadillMiniApp
private val repo = application.miniRepository
val accountName: String? = application.authRepository.cachedName()
val accountEmail: String? = application.authRepository.cachedEmail()
val accountAvatarUrl: String? = application.authRepository.cachedAvatarUrl()
private val _overview = MutableStateFlow<UiState<Overview>>(UiState.Loading)
val overview: StateFlow<UiState<Overview>> = _overview.asStateFlow()
private val _overviewRefreshing = MutableStateFlow(false)
val overviewRefreshing: StateFlow<Boolean> = _overviewRefreshing.asStateFlow()
private val _qrs = MutableStateFlow<UiState<List<PaymentQr>>>(UiState.Loading)
val qrs: StateFlow<UiState<List<PaymentQr>>> = _qrs.asStateFlow()
private val _payments = MutableStateFlow<UiState<List<Payment>>>(UiState.Loading)
val payments: StateFlow<UiState<List<Payment>>> = _payments.asStateFlow()
private val _payouts = MutableStateFlow<UiState<Payouts>>(UiState.Loading)
val payouts: StateFlow<UiState<Payouts>> = _payouts.asStateFlow()
// null = unknown, true/false = payout account set?
private val _hasPayout = MutableStateFlow<Boolean?>(null)
val hasPayout: StateFlow<Boolean?> = _hasPayout.asStateFlow()
private val _unreadNotifications = MutableStateFlow(0)
val unreadNotifications: StateFlow<Int> = _unreadNotifications.asStateFlow()
init {
loadUnreadCount()
}
fun loadUnreadCount() = viewModelScope.launch {
application.notificationRepository.unreadCount()
.onSuccess { _unreadNotifications.value = it }
}
fun setUnreadCount(count: Int) {
_unreadNotifications.value = count
}
fun loadPayoutStatus() = viewModelScope.launch {
repo.payoutAccount().onSuccess { _hasPayout.value = it != null }
}
fun loadOverview() = viewModelScope.launch {
_overview.value = UiState.Loading
repo.overview()
.onSuccess { _overview.value = UiState.Success(it) }
.onFailure { _overview.value = UiState.Error(it.userMessage()) }
}
fun refreshOverview() = viewModelScope.launch {
if (_overviewRefreshing.value) return@launch
_overviewRefreshing.value = true
val overviewResult = repo.overview()
val payoutResult = repo.payoutAccount()
overviewResult
.onSuccess { _overview.value = UiState.Success(it) }
.onFailure { e ->
if (_overview.value !is UiState.Success) {
_overview.value = UiState.Error(e.userMessage())
}
}
payoutResult.onSuccess { _hasPayout.value = it != null }
loadUnreadCount()
_overviewRefreshing.value = false
}
fun loadQrs() = viewModelScope.launch {
_qrs.value = UiState.Loading
repo.paymentQrs()
.onSuccess { _qrs.value = UiState.Success(it) }
.onFailure { _qrs.value = UiState.Error(it.userMessage()) }
}
fun loadPayments(query: String? = null) = viewModelScope.launch {
_payments.value = UiState.Loading
repo.payments(query)
.onSuccess { _payments.value = UiState.Success(it) }
.onFailure { _payments.value = UiState.Error(it.userMessage()) }
}
fun loadPayouts() = viewModelScope.launch {
_payouts.value = UiState.Loading
repo.payouts()
.onSuccess { _payouts.value = UiState.Success(it) }
.onFailure { _payouts.value = UiState.Error(it.userMessage()) }
}
fun setQrActive(id: Long, active: Boolean) = viewModelScope.launch {
repo.setActive(id, active).onSuccess { loadQrs() }
}
fun logout(onDone: () -> Unit) = viewModelScope.launch {
application.authRepository.logout()
onDone()
}
}
@@ -1,291 +0,0 @@
package com.ladill.mini.ui.home
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.clickable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ladill.mini.data.model.Overview
import com.ladill.mini.data.model.Payment
import com.ladill.mini.ui.common.ErrorState
import com.ladill.mini.ui.common.LoadingState
import com.ladill.mini.ui.common.UiState
import com.ladill.mini.ui.components.LadillCard
import com.ladill.mini.ui.components.StatCard
import com.ladill.mini.ui.theme.LadillIndigo
import com.ladill.mini.ui.theme.LadillIndigoSoft
import com.ladill.mini.ui.theme.LadillIndigoText
import com.ladill.mini.ui.theme.LadillMoney
import com.ladill.mini.ui.theme.LadillPrimaryGradient
import com.ladill.mini.ui.theme.White
import com.ladill.mini.util.formatMoney
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OverviewTab(
viewModel: HomeViewModel,
onCreateQr: () -> Unit,
onSeeAllPayments: () -> Unit,
onOpenWallet: () -> Unit,
modifier: Modifier = Modifier,
) {
val state by viewModel.overview.collectAsStateWithLifecycle()
val isRefreshing by viewModel.overviewRefreshing.collectAsStateWithLifecycle()
val hasPayout by viewModel.hasPayout.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.loadOverview() }
// Re-check payout status whenever the app resumes, so the prompt auto-dismisses
// once the user has set up their payout account.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) viewModel.loadPayoutStatus()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val firstName = viewModel.accountName?.trim()?.split(" ")?.firstOrNull()?.takeIf { it.isNotBlank() }
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = viewModel::refreshOverview,
modifier = modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text(
if (firstName != null) "Hello, $firstName" else "Hello",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"Here is today's takings, your payment QRs, and recent payments.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Spacer(Modifier.height(20.dp))
if (hasPayout == false) {
PayoutSetupNote(onOpenWallet)
Spacer(Modifier.height(16.dp))
}
when (val s = state) {
is UiState.Loading -> LoadingState()
is UiState.Error -> ErrorState(s.message, onRetry = { viewModel.loadOverview() })
is UiState.Success -> OverviewContent(s.data, onCreateQr, onSeeAllPayments)
}
}
}
}
@Composable
private fun PayoutSetupNote(onOpenWallet: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(LadillIndigoSoft)
.clickable(onClick = onOpenWallet)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Set up your payout account", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = LadillIndigoText)
Text(
"Add where you'd like to receive your money so you can withdraw your takings.",
style = MaterialTheme.typography.bodySmall,
color = LadillIndigoText,
modifier = Modifier.padding(top = 2.dp),
)
}
Text("", style = MaterialTheme.typography.titleMedium, color = LadillIndigoText, modifier = Modifier.padding(start = 8.dp))
}
}
@Composable
private fun OverviewContent(data: Overview, onCreateQr: () -> Unit, onSeeAllPayments: () -> Unit) {
TakingsHero(formatMoney(data.todayTakingsMinor, data.currency))
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
StatCard(
label = "Payments today",
value = data.todayCount.toString(),
modifier = Modifier.weight(0.9f),
)
StatCard(
label = "Wallet balance",
value = formatMoney(data.walletBalanceMinor, data.currency),
highlight = true,
modifier = Modifier.weight(1.4f),
)
}
Spacer(Modifier.height(20.dp))
LadillCard(contentPadding = 0) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Recent payments", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
"View all",
style = MaterialTheme.typography.labelLarge,
color = LadillIndigo,
modifier = Modifier.clickable(onClick = onSeeAllPayments),
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
if (data.recentPayments.isEmpty()) {
Text(
"No payments yet. Print your payment QR and start accepting payments.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(18.dp),
)
} else {
data.recentPayments.forEachIndexed { i, payment ->
if (i > 0) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
RecentPaymentRow(payment)
}
}
}
Spacer(Modifier.height(20.dp))
LadillCard {
Text("Quick links", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(12.dp))
QuickLink("Create payment QR", onCreateQr)
Spacer(Modifier.height(10.dp))
QuickLink("View payments", onSeeAllPayments)
}
Spacer(Modifier.height(24.dp))
}
@Composable
private fun TakingsHero(amount: String) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(LadillPrimaryGradient),
) {
// Decorative translucent circles in the background (offset partly off the
// card; the parent clip keeps them inside the rounded rectangle).
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = 30.dp, y = (-30).dp)
.size(120.dp)
.clip(CircleShape)
.background(White.copy(alpha = 0.10f)),
)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = (-40).dp, y = 40.dp)
.size(110.dp)
.clip(CircleShape)
.background(White.copy(alpha = 0.08f)),
)
Column(modifier = Modifier.padding(22.dp)) {
Text(
"TODAY'S TAKINGS",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = White.copy(alpha = 0.85f),
)
Spacer(Modifier.height(8.dp))
Text(
amount,
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
color = White,
maxLines = 1,
)
}
}
}
@Composable
private fun RecentPaymentRow(payment: Payment) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
payment.payerName?.ifBlank { null } ?: "Walk-in customer",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
payment.qrLabel ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
formatMoney(payment.merchantAmountMinor, payment.currency),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = LadillMoney,
)
}
}
@Composable
private fun QuickLink(label: String, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp, horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
Text("", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -1,130 +0,0 @@
package com.ladill.mini.ui.home
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.PaddingValues
import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ladill.mini.data.model.PaymentQr
import com.ladill.mini.ui.common.EmptyState
import com.ladill.mini.ui.common.ErrorState
import com.ladill.mini.ui.common.LoadingState
import com.ladill.mini.ui.common.UiState
import com.ladill.mini.ui.components.LadillCard
import com.ladill.mini.ui.components.StatusPill
import com.ladill.mini.ui.theme.LadillBrandGreen
import com.ladill.mini.ui.theme.LadillMoney
import com.ladill.mini.ui.theme.LadillPrimaryGradient
import com.ladill.mini.ui.theme.White
@Composable
fun PaymentQrsTab(
viewModel: HomeViewModel,
onCreateQr: () -> Unit,
onOpenQr: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
val state by viewModel.qrs.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.loadQrs() }
Box(modifier = modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Column(modifier = Modifier.padding(bottom = 8.dp)) {
Text("Payment QRs", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground)
Text(
"Codes your customers scan to pay you.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
when (val s = state) {
is UiState.Loading -> item { LoadingState() }
is UiState.Error -> item { ErrorState(s.message, onRetry = { viewModel.loadQrs() }) }
is UiState.Success -> {
if (s.data.isEmpty()) {
item {
EmptyState(
title = "No payment QRs yet",
subtitle = "Create one to start accepting payments in person.",
)
}
} else {
items(s.data, key = { it.id }) { qr ->
PaymentQrRow(qr, onClick = { onOpenQr(qr.id) })
}
}
}
}
}
// Gradient FAB — matches the web `.btn-fab`.
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
.size(56.dp)
.clip(CircleShape)
.background(LadillPrimaryGradient)
.clickable(onClick = onCreateQr),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Add, contentDescription = "Create payment QR", tint = White)
}
}
}
@Composable
private fun PaymentQrRow(qr: PaymentQr, onClick: () -> Unit) {
LadillCard(modifier = Modifier.clickable(onClick = onClick), contentPadding = 16) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(qr.label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
val sub = listOfNotNull(qr.businessName, qr.branchLabel).joinToString(" · ")
if (sub.isNotBlank()) {
Text(sub, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
}
Text(
"${qr.scansTotal} scans",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
if (qr.isActive) {
StatusPill("Active", fg = LadillMoney, bg = LadillBrandGreen.copy(alpha = 0.12f))
} else {
StatusPill("Paused", fg = MaterialTheme.colorScheme.onSurfaceVariant, bg = MaterialTheme.colorScheme.surfaceVariant)
}
}
}
}
@@ -1,148 +0,0 @@
package com.ladill.mini.ui.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.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.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.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ladill.mini.data.model.Payment
import com.ladill.mini.ui.common.EmptyState
import com.ladill.mini.ui.common.ErrorState
import com.ladill.mini.ui.common.LoadingState
import com.ladill.mini.ui.common.UiState
import com.ladill.mini.ui.components.LadillCard
import com.ladill.mini.ui.components.StatusPill
import com.ladill.mini.ui.theme.LadillIndigo
import com.ladill.mini.ui.theme.LadillMoney
import com.ladill.mini.util.formatDate
import com.ladill.mini.util.formatMoney
@Composable
fun PaymentsTab(
viewModel: HomeViewModel,
modifier: Modifier = Modifier,
) {
val state by viewModel.payments.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text("Payments", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground)
Text(
"Every payment customers have made to you.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
)
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Search payer, note, or reference") },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
singleLine = true,
shape = RoundedCornerShape(14.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = LadillIndigo,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
cursorColor = LadillIndigo,
),
)
}
when (val s = state) {
is UiState.Loading -> item { LoadingState() }
is UiState.Error -> item { ErrorState(s.message, onRetry = { viewModel.loadPayments(query) }) }
is UiState.Success -> {
if (s.data.isEmpty()) {
item { EmptyState("No payments found", "Payments appear here once customers pay.") }
} else {
items(s.data, key = { it.id }) { PaymentRow(it) }
}
}
}
}
// Re-query as the search text changes (simple debounce-free trigger).
LaunchedEffect(query) {
if (query.isBlank()) viewModel.loadPayments() else viewModel.loadPayments(query)
}
}
@Composable
private fun PaymentRow(payment: Payment) {
LadillCard(contentPadding = 16) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
payment.payerName?.ifBlank { null } ?: "Walk-in customer",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
val meta = listOfNotNull(payment.qrLabel, formatDate(payment.paidAt ?: payment.createdAt).ifBlank { null })
.joinToString(" · ")
if (meta.isNotBlank()) {
Text(meta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
}
payment.payerNote?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
}
}
Column(horizontalAlignment = Alignment.End) {
val paid = payment.status == "paid"
Text(
formatMoney(payment.merchantAmountMinor, payment.currency),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (paid) LadillMoney else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
PaymentStatusPill(payment.status)
}
}
}
}
@Composable
private fun PaymentStatusPill(status: String) {
val (label, fg, bg) = when (status) {
"paid" -> Triple("Paid", Color(0xFF047857), Color(0xFFD1FAE5))
"pending" -> Triple("Pending", Color(0xFFB45309), Color(0xFFFEF3C7))
"canceled" -> Triple("Canceled", Color(0xFF64748B), Color(0xFFE2E8F0))
else -> Triple("Failed", Color(0xFFB91C1C), Color(0xFFFEE2E2))
}
StatusPill(text = label, fg = fg, bg = bg)
}
@@ -1,87 +0,0 @@
package com.ladill.mini.ui.home
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ladill.mini.data.model.Payouts
import com.ladill.mini.ui.common.ErrorState
import com.ladill.mini.ui.common.LoadingState
import com.ladill.mini.ui.common.UiState
import com.ladill.mini.ui.components.LadillButton
import com.ladill.mini.ui.components.LadillCard
import com.ladill.mini.ui.components.StatCard
import com.ladill.mini.util.formatMoney
@Composable
fun PayoutsTab(
viewModel: HomeViewModel,
onOpenWallet: () -> Unit,
modifier: Modifier = Modifier,
) {
val state by viewModel.payouts.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.loadPayouts() }
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text("Payouts", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground)
Text(
"Revenue collected and what's ready to withdraw.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Spacer(Modifier.height(16.dp))
when (val s = state) {
is UiState.Loading -> LoadingState()
is UiState.Error -> ErrorState(s.message, onRetry = { viewModel.loadPayouts() })
is UiState.Success -> PayoutsContent(s.data, onOpenWallet)
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun PayoutsContent(data: Payouts, onOpenWallet: () -> Unit) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
StatCard(
label = "Total revenue",
value = formatMoney(data.totalRevenueMinor, data.currency),
modifier = Modifier.weight(1f),
)
StatCard(
label = "Wallet balance",
value = formatMoney(data.walletBalanceMinor, data.currency),
highlight = true,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(12.dp))
LadillCard {
Text(
"Top up and withdraw your balance right here in the app.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(14.dp))
LadillButton(text = "Go to Wallet", onClick = onOpenWallet, showArrow = false)
}
}
@@ -1,85 +0,0 @@
package com.ladill.mini.ui.landing
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.ladill.mini.R
import com.ladill.mini.ui.components.LadillButton
import com.ladill.mini.ui.components.LadillMiniWordmark
import com.ladill.mini.ui.components.LadillSecondaryButton
/**
* Pre-auth welcome screen. Layout benchmarked against ClimpMe Android's
* LandingScreen: top-left wordmark, large headline + subtext, a hero image, and
* the primary "Get started" / secondary "Sign in" CTAs pinned at the bottom.
*/
@Composable
fun LandingScreen(
onSignIn: () -> Unit,
onCreateAccount: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding(),
) {
LadillMiniWordmark(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 36.dp),
height = 30.dp,
)
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) {
Text(
text = "Collect payments,\neffortlessly.",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(10.dp))
Text(
text = "Create pay links and QR codes, share them anywhere, and track every payment — no storefront needed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 20.dp, vertical = 20.dp),
) {
Image(
painter = painterResource(R.drawable.girl),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
)
}
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
LadillButton(text = "Get started", onClick = onCreateAccount)
Spacer(Modifier.height(10.dp))
LadillSecondaryButton(text = "Sign in", onClick = onSignIn)
Spacer(Modifier.height(12.dp))
}
}
}
@@ -1,119 +0,0 @@
package com.ladill.mini.ui.notifications
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ladill.mini.LadillMiniApp
import com.ladill.mini.data.model.AppNotification
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class NotificationsUiState(
val loading: Boolean = true,
val error: String? = null,
val notifications: List<AppNotification> = emptyList(),
val unreadCount: Int = 0,
val isRefreshing: Boolean = false,
val markingAll: Boolean = false,
)
class NotificationsViewModel(app: Application) : AndroidViewModel(app) {
private val repository = (app as LadillMiniApp).notificationRepository
private val _state = MutableStateFlow(NotificationsUiState())
val state = _state.asStateFlow()
init {
load()
}
fun load() {
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) }
repository.getNotifications().fold(
onSuccess = { response ->
_state.update {
it.copy(
loading = false,
notifications = response.data,
unreadCount = response.unreadCount,
)
}
},
onFailure = { e ->
_state.update {
it.copy(loading = false, error = e.message ?: "Could not load notifications")
}
},
)
}
}
fun refresh() {
viewModelScope.launch {
_state.update { it.copy(isRefreshing = true) }
repository.getNotifications().fold(
onSuccess = { response ->
_state.update {
it.copy(
isRefreshing = false,
notifications = response.data,
unreadCount = response.unreadCount,
)
}
},
onFailure = { _state.update { it.copy(isRefreshing = false) } },
)
}
}
fun markAllRead() {
viewModelScope.launch {
_state.update { it.copy(markingAll = true) }
repository.markAllRead().fold(
onSuccess = {
_state.update {
it.copy(
markingAll = false,
unreadCount = 0,
notifications = it.notifications.map { n ->
n.copy(readAt = n.readAt ?: "read")
},
)
}
},
onFailure = { _state.update { it.copy(markingAll = false) } },
)
}
}
fun onNotificationTap(notification: AppNotification) {
if (notification.readAt != null) return
viewModelScope.launch {
repository.markRead(notification.id).fold(
onSuccess = {
_state.update { state ->
val updated = state.notifications.map {
if (it.id == notification.id) it.copy(readAt = "read") else it
}
state.copy(
notifications = updated,
unreadCount = (state.unreadCount - 1).coerceAtLeast(0),
)
}
},
onFailure = { },
)
}
}
class Factory(private val app: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = NotificationsViewModel(app) as T
}
}
@@ -1,76 +0,0 @@
package com.ladill.mini.ui.qr
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ladill.mini.ui.components.LadillButton
import com.ladill.mini.ui.components.LadillTextField
/** New payment QR as a bottom sheet, opened from the QRs tab / Overview. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreatePaymentQrSheet(
onDismiss: () -> Unit,
onCreated: (Long) -> Unit,
viewModel: QrViewModel = viewModel(),
) {
val state by viewModel.create.collectAsStateWithLifecycle()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 24.dp)
.imePadding(),
) {
Text("New payment QR", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.height(4.dp))
Text(
"Create a code customers scan to pay you. Print or display it anywhere.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(20.dp))
LadillTextField(state.label, viewModel::onLabel, "Label", placeholder = "e.g. Front counter", enabled = !state.saving)
Spacer(Modifier.height(16.dp))
LadillTextField(state.businessName, viewModel::onBusinessName, "Business name", placeholder = "Shown to customers", enabled = !state.saving)
Spacer(Modifier.height(16.dp))
LadillTextField(state.branchLabel, viewModel::onBranchLabel, "Branch (optional)", placeholder = "e.g. Osu branch", enabled = !state.saving, error = state.error)
Spacer(Modifier.height(28.dp))
LadillButton(
text = if (state.saving) "Creating…" else "Create payment QR",
onClick = { viewModel.submit(onCreated) },
loading = state.saving,
enabled = viewModel.canCreate,
)
Spacer(Modifier.height(12.dp))
}
}
}
@@ -1,214 +0,0 @@
package com.ladill.mini.ui.qr
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.background
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.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
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.ModalBottomSheet
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ladill.mini.data.model.PaymentQr
import com.ladill.mini.ui.common.ErrorState
import com.ladill.mini.ui.common.LoadingState
import com.ladill.mini.ui.common.UiState
import com.ladill.mini.ui.components.LadillCard
import com.ladill.mini.ui.components.QrImage
import com.ladill.mini.ui.theme.LadillIndigo
import com.ladill.mini.ui.theme.White
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentQrDetailSheet(
qrId: Long,
onDismiss: () -> Unit,
onDeleted: () -> Unit = onDismiss,
viewModel: QrViewModel = viewModel(),
) {
val state by viewModel.detail.collectAsStateWithLifecycle()
val context = LocalContext.current
var confirmDelete by remember { mutableStateOf(false) }
LaunchedEffect(qrId) { viewModel.loadDetail(qrId) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 360.dp, max = 640.dp)
.navigationBarsPadding(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
"Payment QR",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
)
if (state is UiState.Success) {
val qr = (state as UiState.Success).data
if (!qr.publicUrl.isNullOrBlank()) {
IconButton(onClick = { sharePaymentQr(context, qr) }) {
Icon(Icons.Filled.Share, contentDescription = "Share", tint = MaterialTheme.colorScheme.onSurface)
}
}
IconButton(onClick = { confirmDelete = true }) {
Icon(Icons.Filled.DeleteOutline, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
Box(modifier = Modifier.weight(1f, fill = false)) {
when (val s = state) {
is UiState.Loading -> LoadingState()
is UiState.Error -> ErrorState(s.message, onRetry = { viewModel.loadDetail(qrId) })
is UiState.Success -> DetailContent(
qr = s.data,
onToggleActive = { viewModel.toggleActive(qrId, it) },
)
}
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Delete payment QR?") },
text = { Text("This code will stop working immediately. This can't be undone.") },
confirmButton = {
TextButton(onClick = {
confirmDelete = false
viewModel.delete(qrId, onDeleted)
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun DetailContent(qr: PaymentQr, onToggleActive: (Boolean) -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 20.dp),
) {
LadillCard {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(White)
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
val encoded = qr.publicUrl ?: qr.previewUrl
if (encoded != null) {
QrImage(content = encoded, size = 220.dp)
}
}
Spacer(Modifier.height(14.dp))
Text(qr.label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
val sub = listOfNotNull(qr.businessName, qr.branchLabel).joinToString(" · ")
if (sub.isNotBlank()) {
Text(sub, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
}
qr.publicUrl?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = LadillIndigo)
}
}
Spacer(Modifier.height(16.dp))
LadillCard {
DetailRow("Scans", qr.scansTotal.toString())
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp), color = MaterialTheme.colorScheme.outlineVariant)
DetailRow("Currency", qr.currency)
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp), color = MaterialTheme.colorScheme.outlineVariant)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text("Active", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
Text(
if (qr.isActive) "Accepting payments" else "Paused — customers can't pay",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = qr.isActive,
onCheckedChange = onToggleActive,
colors = SwitchDefaults.colors(checkedTrackColor = LadillIndigo),
)
}
}
}
}
private fun sharePaymentQr(context: Context, qr: PaymentQr) {
val url = qr.publicUrl ?: return
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, qr.label)
putExtra(Intent.EXTRA_TEXT, "${qr.label} — pay with QR\n$url")
}
context.startActivity(Intent.createChooser(intent, "Share payment QR"))
}
@Composable
private fun DetailRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
}
}
@@ -1,71 +0,0 @@
package com.ladill.mini.ui.qr
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.ladill.mini.LadillMiniApp
import com.ladill.mini.data.model.PaymentQr
import com.ladill.mini.data.repository.userMessage
import com.ladill.mini.ui.common.UiState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class CreateQrUiState(
val label: String = "",
val businessName: String = "",
val branchLabel: String = "",
val saving: Boolean = false,
val error: String? = null,
)
class QrViewModel(app: Application) : AndroidViewModel(app) {
private val repo = (app as LadillMiniApp).miniRepository
// ---- Create ----
private val _create = MutableStateFlow(CreateQrUiState())
val create: StateFlow<CreateQrUiState> = _create.asStateFlow()
fun onLabel(v: String) = _create.update { it.copy(label = v, error = null) }
fun onBusinessName(v: String) = _create.update { it.copy(businessName = v, error = null) }
fun onBranchLabel(v: String) = _create.update { it.copy(branchLabel = v, error = null) }
val canCreate: Boolean
get() = _create.value.label.isNotBlank() && _create.value.businessName.isNotBlank()
fun submit(onCreated: (Long) -> Unit) {
val s = _create.value
if (s.saving || s.label.isBlank() || s.businessName.isBlank()) return
_create.update { it.copy(saving = true, error = null) }
viewModelScope.launch {
repo.createPaymentQr(s.label, s.businessName, s.branchLabel)
.onSuccess { qr ->
_create.update { it.copy(saving = false) }
onCreated(qr.id)
}
.onFailure { e -> _create.update { it.copy(saving = false, error = e.userMessage()) } }
}
}
// ---- Detail ----
private val _detail = MutableStateFlow<UiState<PaymentQr>>(UiState.Loading)
val detail: StateFlow<UiState<PaymentQr>> = _detail.asStateFlow()
fun loadDetail(id: Long) = viewModelScope.launch {
_detail.value = UiState.Loading
repo.paymentQr(id)
.onSuccess { _detail.value = UiState.Success(it) }
.onFailure { _detail.value = UiState.Error(it.userMessage()) }
}
fun toggleActive(id: Long, active: Boolean) = viewModelScope.launch {
repo.setActive(id, active).onSuccess { _detail.value = UiState.Success(it) }
}
fun delete(id: Long, onDeleted: () -> Unit) = viewModelScope.launch {
repo.deletePaymentQr(id).onSuccess { onDeleted() }
}
}
@@ -1,226 +0,0 @@
package com.ladill.mini.ui.security
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity
import com.ladill.mini.util.BiometricAuth
import com.ladill.mini.util.BiometricUnlockStatus
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.ladill.mini.ui.theme.LadillIndigo
private const val PIN_LENGTH = 4
/**
* Numeric PIN entry. [onComplete] fires when [PIN_LENGTH] digits are entered;
* the caller validates and may [errorMessage] + reset. A biometric affordance
* shows when [onBiometric] is provided (lock mode).
*/
@Composable
fun PinPad(
title: String,
subtitle: String,
pin: String,
onDigit: (String) -> Unit,
onBackspace: () -> Unit,
errorMessage: String? = null,
onBiometric: (() -> Unit)? = null,
) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center)
Spacer(Modifier.height(8.dp))
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
Spacer(Modifier.height(32.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
repeat(PIN_LENGTH) { i ->
val filled = i < pin.length
Box(
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.background(if (filled) LadillIndigo else Color.Transparent)
.border(1.5.dp, if (filled) LadillIndigo else MaterialTheme.colorScheme.outline, CircleShape),
)
}
}
if (errorMessage != null) {
Spacer(Modifier.height(14.dp))
Text(errorMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.height(40.dp))
listOf(listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9")).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(vertical = 8.dp)) {
row.forEach { d -> PinKey(d) { onDigit(d) } }
}
}
Row(horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(vertical = 8.dp)) {
Box(modifier = Modifier.size(64.dp), contentAlignment = Alignment.Center) {
if (onBiometric != null) {
Icon(
Icons.Filled.Fingerprint,
contentDescription = "Biometric unlock",
tint = LadillIndigo,
modifier = Modifier.size(28.dp).clickable(onClick = onBiometric),
)
}
}
PinKey("0") { onDigit("0") }
Box(modifier = Modifier.size(64.dp), contentAlignment = Alignment.Center) {
Icon(
Icons.AutoMirrored.Filled.Backspace,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp).clickable(onClick = onBackspace),
)
}
}
}
}
@Composable
private fun PinKey(digit: String, onClick: () -> Unit) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick),
) {
Text(digit, fontSize = 24.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface)
}
}
/** Two-step PIN setup (enter, then confirm). Calls [onPinSet] with the chosen PIN. */
@Composable
fun PinSetupContent(onPinSet: (String) -> Unit) {
var first by remember { mutableStateOf("") }
var pin by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val confirming = first.isNotEmpty()
fun digit(d: String) {
if (pin.length >= PIN_LENGTH) return
pin += d
if (pin.length == PIN_LENGTH) {
if (!confirming) {
first = pin
pin = ""
} else if (pin == first) {
onPinSet(pin)
} else {
error = "PINs didn't match. Try again."
first = ""
pin = ""
}
}
}
PinPad(
title = if (confirming) "Confirm PIN" else "Create a PIN",
subtitle = if (confirming) "Re-enter your 4-digit PIN" else "Set a 4-digit PIN to lock the app",
pin = pin,
onDigit = { error = null; digit(it) },
onBackspace = { if (pin.isNotEmpty()) pin = pin.dropLast(1) },
errorMessage = error,
)
}
/** PIN lock — verify against [verify]; offers biometric via [onBiometric]. */
@Composable
fun PinLockContent(
verify: (String) -> Boolean,
onUnlocked: () -> Unit,
onBiometric: (() -> Unit)?,
biometricEnabled: Boolean = true,
) {
val context = LocalContext.current
val activity = context as? FragmentActivity
val canUseBiometric = remember(biometricEnabled) {
BiometricAuth.status(context, biometricEnabled) == BiometricUnlockStatus.Ready
}
var biometricPromptShown by remember { mutableStateOf(false) }
val launchBiometric: (() -> Unit)? = if (canUseBiometric && activity != null) {
{
BiometricAuth.showUnlockPrompt(
activity = activity,
subtitle = BiometricAuth.unlockMethodLabel(context),
onSuccess = onUnlocked,
)
}
} else {
null
}
// Auto-prompt once the PIN screen is composed (matches ClimpMe PinScreen).
LaunchedEffect(canUseBiometric) {
if (canUseBiometric && !biometricPromptShown) {
biometricPromptShown = true
launchBiometric?.invoke()
}
}
var pin by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
fun digit(d: String) {
if (pin.length >= PIN_LENGTH) return
pin += d
if (pin.length == PIN_LENGTH) {
if (verify(pin)) {
onUnlocked()
} else {
error = "Incorrect PIN"
pin = ""
}
}
}
PinPad(
title = "Enter PIN",
subtitle = "Unlock Ladill Mini",
pin = pin,
onDigit = { error = null; digit(it) },
onBackspace = { if (pin.isNotEmpty()) pin = pin.dropLast(1) },
errorMessage = error,
onBiometric = launchBiometric ?: onBiometric,
)
}
@@ -1,43 +0,0 @@
package com.ladill.mini.ui.splash
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.background
import com.ladill.mini.ui.components.LadillMiniMark
/**
* Brief brand screen while we decide where to go. Logged-in users with a PIN
* are sent to the lock screen first (matches ClimpMe); everyone else goes to
* Home or Landing.
*/
@Composable
fun SplashScreen(
isLoggedIn: Boolean,
hasPin: Boolean,
toHome: () -> Unit,
toPinLock: () -> Unit,
toLanding: () -> Unit,
) {
LaunchedEffect(Unit) {
when {
!isLoggedIn -> toLanding()
hasPin -> toPinLock()
else -> toHome()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center,
) {
LadillMiniMark(size = 84.dp)
}
}
@@ -1,46 +0,0 @@
package com.ladill.mini.ui.theme
import androidx.compose.ui.graphics.Color
// ---------------------------------------------------------------------------
// Ladill Mini design tokens.
//
// Synthesized from the web app (resources/css/app.css + mini blade views):
// slate page/surfaces, indigo accent, emerald money, Figtree type. The brand
// gradient comes from config/product_landing.php (indigo #4f46e5 -> #1c75bc).
// Brand mark colors (gold/red/green) come from the Ladill logo SVGs.
// ---------------------------------------------------------------------------
// Light — neutrals (Tailwind slate scale)
val LadillInk = Color(0xFF0F172A) // slate-900 — headings
val LadillPage = Color(0xFFF1F5F9) // slate-100 — app background
val LadillMuted = Color(0xFF64748B) // slate-500 — secondary text
val LadillSubtle = Color(0xFF94A3B8) // slate-400 — labels / placeholders
val LadillSurface = Color(0xFFFFFFFF) // cards
val LadillSurfaceSoft = Color(0xFFF8FAFC) // slate-50 — soft fills
val LadillBorder = Color(0xFFE2E8F0) // slate-200 — card borders
val LadillBorderSoft = Color(0xFFF1F5F9) // slate-100 — dividers
// Dark — neutrals
val LadillInkDark = Color(0xFFF1F5F9)
val LadillPageDark = Color(0xFF0B1120)
val LadillMutedDark = Color(0xFF94A3B8)
val LadillSurfaceDark = Color(0xFF111827)
val LadillSurfaceSoftDark = Color(0xFF1E293B)
val LadillBorderDark = Color(0xFF1E293B)
// Accent — Ladill Mini brand gradient (indigo -> blue)
val LadillIndigo = Color(0xFF4F46E5)
val LadillBlue = Color(0xFF1C75BC)
val LadillIndigoSoft = Color(0xFFEEF2FF) // indigo-50 — wallet/highlight cards
val LadillIndigoText = Color(0xFF4338CA) // indigo-700 — accent labels
// Semantic
val LadillMoney = Color(0xFF047857) // emerald-700 — incoming money
val LadillRed = Color(0xFFDC2626) // errors / destructive
val White = Color(0xFFFFFFFF)
// Ladill brand mark (logo) — used sparingly for accents/illustration
val LadillBrandGold = Color(0xFFEEC434)
val LadillBrandRed = Color(0xFFFF3441)
val LadillBrandGreen = Color(0xFF00A749)
@@ -1,62 +0,0 @@
package com.ladill.mini.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Brush
private val LightColorScheme = lightColorScheme(
primary = LadillIndigo,
onPrimary = White,
primaryContainer = LadillIndigoSoft,
onPrimaryContainer = LadillIndigoText,
secondary = LadillBlue,
onSecondary = White,
background = LadillPage,
onBackground = LadillInk,
surface = LadillSurface,
onSurface = LadillInk,
surfaceVariant = LadillSurfaceSoft,
onSurfaceVariant = LadillMuted,
outline = LadillBorder,
outlineVariant = LadillBorderSoft,
error = LadillRed,
onError = White,
)
private val DarkColorScheme = darkColorScheme(
primary = LadillIndigo,
onPrimary = White,
primaryContainer = LadillSurfaceSoftDark,
onPrimaryContainer = LadillInkDark,
secondary = LadillBlue,
onSecondary = White,
background = LadillPageDark,
onBackground = LadillInkDark,
surface = LadillSurfaceDark,
onSurface = LadillInkDark,
surfaceVariant = LadillSurfaceSoftDark,
onSurfaceVariant = LadillMutedDark,
outline = LadillBorderDark,
error = LadillRed,
onError = White,
)
/** The Ladill Mini brand gradient (indigo -> blue), used by primary CTAs. */
val LadillPrimaryGradient
@Composable get() = Brush.horizontalGradient(listOf(LadillIndigo, LadillBlue))
@Composable
fun LadillMiniTheme(
// Ladill Mini is a light-mode app throughout — we ignore the system setting
// so surfaces, the slate page background, and contrast stay as designed.
darkTheme: Boolean = false,
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
typography = Typography,
content = content,
)
}
@@ -1,44 +0,0 @@
package com.ladill.mini.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.ladill.mini.R
/** Figtree — the Ladill web typeface (loaded from fonts.bunny.net on the web). */
val FigtreeFamily = FontFamily(
Font(R.font.figtree_variable, FontWeight.Normal),
)
private fun figtree(
weight: FontWeight = FontWeight.Normal,
size: TextUnit,
lineHeight: TextUnit,
) = TextStyle(
fontFamily = FigtreeFamily,
fontWeight = weight,
fontSize = size,
lineHeight = lineHeight,
)
val Typography = Typography(
displayLarge = figtree(FontWeight.SemiBold, 40.sp, 46.sp),
displayMedium = figtree(FontWeight.SemiBold, 34.sp, 40.sp),
displaySmall = figtree(FontWeight.SemiBold, 28.sp, 34.sp),
headlineLarge = figtree(FontWeight.SemiBold, 26.sp, 32.sp),
headlineMedium = figtree(FontWeight.SemiBold, 22.sp, 28.sp),
headlineSmall = figtree(FontWeight.SemiBold, 20.sp, 26.sp),
titleLarge = figtree(FontWeight.SemiBold, 18.sp, 24.sp),
titleMedium = figtree(FontWeight.Medium, 16.sp, 22.sp),
titleSmall = figtree(FontWeight.Medium, 14.sp, 20.sp),
bodyLarge = figtree(FontWeight.Normal, 16.sp, 24.sp),
bodyMedium = figtree(FontWeight.Normal, 14.sp, 20.sp),
bodySmall = figtree(FontWeight.Normal, 12.sp, 18.sp),
labelLarge = figtree(FontWeight.SemiBold, 15.sp, 20.sp),
labelMedium = figtree(FontWeight.Medium, 12.sp, 16.sp),
labelSmall = figtree(FontWeight.Medium, 11.sp, 14.sp),
)
@@ -1,84 +0,0 @@
package com.ladill.mini.util
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
/** Fingerprint (Class 3) and face unlock (Class 2) on supported devices. */
const val LadillBiometricAuthenticators: Int =
BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK
enum class BiometricUnlockStatus { Disabled, Ready, NoneEnrolled, Unavailable }
object BiometricAuth {
fun status(context: Context, enabledInApp: Boolean): BiometricUnlockStatus {
if (!enabledInApp) return BiometricUnlockStatus.Disabled
return when (BiometricManager.from(context).canAuthenticate(LadillBiometricAuthenticators)) {
BiometricManager.BIOMETRIC_SUCCESS -> BiometricUnlockStatus.Ready
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricUnlockStatus.NoneEnrolled
else -> BiometricUnlockStatus.Unavailable
}
}
fun canUnlock(context: Context, pinManager: PinManager): Boolean =
status(context, pinManager.isBiometricEnabled()) == BiometricUnlockStatus.Ready
fun unlockMethodLabel(context: Context): String {
val pm = context.packageManager
val hasFace = pm.hasSystemFeature(PackageManager.FEATURE_FACE)
val hasFingerprint = pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
return when {
hasFace && hasFingerprint -> "Use fingerprint or face"
hasFace -> "Use face unlock"
hasFingerprint -> "Use fingerprint"
else -> "Use biometrics"
}
}
fun openEnrollmentSettings(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val intent = Intent(Settings.ACTION_BIOMETRIC_ENROLL).apply {
putExtra(Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED, LadillBiometricAuthenticators)
}
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
return
}
}
context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS))
}
fun showUnlockPrompt(
activity: FragmentActivity,
title: String = "Unlock Ladill Mini",
subtitle: String? = null,
onSuccess: () -> Unit,
onUsePin: (() -> Unit)? = null,
) {
val builder = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setNegativeButtonText("Use PIN")
.setAllowedAuthenticators(LadillBiometricAuthenticators)
if (!subtitle.isNullOrBlank()) builder.setSubtitle(subtitle)
BiometricPrompt(
activity,
ContextCompat.getMainExecutor(activity),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = onSuccess()
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || errorCode == BiometricPrompt.ERROR_USER_CANCELED) {
onUsePin?.invoke()
}
}
},
).authenticate(builder.build())
}
}
@@ -1,25 +0,0 @@
package com.ladill.mini.util
import java.text.NumberFormat
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
/** Format minor units (pesewas) as a currency string, e.g. 12345 -> "GHS 123.45". */
fun formatMoney(minor: Long, currency: String = "GHS"): String {
val fmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2
maximumFractionDigits = 2
}
return "$currency ${fmt.format(minor / 100.0)}"
}
fun formatMoney(minor: Int, currency: String = "GHS"): String = formatMoney(minor.toLong(), currency)
/** Render an ISO-8601 timestamp as a short, friendly date — null/blank safe. */
fun formatDate(iso: String?): String {
if (iso.isNullOrBlank()) return ""
return runCatching {
OffsetDateTime.parse(iso).format(DateTimeFormatter.ofPattern("d MMM yyyy, h:mm a", Locale.US))
}.getOrDefault("")
}
@@ -1,39 +0,0 @@
package com.ladill.mini.util
import android.content.Context
import java.security.MessageDigest
/** Stores a hashed app-lock PIN and the biometric-unlock preference. */
class PinManager(context: Context) {
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun hasPin(): Boolean = prefs.contains(KEY_PIN)
fun savePin(pin: String) {
prefs.edit().putString(KEY_PIN, hash(pin)).apply()
}
fun verifyPin(pin: String): Boolean = prefs.getString(KEY_PIN, null) == hash(pin)
fun clearPin() {
prefs.edit().remove(KEY_PIN).apply()
}
fun isBiometricEnabled(): Boolean = prefs.getBoolean(KEY_BIOMETRIC, true)
fun setBiometricEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_BIOMETRIC, enabled).apply()
}
private fun hash(pin: String): String =
MessageDigest.getInstance("SHA-256")
.digest(pin.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
private companion object {
const val PREFS = "ladill_pin"
const val KEY_PIN = "pin_hash"
const val KEY_BIOMETRIC = "biometric_enabled"
}
}
@@ -1,73 +0,0 @@
package com.ladill.mini.util
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Persists the Sanctum token and cached profile fields in an encrypted prefs
* file. Falls back to plain prefs if the keystore is unavailable so the app
* still runs on misbehaving devices.
*/
class SessionManager(context: Context) {
private val prefs = runCatching {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"ladill_mini_session",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}.getOrElse {
context.getSharedPreferences("ladill_mini_session_plain", Context.MODE_PRIVATE)
}
fun getToken(): String? = prefs.getString(KEY_TOKEN, null)
fun saveSession(token: String, name: String?, email: String?, avatarUrl: String?) {
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_NAME, name)
.putString(KEY_EMAIL, email)
.putString(KEY_AVATAR, avatarUrl)
.apply()
}
fun getName(): String? = prefs.getString(KEY_NAME, null)
fun getEmail(): String? = prefs.getString(KEY_EMAIL, null)
fun getAvatarUrl(): String? = prefs.getString(KEY_AVATAR, null)
fun saveName(name: String?) {
prefs.edit().putString(KEY_NAME, name).apply()
}
fun saveAvatarUrl(url: String?) {
prefs.edit().putString(KEY_AVATAR, url).apply()
}
fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val KEY_TOKEN = "token"
const val KEY_NAME = "name"
const val KEY_EMAIL = "email"
const val KEY_AVATAR = "avatar"
}
}
/** Mutable holder so the OkHttp auth interceptor always sees the live token. */
class TokenStore(initial: String?) {
@Volatile
var token: String? = initial
private set
fun update(value: String?) {
token = value
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF" />
</shape>
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- 360-unit brand art scaled into the adaptive-icon safe zone (~47dp, centered)
so the launcher mask leaves comfortable padding around the mark. -->
<group
android:scaleX="0.13"
android:scaleY="0.13"
android:translateX="30.6"
android:translateY="30.6">
<path
android:fillColor="#ff3441"
android:pathData="M-0.3,122.7v225.3c61,0,110.7-49.7,110.7-110.7V12C49.4,12-0.3,61.7-0.3,122.7Z" />
<path
android:fillColor="#00a749"
android:pathData="M249.6,348V122.7c0-61,49.7-110.7,110.7-110.7v225.3c0,61-49.7,110.7-110.7,110.7h0Z" />
<path
android:fillColor="#eec434"
android:pathData="M124.6,12v225.3c0,61,49.7,110.7,110.7,110.7V122.7c0-61-49.7-110.7-110.7-110.7h0Z" />
</group>
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.63,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Ladill Mini brand mark — converted from public/images/launcher-icons/mini.svg -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="360dp"
android:height="360dp"
android:viewportWidth="360"
android:viewportHeight="360">
<path
android:fillColor="#ff3441"
android:pathData="M-0.3,122.7v225.3c61,0,110.7-49.7,110.7-110.7V12C49.4,12-0.3,61.7-0.3,122.7Z" />
<path
android:fillColor="#00a749"
android:pathData="M249.6,348V122.7c0-61,49.7-110.7,110.7-110.7v225.3c0,61-49.7,110.7-110.7,110.7h0Z" />
<path
android:fillColor="#eec434"
android:pathData="M124.6,12v225.3c0,61,49.7,110.7,110.7,110.7V122.7c0-61-49.7-110.7-110.7-110.7h0Z" />
</vector>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<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" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<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" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,3 +0,0 @@
<resources>
<string name="app_name">Ladill Mini</string>
</resources>
@@ -1,8 +0,0 @@
<resources>
<!--
Edge-to-edge is enabled in MainActivity; system bar appearance is driven by
Compose. Keep the platform theme minimal (API 35 ignores the deprecated
statusBar/navigationBar color flags in edge-to-edge mode).
-->
<style name="Theme.LadillMini" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
-6
View File
@@ -1,6 +0,0 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.google.services) apply false
}
-6
View File
@@ -1,6 +0,0 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.parallel=true
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
-54
View File
@@ -1,54 +0,0 @@
[versions]
agp = "8.7.3"
kotlin = "2.0.21"
coreKtx = "1.13.1"
activityCompose = "1.9.3"
composeBom = "2024.10.01"
lifecycleRuntime = "2.8.7"
navigationCompose = "2.8.5"
retrofit = "2.11.0"
okhttp = "4.12.0"
datastorePreferences = "1.1.1"
coroutines = "1.9.0"
securityCrypto = "1.1.0-alpha06"
splashscreen = "1.0.1"
coil = "2.7.0"
biometric = "1.1.0"
zxing = "3.5.3"
firebaseBom = "33.7.0"
googleServices = "4.4.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntime" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntime" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-messaging-ktx = { group = "com.google.firebase", name = "firebase-messaging-ktx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-248
View File
@@ -1,248 +0,0 @@
#!/bin/sh
#
# Copyright © 2015 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
# 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" )
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, 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" \
-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" "$@"
-93
View File
@@ -1,93 +0,0 @@
@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
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -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
-24
View File
@@ -1,24 +0,0 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "LadillMini"
include(":app")
-2
View File
@@ -1,2 +0,0 @@
xcuserdata/
*.xcuserstate
@@ -1,657 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
04B07AE9092AF08CA3B585B3 /* AfiaSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7D37BB542FD8825441324F4 /* AfiaSheet.swift */; };
0576F9A578DB884B8A523BA1 /* PushNotificationRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B8DA566F46B87FAFE2A9D58 /* PushNotificationRouter.swift */; };
14F79FEACC539BC566A2F32F /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85B0C2258B9CB2950BC1B16 /* LoginView.swift */; };
19252EDF929F6EA6DB82E37C /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 812398781431C9E00CD439D9 /* HomeView.swift */; };
1FF2EDB80AA9C86CBFC9FCFA /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 46D5CB704609F56432F8FB13 /* FirebaseCore */; };
2085D5CDAAEE17BA5E9222CD /* PinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 196F838DEBCD70D611BB673B /* PinStore.swift */; };
2311C2BA933BCF17FB4B6B45 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB4AE3C693E4DE54AB83D36 /* AuthViewModel.swift */; };
2CB74AA7CD3995D8F3F08F41 /* LadillButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F47956BC07DDA81AD0AEA9A6 /* LadillButton.swift */; };
31BFD80471D6AA106CCA3995 /* PushTokenRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0633B09F36670A253B9CAE4 /* PushTokenRegistrar.swift */; };
3C0529388083EBD1BF31563A /* QrImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CA88BF6AC275776BFC0E02 /* QrImage.swift */; };
3C9EA52F7BC718A9CFC1208E /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E674C620055A10FD2127194D /* APIClient.swift */; };
53E408597E0CEC28415F2F92 /* LadillMiniApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DAE7F6891B0DE23E9C990F6 /* LadillMiniApp.swift */; };
572786B18AF60EAAAF8B996C /* Formatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE3E2CC48176735C2CBF6A8 /* Formatters.swift */; };
713A6DA601A790D70BF27414 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30B141DC618A287AA75C236 /* SplashView.swift */; };
71F582E609C8879B244C38C9 /* Cards.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCDCCD6AA221A1FE960B9CF /* Cards.swift */; };
72626693186DCB4E9771F687 /* LadillColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C6D355AE86146A84F78083B /* LadillColors.swift */; };
8609A1B78F31187E83C5E49B /* LadillTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E821AD2721FF7DB8A0B089E /* LadillTypography.swift */; };
8916149FDDBC38774330E2EF /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2EE0AF7987C2032D4A437B /* Models.swift */; };
90BC560B187F0F8DF39D1160 /* QrSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21861C581F556E99513CE606 /* QrSheets.swift */; };
9553B9D0693E7424C6D24576 /* HomeTabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81FD233606D1B2431A77511E /* HomeTabs.swift */; };
95B3FE87F18BF06FC4FEC02C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4736D47070F4C83D8F2D9F43 /* Assets.xcassets */; };
A2DC942AE8E115D6BABCF347 /* LandingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AF1E8731B71F89EB1FD9AF /* LandingView.swift */; };
A9054DA6E299540028E2C5A4 /* RegisterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7469503914ADD7C3D4C9108 /* RegisterView.swift */; };
AA9E6A74C50366F3BC2C97E9 /* Figtree-VariableFont_wght.ttf in Resources */ = {isa = PBXBuildFile; fileRef = FAE45CC0979659B06CFD7B5D /* Figtree-VariableFont_wght.ttf */; };
AEAAE6FDD19DC75A04F1205C /* AppRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07029DC532E9DB12953AFEC2 /* AppRouter.swift */; };
B7CFACB09D051161C694CDF9 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = B88B97FAB8790E044624FCE4 /* FirebaseMessaging */; };
D88F6D9EC4E5C7B935B78593 /* SessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D499640CC434DE0B516F6F7A /* SessionStore.swift */; };
E8C7C386237BF60F3C1B1A18 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D8AA2BB6BDD714F2658A672 /* AppDelegate.swift */; };
F0DEA3C438B70BB5377714FF /* NotificationsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE621AF0861FBB86AB8FFA5 /* NotificationsSheet.swift */; };
F22EC58E1B51BB869BE95711 /* PinViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31ED68801068FD9EFB79646C /* PinViews.swift */; };
F356B338A0C0D64DC82BC84E /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9683A6CBF50F439DBB5DE1 /* HomeViewModel.swift */; };
F69C36C067912E6E6CE72DF0 /* AccountViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DBA48860B1CC1B205E02EC5 /* AccountViews.swift */; };
F746461F21367C7DE564DE51 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9D6917908BB6758C5595425F /* GoogleService-Info.plist */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
07029DC532E9DB12953AFEC2 /* AppRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouter.swift; sourceTree = "<group>"; };
0B8DA566F46B87FAFE2A9D58 /* PushNotificationRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationRouter.swift; sourceTree = "<group>"; };
0D8AA2BB6BDD714F2658A672 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
10D174888D5BF76566EA71DB /* LadillMini.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = LadillMini.app; sourceTree = BUILT_PRODUCTS_DIR; };
196F838DEBCD70D611BB673B /* PinStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinStore.swift; sourceTree = "<group>"; };
21861C581F556E99513CE606 /* QrSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrSheets.swift; sourceTree = "<group>"; };
2E821AD2721FF7DB8A0B089E /* LadillTypography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LadillTypography.swift; sourceTree = "<group>"; };
31ED68801068FD9EFB79646C /* PinViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinViews.swift; sourceTree = "<group>"; };
3C2EE0AF7987C2032D4A437B /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
3DAE7F6891B0DE23E9C990F6 /* LadillMiniApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LadillMiniApp.swift; sourceTree = "<group>"; };
4736D47070F4C83D8F2D9F43 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
5CE621AF0861FBB86AB8FFA5 /* NotificationsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsSheet.swift; sourceTree = "<group>"; };
5FB4AE3C693E4DE54AB83D36 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = "<group>"; };
7C6D355AE86146A84F78083B /* LadillColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LadillColors.swift; sourceTree = "<group>"; };
80CEAC5B25457342E72E551D /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
812398781431C9E00CD439D9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
81FD233606D1B2431A77511E /* HomeTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTabs.swift; sourceTree = "<group>"; };
88AF1E8731B71F89EB1FD9AF /* LandingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandingView.swift; sourceTree = "<group>"; };
8E9683A6CBF50F439DBB5DE1 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
8ED7511071CA01305D4D798F /* LadillMini.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LadillMini.entitlements; sourceTree = "<group>"; };
94305400A9DC63655885CD9F /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
94FBC67FE0FEC876F9F28C0A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
9D6917908BB6758C5595425F /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
9DBA48860B1CC1B205E02EC5 /* AccountViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountViews.swift; sourceTree = "<group>"; };
9EE3E2CC48176735C2CBF6A8 /* Formatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatters.swift; sourceTree = "<group>"; };
A0633B09F36670A253B9CAE4 /* PushTokenRegistrar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTokenRegistrar.swift; sourceTree = "<group>"; };
A30B141DC618A287AA75C236 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
A7469503914ADD7C3D4C9108 /* RegisterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterView.swift; sourceTree = "<group>"; };
A85B0C2258B9CB2950BC1B16 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
D0CA88BF6AC275776BFC0E02 /* QrImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrImage.swift; sourceTree = "<group>"; };
D499640CC434DE0B516F6F7A /* SessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStore.swift; sourceTree = "<group>"; };
DBCDCCD6AA221A1FE960B9CF /* Cards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cards.swift; sourceTree = "<group>"; };
E674C620055A10FD2127194D /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
F47956BC07DDA81AD0AEA9A6 /* LadillButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LadillButton.swift; sourceTree = "<group>"; };
F7D37BB542FD8825441324F4 /* AfiaSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfiaSheet.swift; sourceTree = "<group>"; };
FAE45CC0979659B06CFD7B5D /* Figtree-VariableFont_wght.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Figtree-VariableFont_wght.ttf"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
B80FD88B3FCA5C91893A723B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
1FF2EDB80AA9C86CBFC9FCFA /* FirebaseCore in Frameworks */,
B7CFACB09D051161C694CDF9 /* FirebaseMessaging in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0397506BE08E32228EBA75E7 = {
isa = PBXGroup;
children = (
A74F452B492251B9DB340E0C /* LadillMini */,
7D1EF2779E08351BE4FDF8DD /* Products */,
);
sourceTree = "<group>";
};
1B33B7ECE55A1650D0CC4E3B /* Account */ = {
isa = PBXGroup;
children = (
9DBA48860B1CC1B205E02EC5 /* AccountViews.swift */,
);
path = Account;
sourceTree = "<group>";
};
1EAB16A5FC4817A59C14E95F /* Resources */ = {
isa = PBXGroup;
children = (
4736D47070F4C83D8F2D9F43 /* Assets.xcassets */,
9D6917908BB6758C5595425F /* GoogleService-Info.plist */,
94FBC67FE0FEC876F9F28C0A /* Info.plist */,
8ED7511071CA01305D4D798F /* LadillMini.entitlements */,
C5B953AB4865CA4DC7CEA485 /* Config */,
20AAC9FA8622975790F3EF7A /* Fonts */,
);
path = Resources;
sourceTree = "<group>";
};
20AAC9FA8622975790F3EF7A /* Fonts */ = {
isa = PBXGroup;
children = (
FAE45CC0979659B06CFD7B5D /* Figtree-VariableFont_wght.ttf */,
);
path = Fonts;
sourceTree = "<group>";
};
236E542E45C4F9E705D63E54 /* Security */ = {
isa = PBXGroup;
children = (
31ED68801068FD9EFB79646C /* PinViews.swift */,
);
path = Security;
sourceTree = "<group>";
};
380FFE2F5810E78BED7F0029 /* Theme */ = {
isa = PBXGroup;
children = (
7C6D355AE86146A84F78083B /* LadillColors.swift */,
2E821AD2721FF7DB8A0B089E /* LadillTypography.swift */,
);
path = Theme;
sourceTree = "<group>";
};
3EC642CD134062FC87AD956E /* QR */ = {
isa = PBXGroup;
children = (
21861C581F556E99513CE606 /* QrSheets.swift */,
);
path = QR;
sourceTree = "<group>";
};
4FB91BD670A209F3DA80379F /* Core */ = {
isa = PBXGroup;
children = (
AEFA034162B86D09ECE30185 /* Components */,
380FFE2F5810E78BED7F0029 /* Theme */,
E5766C1A691D061645D10764 /* Utils */,
);
path = Core;
sourceTree = "<group>";
};
50591C23A7726DAE315072EF /* Data */ = {
isa = PBXGroup;
children = (
A9BF0E93DD1808165D86909B /* API */,
DD1D964B4D27546BFA6B0118 /* Models */,
);
path = Data;
sourceTree = "<group>";
};
630F1CB05B54D476D5A59CB0 /* App */ = {
isa = PBXGroup;
children = (
07029DC532E9DB12953AFEC2 /* AppRouter.swift */,
3DAE7F6891B0DE23E9C990F6 /* LadillMiniApp.swift */,
);
path = App;
sourceTree = "<group>";
};
7BCD69040D80C409C72F9E77 /* Landing */ = {
isa = PBXGroup;
children = (
88AF1E8731B71F89EB1FD9AF /* LandingView.swift */,
);
path = Landing;
sourceTree = "<group>";
};
7D1EF2779E08351BE4FDF8DD /* Products */ = {
isa = PBXGroup;
children = (
10D174888D5BF76566EA71DB /* LadillMini.app */,
);
name = Products;
sourceTree = "<group>";
};
82DB67A734A5197AAAF515B9 /* Afia */ = {
isa = PBXGroup;
children = (
F7D37BB542FD8825441324F4 /* AfiaSheet.swift */,
);
path = Afia;
sourceTree = "<group>";
};
A74F452B492251B9DB340E0C /* LadillMini */ = {
isa = PBXGroup;
children = (
630F1CB05B54D476D5A59CB0 /* App */,
4FB91BD670A209F3DA80379F /* Core */,
50591C23A7726DAE315072EF /* Data */,
B92F9A727B5BD4305F68ADEC /* Features */,
DE5645BE852C1A578345DDEA /* Push */,
1EAB16A5FC4817A59C14E95F /* Resources */,
);
path = LadillMini;
sourceTree = "<group>";
};
A9BF0E93DD1808165D86909B /* API */ = {
isa = PBXGroup;
children = (
E674C620055A10FD2127194D /* APIClient.swift */,
);
path = API;
sourceTree = "<group>";
};
ABEC4B5172420E2C13D6148B /* Auth */ = {
isa = PBXGroup;
children = (
5FB4AE3C693E4DE54AB83D36 /* AuthViewModel.swift */,
A85B0C2258B9CB2950BC1B16 /* LoginView.swift */,
A7469503914ADD7C3D4C9108 /* RegisterView.swift */,
);
path = Auth;
sourceTree = "<group>";
};
AEFA034162B86D09ECE30185 /* Components */ = {
isa = PBXGroup;
children = (
DBCDCCD6AA221A1FE960B9CF /* Cards.swift */,
F47956BC07DDA81AD0AEA9A6 /* LadillButton.swift */,
D0CA88BF6AC275776BFC0E02 /* QrImage.swift */,
);
path = Components;
sourceTree = "<group>";
};
B649D95664368EDD2A1AC941 /* Splash */ = {
isa = PBXGroup;
children = (
A30B141DC618A287AA75C236 /* SplashView.swift */,
);
path = Splash;
sourceTree = "<group>";
};
B92F9A727B5BD4305F68ADEC /* Features */ = {
isa = PBXGroup;
children = (
1B33B7ECE55A1650D0CC4E3B /* Account */,
82DB67A734A5197AAAF515B9 /* Afia */,
ABEC4B5172420E2C13D6148B /* Auth */,
D7B6316D307BFE4472B84684 /* Home */,
7BCD69040D80C409C72F9E77 /* Landing */,
E7B36443504E305071FC356C /* Notifications */,
3EC642CD134062FC87AD956E /* QR */,
236E542E45C4F9E705D63E54 /* Security */,
B649D95664368EDD2A1AC941 /* Splash */,
);
path = Features;
sourceTree = "<group>";
};
C5B953AB4865CA4DC7CEA485 /* Config */ = {
isa = PBXGroup;
children = (
80CEAC5B25457342E72E551D /* Debug.xcconfig */,
94305400A9DC63655885CD9F /* Release.xcconfig */,
);
path = Config;
sourceTree = "<group>";
};
D7B6316D307BFE4472B84684 /* Home */ = {
isa = PBXGroup;
children = (
81FD233606D1B2431A77511E /* HomeTabs.swift */,
812398781431C9E00CD439D9 /* HomeView.swift */,
8E9683A6CBF50F439DBB5DE1 /* HomeViewModel.swift */,
);
path = Home;
sourceTree = "<group>";
};
DD1D964B4D27546BFA6B0118 /* Models */ = {
isa = PBXGroup;
children = (
3C2EE0AF7987C2032D4A437B /* Models.swift */,
);
path = Models;
sourceTree = "<group>";
};
DE5645BE852C1A578345DDEA /* Push */ = {
isa = PBXGroup;
children = (
0D8AA2BB6BDD714F2658A672 /* AppDelegate.swift */,
0B8DA566F46B87FAFE2A9D58 /* PushNotificationRouter.swift */,
A0633B09F36670A253B9CAE4 /* PushTokenRegistrar.swift */,
);
path = Push;
sourceTree = "<group>";
};
E5766C1A691D061645D10764 /* Utils */ = {
isa = PBXGroup;
children = (
9EE3E2CC48176735C2CBF6A8 /* Formatters.swift */,
196F838DEBCD70D611BB673B /* PinStore.swift */,
D499640CC434DE0B516F6F7A /* SessionStore.swift */,
);
path = Utils;
sourceTree = "<group>";
};
E7B36443504E305071FC356C /* Notifications */ = {
isa = PBXGroup;
children = (
5CE621AF0861FBB86AB8FFA5 /* NotificationsSheet.swift */,
);
path = Notifications;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
CFA3826D1CA0E86DE51E6969 /* LadillMini */ = {
isa = PBXNativeTarget;
buildConfigurationList = 11623E7A9F83C72C08A4491E /* Build configuration list for PBXNativeTarget "LadillMini" */;
buildPhases = (
0DD12CCDA02E15D2056B9E55 /* Sources */,
E25420A5A056A698A72B0E9C /* Resources */,
B80FD88B3FCA5C91893A723B /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = LadillMini;
packageProductDependencies = (
46D5CB704609F56432F8FB13 /* FirebaseCore */,
B88B97FAB8790E044624FCE4 /* FirebaseMessaging */,
);
productName = LadillMini;
productReference = 10D174888D5BF76566EA71DB /* LadillMini.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
D131DC8B22240A0089938D83 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1500;
TargetAttributes = {
CFA3826D1CA0E86DE51E6969 = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = E63620B42A65B8EB80AF7950 /* Build configuration list for PBXProject "LadillMini" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 0397506BE08E32228EBA75E7;
minimizedProjectReferenceProxies = 1;
packageReferences = (
3BDA5B680A5758BA711BFDD8 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 7D1EF2779E08351BE4FDF8DD /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
CFA3826D1CA0E86DE51E6969 /* LadillMini */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
E25420A5A056A698A72B0E9C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
95B3FE87F18BF06FC4FEC02C /* Assets.xcassets in Resources */,
AA9E6A74C50366F3BC2C97E9 /* Figtree-VariableFont_wght.ttf in Resources */,
F746461F21367C7DE564DE51 /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
0DD12CCDA02E15D2056B9E55 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
3C9EA52F7BC718A9CFC1208E /* APIClient.swift in Sources */,
F69C36C067912E6E6CE72DF0 /* AccountViews.swift in Sources */,
04B07AE9092AF08CA3B585B3 /* AfiaSheet.swift in Sources */,
E8C7C386237BF60F3C1B1A18 /* AppDelegate.swift in Sources */,
AEAAE6FDD19DC75A04F1205C /* AppRouter.swift in Sources */,
2311C2BA933BCF17FB4B6B45 /* AuthViewModel.swift in Sources */,
71F582E609C8879B244C38C9 /* Cards.swift in Sources */,
572786B18AF60EAAAF8B996C /* Formatters.swift in Sources */,
9553B9D0693E7424C6D24576 /* HomeTabs.swift in Sources */,
19252EDF929F6EA6DB82E37C /* HomeView.swift in Sources */,
F356B338A0C0D64DC82BC84E /* HomeViewModel.swift in Sources */,
2CB74AA7CD3995D8F3F08F41 /* LadillButton.swift in Sources */,
72626693186DCB4E9771F687 /* LadillColors.swift in Sources */,
53E408597E0CEC28415F2F92 /* LadillMiniApp.swift in Sources */,
8609A1B78F31187E83C5E49B /* LadillTypography.swift in Sources */,
A2DC942AE8E115D6BABCF347 /* LandingView.swift in Sources */,
14F79FEACC539BC566A2F32F /* LoginView.swift in Sources */,
8916149FDDBC38774330E2EF /* Models.swift in Sources */,
F0DEA3C438B70BB5377714FF /* NotificationsSheet.swift in Sources */,
2085D5CDAAEE17BA5E9222CD /* PinStore.swift in Sources */,
F22EC58E1B51BB869BE95711 /* PinViews.swift in Sources */,
0576F9A578DB884B8A523BA1 /* PushNotificationRouter.swift in Sources */,
31BFD80471D6AA106CCA3995 /* PushTokenRegistrar.swift in Sources */,
3C0529388083EBD1BF31563A /* QrImage.swift in Sources */,
90BC560B187F0F8DF39D1160 /* QrSheets.swift in Sources */,
A9054DA6E299540028E2C5A4 /* RegisterView.swift in Sources */,
D88F6D9EC4E5C7B935B78593 /* SessionStore.swift in Sources */,
713A6DA601A790D70BF27414 /* SplashView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
2F3197859BA7845C6FA8A7B6 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.9;
};
name = Debug;
};
48C760C48B7AF129F3C93582 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 94305400A9DC63655885CD9F /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = LadillMini/Resources/LadillMini.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = LadillMini/Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.ladill.mini;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
8F89A0F3452FB0A973B82795 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.9;
};
name = Release;
};
F6B92F5CEEF45CDC217802ED /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 80CEAC5B25457342E72E551D /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = LadillMini/Resources/LadillMini.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = LadillMini/Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.ladill.mini;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
11623E7A9F83C72C08A4491E /* Build configuration list for PBXNativeTarget "LadillMini" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F6B92F5CEEF45CDC217802ED /* Debug */,
48C760C48B7AF129F3C93582 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
E63620B42A65B8EB80AF7950 /* Build configuration list for PBXProject "LadillMini" */ = {
isa = XCConfigurationList;
buildConfigurations = (
2F3197859BA7845C6FA8A7B6 /* Debug */,
8F89A0F3452FB0A973B82795 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
3BDA5B680A5758BA711BFDD8 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/firebase/firebase-ios-sdk";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 11.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
46D5CB704609F56432F8FB13 /* FirebaseCore */ = {
isa = XCSwiftPackageProductDependency;
package = 3BDA5B680A5758BA711BFDD8 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseCore;
};
B88B97FAB8790E044624FCE4 /* FirebaseMessaging */ = {
isa = XCSwiftPackageProductDependency;
package = 3BDA5B680A5758BA711BFDD8 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseMessaging;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = D131DC8B22240A0089938D83 /* Project object */;
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -1,132 +0,0 @@
{
"originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329",
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
"version" : "11.3.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5",
"version" : "11.15.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed",
"version" : "2.3.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "45ce435e9406d3c674dd249a042b932bee006f60",
"version" : "11.15.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c756a29784521063b6a1202907e2cc47f41b667c",
"version" : "4.5.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "f6506eaa86ed2e01cb0ae14a75035b7fdbf0918f",
"version" : "1.38.0"
}
}
],
"version" : 3
}
@@ -1,91 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CFA3826D1CA0E86DE51E6969"
BuildableName = "LadillMini.app"
BlueprintName = "LadillMini"
ReferencedContainer = "container:LadillMini.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CFA3826D1CA0E86DE51E6969"
BuildableName = "LadillMini.app"
BlueprintName = "LadillMini"
ReferencedContainer = "container:LadillMini.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CFA3826D1CA0E86DE51E6969"
BuildableName = "LadillMini.app"
BlueprintName = "LadillMini"
ReferencedContainer = "container:LadillMini.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CFA3826D1CA0E86DE51E6969"
BuildableName = "LadillMini.app"
BlueprintName = "LadillMini"
ReferencedContainer = "container:LadillMini.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
-111
View File
@@ -1,111 +0,0 @@
import SwiftUI
enum AppRoute: Hashable {
case landing, login, register, pinLock, pinSetup, home
case profile, settings, security, wallet, billing, support
}
struct AppRouter: View {
@EnvironmentObject private var session: SessionStore
@State private var path = NavigationPath()
@State private var booted = false
@State private var initialRoute: AppRoute = .landing
var body: some View {
NavigationStack(path: $path) {
Group {
if !booted {
SplashView(onFinish: handleSplashFinish)
} else {
rootView
}
}
.navigationDestination(for: AppRoute.self) { route in
destination(for: route)
}
}
}
@ViewBuilder
private var rootView: some View {
switch initialRoute {
case .pinLock: PinLockView(onUnlocked: { goHome() })
case .home: HomeView(
onNavigate: { path.append($0) },
onLoggedOut: { resetToLanding() }
)
default: LandingView(
onSignIn: { path.append(AppRoute.login) },
onCreateAccount: { path.append(AppRoute.register) }
)
}
}
@ViewBuilder
private func destination(for route: AppRoute) -> some View {
switch route {
case .landing:
LandingView(
onSignIn: { path.append(AppRoute.login) },
onCreateAccount: { path.append(AppRoute.register) }
)
case .login:
LoginView(
onLoggedIn: { goHome() },
onCreateAccount: {
path.removeLast(path.count)
path.append(AppRoute.register)
},
onBack: { path.removeLast() }
)
case .register:
RegisterView(
onRegistered: { goHome() },
onSignIn: {
path.removeLast(path.count)
path.append(AppRoute.login)
},
onBack: { path.removeLast() }
)
case .pinLock:
PinLockView(onUnlocked: { goHome() })
case .pinSetup:
PinSetupView(onPinSet: { goHome() })
case .home:
HomeView(onNavigate: { path.append($0) }, onLoggedOut: { resetToLanding() })
case .profile:
ProfileView(onBack: { path.removeLast() })
case .settings:
SettingsView(onBack: { path.removeLast() })
case .security:
SecurityView(onBack: { path.removeLast() })
case .wallet:
WalletView(onBack: { path.removeLast() })
case .billing:
BillingView(onBack: { path.removeLast() })
case .support:
SupportView(onBack: { path.removeLast() })
}
}
private func handleSplashFinish() {
booted = true
if session.isLoggedIn {
initialRoute = PinStore.shared.hasPin() ? .pinLock : .home
} else {
initialRoute = .landing
}
}
private func goHome() {
path = NavigationPath()
initialRoute = .home
booted = true
}
private func resetToLanding() {
path = NavigationPath()
initialRoute = .landing
booted = true
}
}
@@ -1,34 +0,0 @@
import SwiftUI
@main
struct LadillMiniApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var session = SessionStore.shared
@Environment(\.scenePhase) private var scenePhase
@State private var locked = false
var body: some Scene {
WindowGroup {
AppRouter()
.environmentObject(session)
.environmentObject(PushNotificationRouter.shared)
.font(.ladill(16))
.preferredColorScheme(.light)
.task {
if session.isLoggedIn {
await PushTokenRegistrar.registerIfLoggedIn()
}
}
.overlay {
if locked && session.isLoggedIn && PinStore.shared.hasPin() {
PinLockOverlay(onUnlocked: { locked = false })
}
}
.onChange(of: scenePhase) { _, phase in
if phase == .background && session.isLoggedIn && PinStore.shared.hasPin() {
locked = true
}
}
}
}
}
@@ -1,102 +0,0 @@
import SwiftUI
struct LadillCard<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
content
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(LadillColors.surface)
.overlay(RoundedRectangle(cornerRadius: 16).stroke(LadillColors.border, lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
struct StatCard: View {
let label: String
let value: String
var accent = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.ladill(size: 12, weight: .medium))
.foregroundStyle(accent ? LadillColors.indigoText : LadillColors.muted)
Text(value)
.font(.ladill(size: 22, weight: .semibold))
.foregroundStyle(accent ? LadillColors.money : LadillColors.ink)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(accent ? LadillColors.indigoSoft : LadillColors.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(accent ? LadillColors.indigo.opacity(0.2) : LadillColors.border, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
struct UserAvatar: View {
let name: String?
let photoURL: String?
var size: CGFloat = 40
var onTap: (() -> Void)?
var body: some View {
let content = ZStack {
Circle()
.fill(LadillColors.indigoSoft)
if let initial = name?.trimmingCharacters(in: .whitespaces).first {
Text(String(initial).uppercased())
.font(.ladill(size: size * 0.38, weight: .semibold))
.foregroundStyle(LadillColors.indigoText)
} else {
Image(systemName: "person.fill")
.font(.system(size: size * 0.4))
.foregroundStyle(LadillColors.indigoText)
}
}
.frame(width: size, height: size)
if let onTap {
Button(action: onTap) { content }.buttonStyle(.plain)
} else {
content
}
}
}
struct LoadingView: View {
var body: some View {
VStack(spacing: 12) {
ProgressView().tint(LadillColors.indigo)
Text("Loading…")
.font(.ladill(size: 14))
.foregroundStyle(LadillColors.muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
}
struct ErrorView: View {
let message: String
let onRetry: () -> Void
var body: some View {
VStack(spacing: 12) {
Text(message)
.font(.ladill(size: 14))
.foregroundStyle(LadillColors.muted)
.multilineTextAlignment(.center)
Button("Try again", action: onRetry)
.font(.ladill(size: 14, weight: .semibold))
.foregroundStyle(LadillColors.indigo)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
.padding(.horizontal, 20)
}
}
@@ -1,140 +0,0 @@
import SwiftUI
struct LadillButton: View {
let title: String
var loading = false
var enabled = true
let action: () -> Void
var body: some View {
Button(action: action) {
ZStack {
RoundedRectangle(cornerRadius: 28)
.fill(LadillColors.primaryGradient)
.frame(height: 56)
.opacity(enabled && !loading ? 1 : 0.5)
if loading {
ProgressView().tint(.white)
} else {
HStack {
Text(title)
.font(.ladill(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
Circle()
.fill(.white)
.frame(width: 44, height: 44)
.overlay {
Image(systemName: "arrow.up.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(LadillColors.indigo)
}
}
.padding(.leading, 22)
.padding(.trailing, 6)
}
}
}
.disabled(!enabled || loading)
.buttonStyle(.plain)
}
}
struct LadillSecondaryButton: View {
let title: String
var enabled = true
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.ladill(size: 15, weight: .semibold))
.foregroundStyle(LadillColors.ink)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(LadillColors.surfaceSoft)
.clipShape(Capsule())
.opacity(enabled ? 1 : 0.5)
}
.disabled(!enabled)
.buttonStyle(.plain)
}
}
struct LadillTextField: View {
let label: String
let placeholder: String
@Binding var text: String
var secure = false
var keyboard: UIKeyboardType = .default
var enabled = true
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.ladill(size: 13, weight: .medium))
.foregroundStyle(LadillColors.muted)
Group {
if secure {
SecureField(placeholder, text: $text)
} else {
TextField(placeholder, text: $text)
.keyboardType(keyboard)
.textInputAutocapitalization(keyboard == .emailAddress ? .never : .words)
.autocorrectionDisabled(keyboard == .emailAddress)
}
}
.font(.ladill(size: 16))
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(LadillColors.surface)
.overlay(RoundedRectangle(cornerRadius: 14).stroke(LadillColors.border, lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 14))
.disabled(!enabled)
}
}
}
struct StepBar: View {
let current: Int
let total: Int
var body: some View {
HStack(spacing: 6) {
ForEach(0..<total, id: \.self) { index in
Capsule()
.fill(index <= current ? LadillColors.indigo : LadillColors.border)
.frame(height: 4)
}
}
.padding(.vertical, 8)
}
}
struct MenuScaffold<Content: View>: View {
let title: String
let onBack: () -> Void
@ViewBuilder let content: Content
var body: some View {
VStack(spacing: 0) {
HStack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(LadillColors.ink)
}
Text(title)
.font(.ladill(size: 18, weight: .semibold))
.foregroundStyle(LadillColors.ink)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
content
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(LadillColors.page)
}
}

Some files were not shown because too many files have changed in this diff Show More