By Xavier Collantes
The Android app was delayed release to the Play Store to prioritize building the ML and Image Generation workflows.
1app/src/main/kotlin/com/faxion/android/
2├── core/ # Core utilities and constants
3├── data/ # Data layer (models, API, repositories)
4├── domain/ # Business logic layer (use cases, repositories)
5├── presentation/ # UI layer (screens, ViewModels)
6└── di/ # Dependency injection modules
7
build.gradle.kts
uses modern Android tooling with version catalogs:app/build.gradle.kts
kotlin1plugins {
2 alias(libs.plugins.android.application)
3 alias(libs.plugins.kotlin.android)
4 alias(libs.plugins.kotlin.compose)
5 alias(libs.plugins.kotlin.serialization)
6 alias(libs.plugins.kotlin.parcelize)
7 alias(libs.plugins.hilt)
8 alias(libs.plugins.ksp)
9 alias(libs.plugins.google.services)
10}
11
12android {
13 namespace = "com.faxion.android"
14 compileSdk = 35
15
16 defaultConfig {
17 applicationId = "com.faxion.android"
18 minSdk = 26 // Modern baseline for better API support
19 targetSdk = 35
20 versionCode = 1
21 versionName = "1.0"
22 }
23
24 buildFeatures {
25 compose = true
26 buildConfig = true
27 }
28
29 compileOptions {
30 sourceCompatibility = JavaVersion.VERSION_11
31 targetCompatibility = JavaVersion.VERSION_11
32 }
33}
34
35dependencies {
36 // Modern Compose stack with Material3
37 implementation(platform(libs.androidx.compose.bom))
38 implementation(libs.androidx.material3)
39 implementation(libs.androidx.compose.animation)
40
41 // Architecture components
42 implementation(libs.hilt.android)
43 implementation(libs.androidx.lifecycle.viewmodel.compose)
44 implementation(libs.androidx.navigation.compose)
45
46 // Networking with Kotlin Serialization (faster than Gson)
47 implementation(libs.retrofit)
48 implementation(libs.retrofit.kotlinx.serialization)
49 implementation(libs.kotlinx.serialization.json)
50
51 // Firebase stack for real-time features
52 implementation(platform(libs.firebase.bom))
53 implementation(libs.firebase.auth)
54 implementation(libs.firebase.firestore)
55 implementation(libs.firebase.storage)
56
57 // Additional modern libraries
58 implementation(libs.coil) // Async image loading
59 implementation(libs.androidx.camera.core) // Camera integration
60 implementation(libs.revenuecat) // Subscription management
61}
62
Kotlin Serialization is a faster JSON parsing than Gson because Gson has to recognize the fields at runtime, while Kotlin Serialization does it at compile-time. This is called 'reflection'.
Kotlin Serialization handles nulls more predictably than Gson which reduced the risk of null pointer exceptions which can crash the app.
data/model/Profile.kt
kotlin1@Serializable
2data class Profile(
3 val userId: String? = null,
4 val firstName: String? = null,
5 val lastName: String? = null,
6 val birthDate: String? = null, // ISO date string
7 val gender: Gender? = null,
8 val unit: Unit = Unit.METRIC,
9 val faceImagePath: String? = null,
10 val fullBodyImagePath: String? = null,
11 val heightCm: Double? = null,
12 val weightKg: Double? = null,
13 val bodyShape: BodyShape? = null,
14 val stylePreferences: StylePreferences? = null,
15 val subscription: String? = null,
16 val termsAccepted: Boolean = false
17)
18
19@Serializable
20data class StylePreferences(
21 val preferredColors: List<String> = emptyList(),
22 val favoriteStyles: List<String> = emptyList(),
23 val dislikedStyles: List<String> = emptyList(),
24 val occasionPreferences: List<String> = emptyList()
25)
26
27@Serializable
28enum class BodyShape {
29 HOURGLASS, RECTANGLE, TRIANGLE, INVERTED_TRIANGLE, ROUND, OVAL
30}
31
@Serializable
annotation for better
performancedata/remote/api/FaxionApi.kt
kotlin1interface FaxionApi {
2
3 // Authentication endpoints
4 @POST(Constants.Api.Endpoints.INIT_USER)
5 suspend fun initUser(): Response<User>
6
7 // Profile management
8 @GET(Constants.Api.Endpoints.PROFILE)
9 suspend fun getProfile(): Response<Profile>
10
11 @PUT(Constants.Api.Endpoints.PROFILE)
12 suspend fun updateProfile(@Body profile: Profile): Response<Profile>
13
14 // Outfit CRUD operations
15 @GET(Constants.Api.Endpoints.OUTFITS)
16 suspend fun getOutfits(): Response<List<Outfit>>
17
18 @POST(Constants.Api.Endpoints.SINGLE_OUTFIT)
19 suspend fun createOutfit(@Body outfit: Outfit): Response<Outfit>
20
21 @PUT("${Constants.Api.Endpoints.SINGLE_OUTFIT}/{outfitId}")
22 suspend fun updateOutfit(
23 @Path("outfitId") outfitId: String,
24 @Body outfit: Outfit
25 ): Response<Outfit>
26
27 // Social interactions
28 @POST("${Constants.Api.Endpoints.SET_OUTFIT_LIKE}/{outfitId}")
29 suspend fun likeOutfit(@Path("outfitId") outfitId: String): Response<Unit>
30
31 @POST("${Constants.Api.Endpoints.SET_OUTFIT_FAVORITE}/{outfitId}")
32 suspend fun favoriteOutfit(@Path("outfitId") outfitId: String): Response<Unit>
33
34 @POST("${Constants.Api.Endpoints.SET_SHARED_OUTFIT}/{outfitId}")
35 suspend fun shareOutfit(@Path("outfitId") outfitId: String): Response<Unit>
36
37 // AI recommendations
38 @POST(Constants.Api.Endpoints.RECOMMENDATIONS)
39 suspend fun getRecommendations(@Body request: Map<String, Any>): Response<List<Outfit>>
40}
41
core/Constants.kt
kotlin1object Constants {
2 object Api {
3 const val BASE_URL = "https://api.faxion.ai/"
4
5 object Endpoints {
6 // Outfit management
7 const val OUTFITS = "v1/outfits/outfits"
8 const val SINGLE_OUTFIT = "v1/outfits/outfit"
9 const val SET_SHARED_OUTFIT = "v1/outfits/share"
10 const val SET_OUTFIT_LIKE = "v1/outfits/liked"
11 const val SET_OUTFIT_FAVORITE = "v1/outfits/favorite"
12
13 // Profile management
14 const val PROFILE = "v1/user/profile"
15 const val INIT_USER = "v1/firebase/init-user"
16
17 // AI recommendations
18 const val RECOMMENDATIONS = "v1/gen/generate"
19
20 // Subscription management
21 const val AVAILABLE_PLANS = "v1/subscriptions/available"
22 const val PURCHASE_LINKS = "v1/subscriptions/rc-purchase-links"
23 }
24 }
25}
26
di/NetworkModule.kt
kotlin1@Module
2@InstallIn(SingletonComponent::class)
3object NetworkModule {
4
5 @Provides
6 @Singleton
7 fun provideJson(): Json = Json {
8 ignoreUnknownKeys = true // Graceful API evolution
9 coerceInputValues = true // Handle type mismatches
10 isLenient = true // Flexible parsing
11 }
12
13 @Provides
14 @Singleton
15 fun provideOkHttpClient(): OkHttpClient {
16 return OkHttpClient.Builder()
17 .connectTimeout(30, TimeUnit.SECONDS)
18 .readTimeout(30, TimeUnit.SECONDS)
19 .writeTimeout(30, TimeUnit.SECONDS)
20 .apply {
21 if (BuildConfig.DEBUG) {
22 addInterceptor(
23 HttpLoggingInterceptor().apply {
24 level = HttpLoggingInterceptor.Level.BODY
25 }
26 )
27 }
28 }
29 .build()
30 }
31
32 @Provides
33 @Singleton
34 fun provideRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit {
35 return Retrofit.Builder()
36 .baseUrl(Constants.Api.BASE_URL)
37 .client(okHttpClient)
38 .addConverterFactory(
39 json.asConverterFactory("application/json".toMediaType())
40 )
41 .build()
42 }
43}
44
presentation/screens/auth/AuthViewModel.kt
kotlin1@HiltViewModel
2class AuthViewModel @Inject constructor(
3 private val authRepository: AuthRepository,
4 private val signInWithEmailUseCase: SignInWithEmailUseCase,
5 private val signUpWithEmailUseCase: SignUpWithEmailUseCase,
6 private val resetPasswordUseCase: ResetPasswordUseCase
7) : ViewModel() {
8
9 private val _uiState = MutableStateFlow(AuthUiState())
10 val uiState = _uiState.asStateFlow()
11
12 // Real-time auth state from Firebase
13 val currentUser = authRepository.getCurrentUser()
14 .stateIn(
15 scope = viewModelScope,
16 started = SharingStarted.WhileSubscribed(5000),
17 initialValue = null
18 )
19
20 fun signInWithEmail(email: String, password: String) {
21 viewModelScope.launch {
22 _uiState.value = _uiState.value.copy(isLoading = true, error = null)
23
24 when (val result = signInWithEmailUseCase(email, password)) {
25 is Resource.Success -> {
26 _uiState.value = _uiState.value.copy(
27 isLoading = false,
28 isSignedIn = true,
29 error = null
30 )
31 }
32 is Resource.Error -> {
33 _uiState.value = _uiState.value.copy(
34 isLoading = false,
35 error = result.message
36 )
37 }
38 is Resource.Loading -> {
39 _uiState.value = _uiState.value.copy(isLoading = true)
40 }
41 }
42 }
43 }
44
45 // Additional auth methods...
46 fun togglePasswordVisibility() {
47 _uiState.value = _uiState.value.copy(
48 isPasswordVisible = !_uiState.value.isPasswordVisible
49 )
50 }
51
52 fun clearError() {
53 _uiState.value = _uiState.value.copy(error = null)
54 }
55}
56
57data class AuthUiState(
58 val isLoading: Boolean = false,
59 val isSignedIn: Boolean = false,
60 val isSignInMode: Boolean = true,
61 val email: String = "",
62 val password: String = "",
63 val isPasswordVisible: Boolean = false,
64 val error: String? = null,
65 val resetPasswordEmailSent: Boolean = false
66)
67
Material 3 is an implementation of Google's Material Design. Other implementations I have used include MaterialUI for React.
presentation/screens/home/HomeScreen.kt
kotlin1@OptIn(ExperimentalMaterial3Api::class)
2@Composable
3fun HomeScreen(
4 onNavigateToUpload: () -> Unit,
5 onNavigateToWardrobe: () -> Unit,
6 onNavigateToProfile: () -> Unit,
7 onNavigateToSettings: () -> Unit,
8 viewModel: HomeViewModel = hiltViewModel()
9) {
10 val uiState by viewModel.uiState.collectAsStateWithLifecycle()
11 val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
12
13 Column(modifier = Modifier.fillMaxSize()) {
14 TopAppBar(
15 title = {
16 Text(
17 text = currentUser?.displayName?.let { "Welcome, $it" }
18 ?: currentUser?.email?.substringBefore("@")?.let { "Welcome, $it" }
19 ?: "Faxion"
20 )
21 },
22 actions = {
23 IconButton(onClick = viewModel::refreshData) {
24 Icon(Icons.Default.Refresh, contentDescription = "Refresh")
25 }
26 IconButton(onClick = onNavigateToProfile) {
27 Icon(Icons.Default.Person, contentDescription = "Profile")
28 }
29 IconButton(onClick = onNavigateToSettings) {
30 Icon(Icons.Default.Settings, contentDescription = "Settings")
31 }
32 }
33 )
34
35 LazyColumn(
36 modifier = Modifier
37 .fillMaxSize()
38 .padding(16.dp),
39 verticalArrangement = Arrangement.spacedBy(16.dp)
40 ) {
41 // Profile completion card
42 item {
43 ProfileCompletionCard(
44 userProfile = uiState.userProfile,
45 isLoading = uiState.isLoadingProfile,
46 onNavigateToProfile = onNavigateToProfile
47 )
48 }
49
50 // Quick actions
51 item {
52 QuickActionsSection(
53 onNavigateToUpload = onNavigateToUpload,
54 onNavigateToWardrobe = onNavigateToWardrobe
55 )
56 }
57
58 // User outfits with loading states
59 if (uiState.userOutfits.isEmpty() && !uiState.isLoadingOutfits) {
60 item {
61 EmptyOutfitsCard(onNavigateToUpload = onNavigateToUpload)
62 }
63 } else {
64 items(uiState.userOutfits.take(6)) { outfit ->
65 OutfitCard(outfit = outfit)
66 }
67 }
68
69 // Error handling with retry
70 uiState.error?.let { error ->
71 item {
72 ErrorCard(
73 error = error,
74 onRetry = {
75 viewModel.clearError()
76 viewModel.refreshData()
77 }
78 )
79 }
80 }
81 }
82 }
83}
84
presentation/navigation/FaxionNavGraph.kt
kotlin1@Composable
2fun FaxionNavGraph(
3 modifier: Modifier = Modifier,
4 navController: NavHostController = rememberNavController()
5) {
6 val authViewModel: AuthViewModel = hiltViewModel()
7 val currentUser by authViewModel.currentUser.collectAsStateWithLifecycle()
8
9 // Dynamic start destination based on auth state
10 val startDestination = if (currentUser != null) {
11 Screen.Home.route
12 } else {
13 Screen.Auth.route
14 }
15
16 NavHost(
17 navController = navController,
18 startDestination = startDestination,
19 modifier = modifier
20 ) {
21 composable(Screen.Auth.route) {
22 AuthScreen(
23 onNavigateToHome = {
24 navController.navigate(Screen.Home.route) {
25 popUpTo(Screen.Auth.route) { inclusive = true }
26 }
27 }
28 )
29 }
30
31 composable(Screen.Home.route) {
32 // Protected route with auth guard
33 LaunchedEffect(currentUser) {
34 if (currentUser == null) {
35 navController.navigate(Screen.Auth.route) {
36 popUpTo(Screen.Home.route) { inclusive = true }
37 }
38 }
39 }
40
41 if (currentUser != null) {
42 HomeScreen(
43 onNavigateToUpload = { navController.navigate(Screen.Upload.route) },
44 onNavigateToWardrobe = { navController.navigate(Screen.Wardrobe.route) },
45 onNavigateToProfile = { navController.navigate(Screen.Profile.route) },
46 onNavigateToSettings = { navController.navigate(Screen.Settings.route) }
47 )
48 }
49 }
50
51 // Additional protected routes...
52 }
53}
54
Testing was not the highest priority since we are a startup, but we did our best to test the critical paths manually.
1// Example unit test for Use Case
2@Test
3fun `signInWithEmail should return error for invalid email format`() = runTest {
4 // Given
5 val invalidEmail = "invalid-email"
6 val password = "validPassword123"
7
8 // When
9 val result = signInWithEmailUseCase(invalidEmail, password)
10
11 // Then
12 assertTrue(result is Resource.Error)
13 assertEquals("Invalid email format", result.message)
14}
15
coil
where I had to update the where coil
was pulled. Coil 3 moved from io.coil-kt
to io.coil-kt.coil3, and its artifacts were renamed. That is why Gradle
could not find io.coil-kt:coil-compose:3.3.0.Related by topics: