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