Skip to main content

Android App Bootstrap (Kotlin + Jetpack Compose)

Native Android buyer app for Talbino marketplace.

Tech Stack

  • Language: Kotlin 1.9+
  • UI: Jetpack Compose
  • Min SDK: 24 (Android 7.0)
  • Target SDK: 34
  • Architecture: MVVM + Clean Architecture
  • DI: Hilt
  • Networking: Retrofit + OkHttp
  • Real-time: OkHttp WebSocket
  • Storage: Room + DataStore
  • Image Loading: Coil
  • Navigation: Compose Navigation
  • Push: Firebase Cloud Messaging
  • Analytics: Firebase Analytics

Project Structure

app/src/main/java/com/talbino/
├── TalbinoApplication.kt
├── MainActivity.kt
├── ui/
│ ├── theme/
│ │ ├── Color.kt
│ │ ├── Theme.kt
│ │ └── Type.kt
│ ├── auth/
│ │ ├── PhoneInputScreen.kt
│ │ ├── OTPVerificationScreen.kt
│ │ └── AuthViewModel.kt
│ ├── requests/
│ │ ├── RequestsListScreen.kt
│ │ ├── RequestDetailScreen.kt
│ │ ├── CreateRequestScreen.kt
│ │ ├── RequestsViewModel.kt
│ │ └── components/
│ │ └── RequestCard.kt
│ ├── offers/
│ │ ├── OffersListScreen.kt
│ │ ├── OfferDetailScreen.kt
│ │ ├── OffersViewModel.kt
│ │ └── components/
│ │ └── OfferCard.kt
│ ├── chat/
│ │ ├── ConversationsScreen.kt
│ │ ├── ChatScreen.kt
│ │ ├── ChatViewModel.kt
│ │ └── components/
│ │ └── MessageBubble.kt
│ ├── deals/
│ │ ├── DealsListScreen.kt
│ │ ├── DealDetailScreen.kt
│ │ ├── RatingScreen.kt
│ │ └── DealsViewModel.kt
│ └── profile/
│ ├── ProfileScreen.kt
│ ├── SettingsScreen.kt
│ └── ProfileViewModel.kt
├── data/
│ ├── models/
│ │ ├── User.kt
│ │ ├── BuyRequest.kt
│ │ ├── Offer.kt
│ │ └── Deal.kt
│ ├── repository/
│ │ ├── AuthRepository.kt
│ │ ├── RequestsRepository.kt
│ │ ├── OffersRepository.kt
│ │ └── ChatRepository.kt
│ ├── api/
│ │ ├── ApiService.kt
│ │ ├── WebSocketService.kt
│ │ └── interceptors/
│ │ └── AuthInterceptor.kt
│ ├── local/
│ │ ├── AppDatabase.kt
│ │ └── dao/
│ │ └── MessageDao.kt
│ └── preferences/
│ └── UserPreferences.kt
├── di/
│ ├── AppModule.kt
│ ├── NetworkModule.kt
│ └── DatabaseModule.kt
└── utils/
├── Constants.kt
├── Extensions.kt
└── DeepLinkHandler.kt
@Composable
fun TalbinoNavHost(navController: NavHostController) {
NavHost(navController, startDestination = "requests") {
composable("requests") { RequestsListScreen() }
composable("requests/{id}") { RequestDetailScreen() }
composable("create_request") { CreateRequestScreen() }
composable("offers/{requestId}") { OffersListScreen() }
composable("deals") { DealsListScreen() }
composable("chat") { ConversationsScreen() }
composable("chat/{conversationId}") { ChatScreen() }
composable("profile") { ProfileScreen() }
}
}

Key ViewModels

@HiltViewModel
class RequestsViewModel @Inject constructor(
private val repository: RequestsRepository
) : ViewModel() {

private val _requests = MutableStateFlow<List<BuyRequest>>(emptyList())
val requests: StateFlow<List<BuyRequest>> = _requests.asStateFlow()

private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

fun fetchRequests() {
viewModelScope.launch {
_isLoading.value = true
try {
_requests.value = repository.getMyRequests()
} catch (e: Exception) {
// Handle error
} finally {
_isLoading.value = false
}
}
}
}

API Service

interface ApiService {
@POST("auth/request-otp")
suspend fun requestOTP(@Body request: OTPRequest): Response<OTPResponse>

@POST("auth/verify-otp")
suspend fun verifyOTP(@Body request: VerifyOTPRequest): Response<AuthResponse>

@GET("requests/my")
suspend fun getMyRequests(): Response<RequestsResponse>

@POST("requests")
suspend fun createRequest(@Body request: CreateRequestInput): Response<BuyRequest>

@GET("requests/{id}/offers")
suspend fun getOffers(@Path("id") requestId: String): Response<OffersResponse>

@POST("offers/{id}/accept")
suspend fun acceptOffer(@Path("id") offerId: String): Response<DealResponse>
}

Key Composables

CreateRequestScreen

@Composable
fun CreateRequestScreen(
viewModel: CreateRequestViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
val state by viewModel.state.collectAsState()

Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.create_request)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, null)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Category
ExposedDropdownMenuBox(
expanded = state.categoryExpanded,
onExpandedChange = { viewModel.toggleCategoryDropdown() }
) {
OutlinedTextField(
value = state.category,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.category)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = state.categoryExpanded) },
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = state.categoryExpanded,
onDismissRequest = { viewModel.toggleCategoryDropdown() }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.phone)) },
onClick = { viewModel.selectCategory("phone") }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.accessory)) },
onClick = { viewModel.selectCategory("accessory") }
)
}
}

Spacer(modifier = Modifier.height(16.dp))

// Brand
OutlinedTextField(
value = state.brand,
onValueChange = { viewModel.updateBrand(it) },
label = { Text(stringResource(R.string.brand)) },
modifier = Modifier.fillMaxWidth()
)

Spacer(modifier = Modifier.height(16.dp))

// Budget
Row(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = state.budgetMin,
onValueChange = { viewModel.updateBudgetMin(it) },
label = { Text(stringResource(R.string.min_budget)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
OutlinedTextField(
value = state.budgetMax,
onValueChange = { viewModel.updateBudgetMax(it) },
label = { Text(stringResource(R.string.max_budget)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
}

Spacer(modifier = Modifier.height(24.dp))

// Submit Button
Button(
onClick = { viewModel.submit() },
enabled = state.isValid && !state.isLoading,
modifier = Modifier.fillMaxWidth()
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(stringResource(R.string.post_request))
}
}
}
}
}

OfferCard

@Composable
fun OfferCard(
offer: Offer,
onChatClick: () -> Unit,
onAcceptClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = offer.seller.name,
style = MaterialTheme.typography.titleMedium
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Star,
contentDescription = null,
tint = Color(0xFFFFC107),
modifier = Modifier.size(16.dp)
)
Text(
text = "%.1f (%d)".format(offer.seller.ratingAvg, offer.seller.ratingCount),
style = MaterialTheme.typography.bodySmall
)
}
}

Column(horizontalAlignment = Alignment.End) {
Text(
text = "${offer.price} جنيه",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = offer.availability,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

if (offer.conditionNotes.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = offer.conditionNotes,
style = MaterialTheme.typography.bodyMedium
)
}

Spacer(modifier = Modifier.height(12.dp))

Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onChatClick,
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.chat))
}
Button(
onClick = onAcceptClick,
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.accept_offer))
}
}
}
}
}

Dependency Injection

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}

@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}

@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
}

Localization

strings.xml (ar)

<resources>
<string name="app_name">طلبينو</string>
<string name="requests_title">طلباتي</string>
<string name="create_request">طلب جديد</string>
<string name="category">الفئة</string>
<string name="phone">هاتف</string>
<string name="accessory">إكسسوار</string>
<string name="brand">الماركة</string>
<string name="min_budget">الحد الأدنى</string>
<string name="max_budget">الحد الأقصى</string>
<string name="post_request">نشر الطلب</string>
<string name="offers_count">%d عروض</string>
<string name="chat">محادثة</string>
<string name="accept_offer">قبول العرض</string>
</resources>

Push Notifications

class TalbinoFirebaseMessagingService : FirebaseMessagingService() {

override fun onNewToken(token: String) {
super.onNewToken(token)
// Send token to server
CoroutineScope(Dispatchers.IO).launch {
try {
apiService.registerDeviceToken(
DeviceTokenRequest(platform = "android", pushToken = token)
)
} catch (e: Exception) {
Log.e("FCM", "Failed to register token", e)
}
}
}

override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)

val deepLink = message.data["deep_link"]
showNotification(message.notification, deepLink)
}
}

Build Configuration

build.gradle.kts (app)

android {
namespace = "com.talbino"
compileSdk = 34

defaultConfig {
applicationId = "com.talbino"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
}

buildTypes {
debug {
buildConfigField("String", "API_BASE_URL", "\"https://api-dev.talbino.com/api/v1\"")
}
release {
buildConfigField("String", "API_BASE_URL", "\"https://api.talbino.com/api/v1\"")
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}

buildFeatures {
compose = true
buildConfig = true
}

composeOptions {
kotlinCompilerExtensionVersion = "1.5.3"
}
}

dependencies {
// Compose
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")

// Hilt
implementation("com.google.dagger:hilt-android:2.48")
kapt("com.google.dagger:hilt-compiler:2.48")

// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")

// Coil
implementation("io.coil-kt:coil-compose:2.5.0")

// Firebase
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
}

MVP Screens Checklist

  • Phone input + OTP verification
  • Profile setup
  • Requests list
  • Create request
  • Request detail with offers
  • Offer detail
  • Chat
  • Deals list
  • Deal detail
  • Complete deal + rating
  • Profile & settings
  • Become seller application