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