Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ name: Android CI/CD Rho.Studio®
on:
workflow_dispatch:
push:
branches: [ "pre-release" ]
branches: [ "15-ui-migration-compose" ]
pull_request:
branches: [ "main" ]
branches: [ "dev" ]

jobs:
build:
Expand Down
10 changes: 4 additions & 6 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.legacy.kapt)
}

android {
Expand Down Expand Up @@ -35,8 +34,6 @@ android {

buildFeatures {
compose = true
dataBinding = true
viewBinding = true // Optional but recommended
}

sourceSets {
Expand Down Expand Up @@ -66,15 +63,16 @@ dependencies {

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.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.fragment.ktx)
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.androidx.navigation.compose)
implementation(libs.gson)
implementation(libs.material)
testImplementation(libs.junit)
Expand Down
223 changes: 85 additions & 138 deletions app/src/main/java/com/rho/studio/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,189 +10,136 @@
* File: MainActivity.kt
* Author: Alexis Tercero
* Email: alexis.tercero@rho.studio
* Date: 2026-07-20
* Date: 2026-07-29
* ==========================================================================
* Description:
* This activity follows the "Single Activity" architecture pattern, acting as the
* main orchestrator for fragment navigation and global state management.
* The primary entry point for the RHO Studio application, migrated to
* pure Jetpack Compose.
* Screen Assembly: Built LoginScreen and HomeScreen to unify the components.
* Main Entry Point: Migrated MainActivity to ComponentActivity.
* Compose Navigation: Implemented a NavHost in MainActivity to handle routing
* based on SessionManager state, replacing nav_graph.xml.
* ==========================================================================
*/
package com.rho.studio.ui

import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.rho.studio.ui.core.manager.SessionManager
import com.rho.studio.ui.databinding.ActivityMainBinding
import com.rho.studio.ui.features.auth.LoginFragment
import com.rho.studio.ui.features.auth.LoginScreen
import com.rho.studio.ui.features.auth.LoginViewModel
import com.rho.studio.ui.features.home.HomeFragment
import com.rho.studio.ui.features.home.HomeScreen
import com.rho.studio.ui.features.home.HomeViewModel
import com.rho.studio.ui.ui.theme.UITheme

/**
* The primary entry point and root container for the RHO Studio application.
*
* This activity follows the "Single Activity" architecture pattern, acting as the
* main orchestrator for fragment navigation and global state management.
*
* ### Key Responsibilities:
* 1. **Initialization:** Bootstraps the [SessionManager] and core ViewModels.
* 2. **Authentication Routing:** Observes [SessionManager.isAuthenticated] to
* automatically toggle between the login flow and the home dashboard.
* 3. **Global Error Handling:** Implements a top-level [Thread.UncaughtExceptionHandler]
* to log and display fatal crashes during development.
* 4. **Resource Management:** Ensures the [SessionManager] is cleaned up during
* the activity destruction to prevent memory leaks.
*
* ### UI Components:
* - Uses [ActivityMainBinding] for layout management.
* - Hosts fragments within the `main_container` (ID: R.id.main_container).
* - Manages a global progress indicator synchronized with [SessionManager.isLoading].
*/
class MainActivity : AppCompatActivity() {
class MainActivity : ComponentActivity() {

private lateinit var binding: ActivityMainBinding
private lateinit var sessionManager: SessionManager
private lateinit var loginViewModel: LoginViewModel
private lateinit var navController: NavController
private var isNavGraphReady = false
private lateinit var homeViewModel: HomeViewModel

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

try {
// Set a default error handler
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Log.e("MainActivityCrash", "Uncaught exception", throwable)
// Show error in a Toast (might not work if UI thread is dead)
runOnUiThread {
Toast.makeText(
this,
"Crash: ${throwable.message}",
Toast.LENGTH_LONG
).show()
}
}

initializeBinding()
initializeManagers()
setupObservers()
initializeManagers()

// Removed showInitialScreen() as handleAuthStateChange
// will be triggered by the SessionManager observer automatically.
} catch (e: Exception) {
Log.e("MainActivity", "Initialization failed", e)
Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_LONG).show()
finish()
setContent {
UITheme {
MainContent()
}
}
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean("is_initialized", true)
}

private fun initializeBinding() {
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
binding.lifecycleOwner = this

val navHostFragment = supportFragmentManager
.findFragmentById(R.id.main_container) as NavHostFragment
navController = navHostFragment.navController
}

private fun initializeManagers() {
SessionManager.init(applicationContext)
sessionManager = SessionManager.getInstance()
loginViewModel = ViewModelProvider(this)[LoginViewModel::class.java]
binding.sessionManager = sessionManager
}

private fun setupObservers() {
// Wait for session check before deciding initial route
sessionManager.isSessionChecked.observe(this) { isChecked ->
if (isChecked) {
handleAuthStateChange(sessionManager.isAuthenticatedSync())
} else {
// Show loading while checking
binding.progressBar.visibility = View.VISIBLE
}
}

sessionManager.isAuthenticated.observe(this) { isAuthenticated ->
// Only handle subsequent changes if graph is already ready
if (isNavGraphReady) {
handleAuthStateChange(isAuthenticated)
}
}

sessionManager.isLoading.observe(this) { isLoading ->
// Combine with isSessionChecked logic
if (sessionManager.isSessionChecked.value == true) {
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
}
}

homeViewModel = ViewModelProvider(this)[HomeViewModel::class.java]

sessionManager.error.observe(this) { error ->
error?.let {
android.widget.Toast.makeText(this, it, Toast.LENGTH_LONG).show()
Toast.makeText(this, it, Toast.LENGTH_LONG).show()
sessionManager.clearError()
}
}
}

private fun showLoginScreen() {
val currentDest = navController.currentDestination?.id
if (currentDest != null && currentDest != R.id.loginFragment) {
navController.navigate(R.id.action_homeFragment_to_loginFragment)
}
}
@Composable
private fun MainContent() {
val navController = rememberNavController()
val isSessionChecked by sessionManager.isSessionChecked.observeAsState(false)
val isAuthenticated by sessionManager.isAuthenticated.observeAsState(false)
val isLoading by sessionManager.isLoading.observeAsState(false)

private fun showHomeScreen() {
val currentDest = navController.currentDestination?.id
if (currentDest != null && currentDest != R.id.homeFragment) {
navController.navigate(R.id.action_loginFragment_to_homeFragment)
if (!isSessionChecked) {
LoadingScreen()
return
}
}

private fun handleAuthStateChange(isAuthenticated: Boolean) {
Log.d("MainActivity", "Auth state change: isAuthenticated = $isAuthenticated")

if (!isNavGraphReady) {
setupNavGraph(isAuthenticated)
isNavGraphReady = true
// Hide initial loading
binding.progressBar.visibility = if (sessionManager.isLoading.value == true)
View.VISIBLE else View.GONE
} else {
// Handle navigation based on auth state
LaunchedEffect(isAuthenticated) {
if (isAuthenticated) {
showHomeScreen()
loginViewModel.resetForm()
navController.navigate("home") {
popUpTo("login") { inclusive = true }
}
} else {
showLoginScreen()
loginViewModel.resetForm()
navController.navigate("login") {
popUpTo("home") { inclusive = true }
}
}
}
}

/**
* Set up the Navigation Graph programmatically to avoid the "Start Destination" flicker.
*/
private fun setupNavGraph(isAuthenticated: Boolean) {
val navInflater = navController.navInflater
val graph = navInflater.inflate(R.navigation.nav_graph)
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = if (isAuthenticated) "home" else "login"
) {
composable("login") {
LoginScreen(viewModel = loginViewModel)
}
composable("home") {
HomeScreen(homeViewModel = homeViewModel)
}
}

// Choose start destination based on authentication state
graph.setStartDestination(if (isAuthenticated) R.id.homeFragment else R.id.loginFragment)

navController.graph = graph
if (isLoading) {
LoadingOverlay()
}
}
}

fun getLoginViewModel(): LoginViewModel = loginViewModel
@Composable
private fun LoadingScreen() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}

@Composable
private fun LoadingOverlay() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}

override fun onDestroy() {
super.onDestroy()
Expand Down
Loading