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
Navigation
@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