Ship full-featured mobile clients with auth, payments, QRs, push notifications, and iOS polish including Figtree typography, app icons, and QR previews. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,16 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
*.apk
|
||||
*.aab
|
||||
keystore.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
/app/build
|
||||
.kotlin
|
||||
@@ -0,0 +1,125 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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.**
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 677 B |
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 910 B |
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 685 B |
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 903 B |
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 837 B |
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 939 B |
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 785 B |
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1021 B |
@@ -0,0 +1,25 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 727 B |
@@ -0,0 +1,43 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,72 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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", "Murang’a", "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()
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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?",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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)
|
||||
@@ -0,0 +1,62 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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),
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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("")
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Ladill Mini</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<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>
|
||||
@@ -0,0 +1,6 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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
|
||||
@@ -0,0 +1,54 @@
|
||||
[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" }
|
||||
@@ -0,0 +1,7 @@
|
||||
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
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/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" "$@"
|
||||
@@ -0,0 +1,93 @@
|
||||
@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
|
||||
@@ -0,0 +1,24 @@
|
||||
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")
|
||||
@@ -0,0 +1,2 @@
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
@@ -0,0 +1,657 @@
|
||||
// !$*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 */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,111 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||