diff --git a/apps/android/.gitignore b/apps/android/.gitignore deleted file mode 100644 index 1a4dbc3..0000000 --- a/apps/android/.gitignore +++ /dev/null @@ -1,16 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea -.DS_Store -/build -/captures -.externalNativeBuild -.cxx -*.apk -*.aab -keystore.properties -*.jks -*.keystore -/app/build -.kotlin diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts deleted file mode 100644 index a23c910..0000000 --- a/apps/android/app/build.gradle.kts +++ /dev/null @@ -1,125 +0,0 @@ -import java.util.Properties -import org.gradle.api.GradleException - -val keystoreProperties = Properties().apply { - val file = rootProject.file("keystore.properties") - if (file.exists()) { - file.inputStream().use(::load) - } -} - -fun signingProperty(name: String): String? = - keystoreProperties.getProperty(name) - ?: providers.gradleProperty(name).orNull - ?: providers.environmentVariable(name).orNull - -val releaseStoreFile = signingProperty("LADILL_UPLOAD_STORE_FILE") ?: signingProperty("storeFile") -val releaseStorePassword = signingProperty("LADILL_UPLOAD_STORE_PASSWORD") ?: signingProperty("storePassword") -val releaseKeyAlias = signingProperty("LADILL_UPLOAD_KEY_ALIAS") ?: signingProperty("keyAlias") -val releaseKeyPassword = signingProperty("LADILL_UPLOAD_KEY_PASSWORD") ?: signingProperty("keyPassword") -val hasReleaseSigning = listOf(releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword).all { !it.isNullOrBlank() } -val requestedReleaseBuild = gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) } - -if (requestedReleaseBuild && !hasReleaseSigning) { - throw GradleException( - "Release signing is not configured. Create apps/android/keystore.properties with storeFile, storePassword, keyAlias, and keyPassword, or provide LADILL_UPLOAD_STORE_FILE, LADILL_UPLOAD_STORE_PASSWORD, LADILL_UPLOAD_KEY_ALIAS, and LADILL_UPLOAD_KEY_PASSWORD.", - ) -} - -plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) -} - -if (file("google-services.json").exists()) { - apply(plugin = "com.google.gms.google-services") -} - -android { - namespace = "com.ladill.mini" - compileSdk = 35 - - defaultConfig { - applicationId = "com.ladill.mini" - minSdk = 26 - targetSdk = 35 - versionCode = 1 - versionName = "1.0" - } - - signingConfigs { - if (hasReleaseSigning) { - create("release") { - storeFile = rootProject.file(releaseStoreFile!!) - storePassword = releaseStorePassword - keyAlias = releaseKeyAlias - keyPassword = releaseKeyPassword - } - } - } - - buildTypes { - debug { - buildConfigField("String", "BASE_URL", "\"https://mini.ladill.com/api/v1/\"") - } - release { - isMinifyEnabled = true - isShrinkResources = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - buildConfigField("String", "BASE_URL", "\"https://mini.ladill.com/api/v1/\"") - if (hasReleaseSigning) { - signingConfig = signingConfigs.getByName("release") - } - } - } - - buildFeatures { - compose = true - buildConfig = true - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.splashscreen) - implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.datastore.preferences) - implementation(libs.androidx.security.crypto) - implementation(libs.androidx.biometric) - implementation(libs.kotlinx.coroutines.android) - - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.material.icons.extended) - - implementation(libs.retrofit) - implementation(libs.retrofit.converter.gson) - implementation(libs.okhttp) - implementation(libs.okhttp.logging.interceptor) - - implementation(libs.coil.compose) - implementation(libs.coil.svg) - implementation(libs.zxing.core) - - implementation(platform(libs.firebase.bom)) - implementation(libs.firebase.messaging.ktx) - - debugImplementation(libs.androidx.compose.ui.tooling) -} diff --git a/apps/android/app/google-services.json b/apps/android/app/google-services.json deleted file mode 100644 index bfdd2d9..0000000 --- a/apps/android/app/google-services.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "project_info": { - "project_number": "578991696856", - "firebase_url": "https://laddile-30129-default-rtdb.firebaseio.com", - "project_id": "laddile-30129", - "storage_bucket": "laddile-30129.appspot.com" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:578991696856:android:e5239a3350aef0e069e0ad", - "android_client_info": { - "package_name": "com.climp.me" - } - }, - "oauth_client": [ - { - "client_id": "578991696856-amffvm0esftuar2hegn0k7tp8jampf4t.apps.googleusercontent.com", - "client_type": 1, - "android_info": { - "package_name": "com.climp.me", - "certificate_hash": "57bb2009ae16fbdee470878df06bd4df5d7069d4" - } - }, - { - "client_id": "578991696856-mtbgssqga5gfd8k6578hlhde39s9jct4.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyCJcn0V8qZky_oFx-xe-k4iUbLYNdBB3Lc" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "578991696856-6s72642bttvg6787rk7ldv8mrkhba844.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "578991696856-5gkpjsrre8uhfnpanagmj9cgp87drjso.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.zipxapp.com" - } - } - ] - } - } - }, - { - "client_info": { - "mobilesdk_app_id": "1:578991696856:android:724fa8fce0bcaf5d69e0ad", - "android_client_info": { - "package_name": "com.ladill.mini" - } - }, - "oauth_client": [ - { - "client_id": "578991696856-mtbgssqga5gfd8k6578hlhde39s9jct4.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyCJcn0V8qZky_oFx-xe-k4iUbLYNdBB3Lc" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "578991696856-6s72642bttvg6787rk7ldv8mrkhba844.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "578991696856-5gkpjsrre8uhfnpanagmj9cgp87drjso.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.zipxapp.com" - } - } - ] - } - } - } - ], - "configuration_version": "1" -} \ No newline at end of file diff --git a/apps/android/app/proguard-rules.pro b/apps/android/app/proguard-rules.pro deleted file mode 100644 index 67a9f2c..0000000 --- a/apps/android/app/proguard-rules.pro +++ /dev/null @@ -1,18 +0,0 @@ -# Retrofit / Gson models are accessed reflectively. --keepattributes Signature --keepattributes *Annotation* --keep class com.ladill.mini.data.model.** { *; } - -# Retrofit --keepclasseswithmembers class * { - @retrofit2.http.* ; -} --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.** diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 40dcebb..0000000 --- a/apps/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/android/app/src/main/assets/icons/eyeball-hide.svg b/apps/android/app/src/main/assets/icons/eyeball-hide.svg deleted file mode 100644 index 92cbffa..0000000 --- a/apps/android/app/src/main/assets/icons/eyeball-hide.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/apps/android/app/src/main/assets/icons/eyeball.svg b/apps/android/app/src/main/assets/icons/eyeball.svg deleted file mode 100644 index 0e1d922..0000000 --- a/apps/android/app/src/main/assets/icons/eyeball.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/android/app/src/main/assets/icons/help.svg b/apps/android/app/src/main/assets/icons/help.svg deleted file mode 100644 index c9583d0..0000000 --- a/apps/android/app/src/main/assets/icons/help.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/apps/android/app/src/main/assets/icons/home.svg b/apps/android/app/src/main/assets/icons/home.svg deleted file mode 100644 index 4169776..0000000 --- a/apps/android/app/src/main/assets/icons/home.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/apps/android/app/src/main/assets/icons/logout.svg b/apps/android/app/src/main/assets/icons/logout.svg deleted file mode 100644 index 7ebd9c8..0000000 --- a/apps/android/app/src/main/assets/icons/logout.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/apps/android/app/src/main/assets/icons/money-owed.svg b/apps/android/app/src/main/assets/icons/money-owed.svg deleted file mode 100644 index 93de8ce..0000000 --- a/apps/android/app/src/main/assets/icons/money-owed.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/apps/android/app/src/main/assets/icons/money.svg b/apps/android/app/src/main/assets/icons/money.svg deleted file mode 100644 index 83e791c..0000000 --- a/apps/android/app/src/main/assets/icons/money.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/apps/android/app/src/main/assets/icons/notifications.svg b/apps/android/app/src/main/assets/icons/notifications.svg deleted file mode 100644 index 8c1704e..0000000 --- a/apps/android/app/src/main/assets/icons/notifications.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/apps/android/app/src/main/assets/icons/password.svg b/apps/android/app/src/main/assets/icons/password.svg deleted file mode 100644 index 51325a1..0000000 --- a/apps/android/app/src/main/assets/icons/password.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/apps/android/app/src/main/assets/icons/profile.svg b/apps/android/app/src/main/assets/icons/profile.svg deleted file mode 100644 index c4598b9..0000000 --- a/apps/android/app/src/main/assets/icons/profile.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/apps/android/app/src/main/assets/icons/qr.svg b/apps/android/app/src/main/assets/icons/qr.svg deleted file mode 100644 index a67fafa..0000000 --- a/apps/android/app/src/main/assets/icons/qr.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/android/app/src/main/assets/icons/setting.svg b/apps/android/app/src/main/assets/icons/setting.svg deleted file mode 100644 index 625c171..0000000 --- a/apps/android/app/src/main/assets/icons/setting.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/android/app/src/main/assets/icons/support.svg b/apps/android/app/src/main/assets/icons/support.svg deleted file mode 100644 index ee7069f..0000000 --- a/apps/android/app/src/main/assets/icons/support.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/android/app/src/main/assets/ladillmini-icon.svg b/apps/android/app/src/main/assets/ladillmini-icon.svg deleted file mode 100644 index 62957e6..0000000 --- a/apps/android/app/src/main/assets/ladillmini-icon.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/apps/android/app/src/main/assets/ladillmini-logo.svg b/apps/android/app/src/main/assets/ladillmini-logo.svg deleted file mode 100644 index 76652b5..0000000 --- a/apps/android/app/src/main/assets/ladillmini-logo.svg +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/LadillMiniApp.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/LadillMiniApp.kt deleted file mode 100644 index dc95cea..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/LadillMiniApp.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.ladill.mini - -import android.app.Application -import coil.Coil -import coil.ImageLoader -import coil.decode.SvgDecoder -import com.ladill.mini.data.api.ApiClient -import com.ladill.mini.data.repository.AuthRepository -import com.ladill.mini.data.repository.MiniRepository -import com.ladill.mini.data.repository.NotificationRepository -import com.ladill.mini.push.PushTokenRegistrar -import com.ladill.mini.util.PinManager -import com.ladill.mini.util.SessionManager -import com.ladill.mini.util.TokenStore -import okhttp3.OkHttpClient - -/** - * Manual DI container — mirrors the ClimpMe Android pattern: construct the - * session/token/api/repositories once and expose them as `lateinit` singletons - * the screens read via `application as LadillMiniApp`. - */ -class LadillMiniApp : Application() { - - lateinit var sessionManager: SessionManager - private set - lateinit var tokenStore: TokenStore - private set - lateinit var apiClient: ApiClient - private set - lateinit var authRepository: AuthRepository - private set - lateinit var miniRepository: MiniRepository - private set - lateinit var notificationRepository: NotificationRepository - private set - lateinit var pinManager: PinManager - private set - - override fun onCreate() { - super.onCreate() - - sessionManager = SessionManager(applicationContext) - tokenStore = TokenStore(sessionManager.getToken()) - - // Coil loads bundled brand SVGs and the authenticated QR preview PNGs, so - // it shares a bearer-injecting client and the SVG decoder. - val imageClient = OkHttpClient.Builder() - .addInterceptor { chain -> - val builder = chain.request().newBuilder().header("Accept", "image/*") - tokenStore.token?.let { builder.header("Authorization", "Bearer $it") } - chain.proceed(builder.build()) - } - .build() - - Coil.setImageLoader( - ImageLoader.Builder(this) - .okHttpClient(imageClient) - .components { add(SvgDecoder.Factory()) } - .build(), - ) - - apiClient = ApiClient(tokenStore) - authRepository = AuthRepository(applicationContext, sessionManager, tokenStore, apiClient) - miniRepository = MiniRepository(apiClient) - notificationRepository = NotificationRepository(apiClient) - pinManager = PinManager(applicationContext) - - if (authRepository.isLoggedIn()) { - PushTokenRegistrar.registerIfLoggedIn(this) - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/MainActivity.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/MainActivity.kt deleted file mode 100644 index 5c8e8fb..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/MainActivity.kt +++ /dev/null @@ -1,239 +0,0 @@ -package com.ladill.mini - -import android.Manifest -import android.graphics.Color -import android.os.Build -import android.os.Bundle -import androidx.activity.SystemBarStyle -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Surface -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import com.ladill.mini.navigation.Route -import com.ladill.mini.ui.security.PinLockContent -import com.ladill.mini.ui.security.PinSetupContent -import com.ladill.mini.ui.account.BillingScreen -import com.ladill.mini.ui.account.ProfileScreen -import com.ladill.mini.ui.account.SecurityScreen -import com.ladill.mini.ui.account.SettingsScreen -import com.ladill.mini.ui.account.SupportScreen -import com.ladill.mini.ui.account.WalletScreen -import com.ladill.mini.ui.auth.LoginScreen -import com.ladill.mini.ui.auth.RegisterScreen -import com.ladill.mini.ui.home.HomeScreen -import com.ladill.mini.ui.landing.LandingScreen -import com.ladill.mini.ui.splash.SplashScreen -import com.ladill.mini.push.PushTokenRegistrar -import com.ladill.mini.ui.theme.LadillMiniTheme - -class MainActivity : FragmentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() - // Light-mode app: force dark system-bar icons on the light background. - val lightBars = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT) - enableEdgeToEdge(statusBarStyle = lightBars, navigationBarStyle = lightBars) - super.onCreate(savedInstanceState) - - val app = application as LadillMiniApp - - setContent { - LadillMiniTheme { - val notificationPermission = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { granted -> - if (granted && app.authRepository.isLoggedIn()) { - PushTokenRegistrar.registerIfLoggedIn(app) - } - } - - androidx.compose.runtime.LaunchedEffect(Unit) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) - } else if (app.authRepository.isLoggedIn()) { - PushTokenRegistrar.registerIfLoggedIn(app) - } - } - - AppLock(app) { - val navController = rememberNavController() - - NavHost(navController = navController, startDestination = Route.SPLASH) { - composable(Route.SPLASH) { - SplashScreen( - isLoggedIn = app.authRepository.isLoggedIn(), - hasPin = app.pinManager.hasPin(), - toHome = { - navController.navigate(Route.HOME) { - popUpTo(Route.SPLASH) { inclusive = true } - } - }, - toPinLock = { - navController.navigate(Route.PIN_LOCK) { - popUpTo(Route.SPLASH) { inclusive = true } - } - }, - toLanding = { - navController.navigate(Route.LANDING) { - popUpTo(Route.SPLASH) { inclusive = true } - } - }, - ) - } - - composable(Route.PIN_LOCK) { - PinLockContent( - verify = { app.pinManager.verifyPin(it) }, - onUnlocked = { - navController.navigate(Route.HOME) { - popUpTo(Route.PIN_LOCK) { inclusive = true } - } - }, - onBiometric = null, - biometricEnabled = app.pinManager.isBiometricEnabled(), - ) - } - - composable(Route.PIN_SETUP) { - PinSetupContent(onPinSet = { code -> - app.pinManager.savePin(code) - navController.navigate(Route.HOME) { - popUpTo(Route.PIN_SETUP) { inclusive = true } - } - }) - } - - composable(Route.LANDING) { - LandingScreen( - onSignIn = { navController.navigate(Route.LOGIN) }, - onCreateAccount = { navController.navigate(Route.REGISTER) }, - ) - } - - composable(Route.LOGIN) { - LoginScreen( - onLoggedIn = { - navController.navigate(Route.HOME) { - popUpTo(Route.LOGIN) { inclusive = true } - } - }, - onCreateAccount = { navController.navigate(Route.REGISTER) }, - onBack = { navController.popBackStack() }, - ) - } - - composable(Route.REGISTER) { - RegisterScreen( - onRegistered = { - navController.navigate(Route.HOME) { - popUpTo(Route.LOGIN) { inclusive = true } - } - }, - onBack = { navController.popBackStack() }, - onSignIn = { - navController.navigate(Route.LOGIN) { - popUpTo(Route.REGISTER) { inclusive = true } - } - }, - ) - } - - composable(Route.HOME) { - HomeScreen( - onOpenProfile = { navController.navigate(Route.PROFILE) }, - onOpenSettings = { navController.navigate(Route.SETTINGS) }, - onOpenSecurity = { navController.navigate(Route.SECURITY) }, - onOpenWallet = { navController.navigate(Route.WALLET) }, - onOpenBilling = { navController.navigate(Route.BILLING) }, - onOpenSupport = { navController.navigate(Route.SUPPORT) }, - onLoggedOut = { - navController.navigate(Route.LANDING) { - popUpTo(0) { inclusive = true } - } - }, - ) - } - - composable(Route.PROFILE) { - ProfileScreen(onBack = { navController.popBackStack() }) - } - - composable(Route.SETTINGS) { - SettingsScreen(onBack = { navController.popBackStack() }) - } - - composable(Route.SECURITY) { - SecurityScreen(onBack = { navController.popBackStack() }) - } - - composable(Route.WALLET) { - WalletScreen(onBack = { navController.popBackStack() }) - } - - composable(Route.BILLING) { - BillingScreen(onBack = { navController.popBackStack() }) - } - - composable(Route.SUPPORT) { - SupportScreen(onBack = { navController.popBackStack() }) - } - - } - } - } - } - } -} - -/** - * Re-locks over app content when the user returns from the background. Cold-start - * unlock is handled by the [Route.PIN_LOCK] screen (matches ClimpMe). Biometric - * auto-prompt lives in [PinLockContent] via LaunchedEffect, not lifecycle resume. - */ -@androidx.compose.runtime.Composable -private fun AppLock(app: LadillMiniApp, content: @androidx.compose.runtime.Composable () -> Unit) { - val pin = app.pinManager - var locked by remember { mutableStateOf(false) } - val lifecycleOwner = LocalLifecycleOwner.current - - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_STOP && - app.authRepository.isLoggedIn() && pin.hasPin() - ) { - locked = true - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - - content() - - if (locked) { - Surface(modifier = Modifier.fillMaxSize(), color = androidx.compose.material3.MaterialTheme.colorScheme.background) { - PinLockContent( - verify = { pin.verifyPin(it) }, - onUnlocked = { locked = false }, - onBiometric = null, - biometricEnabled = pin.isBiometricEnabled(), - ) - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/data/api/ApiClient.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/data/api/ApiClient.kt deleted file mode 100644 index 6d514a9..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/data/api/ApiClient.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.ladill.mini.data.api - -import com.google.gson.FieldNamingPolicy -import com.google.gson.GsonBuilder -import com.ladill.mini.BuildConfig -import com.ladill.mini.util.TokenStore -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import java.util.concurrent.TimeUnit - -/** - * Builds the Retrofit [ApiService]. A bearer interceptor reads the live token - * from [TokenStore] on every request, so login/logout take effect immediately - * without rebuilding the client. - */ -class ApiClient(tokenStore: TokenStore) { - - val service: ApiService - - init { - val gson = GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .create() - - val logging = HttpLoggingInterceptor().apply { - level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE - } - - val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .addInterceptor { chain -> - val builder = chain.request().newBuilder() - .header("Accept", "application/json") - tokenStore.token?.let { builder.header("Authorization", "Bearer $it") } - chain.proceed(builder.build()) - } - .addInterceptor(logging) - .build() - - service = Retrofit.Builder() - .baseUrl(BuildConfig.BASE_URL) - .client(client) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - .create(ApiService::class.java) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/data/api/ApiService.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/data/api/ApiService.kt deleted file mode 100644 index b91b76f..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/data/api/ApiService.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.ladill.mini.data.api - -import com.ladill.mini.data.model.CreatePaymentQrRequest -import com.ladill.mini.data.model.AccountSettings -import com.ladill.mini.data.model.AfiaChatRequest -import com.ladill.mini.data.model.AfiaChatResponse -import com.ladill.mini.data.model.AvatarResult -import com.ladill.mini.data.model.Bank -import com.ladill.mini.data.model.ChangePasswordRequest -import com.ladill.mini.data.model.CheckoutUrl -import com.ladill.mini.data.model.Envelope -import com.ladill.mini.data.model.LoginRequest -import com.ladill.mini.data.model.LoginResult -import com.ladill.mini.data.model.MessageResponse -import com.ladill.mini.data.model.NotificationPrefs -import com.ladill.mini.data.model.NotificationsListResponse -import com.ladill.mini.data.model.PushTokenRequest -import com.ladill.mini.data.model.UnreadCountEnvelope -import com.ladill.mini.data.model.Overview -import com.ladill.mini.data.model.PagedEnvelope -import com.ladill.mini.data.model.Payment -import com.ladill.mini.data.model.PaymentQr -import com.ladill.mini.data.model.PayoutAccountWrapper -import com.ladill.mini.data.model.Payouts -import com.ladill.mini.data.model.ProfileInfo -import com.ladill.mini.data.model.CreateTicketRequest -import com.ladill.mini.data.model.RegisterRequest -import com.ladill.mini.data.model.SupportTicket -import com.ladill.mini.data.model.TopupRequest -import com.ladill.mini.data.model.UpdatePaymentQrRequest -import com.ladill.mini.data.model.UpdatePayoutAccountRequest -import com.ladill.mini.data.model.UpdateProfileRequest -import com.ladill.mini.data.model.UpdateSettingsRequest -import com.ladill.mini.data.model.User -import com.ladill.mini.data.model.WalletInfo -import com.ladill.mini.data.model.Withdrawal -import com.ladill.mini.data.model.WithdrawRequest -import okhttp3.MultipartBody -import retrofit2.http.Body -import retrofit2.http.DELETE -import retrofit2.http.HTTP -import retrofit2.http.GET -import retrofit2.http.Multipart -import retrofit2.http.PATCH -import retrofit2.http.POST -import retrofit2.http.PUT -import retrofit2.http.Part -import retrofit2.http.Path -import retrofit2.http.Query - -interface ApiService { - - @POST("auth/login") - suspend fun login(@Body body: LoginRequest): Envelope - - @POST("auth/register") - suspend fun register(@Body body: RegisterRequest): Envelope - - @POST("auth/logout") - suspend fun logout() - - @GET("me") - suspend fun me(): Envelope - - @GET("mini/overview") - suspend fun overview(): Envelope - - @POST("mini/afia/chat") - suspend fun afiaChat(@Body body: AfiaChatRequest): AfiaChatResponse - - @GET("mini/payment-qrs") - suspend fun paymentQrs(): Envelope> - - @POST("mini/payment-qrs") - suspend fun createPaymentQr(@Body body: CreatePaymentQrRequest): Envelope - - @GET("mini/payment-qrs/{id}") - suspend fun paymentQr(@Path("id") id: Long): Envelope - - @PATCH("mini/payment-qrs/{id}") - suspend fun updatePaymentQr(@Path("id") id: Long, @Body body: UpdatePaymentQrRequest): Envelope - - @DELETE("mini/payment-qrs/{id}") - suspend fun deletePaymentQr(@Path("id") id: Long) - - @GET("mini/payments") - suspend fun payments(@Query("q") query: String? = null): PagedEnvelope - - @GET("mini/payouts") - suspend fun payouts(): Envelope - - @GET("mini/account/settings") - suspend fun accountSettings(): Envelope - - @PUT("mini/account/settings") - suspend fun updateSettings(@Body body: UpdateSettingsRequest): Envelope - - @PUT("mini/account/profile") - suspend fun updateProfile(@Body body: UpdateProfileRequest): Envelope - - @Multipart - @POST("mini/account/avatar") - suspend fun uploadAvatar(@Part avatar: MultipartBody.Part): Envelope - - @POST("mini/account/change-password") - suspend fun changePassword(@Body body: ChangePasswordRequest): Envelope - - @GET("mini/wallet") - suspend fun wallet(): Envelope - - @POST("mini/wallet/topup") - suspend fun walletTopup(@Body body: TopupRequest): Envelope - - @GET("mini/wallet/banks") - suspend fun banks(@Query("type") type: String): Envelope> - - @GET("mini/wallet/payout-account") - suspend fun payoutAccount(): Envelope - - @PUT("mini/wallet/payout-account") - suspend fun updatePayoutAccount(@Body body: UpdatePayoutAccountRequest): Envelope - - @GET("mini/wallet/withdrawals") - suspend fun withdrawals(): Envelope> - - @POST("mini/wallet/withdraw") - suspend fun withdraw(@Body body: WithdrawRequest): Envelope - - @GET("mini/support/tickets") - suspend fun supportTickets(): Envelope> - - @POST("mini/support/tickets") - suspend fun createSupportTicket(@Body body: CreateTicketRequest): Envelope - - @GET("mini/support/tickets/{id}") - suspend fun supportTicket(@Path("id") id: Long): Envelope - - @GET("mini/notifications") - suspend fun notifications(): NotificationsListResponse - - @GET("mini/notifications/unread-count") - suspend fun unreadNotificationCount(): Envelope - - @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) -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/data/model/Countries.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/data/model/Countries.kt deleted file mode 100644 index bd46344..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/data/model/Countries.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.ladill.mini.data.model - -/** - * Countries + regions offered at sign-up. Mirrors the web register form - * (RegisteredUserController::countries / statesByCountry on the monolith) so the - * native form submits values the central API accepts. - */ -data class Country( - val code: String, - val name: String, - val dialCode: String, - val states: List, -) - -object Countries { - val all: List = 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() -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/data/model/Models.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/data/model/Models.kt deleted file mode 100644 index 9ee6775..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/data/model/Models.kt +++ /dev/null @@ -1,271 +0,0 @@ -package com.ladill.mini.data.model - -/** - * API DTOs for the Ladill Mini backend (routes/api.php, /api/v1). - * - * Gson is configured with LOWER_CASE_WITH_UNDERSCORES in ApiClient, so these - * camelCase fields map straight onto the snake_case JSON keys. - */ - -// Generic response envelopes -data class Envelope(val data: T) - -data class PagedEnvelope(val data: List, 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 = 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 = 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 = emptyList(), - val unreadCount: Int = 0, -) - -data class NotificationsResponse( - val data: List = 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", -) diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/data/repository/NotificationRepository.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/data/repository/NotificationRepository.kt deleted file mode 100644 index c92e778..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/data/repository/NotificationRepository.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.ladill.mini.data.repository - -import com.ladill.mini.data.api.ApiClient -import com.ladill.mini.data.model.NotificationsResponse -import com.ladill.mini.data.model.PushTokenRequest - -class NotificationRepository(private val api: ApiClient) { - - suspend fun getNotifications(): Result = runCatching { - val body = api.service.notifications() - NotificationsResponse(data = body.data, unreadCount = body.unreadCount) - } - - suspend fun unreadCount(): Result = runCatching { - api.service.unreadNotificationCount().data.unreadCount - } - - suspend fun markAllRead(): Result = runCatching { - api.service.markAllNotificationsRead() - Unit - } - - suspend fun markRead(id: String): Result = runCatching { - api.service.markNotificationRead(id) - Unit - } - - suspend fun registerPushToken(token: String): Result = runCatching { - api.service.registerPushToken(PushTokenRequest(token = token)) - Unit - } - - suspend fun unregisterPushToken(token: String): Result = runCatching { - api.service.unregisterPushToken(PushTokenRequest(token = token)) - Unit - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/data/repository/Repositories.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/data/repository/Repositories.kt deleted file mode 100644 index f3ab0ba..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/data/repository/Repositories.kt +++ /dev/null @@ -1,195 +0,0 @@ -package com.ladill.mini.data.repository - -import com.google.gson.JsonParser -import com.ladill.mini.data.api.ApiClient -import com.ladill.mini.data.model.AccountSettings -import com.ladill.mini.data.model.ChangePasswordRequest -import com.ladill.mini.data.model.CreatePaymentQrRequest -import com.ladill.mini.data.model.CreateTicketRequest -import com.ladill.mini.data.model.LoginRequest -import com.ladill.mini.data.model.LoginResult -import com.ladill.mini.data.model.NotificationPrefs -import com.ladill.mini.data.model.Overview -import com.ladill.mini.data.model.Payment -import com.ladill.mini.data.model.PaymentQr -import com.ladill.mini.data.model.PayoutAccount -import com.ladill.mini.data.model.Payouts -import com.ladill.mini.data.model.ProfileInfo -import com.ladill.mini.data.model.RegisterRequest -import com.ladill.mini.data.model.SupportTicket -import com.ladill.mini.data.model.TopupRequest -import com.ladill.mini.data.model.UpdatePaymentQrRequest -import com.ladill.mini.data.model.UpdatePayoutAccountRequest -import com.ladill.mini.data.model.UpdateProfileRequest -import com.ladill.mini.data.model.UpdateSettingsRequest -import com.ladill.mini.data.model.WalletInfo -import com.ladill.mini.data.model.Withdrawal -import com.ladill.mini.data.model.WithdrawRequest -import com.ladill.mini.push.PushTokenRegistrar -import com.ladill.mini.util.SessionManager -import com.ladill.mini.util.TokenStore -import android.content.Context -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.MultipartBody -import okhttp3.RequestBody.Companion.toRequestBody -import retrofit2.HttpException - -/** Turn an exception into a user-facing message, pulling Laravel's `message`/`errors`. */ -fun Throwable.userMessage(): String = when (this) { - is HttpException -> parseHttpError(this) - is java.io.IOException -> "No connection. Check your network and try again." - else -> message ?: "Something went wrong. Please try again." -} - -private fun parseHttpError(e: HttpException): String { - val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull() - if (!raw.isNullOrBlank()) { - runCatching { - val obj = JsonParser.parseString(raw).asJsonObject - obj.get("errors")?.asJsonObject?.entrySet()?.firstOrNull() - ?.value?.asJsonArray?.firstOrNull()?.asString - ?.let { return it } - obj.get("message")?.asString?.let { return it } - } - } - return when (e.code()) { - 401 -> "Your session expired. Please sign in again." - 403 -> "You don't have permission to do that." - else -> "Request failed (${e.code()})." - } -} - -class AuthRepository( - private val context: Context, - private val session: SessionManager, - private val tokenStore: TokenStore, - private val api: ApiClient, -) { - fun isLoggedIn(): Boolean = !session.getToken().isNullOrBlank() - fun cachedName(): String? = session.getName() - fun cachedEmail(): String? = session.getEmail() - fun cachedAvatarUrl(): String? = session.getAvatarUrl() - - suspend fun login(email: String, password: String): Result = 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 = 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 = runCatching { api.service.overview().data } - - suspend fun afiaChat(message: String, history: List>): Result = 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> = runCatching { api.service.paymentQrs().data } - - suspend fun paymentQr(id: Long): Result = runCatching { api.service.paymentQr(id).data } - - suspend fun createPaymentQr(label: String, businessName: String, branchLabel: String?): Result = - runCatching { - api.service.createPaymentQr( - CreatePaymentQrRequest(label.trim(), businessName.trim(), branchLabel?.trim()?.ifBlank { null }), - ).data - } - - suspend fun setActive(id: Long, active: Boolean): Result = runCatching { - api.service.updatePaymentQr(id, UpdatePaymentQrRequest(isActive = active)).data - } - - suspend fun deletePaymentQr(id: Long): Result = runCatching { api.service.deletePaymentQr(id) } - - suspend fun payments(query: String? = null): Result> = - runCatching { api.service.payments(query?.ifBlank { null }).data } - - suspend fun payouts(): Result = runCatching { api.service.payouts().data } - - // Account settings - suspend fun accountSettings(): Result = runCatching { api.service.accountSettings().data } - - suspend fun updateSettings(prefs: NotificationPrefs): Result = 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 = 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 = runCatching { - api.service.changePassword(ChangePasswordRequest(current, password, confirm)) - Unit - } - - suspend fun uploadAvatar(bytes: ByteArray, filename: String, mime: String): Result = 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 = runCatching { api.service.wallet().data } - - suspend fun topup(amount: Double): Result = runCatching { - api.service.walletTopup(TopupRequest(amount)).data.checkoutUrl - } - - suspend fun banks(type: String): Result> = runCatching { api.service.banks(type).data } - - suspend fun payoutAccount(): Result = runCatching { api.service.payoutAccount().data.payoutAccount } - - suspend fun savePayoutAccount(req: UpdatePayoutAccountRequest): Result = runCatching { - api.service.updatePayoutAccount(req).data.payoutAccount - } - - suspend fun withdrawals(): Result> = runCatching { api.service.withdrawals().data } - - suspend fun withdraw(amount: Double): Result = runCatching { api.service.withdraw(WithdrawRequest(amount)).data } - - // Support tickets - suspend fun supportTickets(): Result> = runCatching { api.service.supportTickets().data } - - suspend fun supportTicket(id: Long): Result = runCatching { api.service.supportTicket(id).data } - - suspend fun createTicket(subject: String, message: String, priority: String): Result = runCatching { - api.service.createSupportTicket(CreateTicketRequest(subject.trim(), message.trim(), priority)).data - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/navigation/Routes.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/navigation/Routes.kt deleted file mode 100644 index c410488..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/navigation/Routes.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.ladill.mini.navigation - -object Route { - const val SPLASH = "splash" - const val LANDING = "landing" - const val LOGIN = "login" - const val REGISTER = "register" - const val PIN_LOCK = "pin_lock" - const val PIN_SETUP = "pin_setup" - const val HOME = "home" - const val PROFILE = "profile" - const val SETTINGS = "settings" - const val SECURITY = "security" - const val WALLET = "wallet" - const val BILLING = "billing" - const val SUPPORT = "support" -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/push/LadillMessagingService.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/push/LadillMessagingService.kt deleted file mode 100644 index 13c8465..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/push/LadillMessagingService.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.ladill.mini.push - -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage - -class LadillMessagingService : FirebaseMessagingService() { - - override fun onNewToken(token: String) { - PushTokenRegistrar.onTokenRefresh(applicationContext, token) - } - - override fun onMessageReceived(message: RemoteMessage) { - val title = message.notification?.title ?: message.data["title"] ?: return - val body = message.notification?.body ?: message.data["body"] ?: return - val milestone = message.data["milestone"] - - PushNotifications.show( - context = applicationContext, - title = title, - body = body, - milestone = milestone, - ) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/push/PushNotifications.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/push/PushNotifications.kt deleted file mode 100644 index e3df1dc..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/push/PushNotifications.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.ladill.mini.push - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import com.ladill.mini.MainActivity -import com.ladill.mini.R - -object PushNotifications { - - const val CHANNEL_PAYMENTS = "payments" - const val CHANNEL_GENERAL = "general" - - fun ensureChannels(context: Context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - - val manager = context.getSystemService(NotificationManager::class.java) - - manager.createNotificationChannel( - NotificationChannel( - CHANNEL_PAYMENTS, - "Payments", - NotificationManager.IMPORTANCE_HIGH, - ).apply { - description = "Instant alerts when customers pay you" - enableVibration(true) - }, - ) - - manager.createNotificationChannel( - NotificationChannel( - CHANNEL_GENERAL, - "Account updates", - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = "Payouts and account activity" - }, - ) - } - - fun show( - context: Context, - title: String, - body: String, - milestone: String?, - notificationId: Int = (System.currentTimeMillis() % Int.MAX_VALUE).toInt(), - ) { - ensureChannels(context) - - val channel = if (milestone == "payment_received") CHANNEL_PAYMENTS else CHANNEL_GENERAL - val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra("open_notifications", true) - } - val pending = PendingIntent.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - - val notification = NotificationCompat.Builder(context, channel) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(title) - .setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - .setContentIntent(pending) - .build() - - NotificationManagerCompat.from(context).notify(notificationId, notification) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/push/PushTokenRegistrar.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/push/PushTokenRegistrar.kt deleted file mode 100644 index 65b8f50..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/push/PushTokenRegistrar.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.ladill.mini.push - -import android.content.Context -import android.util.Log -import com.google.firebase.messaging.FirebaseMessaging -import com.ladill.mini.LadillMiniApp -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch - -object PushTokenRegistrar { - - private const val TAG = "PushTokenRegistrar" - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - fun registerIfLoggedIn(context: Context) { - if (!isFirebaseAvailable()) return - - val app = context.applicationContext as? LadillMiniApp ?: return - if (!app.authRepository.isLoggedIn()) return - - PushNotifications.ensureChannels(context) - - FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - if (!task.isSuccessful) { - Log.w(TAG, "FCM token fetch failed", task.exception) - return@addOnCompleteListener - } - val token = task.result ?: return@addOnCompleteListener - uploadToken(app, token) - } - } - - fun onTokenRefresh(context: Context, token: String) { - val app = context.applicationContext as? LadillMiniApp ?: return - if (!app.authRepository.isLoggedIn()) return - uploadToken(app, token) - } - - fun unregister(context: Context, onDone: (() -> Unit)? = null) { - if (!isFirebaseAvailable()) { - onDone?.invoke() - return - } - - val app = context.applicationContext as? LadillMiniApp ?: run { - onDone?.invoke() - return - } - - FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - val token = task.result - scope.launch { - if (token != null) { - app.notificationRepository.unregisterPushToken(token) - } - onDone?.invoke() - } - } - } - - private fun uploadToken(app: LadillMiniApp, token: String) { - scope.launch { - app.notificationRepository.registerPushToken(token) - .onFailure { e -> Log.w(TAG, "Push token upload failed: ${e.message}") } - } - } - - private fun isFirebaseAvailable(): Boolean = runCatching { - FirebaseMessaging.getInstance() - true - }.getOrDefault(false) -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/MenuCommon.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/MenuCommon.kt deleted file mode 100644 index 00d1288..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/MenuCommon.kt +++ /dev/null @@ -1,228 +0,0 @@ -package com.ladill.mini.ui.account - -import android.app.Application -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.outlined.Payments -import androidx.compose.material.icons.outlined.Notifications -import androidx.compose.material.icons.outlined.AccountBalanceWallet -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ladill.mini.data.model.AppNotification -import com.ladill.mini.ui.components.AppIcon -import com.ladill.mini.ui.notifications.NotificationsViewModel -import com.ladill.mini.ui.theme.LadillIndigo - -/** Shared back-arrow + title header for menu destination screens. */ -@Composable -fun MenuScaffold( - title: String, - onBack: () -> Unit, - content: @Composable () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .statusBarsPadding(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - ) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MaterialTheme.colorScheme.onSurface) - } - Text(title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface) - } - content() - } -} - -@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) -@Composable -fun NotificationsSheet(onDismiss: () -> Unit, onUnreadChanged: (Int) -> Unit = {}) { - val app = LocalContext.current.applicationContext as Application - val vm: NotificationsViewModel = viewModel(factory = NotificationsViewModel.Factory(app)) - val state by vm.state.collectAsStateWithLifecycle() - - androidx.compose.runtime.LaunchedEffect(state.unreadCount) { - onUnreadChanged(state.unreadCount) - } - - androidx.compose.material3.ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = androidx.compose.material3.rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = 20.dp, vertical = 8.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Column(Modifier.weight(1f)) { - Text("Notifications", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface) - if (state.unreadCount > 0) { - Text( - "${state.unreadCount} unread", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - if (state.unreadCount > 0 && !state.loading) { - TextButton(onClick = vm::markAllRead, enabled = !state.markingAll) { - Text("Mark all read", color = LadillIndigo) - } - } - } - Spacer(Modifier.height(12.dp)) - - when { - state.loading -> Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = LadillIndigo) - } - state.error != null -> Box(Modifier.fillMaxWidth().padding(vertical = 32.dp), contentAlignment = Alignment.Center) { - Text(state.error ?: "Error", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - else -> PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = vm::refresh, - modifier = Modifier.fillMaxWidth().height(420.dp), - ) { - if (state.notifications.isEmpty()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - AppIcon(name = "notifications", size = 40.dp, tint = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.height(12.dp)) - Text("You're all caught up", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) - Text( - "Payment alerts and account updates will show up here.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - } - } - } else { - LazyColumn( - contentPadding = PaddingValues(bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - items(state.notifications, key = { it.id }) { notification -> - NotificationRow( - notification = notification, - onClick = { vm.onNotificationTap(notification) }, - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)) - } - } - } - } - } - } - } -} - -@Composable -private fun NotificationRow(notification: AppNotification, onClick: () -> Unit) { - val unread = notification.readAt == null - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = 12.dp), - ) { - Surface( - shape = CircleShape, - color = if (unread) LadillIndigo.copy(alpha = 0.12f) else MaterialTheme.colorScheme.background, - modifier = Modifier.size(40.dp), - ) { - Box(contentAlignment = Alignment.Center) { - val icon = when (notification.milestone) { - "payment_received" -> Icons.Outlined.Payments - "withdrawal_submitted", "wallet_credited" -> Icons.Outlined.AccountBalanceWallet - else -> Icons.Outlined.Notifications - } - Icon( - imageVector = icon, - contentDescription = null, - tint = if (unread) LadillIndigo else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - } - Spacer(Modifier.size(12.dp)) - Column(Modifier.weight(1f)) { - Text( - notification.title, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - notification.message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 2.dp), - ) - } - if (unread) { - Box( - modifier = Modifier - .padding(start = 8.dp, top = 6.dp) - .size(8.dp) - .clip(CircleShape) - .background(LadillIndigo), - ) - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/ProfileScreen.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/ProfileScreen.kt deleted file mode 100644 index f30c63e..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/ProfileScreen.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.ladill.mini.ui.account - -import android.app.Application -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PhotoCamera -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ladill.mini.LadillMiniApp -import com.ladill.mini.data.repository.userMessage -import com.ladill.mini.ui.components.LadillButton -import com.ladill.mini.ui.components.LadillCard -import com.ladill.mini.ui.components.UserAvatar -import com.ladill.mini.ui.theme.LadillIndigo -import com.ladill.mini.ui.theme.LadillMoney -import com.ladill.mini.ui.theme.White -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class ProfileUiState( - val name: String = "", - val email: String = "", - val phone: String = "", - val avatarUrl: String? = null, - val saving: Boolean = false, - val uploading: Boolean = false, - val message: String? = null, -) - -class ProfileViewModel(app: Application) : AndroidViewModel(app) { - private val application = app as LadillMiniApp - private val repo = application.miniRepository - private val session = application.sessionManager - - private val _state = MutableStateFlow( - ProfileUiState( - name = session.getName() ?: "", - email = session.getEmail() ?: "", - avatarUrl = session.getAvatarUrl(), - ), - ) - val state: StateFlow = _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) - } - } - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/SettingsScreen.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/SettingsScreen.kt deleted file mode 100644 index 884895e..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/SettingsScreen.kt +++ /dev/null @@ -1,327 +0,0 @@ -package com.ladill.mini.ui.account - -import android.app.Application -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ladill.mini.LadillMiniApp -import com.ladill.mini.data.repository.userMessage -import com.ladill.mini.ui.common.ErrorState -import com.ladill.mini.ui.common.LoadingState -import com.ladill.mini.ui.components.LadillButton -import com.ladill.mini.ui.components.LadillCard -import com.ladill.mini.ui.components.LadillSecondaryButton -import com.ladill.mini.ui.components.LadillTextField -import com.ladill.mini.ui.components.UserAvatar -import com.ladill.mini.ui.security.PinSetupContent -import com.ladill.mini.ui.theme.LadillIndigo -import com.ladill.mini.util.BiometricAuth -import com.ladill.mini.util.BiometricUnlockStatus -import com.ladill.mini.ui.theme.LadillMoney -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class SettingsUiState( - val loading: Boolean = true, - val loadError: String? = null, - val name: String = "", - val email: String = "", - val notifyEmail: String = "", - val productUpdates: Boolean = true, - val notifyRegistrations: Boolean = true, - val notifyPayouts: Boolean = true, - val currentPassword: String = "", - val newPassword: String = "", - val confirmPassword: String = "", - val savingProfile: Boolean = false, - val savingNotifications: Boolean = false, - val savingPassword: Boolean = false, - val profileMsg: String? = null, - val notificationsMsg: String? = null, - val passwordMsg: String? = null, - val passwordError: String? = null, -) - -class SettingsViewModel(app: Application) : AndroidViewModel(app) { - private val application = app as LadillMiniApp - private val repo = application.miniRepository - - private val _state = MutableStateFlow(SettingsUiState()) - val state: StateFlow = _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) -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/SupportScreen.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/SupportScreen.kt deleted file mode 100644 index 9b8d95e..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/SupportScreen.kt +++ /dev/null @@ -1,248 +0,0 @@ -package com.ladill.mini.ui.account - -import android.app.Application -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ladill.mini.LadillMiniApp -import com.ladill.mini.data.model.SupportTicket -import com.ladill.mini.data.repository.userMessage -import com.ladill.mini.ui.common.EmptyState -import com.ladill.mini.ui.common.ErrorState -import com.ladill.mini.ui.common.LoadingState -import com.ladill.mini.ui.common.UiState -import com.ladill.mini.ui.components.LadillButton -import com.ladill.mini.ui.components.LadillCard -import com.ladill.mini.ui.components.LadillTextField -import com.ladill.mini.ui.components.StatusPill -import com.ladill.mini.util.formatDate -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class SupportUiState( - val tickets: UiState> = 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 = _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)) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/WalletScreen.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/WalletScreen.kt deleted file mode 100644 index 5d1ce41..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/account/WalletScreen.kt +++ /dev/null @@ -1,393 +0,0 @@ -package com.ladill.mini.ui.account - -import android.app.Application -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ladill.mini.LadillMiniApp -import com.ladill.mini.data.model.Bank -import com.ladill.mini.data.model.PayoutAccount -import com.ladill.mini.data.model.UpdatePayoutAccountRequest -import com.ladill.mini.data.model.WalletInfo -import com.ladill.mini.data.model.Withdrawal -import com.ladill.mini.data.repository.userMessage -import com.ladill.mini.ui.common.ErrorState -import com.ladill.mini.ui.common.LoadingState -import com.ladill.mini.ui.common.UiState -import com.ladill.mini.ui.components.LadillButton -import com.ladill.mini.ui.components.LadillCard -import com.ladill.mini.ui.components.LadillSecondaryButton -import com.ladill.mini.ui.components.LadillTextField -import com.ladill.mini.ui.components.StatCard -import com.ladill.mini.ui.components.StatusPill -import com.ladill.mini.ui.theme.LadillIndigo -import com.ladill.mini.ui.theme.LadillIndigoSoft -import com.ladill.mini.ui.theme.LadillMoney -import com.ladill.mini.util.formatMoney -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class WalletUiState( - val wallet: UiState = UiState.Loading, - val payout: PayoutAccount? = null, - val withdrawals: List = 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 = 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 = _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, - 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)) -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/afia/AfiaSheet.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/afia/AfiaSheet.kt deleted file mode 100644 index 4c9427a..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/afia/AfiaSheet.kt +++ /dev/null @@ -1,286 +0,0 @@ -package com.ladill.mini.ui.afia - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ladill.mini.ui.theme.LadillBrandGreen -import com.ladill.mini.ui.theme.LadillIndigo -import com.ladill.mini.ui.theme.LadillIndigoText -import com.ladill.mini.ui.theme.White - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AfiaSheet( - onDismiss: () -> Unit, - viewModel: AfiaViewModel = viewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - val listState = rememberLazyListState() - - LaunchedEffect(state.messages.size, state.loading) { - val last = state.messages.lastIndex + if (state.loading) 1 else 0 - if (last >= 0) listState.animateScrollToItem(last) - } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 420.dp, max = 640.dp) - .navigationBarsPadding() - .imePadding(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 4.dp), - ) { - AfiaOrb(modifier = Modifier.size(36.dp)) - Spacer(Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text("Afia", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Text("Mini assistant", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - - LazyColumn( - state = listState, - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - itemsIndexed(state.messages) { _, message -> - ChatBubble(message) - } - if (state.loading) { - item { - Row(modifier = Modifier.fillMaxWidth()) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(horizontal = 14.dp, vertical = 12.dp), - ) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) - } - } - } - } - } - - if (state.messages.size <= 1) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - viewModel.suggestions.forEach { suggestion -> - SuggestionChip(text = suggestion, enabled = !state.loading) { - viewModel.useSuggestion(suggestion) - } - } - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - ) { - OutlinedTextField( - value = state.input, - onValueChange = viewModel::onInput, - modifier = Modifier.weight(1f), - enabled = !state.loading, - placeholder = { Text("Message Afia…") }, - singleLine = true, - shape = RoundedCornerShape(14.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LadillIndigo, - cursorColor = LadillIndigo, - ), - ) - Spacer(Modifier.width(8.dp)) - IconButton( - onClick = viewModel::send, - enabled = !state.loading && state.input.isNotBlank(), - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background( - Brush.horizontalGradient( - colors = listOf(LadillIndigo, LadillBrandGreen), - ), - ), - ) { - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = White) - } - } - Text( - "Afia can make mistakes — verify important details.", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), - ) - } - } -} - -@Composable -fun AfiaTopBarButton(onClick: () -> Unit, modifier: Modifier = Modifier) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier - .size(36.dp) - .clip(RoundedCornerShape(12.dp)) - .background(Brush.horizontalGradient(listOf(LadillIndigo, Color(0xFF2563EB), LadillBrandGreen))) - .clickable(onClick = onClick), - ) { - Icon(Icons.Filled.AutoAwesome, contentDescription = "AI assistant", tint = White, modifier = Modifier.size(20.dp)) - } -} - -@Composable -private fun AfiaOrb(modifier: Modifier = Modifier) { - Box(contentAlignment = Alignment.Center, modifier = modifier) { - Box( - modifier = Modifier - .matchParentSize() - .clip(CircleShape) - .background( - Brush.radialGradient( - colors = listOf(Color(0xFFA5B4FC), LadillIndigo, Color(0xFF3730A3)), - ), - ), - ) - Box( - modifier = Modifier - .size(10.dp) - .align(Alignment.BottomEnd) - .clip(CircleShape) - .background(LadillBrandGreen) - .border(2.dp, White, CircleShape), - ) - } -} - -@Composable -private fun ChatBubble(message: AfiaMessage) { - val isUser = message.role == "user" - val textColor = if (isUser) White else MaterialTheme.colorScheme.onSurface - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start, - ) { - Text( - text = formatAfiaMarkdown(message.text, textColor), - style = MaterialTheme.typography.bodyMedium, - color = textColor, - modifier = Modifier - .fillMaxWidth(0.88f) - .clip( - RoundedCornerShape( - topStart = 16.dp, - topEnd = 16.dp, - bottomStart = if (isUser) 16.dp else 4.dp, - bottomEnd = if (isUser) 4.dp else 16.dp, - ), - ) - .background(if (isUser) LadillIndigo else MaterialTheme.colorScheme.surfaceVariant) - .padding(horizontal = 14.dp, vertical = 10.dp), - ) - } -} - -/** Renders `**bold**` segments — matches the web Afia chat formatter. */ -private fun formatAfiaMarkdown(text: String, color: Color): AnnotatedString = buildAnnotatedString { - val bold = Regex("\\*\\*([^*]+?)\\*\\*") - var cursor = 0 - for (match in bold.findAll(text)) { - if (match.range.first > cursor) { - append(text.substring(cursor, match.range.first)) - } - withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = color)) { - append(match.groupValues[1]) - } - cursor = match.range.last + 1 - } - if (cursor < text.length) append(text.substring(cursor)) -} - -@Composable -private fun SuggestionChip(text: String, enabled: Boolean, onClick: () -> Unit) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surface) - .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 14.dp, vertical = 12.dp), - ) { - Text("→", color = LadillIndigoText, style = MaterialTheme.typography.labelLarge) - Spacer(Modifier.width(8.dp)) - Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/afia/AfiaViewModel.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/afia/AfiaViewModel.kt deleted file mode 100644 index b3ff173..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/afia/AfiaViewModel.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.ladill.mini.ui.afia - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.ladill.mini.LadillMiniApp -import com.ladill.mini.data.repository.userMessage -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class AfiaMessage(val role: String, val text: String) - -data class AfiaUiState( - val messages: List = 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 = _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?", - ) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/AuthViewModel.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/AuthViewModel.kt deleted file mode 100644 index 7a407e9..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/AuthViewModel.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.ladill.mini.ui.auth - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.ladill.mini.LadillMiniApp -import com.ladill.mini.data.repository.userMessage -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class LoginUiState( - val email: String = "", - val password: String = "", - val loading: Boolean = false, - val error: String? = null, -) - -class AuthViewModel(app: Application) : AndroidViewModel(app) { - - private val repo = (app as LadillMiniApp).authRepository - - private val _state = MutableStateFlow(LoginUiState()) - val state: StateFlow = _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()) } - } - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/LoginScreen.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/LoginScreen.kt deleted file mode 100644 index eb037e4..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/LoginScreen.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.ladill.mini.ui.auth - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ladill.mini.ui.components.LadillButton -import com.ladill.mini.ui.components.LadillTextField -import com.ladill.mini.ui.theme.LadillIndigo - -@Composable -fun LoginScreen( - onLoggedIn: () -> Unit, - onCreateAccount: () -> Unit, - onBack: () -> Unit, - viewModel: AuthViewModel = viewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - var step by remember { mutableIntStateOf(0) } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .statusBarsPadding() - .navigationBarsPadding() - .imePadding(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - ) { - IconButton(onClick = { if (step > 0) step-- else onBack() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MaterialTheme.colorScheme.onSurface) - } - } - - StepBar(current = step, total = 2, modifier = Modifier.padding(horizontal = 24.dp)) - - AnimatedContent( - targetState = step, - transitionSpec = { - if (targetState > initialState) { - (slideInHorizontally { it / 2 } + fadeIn()) togetherWith (slideOutHorizontally { -it / 2 } + fadeOut()) - } else { - (slideInHorizontally { -it / 2 } + fadeIn()) togetherWith (slideOutHorizontally { it / 2 } + fadeOut()) - } - }, - label = "login_step", - modifier = Modifier.weight(1f), - ) { currentStep -> - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)) { - when (currentStep) { - 0 -> { - Text("Welcome back", style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) - Text( - "Enter your email to continue", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 6.dp), - ) - Spacer(Modifier.height(28.dp)) - LadillTextField( - value = state.email, - onValueChange = viewModel::onEmail, - label = "Email", - placeholder = "you@example.com", - keyboardType = KeyboardType.Email, - enabled = !state.loading, - ) - Spacer(Modifier.height(28.dp)) - LadillButton(text = "Continue", onClick = { step = 1 }, enabled = state.email.isNotBlank()) - Spacer(Modifier.height(16.dp)) - Text( - "New to Ladill? Create an account", - style = MaterialTheme.typography.bodyMedium, - color = LadillIndigo, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().clickable(onClick = onCreateAccount).padding(vertical = 8.dp), - ) - } - else -> { - Text("Enter your password", style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) - Text( - "Signing in as", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 6.dp), - ) - Spacer(Modifier.height(8.dp)) - Box( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .clip(RoundedCornerShape(20.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(20.dp)) - .padding(horizontal = 14.dp, vertical = 8.dp) - .widthIn(max = 280.dp), - ) { - Text(state.email, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) - } - Spacer(Modifier.height(24.dp)) - LadillTextField( - value = state.password, - onValueChange = viewModel::onPassword, - label = "Password", - placeholder = "Your password", - keyboardType = KeyboardType.Password, - isPassword = true, - enabled = !state.loading, - error = state.error, - ) - Spacer(Modifier.height(28.dp)) - LadillButton( - text = if (state.loading) "Signing in…" else "Sign in", - onClick = { viewModel.login(onLoggedIn) }, - loading = state.loading, - enabled = viewModel.canSubmit, - ) - } - } - } - } - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/RegisterScreen.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/RegisterScreen.kt deleted file mode 100644 index 9b8fb40..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/RegisterScreen.kt +++ /dev/null @@ -1,268 +0,0 @@ -package com.ladill.mini.ui.auth - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CheckboxDefaults -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ladill.mini.data.model.Countries -import com.ladill.mini.data.model.Country -import com.ladill.mini.ui.components.LadillButton -import com.ladill.mini.ui.components.LadillTextField -import com.ladill.mini.ui.theme.LadillIndigo - -private const val TOTAL_STEPS = 3 - -@Composable -fun RegisterScreen( - onRegistered: () -> Unit, - onBack: () -> Unit, - onSignIn: () -> Unit, - viewModel: RegisterViewModel = viewModel(), -) { - val s by viewModel.state.collectAsStateWithLifecycle() - var step by remember { mutableIntStateOf(0) } - val disabled = s.saving - - val stepValid = when (step) { - 0 -> s.name.isNotBlank() && s.email.isNotBlank() && s.password.length >= 8 && s.passwordConfirmation == s.password - 1 -> s.phone.isNotBlank() - else -> s.address.isNotBlank() && s.city.isNotBlank() && s.state.isNotBlank() && s.zipcode.isNotBlank() && s.terms - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .statusBarsPadding() - .navigationBarsPadding() - .imePadding(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - ) { - IconButton(onClick = { if (step > 0) step-- else onBack() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MaterialTheme.colorScheme.onSurface) - } - } - - StepBar( - current = step, - total = TOTAL_STEPS, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp), - ) - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth().weight(1f), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - StepHeader(step) - Spacer(Modifier.height(24.dp)) - when (step) { - 0 -> StepDetails(s, viewModel, disabled) - 1 -> StepContact(s, viewModel, disabled) - else -> StepAddress(s, viewModel, disabled) - } - } - } - - Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) { - val isLast = step == TOTAL_STEPS - 1 - LadillButton( - text = when { - isLast && s.saving -> "Creating account…" - isLast -> "Create account" - else -> "Continue" - }, - onClick = { if (isLast) viewModel.register(onRegistered) else step++ }, - loading = isLast && s.saving, - enabled = stepValid, - ) - if (step == 0) { - Text( - text = "Already have an account? Sign in", - style = MaterialTheme.typography.bodyMedium, - color = LadillIndigo, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !disabled, onClick = onSignIn) - .padding(vertical = 10.dp), - ) - } else { - Spacer(Modifier.height(4.dp)) - } - } - } -} - -@Composable -private fun StepHeader(step: Int) { - val (title, subtitle) = when (step) { - 0 -> "Your details" to "Tell us who you are and set a password." - 1 -> "Contact" to "How customers and Ladill can reach you." - else -> "Business address" to "Where your business is based." - } - Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center) - Text( - subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 6.dp), - ) -} - -@Composable -private fun StepDetails(s: RegisterUiState, vm: RegisterViewModel, disabled: Boolean) { - LadillTextField(s.name, vm::onName, "Full name", placeholder = "Ama Mensah", enabled = !disabled) - Gap() - LadillTextField(s.email, vm::onEmail, "Email", placeholder = "you@example.com", keyboardType = KeyboardType.Email, enabled = !disabled) - Gap() - LadillTextField(s.password, vm::onPassword, "Password", placeholder = "At least 8 characters", keyboardType = KeyboardType.Password, isPassword = true, enabled = !disabled) - Gap() - LadillTextField( - s.passwordConfirmation, vm::onPasswordConfirmation, "Confirm password", - keyboardType = KeyboardType.Password, isPassword = true, enabled = !disabled, - error = if (s.passwordConfirmation.isNotBlank() && s.passwordConfirmation != s.password) "Passwords do not match." else s.error, - ) -} - -@Composable -private fun StepContact(s: RegisterUiState, vm: RegisterViewModel, disabled: Boolean) { - CountryDropdown("Country", s.country.name, Countries.all, { it.name }, !disabled, vm::onCountry) - Gap() - LadillTextField( - s.phone, vm::onPhone, "Phone (+${s.country.dialCode})", - placeholder = "24 123 4567", keyboardType = KeyboardType.Phone, enabled = !disabled, - ) - Gap() - LadillTextField(s.company, vm::onCompany, "Company (optional)", enabled = !disabled, error = s.error) -} - -@Composable -private fun StepAddress(s: RegisterUiState, vm: RegisterViewModel, disabled: Boolean) { - LadillTextField(s.address, vm::onAddress, "Address", enabled = !disabled) - Gap() - LadillTextField(s.city, vm::onCity, "City", enabled = !disabled) - Gap() - CountryDropdown("Region / State", s.state.ifBlank { "Select region" }, s.country.states, { it }, !disabled, vm::onState) - Gap() - LadillTextField(s.zipcode, vm::onZipcode, "Postal / ZIP code", enabled = !disabled) - Spacer(Modifier.height(16.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = s.terms, - onCheckedChange = vm::onTerms, - enabled = !disabled, - colors = CheckboxDefaults.colors(checkedColor = LadillIndigo), - ) - Text( - "I agree to the Ladill Terms of Service and Privacy Policy.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.clickable(enabled = !disabled) { vm.onTerms(!s.terms) }, - ) - } - s.error?.let { - Spacer(Modifier.height(8.dp)) - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center) - } -} - -@Composable -private fun Gap() = Spacer(Modifier.height(14.dp)) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CountryDropdown( - label: String, - selectedText: String, - options: List, - 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 - }, - ) - } - } - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/RegisterViewModel.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/RegisterViewModel.kt deleted file mode 100644 index 133ddf6..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/RegisterViewModel.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.ladill.mini.ui.auth - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.ladill.mini.LadillMiniApp -import com.ladill.mini.data.model.Countries -import com.ladill.mini.data.model.Country -import com.ladill.mini.data.model.RegisterRequest -import com.ladill.mini.data.repository.userMessage -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class RegisterUiState( - val name: String = "", - val email: String = "", - val password: String = "", - val passwordConfirmation: String = "", - val company: String = "", - val address: String = "", - val city: String = "", - val country: Country = Countries.default, - val state: String = "", - val zipcode: String = "", - val phone: String = "", - val terms: Boolean = false, - val saving: Boolean = false, - val error: String? = null, -) - -class RegisterViewModel(app: Application) : AndroidViewModel(app) { - - private val repo = (app as LadillMiniApp).authRepository - - private val _state = MutableStateFlow(RegisterUiState()) - val state: StateFlow = _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()) } - } - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/StepBar.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/StepBar.kt deleted file mode 100644 index 4d4cd08..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/auth/StepBar.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.ladill.mini.ui.auth - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import com.ladill.mini.ui.theme.LadillIndigo - -/** Segmented progress bar for the multistep sign-up. Benchmarked on ClimpMe. */ -@Composable -fun StepBar(current: Int, total: Int, modifier: Modifier = Modifier) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier) { - repeat(total) { i -> - Box( - modifier = Modifier - .weight(1f) - .height(4.dp) - .clip(RoundedCornerShape(2.dp)) - .background(if (i <= current) LadillIndigo else MaterialTheme.colorScheme.surfaceVariant), - ) - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/common/StateViews.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/common/StateViews.kt deleted file mode 100644 index 0d8491d..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/common/StateViews.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.ladill.mini.ui.common - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.ladill.mini.ui.components.LadillSecondaryButton - -@Composable -fun LoadingState(modifier: Modifier = Modifier) { - Box(modifier.fillMaxWidth().padding(48.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.primary, - strokeWidth = 2.dp, - modifier = Modifier.size(28.dp), - ) - } -} - -@Composable -fun ErrorState(message: String, onRetry: () -> Unit, modifier: Modifier = Modifier) { - Column( - modifier = modifier.fillMaxWidth().padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - LadillSecondaryButton(text = "Try again", onClick = onRetry, modifier = Modifier.fillMaxWidth()) - } -} - -@Composable -fun EmptyState(title: String, subtitle: String, modifier: Modifier = Modifier) { - Column( - modifier = modifier.fillMaxWidth().padding(40.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/common/UiState.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/common/UiState.kt deleted file mode 100644 index 5e07f4f..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/common/UiState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.ladill.mini.ui.common - -/** Minimal async-data wrapper used across the Mini tabs. */ -sealed interface UiState { - data object Loading : UiState - data class Error(val message: String) : UiState - data class Success(val data: T) : UiState -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/AppIcon.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/AppIcon.kt deleted file mode 100644 index 41d1d3a..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/AppIcon.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.ladill.mini.ui.components - -import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import coil.request.ImageRequest - -/** - * Tinted SVG icon loaded from assets/icons (the ClimpMe icon set). Uses the - * global Coil ImageLoader (SVG decoder registered in LadillMiniApp). - */ -@Composable -fun AppIcon( - name: String, - modifier: Modifier = Modifier, - size: Dp = 20.dp, - tint: Color = MaterialTheme.colorScheme.onSurface, -) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data("file:///android_asset/icons/$name.svg") - .build(), - contentDescription = null, - modifier = modifier.size(size), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(tint, BlendMode.SrcIn), - ) -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/Cards.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/Cards.kt deleted file mode 100644 index 2b7ce9a..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/Cards.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.ladill.mini.ui.components - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.ladill.mini.ui.theme.LadillIndigoSoft -import com.ladill.mini.ui.theme.LadillIndigoText - -/** Web `rounded-2xl border border-slate-200 bg-white` surface. */ -@Composable -fun LadillCard( - modifier: Modifier = Modifier, - contentPadding: Int = 20, - content: @Composable ColumnScope.() -> Unit, -) { - Surface( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), - ) { - Column(modifier = Modifier.padding(contentPadding.dp), content = content) - } -} - -/** - * Compact stat tile — uppercase tracked label over a large value, matching the - * Overview dashboard cards. `highlight` paints the indigo wallet-balance variant. - */ -@Composable -fun StatCard( - label: String, - value: String, - modifier: Modifier = Modifier, - highlight: Boolean = false, -) { - Surface( - modifier = modifier, - shape = RoundedCornerShape(20.dp), - color = if (highlight) LadillIndigoSoft else MaterialTheme.colorScheme.surface, - border = BorderStroke( - 1.dp, - if (highlight) LadillIndigoSoft else MaterialTheme.colorScheme.outline, - ), - ) { - Column(modifier = Modifier.padding(18.dp)) { - Text( - text = label.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = if (highlight) LadillIndigoText else MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = value, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 6.dp), - ) - } - } -} - -/** Small status pill (active / inactive, paid / pending). */ -@Composable -fun StatusPill( - text: String, - fg: Color, - bg: Color, - modifier: Modifier = Modifier, -) { - Surface(shape = RoundedCornerShape(999.dp), color = bg, modifier = modifier) { - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - color = fg, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp), - ) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/LadillButton.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/LadillButton.kt deleted file mode 100644 index a75782f..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/LadillButton.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.ladill.mini.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowOutward -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.ladill.mini.ui.theme.LadillIndigo -import com.ladill.mini.ui.theme.LadillPrimaryGradient -import com.ladill.mini.ui.theme.White - -/** - * Primary CTA — a gradient (indigo -> blue) pill with a trailing arrow circle, - * mirroring the web `.btn-primary`. Full-width by default. - */ -@Composable -fun LadillButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - loading: Boolean = false, - enabled: Boolean = true, - showArrow: Boolean = true, -) { - val interaction = remember { MutableInteractionSource() } - val active = enabled && !loading - - Box( - modifier = modifier - .fillMaxWidth() - .height(56.dp) - .clip(CircleShape) - .alpha(if (active) 1f else 0.5f) - .background(LadillPrimaryGradient) - .clickable( - interactionSource = interaction, - indication = null, - enabled = active, - onClick = onClick, - ) - .padding(start = 22.dp, end = 6.dp), - contentAlignment = Alignment.Center, - ) { - if (loading) { - CircularProgressIndicator( - color = White, - strokeWidth = 2.dp, - modifier = Modifier.size(20.dp), - ) - } else { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = White, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .padding(end = if (showArrow) 44.dp else 16.dp), - ) - if (showArrow) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .align(Alignment.CenterEnd) - .size(44.dp) - .clip(CircleShape) - .background(White), - ) { - Icon( - imageVector = Icons.Filled.ArrowOutward, - contentDescription = null, - tint = LadillIndigo, - modifier = Modifier.size(18.dp), - ) - } - } - } - } -} - -/** Secondary / ghost pill — outlined, used for non-primary actions. */ -@Composable -fun LadillSecondaryButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, -) { - val interaction = remember { MutableInteractionSource() } - Box( - modifier = modifier - .fillMaxWidth() - .height(52.dp) - .clip(CircleShape) - .alpha(if (enabled) 1f else 0.5f) - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable(interaction, null, enabled = enabled, onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/LadillLogo.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/LadillLogo.kt deleted file mode 100644 index a8d6e42..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/LadillLogo.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.ladill.mini.ui.components - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import com.ladill.mini.R - -/** - * Full Ladill Mini wordmark, loaded from the bundled brand SVG - * (assets/ladillmini-logo.svg) via Coil's SVG decoder. The source artwork is - * ~387x76, so width tracks height by that ratio. - */ -@Composable -fun LadillMiniWordmark( - modifier: Modifier = Modifier, - height: Dp = 28.dp, -) { - AsyncImage( - model = "file:///android_asset/ladillmini-logo.svg", - contentDescription = "Ladill Mini", - modifier = modifier - .height(height) - .width(height * (387.6f / 76.2f)), - ) -} - -/** Compact tri-colour brand mark (the Ladill "L" tiles). */ -@Composable -fun LadillMiniMark( - modifier: Modifier = Modifier, - size: Dp = 40.dp, -) { - Image( - painter = painterResource(R.drawable.ladill_mini_mark), - contentDescription = "Ladill Mini", - modifier = modifier.size(size), - ) -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/LadillTextField.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/LadillTextField.kt deleted file mode 100644 index 0574ee3..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/LadillTextField.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.ladill.mini.ui.components - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.foundation.text.KeyboardOptions -import com.ladill.mini.ui.theme.LadillIndigo - -/** - * Labelled input matching the web `@tailwindcss/forms` look — rounded, soft - * border, indigo focus ring. - */ -@Composable -fun LadillTextField( - value: String, - onValueChange: (String) -> Unit, - label: String, - modifier: Modifier = Modifier, - placeholder: String? = null, - keyboardType: KeyboardType = KeyboardType.Text, - isPassword: Boolean = false, - singleLine: Boolean = true, - enabled: Boolean = true, - error: String? = null, -) { - var revealed by remember { mutableStateOf(false) } - val obscured = isPassword && !revealed - - Column(modifier = modifier.fillMaxWidth()) { - Text( - text = label, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 6.dp), - ) - OutlinedTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier.fillMaxWidth(), - enabled = enabled, - singleLine = singleLine, - isError = error != null, - placeholder = placeholder?.let { { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) } }, - visualTransformation = if (obscured) PasswordVisualTransformation() else VisualTransformation.None, - trailingIcon = if (isPassword) { - { - IconButton(onClick = { revealed = !revealed }) { - AppIcon( - name = if (revealed) "eyeball-hide" else "eyeball", - size = 20.dp, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - null - }, - keyboardOptions = KeyboardOptions(keyboardType = keyboardType), - shape = RoundedCornerShape(14.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LadillIndigo, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - cursorColor = LadillIndigo, - ), - ) - if (error != null) { - Text( - text = error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 4.dp, start = 4.dp), - ) - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/QrImage.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/QrImage.kt deleted file mode 100644 index 3a23d80..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/QrImage.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.ladill.mini.ui.components - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.core.graphics.createBitmap -import androidx.core.graphics.set -import com.google.zxing.BarcodeFormat -import com.google.zxing.EncodeHintType -import com.google.zxing.qrcode.QRCodeWriter -import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel -import com.ladill.mini.ui.theme.LadillInk -import com.ladill.mini.ui.theme.White - -/** - * Renders a QR code on-device from [content] with ZXing. Offline and - * auth-free — the printed/scanned link is the QR's encoded payload. - */ -@Composable -fun QrImage( - content: String, - modifier: Modifier = Modifier, - size: Dp = 220.dp, -) { - val dark = LadillInk.toArgb() - val light = White.toArgb() - - val bitmap = remember(content) { - runCatching { - val px = 600 - val hints = mapOf( - EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M, - EncodeHintType.MARGIN to 1, - ) - val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, px, px, hints) - val bmp = createBitmap(px, px) - for (x in 0 until px) { - for (y in 0 until px) { - bmp[x, y] = if (matrix[x, y]) dark else light - } - } - bmp - }.getOrNull() - } - - if (bitmap != null) { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = "QR code", - modifier = modifier.size(size), - ) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/UserAvatar.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/UserAvatar.kt deleted file mode 100644 index 74850a8..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/components/UserAvatar.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.ladill.mini.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import com.ladill.mini.ui.theme.LadillBlue -import com.ladill.mini.ui.theme.LadillIndigo -import com.ladill.mini.ui.theme.White - -/** - * Circular avatar. Shows the uploaded photo when [photoUrl] is set, otherwise - * the user's initial on the Ladill gradient. - */ -@Composable -fun UserAvatar( - name: String?, - modifier: Modifier = Modifier, - photoUrl: String? = null, - size: Dp = 40.dp, - onClick: (() -> Unit)? = null, -) { - val shaped = modifier - .size(size) - .clip(CircleShape) - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - - if (!photoUrl.isNullOrBlank()) { - AsyncImage( - model = photoUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = shaped, - ) - return - } - - val initial = name?.trim()?.firstOrNull()?.uppercaseChar()?.toString() ?: "?" - Box( - contentAlignment = Alignment.Center, - modifier = shaped.background( - Brush.linearGradient( - colors = listOf(LadillIndigo, LadillBlue), - start = Offset(0f, 0f), - end = Offset(size.value, size.value), - ), - ), - ) { - Text( - text = initial, - color = White, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - ) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/HomeScreen.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/HomeScreen.kt deleted file mode 100644 index 49c097d..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/HomeScreen.kt +++ /dev/null @@ -1,350 +0,0 @@ -package com.ladill.mini.ui.home - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.ui.draw.clip -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.border -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.NavigationBarItemDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ladill.mini.ui.account.NotificationsSheet -import com.ladill.mini.ui.afia.AfiaSheet -import com.ladill.mini.ui.afia.AfiaTopBarButton -import com.ladill.mini.ui.components.AppIcon -import com.ladill.mini.ui.components.UserAvatar -import com.ladill.mini.ui.qr.CreatePaymentQrSheet -import com.ladill.mini.ui.qr.PaymentQrDetailSheet -import com.ladill.mini.ui.theme.LadillIndigo -import com.ladill.mini.ui.theme.LadillIndigoSoft - -private enum class HomeTab { Overview, Qrs, Payments, Payouts } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun HomeScreen( - onOpenProfile: () -> Unit, - onOpenSettings: () -> Unit, - onOpenSecurity: () -> Unit, - onOpenWallet: () -> Unit, - onOpenBilling: () -> Unit, - onOpenSupport: () -> Unit, - onLoggedOut: () -> Unit, - viewModel: HomeViewModel = viewModel(), -) { - var selected by remember { mutableIntStateOf(0) } - var showMenu by remember { mutableStateOf(false) } - var showCreateSheet by remember { mutableStateOf(false) } - var showNotifications by remember { mutableStateOf(false) } - var showAfia by remember { mutableStateOf(false) } - var selectedQrId by remember { mutableStateOf(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) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/HomeViewModel.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/HomeViewModel.kt deleted file mode 100644 index a269fba..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/HomeViewModel.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.ladill.mini.ui.home - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.ladill.mini.LadillMiniApp -import com.ladill.mini.data.model.Overview -import com.ladill.mini.data.model.Payment -import com.ladill.mini.data.model.PaymentQr -import com.ladill.mini.data.model.Payouts -import com.ladill.mini.data.repository.userMessage -import com.ladill.mini.ui.common.UiState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -class HomeViewModel(app: Application) : AndroidViewModel(app) { - - private val application = app as LadillMiniApp - private val repo = application.miniRepository - - val accountName: String? = application.authRepository.cachedName() - val accountEmail: String? = application.authRepository.cachedEmail() - val accountAvatarUrl: String? = application.authRepository.cachedAvatarUrl() - - private val _overview = MutableStateFlow>(UiState.Loading) - val overview: StateFlow> = _overview.asStateFlow() - - private val _overviewRefreshing = MutableStateFlow(false) - val overviewRefreshing: StateFlow = _overviewRefreshing.asStateFlow() - - private val _qrs = MutableStateFlow>>(UiState.Loading) - val qrs: StateFlow>> = _qrs.asStateFlow() - - private val _payments = MutableStateFlow>>(UiState.Loading) - val payments: StateFlow>> = _payments.asStateFlow() - - private val _payouts = MutableStateFlow>(UiState.Loading) - val payouts: StateFlow> = _payouts.asStateFlow() - - // null = unknown, true/false = payout account set? - private val _hasPayout = MutableStateFlow(null) - val hasPayout: StateFlow = _hasPayout.asStateFlow() - - private val _unreadNotifications = MutableStateFlow(0) - val unreadNotifications: StateFlow = _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() - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/OverviewTab.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/OverviewTab.kt deleted file mode 100644 index 823097b..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/OverviewTab.kt +++ /dev/null @@ -1,291 +0,0 @@ -package com.ladill.mini.ui.home - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.clickable -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.ladill.mini.data.model.Overview -import com.ladill.mini.data.model.Payment -import com.ladill.mini.ui.common.ErrorState -import com.ladill.mini.ui.common.LoadingState -import com.ladill.mini.ui.common.UiState -import com.ladill.mini.ui.components.LadillCard -import com.ladill.mini.ui.components.StatCard -import com.ladill.mini.ui.theme.LadillIndigo -import com.ladill.mini.ui.theme.LadillIndigoSoft -import com.ladill.mini.ui.theme.LadillIndigoText -import com.ladill.mini.ui.theme.LadillMoney -import com.ladill.mini.ui.theme.LadillPrimaryGradient -import com.ladill.mini.ui.theme.White -import com.ladill.mini.util.formatMoney - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun OverviewTab( - viewModel: HomeViewModel, - onCreateQr: () -> Unit, - onSeeAllPayments: () -> Unit, - onOpenWallet: () -> Unit, - modifier: Modifier = Modifier, -) { - val state by viewModel.overview.collectAsStateWithLifecycle() - val isRefreshing by viewModel.overviewRefreshing.collectAsStateWithLifecycle() - val hasPayout by viewModel.hasPayout.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { viewModel.loadOverview() } - - // Re-check payout status whenever the app resumes, so the prompt auto-dismisses - // once the user has set up their payout account. - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) viewModel.loadPayoutStatus() - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - - val firstName = viewModel.accountName?.trim()?.split(" ")?.firstOrNull()?.takeIf { it.isNotBlank() } - - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = viewModel::refreshOverview, - modifier = modifier.fillMaxSize(), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(20.dp), - ) { - Text( - if (firstName != null) "Hello, $firstName" else "Hello", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - "Here is today's takings, your payment QRs, and recent payments.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - Spacer(Modifier.height(20.dp)) - - if (hasPayout == false) { - PayoutSetupNote(onOpenWallet) - Spacer(Modifier.height(16.dp)) - } - - when (val s = state) { - is UiState.Loading -> LoadingState() - is UiState.Error -> ErrorState(s.message, onRetry = { viewModel.loadOverview() }) - is UiState.Success -> OverviewContent(s.data, onCreateQr, onSeeAllPayments) - } - } - } -} - -@Composable -private fun PayoutSetupNote(onOpenWallet: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(LadillIndigoSoft) - .clickable(onClick = onOpenWallet) - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Set up your payout account", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = LadillIndigoText) - Text( - "Add where you'd like to receive your money so you can withdraw your takings.", - style = MaterialTheme.typography.bodySmall, - color = LadillIndigoText, - modifier = Modifier.padding(top = 2.dp), - ) - } - Text("→", style = MaterialTheme.typography.titleMedium, color = LadillIndigoText, modifier = Modifier.padding(start = 8.dp)) - } -} - -@Composable -private fun OverviewContent(data: Overview, onCreateQr: () -> Unit, onSeeAllPayments: () -> Unit) { - TakingsHero(formatMoney(data.todayTakingsMinor, data.currency)) - Spacer(Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - StatCard( - label = "Payments today", - value = data.todayCount.toString(), - modifier = Modifier.weight(0.9f), - ) - StatCard( - label = "Wallet balance", - value = formatMoney(data.walletBalanceMinor, data.currency), - highlight = true, - modifier = Modifier.weight(1.4f), - ) - } - - Spacer(Modifier.height(20.dp)) - LadillCard(contentPadding = 0) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("Recent payments", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Text( - "View all", - style = MaterialTheme.typography.labelLarge, - color = LadillIndigo, - modifier = Modifier.clickable(onClick = onSeeAllPayments), - ) - } - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - if (data.recentPayments.isEmpty()) { - Text( - "No payments yet. Print your payment QR and start accepting payments.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(18.dp), - ) - } else { - data.recentPayments.forEachIndexed { i, payment -> - if (i > 0) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - RecentPaymentRow(payment) - } - } - } - - Spacer(Modifier.height(20.dp)) - LadillCard { - Text("Quick links", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(12.dp)) - QuickLink("Create payment QR", onCreateQr) - Spacer(Modifier.height(10.dp)) - QuickLink("View payments", onSeeAllPayments) - } - Spacer(Modifier.height(24.dp)) -} - -@Composable -private fun TakingsHero(amount: String) { - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .background(LadillPrimaryGradient), - ) { - // Decorative translucent circles in the background (offset partly off the - // card; the parent clip keeps them inside the rounded rectangle). - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 30.dp, y = (-30).dp) - .size(120.dp) - .clip(CircleShape) - .background(White.copy(alpha = 0.10f)), - ) - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .offset(x = (-40).dp, y = 40.dp) - .size(110.dp) - .clip(CircleShape) - .background(White.copy(alpha = 0.08f)), - ) - Column(modifier = Modifier.padding(22.dp)) { - Text( - "TODAY'S TAKINGS", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = White.copy(alpha = 0.85f), - ) - Spacer(Modifier.height(8.dp)) - Text( - amount, - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.Bold, - color = White, - maxLines = 1, - ) - } - } -} - -@Composable -private fun RecentPaymentRow(payment: Payment) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - payment.payerName?.ifBlank { null } ?: "Walk-in customer", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - payment.qrLabel ?: "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - formatMoney(payment.merchantAmountMinor, payment.currency), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = LadillMoney, - ) - } -} - -@Composable -private fun QuickLink(label: String, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = 12.dp, horizontal = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) - Text("→", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/PaymentQrsTab.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/PaymentQrsTab.kt deleted file mode 100644 index 0c8fb23..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/PaymentQrsTab.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.ladill.mini.ui.home - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.ladill.mini.data.model.PaymentQr -import com.ladill.mini.ui.common.EmptyState -import com.ladill.mini.ui.common.ErrorState -import com.ladill.mini.ui.common.LoadingState -import com.ladill.mini.ui.common.UiState -import com.ladill.mini.ui.components.LadillCard -import com.ladill.mini.ui.components.StatusPill -import com.ladill.mini.ui.theme.LadillBrandGreen -import com.ladill.mini.ui.theme.LadillMoney -import com.ladill.mini.ui.theme.LadillPrimaryGradient -import com.ladill.mini.ui.theme.White - -@Composable -fun PaymentQrsTab( - viewModel: HomeViewModel, - onCreateQr: () -> Unit, - onOpenQr: (Long) -> Unit, - modifier: Modifier = Modifier, -) { - val state by viewModel.qrs.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { viewModel.loadQrs() } - - Box(modifier = modifier.fillMaxSize()) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - Column(modifier = Modifier.padding(bottom = 8.dp)) { - Text("Payment QRs", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground) - Text( - "Codes your customers scan to pay you.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - } - } - when (val s = state) { - is UiState.Loading -> item { LoadingState() } - is UiState.Error -> item { ErrorState(s.message, onRetry = { viewModel.loadQrs() }) } - is UiState.Success -> { - if (s.data.isEmpty()) { - item { - EmptyState( - title = "No payment QRs yet", - subtitle = "Create one to start accepting payments in person.", - ) - } - } else { - items(s.data, key = { it.id }) { qr -> - PaymentQrRow(qr, onClick = { onOpenQr(qr.id) }) - } - } - } - } - } - - // Gradient FAB — matches the web `.btn-fab`. - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(20.dp) - .size(56.dp) - .clip(CircleShape) - .background(LadillPrimaryGradient) - .clickable(onClick = onCreateQr), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Filled.Add, contentDescription = "Create payment QR", tint = White) - } - } -} - -@Composable -private fun PaymentQrRow(qr: PaymentQr, onClick: () -> Unit) { - LadillCard(modifier = Modifier.clickable(onClick = onClick), contentPadding = 16) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(qr.label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) - val sub = listOfNotNull(qr.businessName, qr.branchLabel).joinToString(" · ") - if (sub.isNotBlank()) { - Text(sub, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp)) - } - Text( - "${qr.scansTotal} scans", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - } - if (qr.isActive) { - StatusPill("Active", fg = LadillMoney, bg = LadillBrandGreen.copy(alpha = 0.12f)) - } else { - StatusPill("Paused", fg = MaterialTheme.colorScheme.onSurfaceVariant, bg = MaterialTheme.colorScheme.surfaceVariant) - } - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/PaymentsTab.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/PaymentsTab.kt deleted file mode 100644 index d38e175..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/PaymentsTab.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.ladill.mini.ui.home - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.ladill.mini.data.model.Payment -import com.ladill.mini.ui.common.EmptyState -import com.ladill.mini.ui.common.ErrorState -import com.ladill.mini.ui.common.LoadingState -import com.ladill.mini.ui.common.UiState -import com.ladill.mini.ui.components.LadillCard -import com.ladill.mini.ui.components.StatusPill -import com.ladill.mini.ui.theme.LadillIndigo -import com.ladill.mini.ui.theme.LadillMoney -import com.ladill.mini.util.formatDate -import com.ladill.mini.util.formatMoney - -@Composable -fun PaymentsTab( - viewModel: HomeViewModel, - modifier: Modifier = Modifier, -) { - val state by viewModel.payments.collectAsStateWithLifecycle() - var query by remember { mutableStateOf("") } - - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - Text("Payments", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground) - Text( - "Every payment customers have made to you.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), - ) - OutlinedTextField( - value = query, - onValueChange = { query = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search payer, note, or reference") }, - leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, - singleLine = true, - shape = RoundedCornerShape(14.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = LadillIndigo, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - cursorColor = LadillIndigo, - ), - ) - } - - when (val s = state) { - is UiState.Loading -> item { LoadingState() } - is UiState.Error -> item { ErrorState(s.message, onRetry = { viewModel.loadPayments(query) }) } - is UiState.Success -> { - if (s.data.isEmpty()) { - item { EmptyState("No payments found", "Payments appear here once customers pay.") } - } else { - items(s.data, key = { it.id }) { PaymentRow(it) } - } - } - } - } - - // Re-query as the search text changes (simple debounce-free trigger). - LaunchedEffect(query) { - if (query.isBlank()) viewModel.loadPayments() else viewModel.loadPayments(query) - } -} - -@Composable -private fun PaymentRow(payment: Payment) { - LadillCard(contentPadding = 16) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text( - payment.payerName?.ifBlank { null } ?: "Walk-in customer", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - val meta = listOfNotNull(payment.qrLabel, formatDate(payment.paidAt ?: payment.createdAt).ifBlank { null }) - .joinToString(" · ") - if (meta.isNotBlank()) { - Text(meta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp)) - } - payment.payerNote?.takeIf { it.isNotBlank() }?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp)) - } - } - Column(horizontalAlignment = Alignment.End) { - val paid = payment.status == "paid" - Text( - formatMoney(payment.merchantAmountMinor, payment.currency), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = if (paid) LadillMoney else MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(4.dp)) - PaymentStatusPill(payment.status) - } - } - } -} - -@Composable -private fun PaymentStatusPill(status: String) { - val (label, fg, bg) = when (status) { - "paid" -> Triple("Paid", Color(0xFF047857), Color(0xFFD1FAE5)) - "pending" -> Triple("Pending", Color(0xFFB45309), Color(0xFFFEF3C7)) - "canceled" -> Triple("Canceled", Color(0xFF64748B), Color(0xFFE2E8F0)) - else -> Triple("Failed", Color(0xFFB91C1C), Color(0xFFFEE2E2)) - } - StatusPill(text = label, fg = fg, bg = bg) -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/PayoutsTab.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/PayoutsTab.kt deleted file mode 100644 index cbe41de..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/home/PayoutsTab.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.ladill.mini.ui.home - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.ladill.mini.data.model.Payouts -import com.ladill.mini.ui.common.ErrorState -import com.ladill.mini.ui.common.LoadingState -import com.ladill.mini.ui.common.UiState -import com.ladill.mini.ui.components.LadillButton -import com.ladill.mini.ui.components.LadillCard -import com.ladill.mini.ui.components.StatCard -import com.ladill.mini.util.formatMoney - -@Composable -fun PayoutsTab( - viewModel: HomeViewModel, - onOpenWallet: () -> Unit, - modifier: Modifier = Modifier, -) { - val state by viewModel.payouts.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { viewModel.loadPayouts() } - - Column( - modifier = modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(20.dp), - ) { - Text("Payouts", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground) - Text( - "Revenue collected and what's ready to withdraw.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - Spacer(Modifier.height(16.dp)) - - when (val s = state) { - is UiState.Loading -> LoadingState() - is UiState.Error -> ErrorState(s.message, onRetry = { viewModel.loadPayouts() }) - is UiState.Success -> PayoutsContent(s.data, onOpenWallet) - } - Spacer(Modifier.height(24.dp)) - } -} - -@Composable -private fun PayoutsContent(data: Payouts, onOpenWallet: () -> Unit) { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { - StatCard( - label = "Total revenue", - value = formatMoney(data.totalRevenueMinor, data.currency), - modifier = Modifier.weight(1f), - ) - StatCard( - label = "Wallet balance", - value = formatMoney(data.walletBalanceMinor, data.currency), - highlight = true, - modifier = Modifier.weight(1f), - ) - } - Spacer(Modifier.height(12.dp)) - LadillCard { - Text( - "Top up and withdraw your balance right here in the app.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(14.dp)) - LadillButton(text = "Go to Wallet", onClick = onOpenWallet, showArrow = false) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/landing/LandingScreen.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/landing/LandingScreen.kt deleted file mode 100644 index eb20b98..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/landing/LandingScreen.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.ladill.mini.ui.landing - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import com.ladill.mini.R -import com.ladill.mini.ui.components.LadillButton -import com.ladill.mini.ui.components.LadillMiniWordmark -import com.ladill.mini.ui.components.LadillSecondaryButton - -/** - * Pre-auth welcome screen. Layout benchmarked against ClimpMe Android's - * LandingScreen: top-left wordmark, large headline + subtext, a hero image, and - * the primary "Get started" / secondary "Sign in" CTAs pinned at the bottom. - */ -@Composable -fun LandingScreen( - onSignIn: () -> Unit, - onCreateAccount: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .statusBarsPadding() - .navigationBarsPadding(), - ) { - LadillMiniWordmark( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 36.dp), - height = 30.dp, - ) - - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) { - Text( - text = "Collect payments,\neffortlessly.", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.height(10.dp)) - Text( - text = "Create pay links and QR codes, share them anywhere, and track every payment — no storefront needed.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(horizontal = 20.dp, vertical = 20.dp), - ) { - Image( - painter = painterResource(R.drawable.girl), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxWidth(), - ) - } - - Column(modifier = Modifier.padding(horizontal = 24.dp)) { - LadillButton(text = "Get started", onClick = onCreateAccount) - Spacer(Modifier.height(10.dp)) - LadillSecondaryButton(text = "Sign in", onClick = onSignIn) - Spacer(Modifier.height(12.dp)) - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/notifications/NotificationsViewModel.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/notifications/NotificationsViewModel.kt deleted file mode 100644 index d93414f..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/notifications/NotificationsViewModel.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.ladill.mini.ui.notifications - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.ladill.mini.LadillMiniApp -import com.ladill.mini.data.model.AppNotification -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class NotificationsUiState( - val loading: Boolean = true, - val error: String? = null, - val notifications: List = 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 create(modelClass: Class): T = NotificationsViewModel(app) as T - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/qr/CreatePaymentQrScreen.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/qr/CreatePaymentQrScreen.kt deleted file mode 100644 index 47a3008..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/qr/CreatePaymentQrScreen.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.ladill.mini.ui.qr - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ladill.mini.ui.components.LadillButton -import com.ladill.mini.ui.components.LadillTextField - -/** New payment QR as a bottom sheet, opened from the QRs tab / Overview. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CreatePaymentQrSheet( - onDismiss: () -> Unit, - onCreated: (Long) -> Unit, - viewModel: QrViewModel = viewModel(), -) { - val state by viewModel.create.collectAsStateWithLifecycle() - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 24.dp) - .imePadding(), - ) { - Text("New payment QR", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) - Spacer(Modifier.height(4.dp)) - Text( - "Create a code customers scan to pay you. Print or display it anywhere.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(20.dp)) - - LadillTextField(state.label, viewModel::onLabel, "Label", placeholder = "e.g. Front counter", enabled = !state.saving) - Spacer(Modifier.height(16.dp)) - LadillTextField(state.businessName, viewModel::onBusinessName, "Business name", placeholder = "Shown to customers", enabled = !state.saving) - Spacer(Modifier.height(16.dp)) - LadillTextField(state.branchLabel, viewModel::onBranchLabel, "Branch (optional)", placeholder = "e.g. Osu branch", enabled = !state.saving, error = state.error) - - Spacer(Modifier.height(28.dp)) - LadillButton( - text = if (state.saving) "Creating…" else "Create payment QR", - onClick = { viewModel.submit(onCreated) }, - loading = state.saving, - enabled = viewModel.canCreate, - ) - Spacer(Modifier.height(12.dp)) - } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/qr/PaymentQrDetailScreen.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/qr/PaymentQrDetailScreen.kt deleted file mode 100644 index 85d30f9..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/qr/PaymentQrDetailScreen.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.ladill.mini.ui.qr - -import android.content.Context -import android.content.Intent -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DeleteOutline -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ladill.mini.data.model.PaymentQr -import com.ladill.mini.ui.common.ErrorState -import com.ladill.mini.ui.common.LoadingState -import com.ladill.mini.ui.common.UiState -import com.ladill.mini.ui.components.LadillCard -import com.ladill.mini.ui.components.QrImage -import com.ladill.mini.ui.theme.LadillIndigo -import com.ladill.mini.ui.theme.White - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PaymentQrDetailSheet( - qrId: Long, - onDismiss: () -> Unit, - onDeleted: () -> Unit = onDismiss, - viewModel: QrViewModel = viewModel(), -) { - val state by viewModel.detail.collectAsStateWithLifecycle() - val context = LocalContext.current - var confirmDelete by remember { mutableStateOf(false) } - LaunchedEffect(qrId) { viewModel.loadDetail(qrId) } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 360.dp, max = 640.dp) - .navigationBarsPadding(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Text( - "Payment QR", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .weight(1f) - .padding(start = 12.dp), - ) - if (state is UiState.Success) { - val qr = (state as UiState.Success).data - if (!qr.publicUrl.isNullOrBlank()) { - IconButton(onClick = { sharePaymentQr(context, qr) }) { - Icon(Icons.Filled.Share, contentDescription = "Share", tint = MaterialTheme.colorScheme.onSurface) - } - } - IconButton(onClick = { confirmDelete = true }) { - Icon(Icons.Filled.DeleteOutline, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error) - } - } - } - - Box(modifier = Modifier.weight(1f, fill = false)) { - when (val s = state) { - is UiState.Loading -> LoadingState() - is UiState.Error -> ErrorState(s.message, onRetry = { viewModel.loadDetail(qrId) }) - is UiState.Success -> DetailContent( - qr = s.data, - onToggleActive = { viewModel.toggleActive(qrId, it) }, - ) - } - } - } - } - - if (confirmDelete) { - AlertDialog( - onDismissRequest = { confirmDelete = false }, - title = { Text("Delete payment QR?") }, - text = { Text("This code will stop working immediately. This can't be undone.") }, - confirmButton = { - TextButton(onClick = { - confirmDelete = false - viewModel.delete(qrId, onDeleted) - }) { Text("Delete", color = MaterialTheme.colorScheme.error) } - }, - dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, - ) - } -} - -@Composable -private fun DetailContent(qr: PaymentQr, onToggleActive: (Boolean) -> Unit) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 20.dp), - ) { - LadillCard { - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(White) - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - val encoded = qr.publicUrl ?: qr.previewUrl - if (encoded != null) { - QrImage(content = encoded, size = 220.dp) - } - } - Spacer(Modifier.height(14.dp)) - Text(qr.label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) - val sub = listOfNotNull(qr.businessName, qr.branchLabel).joinToString(" · ") - if (sub.isNotBlank()) { - Text(sub, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp)) - } - qr.publicUrl?.let { - Spacer(Modifier.height(8.dp)) - Text(it, style = MaterialTheme.typography.bodyMedium, color = LadillIndigo) - } - } - - Spacer(Modifier.height(16.dp)) - LadillCard { - DetailRow("Scans", qr.scansTotal.toString()) - HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp), color = MaterialTheme.colorScheme.outlineVariant) - DetailRow("Currency", qr.currency) - HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp), color = MaterialTheme.colorScheme.outlineVariant) - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.weight(1f)) { - Text("Active", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) - Text( - if (qr.isActive) "Accepting payments" else "Paused — customers can't pay", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = qr.isActive, - onCheckedChange = onToggleActive, - colors = SwitchDefaults.colors(checkedTrackColor = LadillIndigo), - ) - } - } - } -} - -private fun sharePaymentQr(context: Context, qr: PaymentQr) { - val url = qr.publicUrl ?: return - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_SUBJECT, qr.label) - putExtra(Intent.EXTRA_TEXT, "${qr.label} — pay with QR\n$url") - } - context.startActivity(Intent.createChooser(intent, "Share payment QR")) -} - -@Composable -private fun DetailRow(label: String, value: String) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(value, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/qr/QrViewModel.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/qr/QrViewModel.kt deleted file mode 100644 index 19819e7..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/qr/QrViewModel.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.ladill.mini.ui.qr - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.ladill.mini.LadillMiniApp -import com.ladill.mini.data.model.PaymentQr -import com.ladill.mini.data.repository.userMessage -import com.ladill.mini.ui.common.UiState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class CreateQrUiState( - val label: String = "", - val businessName: String = "", - val branchLabel: String = "", - val saving: Boolean = false, - val error: String? = null, -) - -class QrViewModel(app: Application) : AndroidViewModel(app) { - - private val repo = (app as LadillMiniApp).miniRepository - - // ---- Create ---- - private val _create = MutableStateFlow(CreateQrUiState()) - val create: StateFlow = _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.Loading) - val detail: StateFlow> = _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() } - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/security/PinScreens.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/security/PinScreens.kt deleted file mode 100644 index e4c7d4f..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/security/PinScreens.kt +++ /dev/null @@ -1,226 +0,0 @@ -package com.ladill.mini.ui.security - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Backspace -import androidx.compose.material.icons.filled.Fingerprint -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.fragment.app.FragmentActivity -import com.ladill.mini.util.BiometricAuth -import com.ladill.mini.util.BiometricUnlockStatus -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.ladill.mini.ui.theme.LadillIndigo - -private const val PIN_LENGTH = 4 - -/** - * Numeric PIN entry. [onComplete] fires when [PIN_LENGTH] digits are entered; - * the caller validates and may [errorMessage] + reset. A biometric affordance - * shows when [onBiometric] is provided (lock mode). - */ -@Composable -fun PinPad( - title: String, - subtitle: String, - pin: String, - onDigit: (String) -> Unit, - onBackspace: () -> Unit, - errorMessage: String? = null, - onBiometric: (() -> Unit)? = null, -) { - Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center) - Spacer(Modifier.height(8.dp)) - Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) - - Spacer(Modifier.height(32.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - repeat(PIN_LENGTH) { i -> - val filled = i < pin.length - Box( - modifier = Modifier - .size(16.dp) - .clip(CircleShape) - .background(if (filled) LadillIndigo else Color.Transparent) - .border(1.5.dp, if (filled) LadillIndigo else MaterialTheme.colorScheme.outline, CircleShape), - ) - } - } - if (errorMessage != null) { - Spacer(Modifier.height(14.dp)) - Text(errorMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - } - - Spacer(Modifier.height(40.dp)) - listOf(listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9")).forEach { row -> - Row(horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(vertical = 8.dp)) { - row.forEach { d -> PinKey(d) { onDigit(d) } } - } - } - Row(horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(vertical = 8.dp)) { - Box(modifier = Modifier.size(64.dp), contentAlignment = Alignment.Center) { - if (onBiometric != null) { - Icon( - Icons.Filled.Fingerprint, - contentDescription = "Biometric unlock", - tint = LadillIndigo, - modifier = Modifier.size(28.dp).clickable(onClick = onBiometric), - ) - } - } - PinKey("0") { onDigit("0") } - Box(modifier = Modifier.size(64.dp), contentAlignment = Alignment.Center) { - Icon( - Icons.AutoMirrored.Filled.Backspace, - contentDescription = "Delete", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp).clickable(onClick = onBackspace), - ) - } - } - } -} - -@Composable -private fun PinKey(digit: String, onClick: () -> Unit) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable(onClick = onClick), - ) { - Text(digit, fontSize = 24.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface) - } -} - -/** Two-step PIN setup (enter, then confirm). Calls [onPinSet] with the chosen PIN. */ -@Composable -fun PinSetupContent(onPinSet: (String) -> Unit) { - var first by remember { mutableStateOf("") } - var pin by remember { mutableStateOf("") } - var error by remember { mutableStateOf(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(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, - ) -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/splash/SplashScreen.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/splash/SplashScreen.kt deleted file mode 100644 index c6ce7c7..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/splash/SplashScreen.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.ladill.mini.ui.splash - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.compose.foundation.background -import com.ladill.mini.ui.components.LadillMiniMark - -/** - * Brief brand screen while we decide where to go. Logged-in users with a PIN - * are sent to the lock screen first (matches ClimpMe); everyone else goes to - * Home or Landing. - */ -@Composable -fun SplashScreen( - isLoggedIn: Boolean, - hasPin: Boolean, - toHome: () -> Unit, - toPinLock: () -> Unit, - toLanding: () -> Unit, -) { - LaunchedEffect(Unit) { - when { - !isLoggedIn -> toLanding() - hasPin -> toPinLock() - else -> toHome() - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.White), - contentAlignment = Alignment.Center, - ) { - LadillMiniMark(size = 84.dp) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/theme/Color.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/theme/Color.kt deleted file mode 100644 index aa0fe9d..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/theme/Color.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.ladill.mini.ui.theme - -import androidx.compose.ui.graphics.Color - -// --------------------------------------------------------------------------- -// Ladill Mini design tokens. -// -// Synthesized from the web app (resources/css/app.css + mini blade views): -// slate page/surfaces, indigo accent, emerald money, Figtree type. The brand -// gradient comes from config/product_landing.php (indigo #4f46e5 -> #1c75bc). -// Brand mark colors (gold/red/green) come from the Ladill logo SVGs. -// --------------------------------------------------------------------------- - -// Light — neutrals (Tailwind slate scale) -val LadillInk = Color(0xFF0F172A) // slate-900 — headings -val LadillPage = Color(0xFFF1F5F9) // slate-100 — app background -val LadillMuted = Color(0xFF64748B) // slate-500 — secondary text -val LadillSubtle = Color(0xFF94A3B8) // slate-400 — labels / placeholders -val LadillSurface = Color(0xFFFFFFFF) // cards -val LadillSurfaceSoft = Color(0xFFF8FAFC) // slate-50 — soft fills -val LadillBorder = Color(0xFFE2E8F0) // slate-200 — card borders -val LadillBorderSoft = Color(0xFFF1F5F9) // slate-100 — dividers - -// Dark — neutrals -val LadillInkDark = Color(0xFFF1F5F9) -val LadillPageDark = Color(0xFF0B1120) -val LadillMutedDark = Color(0xFF94A3B8) -val LadillSurfaceDark = Color(0xFF111827) -val LadillSurfaceSoftDark = Color(0xFF1E293B) -val LadillBorderDark = Color(0xFF1E293B) - -// Accent — Ladill Mini brand gradient (indigo -> blue) -val LadillIndigo = Color(0xFF4F46E5) -val LadillBlue = Color(0xFF1C75BC) -val LadillIndigoSoft = Color(0xFFEEF2FF) // indigo-50 — wallet/highlight cards -val LadillIndigoText = Color(0xFF4338CA) // indigo-700 — accent labels - -// Semantic -val LadillMoney = Color(0xFF047857) // emerald-700 — incoming money -val LadillRed = Color(0xFFDC2626) // errors / destructive -val White = Color(0xFFFFFFFF) - -// Ladill brand mark (logo) — used sparingly for accents/illustration -val LadillBrandGold = Color(0xFFEEC434) -val LadillBrandRed = Color(0xFFFF3441) -val LadillBrandGreen = Color(0xFF00A749) diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/theme/Theme.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/theme/Theme.kt deleted file mode 100644 index d5071ba..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/theme/Theme.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.ladill.mini.ui.theme - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Brush - -private val LightColorScheme = lightColorScheme( - primary = LadillIndigo, - onPrimary = White, - primaryContainer = LadillIndigoSoft, - onPrimaryContainer = LadillIndigoText, - secondary = LadillBlue, - onSecondary = White, - background = LadillPage, - onBackground = LadillInk, - surface = LadillSurface, - onSurface = LadillInk, - surfaceVariant = LadillSurfaceSoft, - onSurfaceVariant = LadillMuted, - outline = LadillBorder, - outlineVariant = LadillBorderSoft, - error = LadillRed, - onError = White, -) - -private val DarkColorScheme = darkColorScheme( - primary = LadillIndigo, - onPrimary = White, - primaryContainer = LadillSurfaceSoftDark, - onPrimaryContainer = LadillInkDark, - secondary = LadillBlue, - onSecondary = White, - background = LadillPageDark, - onBackground = LadillInkDark, - surface = LadillSurfaceDark, - onSurface = LadillInkDark, - surfaceVariant = LadillSurfaceSoftDark, - onSurfaceVariant = LadillMutedDark, - outline = LadillBorderDark, - error = LadillRed, - onError = White, -) - -/** The Ladill Mini brand gradient (indigo -> blue), used by primary CTAs. */ -val LadillPrimaryGradient - @Composable get() = Brush.horizontalGradient(listOf(LadillIndigo, LadillBlue)) - -@Composable -fun LadillMiniTheme( - // Ladill Mini is a light-mode app throughout — we ignore the system setting - // so surfaces, the slate page background, and contrast stay as designed. - darkTheme: Boolean = false, - content: @Composable () -> Unit, -) { - MaterialTheme( - colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme, - typography = Typography, - content = content, - ) -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/theme/Type.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/ui/theme/Type.kt deleted file mode 100644 index 25be2f4..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/ui/theme/Type.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.ladill.mini.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.sp -import com.ladill.mini.R - -/** Figtree — the Ladill web typeface (loaded from fonts.bunny.net on the web). */ -val FigtreeFamily = FontFamily( - Font(R.font.figtree_variable, FontWeight.Normal), -) - -private fun figtree( - weight: FontWeight = FontWeight.Normal, - size: TextUnit, - lineHeight: TextUnit, -) = TextStyle( - fontFamily = FigtreeFamily, - fontWeight = weight, - fontSize = size, - lineHeight = lineHeight, -) - -val Typography = Typography( - displayLarge = figtree(FontWeight.SemiBold, 40.sp, 46.sp), - displayMedium = figtree(FontWeight.SemiBold, 34.sp, 40.sp), - displaySmall = figtree(FontWeight.SemiBold, 28.sp, 34.sp), - headlineLarge = figtree(FontWeight.SemiBold, 26.sp, 32.sp), - headlineMedium = figtree(FontWeight.SemiBold, 22.sp, 28.sp), - headlineSmall = figtree(FontWeight.SemiBold, 20.sp, 26.sp), - titleLarge = figtree(FontWeight.SemiBold, 18.sp, 24.sp), - titleMedium = figtree(FontWeight.Medium, 16.sp, 22.sp), - titleSmall = figtree(FontWeight.Medium, 14.sp, 20.sp), - bodyLarge = figtree(FontWeight.Normal, 16.sp, 24.sp), - bodyMedium = figtree(FontWeight.Normal, 14.sp, 20.sp), - bodySmall = figtree(FontWeight.Normal, 12.sp, 18.sp), - labelLarge = figtree(FontWeight.SemiBold, 15.sp, 20.sp), - labelMedium = figtree(FontWeight.Medium, 12.sp, 16.sp), - labelSmall = figtree(FontWeight.Medium, 11.sp, 14.sp), -) diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/util/BiometricAuth.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/util/BiometricAuth.kt deleted file mode 100644 index 60b3ed5..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/util/BiometricAuth.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.ladill.mini.util - -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import android.provider.Settings -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity - -/** Fingerprint (Class 3) and face unlock (Class 2) on supported devices. */ -const val LadillBiometricAuthenticators: Int = - BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK - -enum class BiometricUnlockStatus { Disabled, Ready, NoneEnrolled, Unavailable } - -object BiometricAuth { - - fun status(context: Context, enabledInApp: Boolean): BiometricUnlockStatus { - if (!enabledInApp) return BiometricUnlockStatus.Disabled - return when (BiometricManager.from(context).canAuthenticate(LadillBiometricAuthenticators)) { - BiometricManager.BIOMETRIC_SUCCESS -> BiometricUnlockStatus.Ready - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricUnlockStatus.NoneEnrolled - else -> BiometricUnlockStatus.Unavailable - } - } - - fun canUnlock(context: Context, pinManager: PinManager): Boolean = - status(context, pinManager.isBiometricEnabled()) == BiometricUnlockStatus.Ready - - fun unlockMethodLabel(context: Context): String { - val pm = context.packageManager - val hasFace = pm.hasSystemFeature(PackageManager.FEATURE_FACE) - val hasFingerprint = pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) - return when { - hasFace && hasFingerprint -> "Use fingerprint or face" - hasFace -> "Use face unlock" - hasFingerprint -> "Use fingerprint" - else -> "Use biometrics" - } - } - - fun openEnrollmentSettings(context: Context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val intent = Intent(Settings.ACTION_BIOMETRIC_ENROLL).apply { - putExtra(Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED, LadillBiometricAuthenticators) - } - if (intent.resolveActivity(context.packageManager) != null) { - context.startActivity(intent) - return - } - } - context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) - } - - fun showUnlockPrompt( - activity: FragmentActivity, - title: String = "Unlock Ladill Mini", - subtitle: String? = null, - onSuccess: () -> Unit, - onUsePin: (() -> Unit)? = null, - ) { - val builder = BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .setNegativeButtonText("Use PIN") - .setAllowedAuthenticators(LadillBiometricAuthenticators) - if (!subtitle.isNullOrBlank()) builder.setSubtitle(subtitle) - - BiometricPrompt( - activity, - ContextCompat.getMainExecutor(activity), - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = onSuccess() - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || errorCode == BiometricPrompt.ERROR_USER_CANCELED) { - onUsePin?.invoke() - } - } - }, - ).authenticate(builder.build()) - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/util/Format.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/util/Format.kt deleted file mode 100644 index 74350ec..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/util/Format.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.ladill.mini.util - -import java.text.NumberFormat -import java.time.OffsetDateTime -import java.time.format.DateTimeFormatter -import java.util.Locale - -/** Format minor units (pesewas) as a currency string, e.g. 12345 -> "GHS 123.45". */ -fun formatMoney(minor: Long, currency: String = "GHS"): String { - val fmt = NumberFormat.getNumberInstance(Locale.US).apply { - minimumFractionDigits = 2 - maximumFractionDigits = 2 - } - return "$currency ${fmt.format(minor / 100.0)}" -} - -fun formatMoney(minor: Int, currency: String = "GHS"): String = formatMoney(minor.toLong(), currency) - -/** Render an ISO-8601 timestamp as a short, friendly date — null/blank safe. */ -fun formatDate(iso: String?): String { - if (iso.isNullOrBlank()) return "" - return runCatching { - OffsetDateTime.parse(iso).format(DateTimeFormatter.ofPattern("d MMM yyyy, h:mm a", Locale.US)) - }.getOrDefault("") -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/util/PinManager.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/util/PinManager.kt deleted file mode 100644 index ef42085..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/util/PinManager.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.ladill.mini.util - -import android.content.Context -import java.security.MessageDigest - -/** Stores a hashed app-lock PIN and the biometric-unlock preference. */ -class PinManager(context: Context) { - - private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - - fun hasPin(): Boolean = prefs.contains(KEY_PIN) - - fun savePin(pin: String) { - prefs.edit().putString(KEY_PIN, hash(pin)).apply() - } - - fun verifyPin(pin: String): Boolean = prefs.getString(KEY_PIN, null) == hash(pin) - - fun clearPin() { - prefs.edit().remove(KEY_PIN).apply() - } - - fun isBiometricEnabled(): Boolean = prefs.getBoolean(KEY_BIOMETRIC, true) - - fun setBiometricEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_BIOMETRIC, enabled).apply() - } - - private fun hash(pin: String): String = - MessageDigest.getInstance("SHA-256") - .digest(pin.toByteArray(Charsets.UTF_8)) - .joinToString("") { "%02x".format(it) } - - private companion object { - const val PREFS = "ladill_pin" - const val KEY_PIN = "pin_hash" - const val KEY_BIOMETRIC = "biometric_enabled" - } -} diff --git a/apps/android/app/src/main/kotlin/com/ladill/mini/util/SessionManager.kt b/apps/android/app/src/main/kotlin/com/ladill/mini/util/SessionManager.kt deleted file mode 100644 index 6604c16..0000000 --- a/apps/android/app/src/main/kotlin/com/ladill/mini/util/SessionManager.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.ladill.mini.util - -import android.content.Context -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey - -/** - * Persists the Sanctum token and cached profile fields in an encrypted prefs - * file. Falls back to plain prefs if the keystore is unavailable so the app - * still runs on misbehaving devices. - */ -class SessionManager(context: Context) { - - private val prefs = runCatching { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - EncryptedSharedPreferences.create( - context, - "ladill_mini_session", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - }.getOrElse { - context.getSharedPreferences("ladill_mini_session_plain", Context.MODE_PRIVATE) - } - - fun getToken(): String? = prefs.getString(KEY_TOKEN, null) - - fun saveSession(token: String, name: String?, email: String?, avatarUrl: String?) { - prefs.edit() - .putString(KEY_TOKEN, token) - .putString(KEY_NAME, name) - .putString(KEY_EMAIL, email) - .putString(KEY_AVATAR, avatarUrl) - .apply() - } - - fun getName(): String? = prefs.getString(KEY_NAME, null) - fun getEmail(): String? = prefs.getString(KEY_EMAIL, null) - fun getAvatarUrl(): String? = prefs.getString(KEY_AVATAR, null) - - fun saveName(name: String?) { - prefs.edit().putString(KEY_NAME, name).apply() - } - - fun saveAvatarUrl(url: String?) { - prefs.edit().putString(KEY_AVATAR, url).apply() - } - - fun clear() { - prefs.edit().clear().apply() - } - - private companion object { - const val KEY_TOKEN = "token" - const val KEY_NAME = "name" - const val KEY_EMAIL = "email" - const val KEY_AVATAR = "avatar" - } -} - -/** Mutable holder so the OkHttp auth interceptor always sees the live token. */ -class TokenStore(initial: String?) { - @Volatile - var token: String? = initial - private set - - fun update(value: String?) { - token = value - } -} diff --git a/apps/android/app/src/main/res/drawable-nodpi/girl.png b/apps/android/app/src/main/res/drawable-nodpi/girl.png deleted file mode 100644 index 7bfa449..0000000 Binary files a/apps/android/app/src/main/res/drawable-nodpi/girl.png and /dev/null differ diff --git a/apps/android/app/src/main/res/drawable/ic_launcher_background.xml b/apps/android/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index ba9d942..0000000 --- a/apps/android/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index baa6649..0000000 --- a/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - diff --git a/apps/android/app/src/main/res/drawable/ic_notification.xml b/apps/android/app/src/main/res/drawable/ic_notification.xml deleted file mode 100644 index d3d2d92..0000000 --- a/apps/android/app/src/main/res/drawable/ic_notification.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/apps/android/app/src/main/res/drawable/ladill_mini_mark.xml b/apps/android/app/src/main/res/drawable/ladill_mini_mark.xml deleted file mode 100644 index 2f16d67..0000000 --- a/apps/android/app/src/main/res/drawable/ladill_mini_mark.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/apps/android/app/src/main/res/font/figtree_variable.ttf b/apps/android/app/src/main/res/font/figtree_variable.ttf deleted file mode 100644 index 579e2ab..0000000 Binary files a/apps/android/app/src/main/res/font/figtree_variable.ttf and /dev/null differ diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index b3e26b4..0000000 --- a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index b3e26b4..0000000 --- a/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml deleted file mode 100644 index e783a6b..0000000 --- a/apps/android/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - Ladill Mini - diff --git a/apps/android/app/src/main/res/values/themes.xml b/apps/android/app/src/main/res/values/themes.xml deleted file mode 100644 index 33e29b7..0000000 --- a/apps/android/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,8 +0,0 @@ - - -