diff --git a/apps/android/ChamaApp/app/build.gradle.kts b/apps/android/ChamaApp/app/build.gradle.kts index 096fc22..5f4d90e 100644 --- a/apps/android/ChamaApp/app/build.gradle.kts +++ b/apps/android/ChamaApp/app/build.gradle.kts @@ -7,20 +7,24 @@ plugins { android { namespace = "com.example.chama" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.example.chama" minSdk = 26 - targetSdk = 35 + targetSdk = 36 versionCode = 1 versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } + } buildTypes { release { @@ -39,58 +43,53 @@ android { kotlinOptions { jvmTarget = "11" - freeCompilerArgs = freeCompilerArgs + listOf( - "-opt-in=kotlin.RequiresOptIn", - "-Xskip-prerelease-check" - ) } buildFeatures { + compose = true viewBinding = true } } dependencies { - // Core dependencies - implementation("androidx.core:core-ktx:1.12.0") - implementation("androidx.appcompat:appcompat:1.6.1") - - // Lifecycle - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") - - // Jetpack Compose Dependencies - implementation(platform("androidx.compose:compose-bom:2023.10.01")) - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-graphics") - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3") - implementation("androidx.activity:activity-compose:1.8.0") - implementation("androidx.compose.material:material-icons-extended") - - // Material 3 - // implementation("androidx.compose.material:material:1.5.4") - - // ROOM DATABASE - implementation("androidx.room:room-runtime:2.6.1") - kapt("androidx.room:room-compiler:2.6.1") - implementation("androidx.room:room-ktx:2.6.1") - - // Retrofit - implementation("com.squareup.retrofit2:retrofit:2.9.0") - implementation("com.squareup.retrofit2:converter-gson:2.9.0") - - -// OkHttp - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") - // Coroutines - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") - - // Testing + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.runtime.livedata) + implementation(libs.androidx.activity.compose) + + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation.fragment.ktx) + implementation(libs.androidx.navigation.ui.ktx) + + implementation(libs.androidx.fragment.ktx) + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + kapt(libs.androidx.room.compiler) + + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) + + implementation(libs.kotlinx.coroutines.android) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit.v115) androidTestImplementation(libs.androidx.espresso.core.v351) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) } \ No newline at end of file diff --git a/apps/android/ChamaApp/app/schemas/com.example.chama.data.AppDatabase/1.json b/apps/android/ChamaApp/app/schemas/com.example.chama.data.AppDatabase/1.json new file mode 100644 index 0000000..500e16e --- /dev/null +++ b/apps/android/ChamaApp/app/schemas/com.example.chama.data.AppDatabase/1.json @@ -0,0 +1,46 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "d0164d487501fc37641a96d77fd86fc4", + "entities": [ + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `email` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd0164d487501fc37641a96d77fd86fc4')" + ] + } +} \ No newline at end of file diff --git a/apps/android/ChamaApp/app/src/main/AndroidManifest.xml b/apps/android/ChamaApp/app/src/main/AndroidManifest.xml index 761c510..b808047 100644 --- a/apps/android/ChamaApp/app/src/main/AndroidManifest.xml +++ b/apps/android/ChamaApp/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + + - + + + + + \ No newline at end of file diff --git a/apps/android/ChamaApp/app/src/main/java/com/example/chama/MainActivity.kt b/apps/android/ChamaApp/app/src/main/java/com/example/chama/MainActivity.kt index 73b63f7..34851a3 100644 --- a/apps/android/ChamaApp/app/src/main/java/com/example/chama/MainActivity.kt +++ b/apps/android/ChamaApp/app/src/main/java/com/example/chama/MainActivity.kt @@ -4,15 +4,38 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.* 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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.example.chama.ui.theme.ChamaAppTheme +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import com.example.chama.network.TokenManager +import com.example.chama.ui.contribution.ContributionScreen +import com.example.chama.ui.theme.ChamaAppTheme + +// ── Brand Colors ────────────────────────────────────────────────────────── +private val Primary = Color(0xFF1A237E) +private val PrimaryLight = Color(0xFF3949AB) +private val BgStart = Color(0xFFE3F2FD) +private val BgEnd = Color(0xFFF5F5F5) +private val TextGray = Color(0xFF888888) + class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -20,29 +43,179 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { ChamaAppTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = "welcome" + ) { + // Screen 1 — Welcome + composable("welcome") { + WelcomeScreen( + onGetStarted = { navController.navigate("contribution") }, + onSignIn = { navController.navigate("contribution") } + ) + } + + // Screen 2 — Contribution + composable("contribution") { + ContributionScreen( + groupId = "1", + groupName = "Kilimani Savings", + onBack = { navController.popBackStack() } + ) + } } } } } } +// ── Welcome Screen ──────────────────────────────────────────────────────── @Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) +fun WelcomeScreen( + onGetStarted : () -> Unit = {}, + onSignIn : () -> Unit = {} +) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient(listOf(BgStart, Color.White, BgEnd)) + ) + ) { + // Top-right decoration circle + Box( + modifier = Modifier + .size(200.dp) + .align(Alignment.TopEnd) + .offset(x = 80.dp, y = (-80).dp) + .clip(CircleShape) + .background(Primary.copy(alpha = 0.15f)) + ) + + // Bottom-left decoration circle + Box( + modifier = Modifier + .size(150.dp) + .align(Alignment.BottomStart) + .offset(x = (-60).dp, y = 60.dp) + .clip(CircleShape) + .background(Primary.copy(alpha = 0.10f)) + ) + + // Main content + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp) + ) { + + // Logo + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(130.dp) + .clip(CircleShape) + .background( + Brush.linearGradient(listOf(Primary, PrimaryLight)) + ) + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "Logo", + tint = Color.White, + modifier = Modifier.size(70.dp) + ) + } + + Spacer(Modifier.height(40.dp)) + + // Subtitle + Text( + text = "Welcome to Chama App", + fontSize = 16.sp, + color = TextGray, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(8.dp)) + + // App name + Text( + text = "Chama App", + fontSize = 36.sp, + fontWeight = FontWeight.Bold, + color = Primary, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(12.dp)) + + // Tagline + Text( + text = "Together We Save, Together We Grow", + fontSize = 14.sp, + color = TextGray, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(60.dp)) + + // Get Started button + Button( + onClick = onGetStarted, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(28.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Primary + ) + ) { + Text( + text = "Get Started", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + + Spacer(Modifier.height(20.dp)) + + // Sign In link + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "Already have an account? ", + fontSize = 14.sp, + color = TextGray + ) + TextButton( + onClick = onSignIn, + contentPadding = PaddingValues(0.dp) + ) { + Text( + text = "Sign In", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Primary + ) + } + } + } + } } +// ── Preview ─────────────────────────────────────────────────────────────── @Preview(showBackground = true) @Composable -fun GreetingPreview() { +fun WelcomePreview() { ChamaAppTheme { - Greeting("Android") + WelcomeScreen() } } \ No newline at end of file diff --git a/apps/android/ChamaApp/app/src/main/java/com/example/chama/data/ContributionRepository.kt b/apps/android/ChamaApp/app/src/main/java/com/example/chama/data/ContributionRepository.kt new file mode 100644 index 0000000..aef98d2 --- /dev/null +++ b/apps/android/ChamaApp/app/src/main/java/com/example/chama/data/ContributionRepository.kt @@ -0,0 +1,33 @@ +package com.example.chama.data + +import com.example.chama.models.ContributionRequest +import com.example.chama.models.ContributionResponse +import com.example.chama.network.ApiClient +import com.example.chama.network.TokenManager + +class ContributionRepository { + + private val apiService = ApiClient.contributionApiService + + suspend fun makeContribution(request: ContributionRequest): Result { + return try { + val token = TokenManager.getToken() ?: "" + val response = apiService.makeContribution( + token = "Bearer $token", + request = request + ) + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + if (body.success) { + Result.success(body) + } else { + Result.failure(Exception(body.message)) + } + } else { + Result.failure(Exception("Server error: ${response.code()}")) + } + } catch (e: Exception) { + Result.failure(Exception(e.message ?: "Network error. Please try again.")) + } + } +} \ No newline at end of file diff --git a/apps/android/ChamaApp/app/src/main/java/com/example/chama/models/ContributionRequest.kt b/apps/android/ChamaApp/app/src/main/java/com/example/chama/models/ContributionRequest.kt new file mode 100644 index 0000000..6cb9cdf --- /dev/null +++ b/apps/android/ChamaApp/app/src/main/java/com/example/chama/models/ContributionRequest.kt @@ -0,0 +1,21 @@ +package com.example.chama.models + + +import com.google.gson.annotations.SerializedName + +data class ContributionRequest( + @SerializedName("group_id") + val groupId: String, + + @SerializedName("amount") + val amount: Double, + + @SerializedName("payment_method") + val paymentMethod: String, // "mpesa" | "airtel" | "paypal" + + @SerializedName("phone_number") + val phoneNumber: String? = null, // M-Pesa / Airtel + + @SerializedName("email") + val email: String? = null // PayPal +) \ No newline at end of file diff --git a/apps/android/ChamaApp/app/src/main/java/com/example/chama/models/ContributionResponse.kt b/apps/android/ChamaApp/app/src/main/java/com/example/chama/models/ContributionResponse.kt new file mode 100644 index 0000000..cf31e5a --- /dev/null +++ b/apps/android/ChamaApp/app/src/main/java/com/example/chama/models/ContributionResponse.kt @@ -0,0 +1,20 @@ +package com.example.chama.models + +import com.google.gson.annotations.SerializedName + +data class ContributionResponse( + @SerializedName("success") + val success: Boolean, + + @SerializedName("message") + val message: String, + + @SerializedName("transaction_reference") + val transactionReference: String?, + + @SerializedName("group_name") + val groupName: String?, + + @SerializedName("amount") + val amount: Double? +) \ No newline at end of file diff --git a/apps/android/ChamaApp/app/src/main/java/com/example/chama/network/ApiClient.kt b/apps/android/ChamaApp/app/src/main/java/com/example/chama/network/ApiClient.kt new file mode 100644 index 0000000..06b6507 --- /dev/null +++ b/apps/android/ChamaApp/app/src/main/java/com/example/chama/network/ApiClient.kt @@ -0,0 +1,33 @@ +package com.example.chama.network + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object ApiClient { + + // TODO: Replace with your actual base URL + private const val BASE_URL = "https://your-api-base-url.com/" + + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + private val retrofit: Retrofit = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val contributionApiService: ContributionApiService = + retrofit.create(ContributionApiService::class.java) +} \ No newline at end of file diff --git a/apps/android/ChamaApp/app/src/main/java/com/example/chama/network/ContributionApiService.kt b/apps/android/ChamaApp/app/src/main/java/com/example/chama/network/ContributionApiService.kt new file mode 100644 index 0000000..0b167d6 --- /dev/null +++ b/apps/android/ChamaApp/app/src/main/java/com/example/chama/network/ContributionApiService.kt @@ -0,0 +1,17 @@ +package com.example.chama.network + +import com.example.chama.models.ContributionRequest +import com.example.chama.models.ContributionResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST + +interface ContributionApiService { + + @POST("api/contributions") + suspend fun makeContribution( + @Header("Authorization") token: String, + @Body request: ContributionRequest + ): Response +} \ No newline at end of file diff --git a/apps/android/ChamaApp/app/src/main/java/com/example/chama/ui/contribution/ContributionFragment.kt b/apps/android/ChamaApp/app/src/main/java/com/example/chama/ui/contribution/ContributionFragment.kt new file mode 100644 index 0000000..c526b17 --- /dev/null +++ b/apps/android/ChamaApp/app/src/main/java/com/example/chama/ui/contribution/ContributionFragment.kt @@ -0,0 +1,542 @@ +package com.example.chama.ui.contribution + +import android.app.AlertDialog +import android.graphics.Color +import android.graphics.Typeface +import android.os.Bundle +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.example.chama.viewmodel.ContributionViewModel +import java.text.NumberFormat +import java.util.Locale + +class ContributionFragment : Fragment() { + + private val viewModel: ContributionViewModel by viewModels() + + // Group info — set these before navigating to this fragment + var groupId: String = "1" + var groupName: String = "Kilimani Savings" + + // Selected payment method + private var selectedMethod = "mpesa" + + // Views we need to reference + private lateinit var etAmount: EditText + private lateinit var etPhone: EditText + private lateinit var etEmail: EditText + private lateinit var tvPhoneLabel: TextView + private lateinit var tvEmailLabel: TextView + private lateinit var tvSummaryAmount: TextView + private lateinit var tvSummaryFee: TextView + private lateinit var tvSummaryTotal: TextView + private lateinit var btnPay: Button + private lateinit var tvHelper: TextView + private lateinit var btnMpesa: LinearLayout + private lateinit var btnAirtel: LinearLayout + private lateinit var btnPaypal: LinearLayout + + // Colors + private val colorPrimary = Color.parseColor("#1A237E") + private val colorLight = Color.parseColor("#E3F2FD") + private val colorDivider = Color.parseColor("#E0E0E0") + private val colorSecondary = Color.parseColor("#666666") + private val colorTertiary = Color.parseColor("#888888") + private val colorSuccess = Color.parseColor("#4CAF50") + private val colorError = Color.parseColor("#F44336") + private val colorWhite = Color.WHITE + + // --------------------------------------------------------------- + // Build UI programmatically (matching your project style) + // --------------------------------------------------------------- + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + + // Root scroll + container + val scroll = ScrollView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(colorLight) + } + + val root = LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + setPadding(48, 48, 48, 48) + } + + scroll.addView(root) + + // ---------- Back button + title row ---------- + val topRow = LinearLayout(requireContext()).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + + val btnBack = Button(requireContext()).apply { + text = "←" + textSize = 18f + setTextColor(colorPrimary) + setBackgroundColor(Color.TRANSPARENT) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + setOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } + + val tvTitle = TextView(requireContext()).apply { + text = "Make Contribution" + textSize = 17f + setTypeface(null, Typeface.BOLD) + setTextColor(colorPrimary) + setPadding(16, 0, 0, 0) + } + + topRow.addView(btnBack) + topRow.addView(tvTitle) + root.addView(topRow) + + // ---------- Group name ---------- + root.addView(TextView(requireContext()).apply { + text = groupName + textSize = 24f + setTypeface(null, Typeface.BOLD) + setTextColor(colorPrimary) + setPadding(0, 24, 0, 4) + }) + + root.addView(TextView(requireContext()).apply { + text = "Monthly Contribution" + textSize = 14f + setTextColor(colorSecondary) + setPadding(0, 0, 0, 24) + }) + + // ---------- Amount field ---------- + root.addView(makeLabel("Amount (KSh)")) + etAmount = makeEditText("Enter amount", InputType.TYPE_CLASS_NUMBER).also { + it.setText("5000") + it.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + updateSummary(s?.toString()?.toDoubleOrNull() ?: 0.0) + } + }) + } + root.addView(etAmount) + + // ---------- Payment method label ---------- + root.addView(makeLabel("Payment Method")) + + // ---------- Payment method row ---------- + val methodRow = LinearLayout(requireContext()).apply { + orientation = LinearLayout.HORIZONTAL + weightSum = 3f + setPadding(0, 8, 0, 8) + } + + btnMpesa = makeMethodButton("M-Pesa", selected = true) + btnAirtel = makeMethodButton("Airtel Money", selected = false) + btnPaypal = makeMethodButton("PayPal", selected = false) + + btnMpesa.setOnClickListener { selectMethod("mpesa") } + btnAirtel.setOnClickListener { selectMethod("airtel") } + btnPaypal.setOnClickListener { selectMethod("paypal") } + + val p = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + .also { it.setMargins(4, 0, 4, 0) } + + methodRow.addView(btnMpesa, p) + methodRow.addView(btnAirtel, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).also { it.setMargins(4, 0, 4, 0) }) + methodRow.addView(btnPaypal, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).also { it.setMargins(4, 0, 4, 0) }) + + root.addView(methodRow) + + // ---------- Phone number field ---------- + tvPhoneLabel = makeLabel("Phone Number (STK Push)") + root.addView(tvPhoneLabel) + etPhone = makeEditText("254 7XX XXX XXX", InputType.TYPE_CLASS_PHONE) + root.addView(etPhone) + + // ---------- Email field (PayPal, hidden by default) ---------- + tvEmailLabel = makeLabel("PayPal Email") + tvEmailLabel.visibility = View.GONE + root.addView(tvEmailLabel) + + etEmail = makeEditText("your@email.com", InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS) + etEmail.visibility = View.GONE + root.addView(etEmail) + + // ---------- Summary card ---------- + root.addView(makeDivider()) + + root.addView(makeLabel("Summary")) + + val summaryCard = LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(colorWhite) + setPadding(32, 24, 32, 24) + } + + tvSummaryAmount = makeSummaryRow(summaryCard, "Contribution Amount", "KSh 5,000") + tvSummaryFee = makeSummaryRow(summaryCard, "Processing Fee", "KSh 0") + summaryCard.addView(makeDivider()) + tvSummaryTotal = makeSummaryRow(summaryCard, "Total", "KSh 5,000", bold = true) + + root.addView(summaryCard) + + // ---------- Pay button ---------- + root.addView(makeDivider()) + + btnPay = Button(requireContext()).apply { + text = "Pay with M-Pesa" + textSize = 15f + setTypeface(null, Typeface.BOLD) + setTextColor(colorWhite) + setBackgroundColor(colorPrimary) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 160 + ).also { it.setMargins(0, 16, 0, 8) } + setOnClickListener { submitContribution() } + } + root.addView(btnPay) + + // ---------- Helper text ---------- + tvHelper = TextView(requireContext()).apply { + text = "You will receive an STK push on your phone" + textSize = 12f + setTextColor(colorTertiary) + gravity = Gravity.CENTER + setPadding(0, 4, 0, 0) + } + root.addView(tvHelper) + + return scroll + } + + // --------------------------------------------------------------- + // onViewCreated — observe ViewModel + // --------------------------------------------------------------- + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.state.observe(viewLifecycleOwner) { state -> + when (state) { + is ContributionViewModel.ContributionState.Idle -> { /* nothing */ } + + is ContributionViewModel.ContributionState.Loading -> { + showLoadingDialog() + } + + is ContributionViewModel.ContributionState.Success -> { + dismissAllDialogs() + showSuccessDialog(state.amount, state.groupName, state.reference) + } + + is ContributionViewModel.ContributionState.Failure -> { + dismissAllDialogs() + showFailureDialog(state.message) + } + } + } + } + + // --------------------------------------------------------------- + // Payment method selection + // --------------------------------------------------------------- + + private fun selectMethod(method: String) { + selectedMethod = method + + // Reset all + listOf(btnMpesa, btnAirtel, btnPaypal).forEach { btn -> + btn.setBackgroundColor(colorWhite) + btn.setPadding(8, 16, 8, 16) + (btn.getChildAt(1) as? TextView)?.setTextColor(colorSecondary) + } + + // Highlight selected + val selectedBtn = when (method) { + "mpesa" -> btnMpesa + "airtel" -> btnAirtel + "paypal" -> btnPaypal + else -> btnMpesa + } + selectedBtn.setBackgroundColor(Color.parseColor("#E8EAF6")) + (selectedBtn.getChildAt(1) as? TextView)?.setTextColor(colorPrimary) + + // Show/hide fields + when (method) { + "mpesa" -> { + tvPhoneLabel.text = "Phone Number (M-Pesa STK Push)" + tvPhoneLabel.visibility = View.VISIBLE + etPhone.visibility = View.VISIBLE + tvEmailLabel.visibility = View.GONE + etEmail.visibility = View.GONE + btnPay.text = "Pay with M-Pesa" + tvHelper.text = "You will receive an STK push on your phone" + } + "airtel" -> { + tvPhoneLabel.text = "Airtel Phone Number" + tvPhoneLabel.visibility = View.VISIBLE + etPhone.visibility = View.VISIBLE + tvEmailLabel.visibility = View.GONE + etEmail.visibility = View.GONE + btnPay.text = "Pay with Airtel Money" + tvHelper.text = "An Airtel Money prompt will be sent to your phone" + } + "paypal" -> { + tvPhoneLabel.visibility = View.GONE + etPhone.visibility = View.GONE + tvEmailLabel.visibility = View.VISIBLE + etEmail.visibility = View.VISIBLE + btnPay.text = "Pay with PayPal" + tvHelper.text = "You will be redirected to PayPal to complete payment" + } + } + + updateSummary(etAmount.text?.toString()?.toDoubleOrNull() ?: 0.0) + } + + // --------------------------------------------------------------- + // Summary calculation + // --------------------------------------------------------------- + + private fun updateSummary(amount: Double) { + val fee = if (selectedMethod == "paypal" && amount > 0) amount * 0.029 + 30.0 else 0.0 + val total = amount + fee + tvSummaryAmount.text = formatKsh(amount) + tvSummaryFee.text = formatKsh(fee) + tvSummaryTotal.text = formatKsh(total) + } + + private fun formatKsh(amount: Double): String { + val fmt = NumberFormat.getNumberInstance(Locale.US) + fmt.maximumFractionDigits = 0 + return "KSh ${fmt.format(amount)}" + } + + // --------------------------------------------------------------- + // Validation + submit + // --------------------------------------------------------------- + + private fun submitContribution() { + val amountText = etAmount.text?.toString()?.trim() + val amount = amountText?.toDoubleOrNull() + + if (amount == null || amount <= 0) { + etAmount.error = "Please enter a valid amount" + return + } + + when (selectedMethod) { + "mpesa", "airtel" -> { + val phone = etPhone.text?.toString()?.trim() + if (phone.isNullOrEmpty()) { + etPhone.error = "Please enter your phone number" + return + } + viewModel.contributeViaMobileMoney( + groupId = groupId, + amount = amount, + phoneNumber = phone, + method = selectedMethod + ) + } + "paypal" -> { + val email = etEmail.text?.toString()?.trim() + if (email.isNullOrEmpty() || + !android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() + ) { + etEmail.error = "Please enter a valid email" + return + } + viewModel.contributeViaPaypal( + groupId = groupId, + amount = amount, + email = email + ) + } + } + } + + // --------------------------------------------------------------- + // Dialogs + // --------------------------------------------------------------- + + private var loadingDialog: AlertDialog? = null + private var resultDialog: AlertDialog? = null + + private fun showLoadingDialog() { + if (loadingDialog?.isShowing == true) return + + val msg = when (selectedMethod) { + "mpesa" -> "Please enter your M-Pesa PIN on your phone." + "airtel" -> "Please enter your Airtel Money PIN on your phone." + "paypal" -> "Connecting to PayPal..." + else -> "Processing payment..." + } + + loadingDialog = AlertDialog.Builder(requireContext()) + .setTitle("Processing Payment") + .setMessage(msg) + .setCancelable(false) + .create() + + loadingDialog?.show() + } + + private fun showSuccessDialog(amount: Double, groupName: String, reference: String?) { + val refText = if (!reference.isNullOrEmpty()) "\nRef: $reference" else "" + resultDialog = AlertDialog.Builder(requireContext()) + .setTitle("✅ Contribution Successful!") + .setMessage("${formatKsh(amount)} has been contributed to $groupName.$refText") + .setCancelable(false) + .setPositiveButton("Done") { dialog, _ -> + dialog.dismiss() + requireActivity().onBackPressedDispatcher.onBackPressed() + } + .create() + + resultDialog?.show() + } + + private fun showFailureDialog(errorMessage: String) { + resultDialog = AlertDialog.Builder(requireContext()) + .setTitle("❌ Payment Failed") + .setMessage(errorMessage) + .setCancelable(false) + .setPositiveButton("Try Again") { dialog, _ -> + dialog.dismiss() + viewModel.resetState() + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + requireActivity().onBackPressedDispatcher.onBackPressed() + } + .create() + + resultDialog?.show() + } + + private fun dismissAllDialogs() { + loadingDialog?.dismiss() + loadingDialog = null + resultDialog?.dismiss() + resultDialog = null + } + + // --------------------------------------------------------------- + // UI helper builders + // --------------------------------------------------------------- + + private fun makeLabel(text: String) = TextView(requireContext()).apply { + this.text = text + textSize = 13f + setTypeface(null, Typeface.BOLD) + setTextColor(colorSecondary) + setPadding(0, 16, 0, 6) + } + + private fun makeEditText(hint: String, inputType: Int) = + EditText(requireContext()).apply { + this.hint = hint + this.inputType = inputType + textSize = 15f + setTextColor(Color.parseColor("#212121")) + setHintTextColor(colorTertiary) + setBackgroundColor(colorWhite) + setPadding(24, 24, 24, 24) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).also { it.setMargins(0, 0, 0, 8) } + } + + private fun makeMethodButton(label: String, selected: Boolean): LinearLayout { + return LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(8, 16, 8, 16) + setBackgroundColor(if (selected) Color.parseColor("#E8EAF6") else colorWhite) + + addView(TextView(requireContext()).apply { + text = when (label) { + "M-Pesa" -> "📱" + "Airtel Money" -> "📶" + "PayPal" -> "💳" + else -> "💰" + } + textSize = 20f + gravity = Gravity.CENTER + }) + + addView(TextView(requireContext()).apply { + text = label + textSize = 11f + gravity = Gravity.CENTER + setTextColor(if (selected) colorPrimary else colorSecondary) + setPadding(0, 4, 0, 0) + }) + } + } + + private fun makeDivider() = View(requireContext()).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 2 + ).also { it.setMargins(0, 16, 0, 16) } + setBackgroundColor(colorDivider) + } + + // Adds a row to a card and returns the value TextView for later updates + private fun makeSummaryRow( + parent: LinearLayout, + label: String, + initialValue: String, + bold: Boolean = false + ): TextView { + val row = LinearLayout(requireContext()).apply { + orientation = LinearLayout.HORIZONTAL + setPadding(0, 8, 0, 8) + } + + row.addView(TextView(requireContext()).apply { + text = label + textSize = 14f + setTextColor(if (bold) colorPrimary else colorSecondary) + if (bold) setTypeface(null, Typeface.BOLD) + layoutParams = LinearLayout.LayoutParams(0, + LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + }) + + val valueView = TextView(requireContext()).apply { + text = initialValue + textSize = 14f + setTextColor(colorPrimary) + if (bold) setTypeface(null, Typeface.BOLD) + } + + row.addView(valueView) + parent.addView(row) + return valueView + } +} \ No newline at end of file diff --git a/apps/android/ChamaApp/app/src/main/java/com/example/chama/ui/contribution/ContributionScreen.kt b/apps/android/ChamaApp/app/src/main/java/com/example/chama/ui/contribution/ContributionScreen.kt new file mode 100644 index 0000000..9e5c46a --- /dev/null +++ b/apps/android/ChamaApp/app/src/main/java/com/example/chama/ui/contribution/ContributionScreen.kt @@ -0,0 +1,624 @@ +package com.example.chama.ui.contribution + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +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.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.chama.viewmodel.ContributionViewModel +import java.text.NumberFormat +import java.util.Locale +import androidx.compose.foundation.BorderStroke + +// ───────────────────────────────────────────── +// Colors matching the ChamaApp design spec +// ───────────────────────────────────────────── +private val Primary = Color(0xFF1A237E) +private val PrimaryLight = Color(0xFF3949AB) +private val BgStart = Color(0xFFE3F2FD) +private val BgEnd = Color(0xFFF5F5F5) +private val TextSecondary = Color(0xFF666666) +private val TextTertiary = Color(0xFF888888) +private val Divider = Color(0xFFE0E0E0) +private val SuccessGreen = Color(0xFF4CAF50) +private val ErrorRed = Color(0xFFF44336) +private val CardBg = Color(0xFFF3F4F9) + +// ───────────────────────────────────────────── +// Entry point composable +// ───────────────────────────────────────────── +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContributionScreen( + groupId: String = "1", + groupName: String = "Kilimani Savings", + onBack: () -> Unit = {}, + viewModel: ContributionViewModel = viewModel() +) { + val state by viewModel.state.observeAsState(ContributionViewModel.ContributionState.Idle) + + // Form state + var amount by remember { mutableStateOf("5000") } + var phoneNumber by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + var selectedMethod by remember { mutableStateOf("mpesa") } + + // Error state + var amountError by remember { mutableStateOf("") } + var phoneError by remember { mutableStateOf("") } + var emailError by remember { mutableStateOf("") } + + // Dialogs + var showLoading by remember { mutableStateOf(false) } + var showSuccess by remember { mutableStateOf(false) } + var showFailure by remember { mutableStateOf(false) } + var failureMsg by remember { mutableStateOf("") } + var successRef by remember { mutableStateOf("") } + + // React to ViewModel state + LaunchedEffect(state) { + when (state) { + is ContributionViewModel.ContributionState.Loading -> { + showLoading = true + showSuccess = false + showFailure = false + } + is ContributionViewModel.ContributionState.Success -> { + val s = state as ContributionViewModel.ContributionState.Success + successRef = s.reference ?: "" + showLoading = false + showSuccess = true + } + is ContributionViewModel.ContributionState.Failure -> { + val f = state as ContributionViewModel.ContributionState.Failure + failureMsg = f.message + showLoading = false + showFailure = true + } + else -> { + showLoading = false + } + } + } + + // ── Dialogs ────────────────────────────────────────────────────────── + if (showLoading) { + LoadingDialog(selectedMethod = selectedMethod) + } + + if (showSuccess) { + SuccessDialog( + amount = amount.toDoubleOrNull() ?: 0.0, + groupName = groupName, + reference = successRef, + onDone = { + showSuccess = false + viewModel.resetState() + onBack() + } + ) + } + + if (showFailure) { + FailureDialog( + message = failureMsg, + onRetry = { + showFailure = false + viewModel.resetState() + }, + onCancel = { + showFailure = false + viewModel.resetState() + onBack() + } + ) + } + + // ── Main screen ────────────────────────────────────────────────────── + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf(BgStart, Color.White, BgEnd) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + + // ── Top bar ────────────────────────────────────────────── + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 24.dp, bottom = 8.dp) + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = Primary + ) + } + Text( + text = "Make Contribution", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = Primary + ) + } + + // ── Group name + subtitle ───────────────────────────── + Text( + text = groupName, + fontSize = 26.sp, + fontWeight = FontWeight.Bold, + color = Primary, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = "Monthly Contribution", + fontSize = 14.sp, + color = TextSecondary, + modifier = Modifier.padding(bottom = 24.dp) + ) + + // ── Form card ───────────────────────────────────────── + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + + // Amount + FieldLabel("Amount (KSh)") + OutlinedTextField( + value = amount, + onValueChange = { + amount = it + amountError = "" + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Enter amount") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = amountError.isNotEmpty(), + supportingText = if (amountError.isNotEmpty()) { + { Text(amountError, color = ErrorRed) } + } else null, + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Primary, + unfocusedBorderColor = Divider + ) + ) + + Spacer(Modifier.height(20.dp)) + + // Payment method + FieldLabel("Payment Method") + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PaymentMethodButton( + emoji = "📱", + label = "M-Pesa", + selected = selectedMethod == "mpesa", + modifier = Modifier.weight(1f), + onClick = { selectedMethod = "mpesa" } + ) + PaymentMethodButton( + emoji = "📶", + label = "Airtel", + selected = selectedMethod == "airtel", + modifier = Modifier.weight(1f), + onClick = { selectedMethod = "airtel" } + ) + PaymentMethodButton( + emoji = "💳", + label = "PayPal", + selected = selectedMethod == "paypal", + modifier = Modifier.weight(1f), + onClick = { selectedMethod = "paypal" } + ) + } + + Spacer(Modifier.height(20.dp)) + + // Phone field (M-Pesa / Airtel) + if (selectedMethod == "mpesa" || selectedMethod == "airtel") { + val phoneLabel = if (selectedMethod == "mpesa") + "Phone Number (STK Push)" else "Airtel Phone Number" + + FieldLabel(phoneLabel) + OutlinedTextField( + value = phoneNumber, + onValueChange = { + phoneNumber = it + phoneError = "" + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("254 7XX XXX XXX") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + isError = phoneError.isNotEmpty(), + supportingText = if (phoneError.isNotEmpty()) { + { Text(phoneError, color = ErrorRed) } + } else null, + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Primary, + unfocusedBorderColor = Divider + ) + ) + } + + // Email field (PayPal) + if (selectedMethod == "paypal") { + FieldLabel("PayPal Email") + OutlinedTextField( + value = email, + onValueChange = { + email = it + emailError = "" + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("your@email.com") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + isError = emailError.isNotEmpty(), + supportingText = if (emailError.isNotEmpty()) { + { Text(emailError, color = ErrorRed) } + } else null, + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Primary, + unfocusedBorderColor = Divider + ) + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + // ── Summary card ────────────────────────────────────── + val amountValue = amount.toDoubleOrNull() ?: 0.0 + val fee = if (selectedMethod == "paypal" && amountValue > 0) amountValue * 0.029 + 30.0 else 0.0 + val total = amountValue + fee + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = CardBg), + elevation = CardDefaults.cardElevation(0.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + SummaryRow("Contribution Amount", formatKsh(amountValue)) + SummaryRow("Processing Fee", formatKsh(fee)) + Divider( + color = Divider, + modifier = Modifier.padding(vertical = 10.dp) + ) + SummaryRow("Total", formatKsh(total), bold = true) + } + } + + Spacer(Modifier.height(24.dp)) + + // ── Pay button ──────────────────────────────────────── + val btnLabel = when (selectedMethod) { + "mpesa" -> "Pay with M-Pesa" + "airtel" -> "Pay with Airtel Money" + "paypal" -> "Pay with PayPal" + else -> "Pay Now" + } + + Button( + onClick = { + // Validate + var valid = true + if (amountValue <= 0) { + amountError = "Please enter a valid amount" + valid = false + } + if ((selectedMethod == "mpesa" || selectedMethod == "airtel") && phoneNumber.isBlank()) { + phoneError = "Please enter your phone number" + valid = false + } + if (selectedMethod == "paypal" && + !android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() + ) { + emailError = "Please enter a valid email" + valid = false + } + if (!valid) return@Button + + // Submit + when (selectedMethod) { + "mpesa", "airtel" -> viewModel.contributeViaMobileMoney( + groupId = groupId, + amount = amountValue, + phoneNumber = phoneNumber, + method = selectedMethod + ) + "paypal" -> viewModel.contributeViaPaypal( + groupId = groupId, + amount = amountValue, + email = email + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = Primary) + ) { + Text( + text = btnLabel, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + + // Helper text + val helperText = when (selectedMethod) { + "mpesa" -> "You will receive an STK push on your phone" + "airtel" -> "An Airtel Money prompt will be sent to your phone" + "paypal" -> "You will be redirected to PayPal to complete payment" + else -> "" + } + + Text( + text = helperText, + fontSize = 12.sp, + color = TextTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 32.dp) + ) + } + } +} + +// ───────────────────────────────────────────── +// Reusable small composables +// ───────────────────────────────────────────── + +@Composable +private fun FieldLabel(text: String) { + Text( + text = text, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = TextSecondary, + modifier = Modifier.padding(bottom = 6.dp) + ) +} + +@Composable +private fun PaymentMethodButton( + emoji: String, + label: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + val bgColor = if (selected) Color(0xFFE8EAF6) else Color.White + val borderColor = if (selected) Primary else Divider + val textColor = if (selected) Primary else TextSecondary + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(bgColor) + .border( + width = if (selected) 2.dp else 1.dp, + color = borderColor, + shape = RoundedCornerShape(12.dp) + ) + .clickable { onClick() } + .padding(vertical = 14.dp, horizontal = 8.dp) + ) { + Text(text = emoji, fontSize = 20.sp) + Spacer(Modifier.height(4.dp)) + Text( + text = label, + fontSize = 11.sp, + color = textColor, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun SummaryRow(label: String, value: String, bold: Boolean = false) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Text( + text = label, + fontSize = 14.sp, + color = if (bold) Primary else TextSecondary, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier.weight(1f) + ) + Text( + text = value, + fontSize = 14.sp, + color = Primary, + fontWeight = if (bold) FontWeight.Bold else FontWeight.SemiBold + ) + } +} + +// ───────────────────────────────────────────── +// Dialog composables +// ───────────────────────────────────────────── + +@Composable +private fun LoadingDialog(selectedMethod: String) { + val message = when (selectedMethod) { + "mpesa" -> "Please enter your M-Pesa PIN on your phone." + "airtel" -> "Please enter your Airtel Money PIN on your phone." + else -> "Connecting to PayPal..." + } + + Dialog(onDismissRequest = {}) { + Card( + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + CircularProgressIndicator(color = Primary, modifier = Modifier.size(52.dp)) + Spacer(Modifier.height(20.dp)) + Text("Processing Payment", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Primary) + Spacer(Modifier.height(8.dp)) + Text(message, fontSize = 13.sp, color = TextSecondary, textAlign = TextAlign.Center) + } + } + } +} + +@Composable +private fun SuccessDialog( + amount: Double, + groupName: String, + reference: String, + onDone: () -> Unit +) { + Dialog(onDismissRequest = {}) { + Card( + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(36.dp)) + .background(SuccessGreen) + ) { + Text("✓", fontSize = 32.sp, color = Color.White, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(20.dp)) + Text("Contribution Successful!", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Primary) + Spacer(Modifier.height(8.dp)) + Text( + text = "${formatKsh(amount)} has been contributed to $groupName", + fontSize = 14.sp, + color = TextSecondary, + textAlign = TextAlign.Center + ) + if (reference.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text("Ref: $reference", fontSize = 12.sp, color = TextTertiary) + } + Spacer(Modifier.height(24.dp)) + Button( + onClick = onDone, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = Primary) + ) { + Text("Done", fontWeight = FontWeight.Bold, color = Color.White) + } + } + } + } +} + +@Composable +private fun FailureDialog( + message: String, + onRetry: () -> Unit, + onCancel: () -> Unit +) { + Dialog(onDismissRequest = {}) { + Card( + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(36.dp)) + .background(ErrorRed) + ) { + Text("✕", fontSize = 32.sp, color = Color.White, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(20.dp)) + Text("Payment Failed", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = ErrorRed) + Spacer(Modifier.height(8.dp)) + Text(message, fontSize = 14.sp, color = TextSecondary, textAlign = TextAlign.Center) + Spacer(Modifier.height(24.dp)) + Button( + onClick = onRetry, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = Primary) + ) { + Text("Try Again", fontWeight = FontWeight.Bold, color = Color.White) + } + Spacer(Modifier.height(10.dp)) + OutlinedButton( + onClick = onCancel, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.5.dp, Primary) + ) { + Text("Cancel", fontWeight = FontWeight.SemiBold, color = Primary) + } + } + } + } +} + +// ───────────────────────────────────────────── +// Utility +// ───────────────────────────────────────────── +private fun formatKsh(amount: Double): String { + val fmt = NumberFormat.getNumberInstance(Locale.US) + fmt.maximumFractionDigits = 0 + return "KSh ${fmt.format(amount)}" +} \ No newline at end of file diff --git a/apps/android/ChamaApp/app/src/main/java/com/example/chama/ui/viewmodel/ContributionViewModel.kt b/apps/android/ChamaApp/app/src/main/java/com/example/chama/ui/viewmodel/ContributionViewModel.kt new file mode 100644 index 0000000..3bfc856 --- /dev/null +++ b/apps/android/ChamaApp/app/src/main/java/com/example/chama/ui/viewmodel/ContributionViewModel.kt @@ -0,0 +1,98 @@ +package com.example.chama.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.chama.data.ContributionRepository +import com.example.chama.models.ContributionRequest +import kotlinx.coroutines.launch + +class ContributionViewModel : ViewModel() { + + private val repository = ContributionRepository() + + // ── UI State ────────────────────────────────────────────────────────── + sealed class ContributionState { + object Idle : ContributionState() + object Loading : ContributionState() + data class Success( + val amount : Double, + val groupName : String, + val reference : String? + ) : ContributionState() + data class Failure(val message: String) : ContributionState() + } + + private val _state = MutableLiveData(ContributionState.Idle) + val state: LiveData = _state + + // ── Actions ─────────────────────────────────────────────────────────── + + fun contributeViaMobileMoney( + groupId : String, + amount : Double, + phoneNumber : String, + method : String // "mpesa" or "airtel" + ) { + if (_state.value is ContributionState.Loading) return + viewModelScope.launch { + _state.value = ContributionState.Loading + repository.makeContribution( + ContributionRequest( + groupId = groupId, + amount = amount, + paymentMethod = method, + phoneNumber = phoneNumber + ) + ) + .onSuccess { response -> + _state.value = ContributionState.Success( + amount = amount, + groupName = response.groupName ?: "your group", + reference = response.transactionReference + ) + } + .onFailure { error -> + _state.value = ContributionState.Failure( + message = error.message ?: "Payment failed. Please try again." + ) + } + } + } + + fun contributeViaPaypal( + groupId : String, + amount : Double, + email : String + ) { + if (_state.value is ContributionState.Loading) return + viewModelScope.launch { + _state.value = ContributionState.Loading + repository.makeContribution( + ContributionRequest( + groupId = groupId, + amount = amount, + paymentMethod = "paypal", + email = email + ) + ) + .onSuccess { response -> + _state.value = ContributionState.Success( + amount = amount, + groupName = response.groupName ?: "your group", + reference = response.transactionReference + ) + } + .onFailure { error -> + _state.value = ContributionState.Failure( + message = error.message ?: "PayPal payment failed. Please try again." + ) + } + } + } + + fun resetState() { + _state.value = ContributionState.Idle + } +} \ No newline at end of file diff --git a/apps/android/ChamaApp/app/src/main/java/com/example/chama/viewmodel/UserViewModel.kt b/apps/android/ChamaApp/app/src/main/java/com/example/chama/viewmodel/UserViewModel.kt deleted file mode 100644 index 7dc3080..0000000 --- a/apps/android/ChamaApp/app/src/main/java/com/example/chama/viewmodel/UserViewModel.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.chama.viewmodel - -import android.app.Application -import androidx.lifecycle.* -import com.example.chama.data.* -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch - -class UserViewModel(application: Application) : AndroidViewModel(application) { - - private val repository: UserRepository - val allUsers: LiveData> - - init { - val dao = AppDatabase.getDatabase(application).userDao() - repository = UserRepository(dao) - allUsers = repository.allUsers - } - - fun insert(user: User) = viewModelScope.launch { - repository.insert(user) - } - - fun update(user: User) = viewModelScope.launch { - repository.update(user) - } - - fun delete(user: User) = viewModelScope.launch { - repository.delete(user) - } -} diff --git a/apps/android/ChamaApp/gradle/libs.versions.toml b/apps/android/ChamaApp/gradle/libs.versions.toml index 2a0f572..e227b4d 100644 --- a/apps/android/ChamaApp/gradle/libs.versions.toml +++ b/apps/android/ChamaApp/gradle/libs.versions.toml @@ -1,24 +1,63 @@ [versions] agp = "8.10.1" -espressoCoreVersion = "3.5.1" -androidxJunit = "1.1.5" kotlin = "2.0.21" + +# Core coreKtx = "1.17.0" +appcompat = "1.7.0" + +# Testing junit = "4.13.2" +androidxJunit = "1.1.5" +espressoCoreVersion = "3.5.1" junitVersion = "1.3.0" espressoCore = "3.7.0" -lifecycleRuntimeKtx = "2.6.1" -activityCompose = "1.12.3" + +# Lifecycle +lifecycleRuntimeKtx = "2.8.3" +lifecycleViewmodelKtx = "2.8.3" +lifecycleViewmodelCompose = "2.8.3" + +# Compose +activityCompose = "1.9.1" composeBom = "2024.09.00" +# Navigation +navigationCompose = "2.7.7" +navigationFragmentKtx = "2.7.7" +navigationUiKtx = "2.7.7" + +# Fragment +fragmentKtx = "1.8.2" + +# Room +room = "2.6.1" + +# Retrofit + OkHttp +retrofit = "2.9.0" +okhttp = "4.12.0" + +# Coroutines +coroutines = "1.8.1" + [libraries] +# Core androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -androidx-junit-v115 = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" } -androidx-espresso-core-v351 = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCoreVersion" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } + +# Testing junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-junit-v115 = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-espresso-core-v351 = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCoreVersion" } + +# Lifecycle androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } + +# Compose androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } @@ -28,10 +67,34 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } + +# Navigation +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtx" } +androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" } + +# Fragment +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" } + +# Room +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } + +# Retrofit +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } + +# OkHttp +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } + +# Coroutines +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } - - +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file