diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..1af7126 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,35 @@ +name: Android CI/CD Rho.Studio® + +on: + workflow_dispatch: + push: + branches: [ "pre-release" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + name: Build and Test Rho.Studio® + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v6.1.0 + + - name: Set up JDK 17 + uses: actions/setup-java@v5.5.0 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle + run: ./gradlew build + + - name: Upload APK + uses: actions/upload-artifact@v7.0.1 + with: + name: app-debug + path: app/build/outputs/apk/debug/app-debug.apk diff --git a/.gitignore b/.gitignore index 14e5685..8773f8b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ replay_pid* .kotlin/ *.lock *.bin +.idea/ +.gradle/ +build/ diff --git a/.gradle/8.13/gc.properties b/.gradle/8.13/gc.properties deleted file mode 100644 index e69de29..0000000 diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties deleted file mode 100644 index 67d56c5..0000000 --- a/.gradle/buildOutputCleanup/cache.properties +++ /dev/null @@ -1,2 +0,0 @@ -#Wed Nov 19 15:12:11 CST 2025 -gradle.version=8.13 diff --git a/.gradle/config.properties b/.gradle/config.properties deleted file mode 100644 index f857839..0000000 --- a/.gradle/config.properties +++ /dev/null @@ -1,2 +0,0 @@ -#Wed Nov 19 15:11:51 CST 2025 -java.home=C\:\\Program Files\\Android\\Android Studio\\jbr diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe deleted file mode 100644 index 0101824..0000000 Binary files a/.gradle/file-system.probe and /dev/null differ diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties deleted file mode 100644 index e69de29..0000000 diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml deleted file mode 100644 index 4a53bee..0000000 --- a/.idea/AndroidProjectSystem.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml deleted file mode 100644 index 4390067..0000000 --- a/.idea/caches/deviceStreaming.xml +++ /dev/null @@ -1,1390 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index b86273d..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml deleted file mode 100644 index b268ef3..0000000 --- a/.idea/deploymentTargetSelector.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml deleted file mode 100644 index 91f9558..0000000 --- a/.idea/deviceManager.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 639c779..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 7061a0d..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml deleted file mode 100644 index f8051a6..0000000 --- a/.idea/migrations.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index b2c751a..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 16660f1..0000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index 3f19be7..29087e0 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,18 @@ -# UI-Utils-Rho-Studio -Reusable fragments +# Rho Studio UI App +#### An Android View System app. -```mermaid -classDiagram - class Credentials { - +String email - +String password - +Constructor(email: String, password: String) - } +logintohomepageMVVM - class LoginViewModel { - -Credentials credentialslive - -String successMessage - -String errorMessage - +String? toastMessage - +String? userEmail - +String? userPassword - +onButtonClicked() - -isValid(): Boolean - } +## Architecture - class BaseObservable { - <> - +notifyPropertyChanged() - } +- MVVM Architecture for managing the app and code. +- Single-activity Android architecture. +- Android View System for UI. +- Session manager. - class BR { - <> - +Int userEmail - +Int userPassword - +Int toastMessage - } +### Current navigation graph - class ActivityMainBinding { - +LoginViewModel loginViewModel - +executePendingBindings() - } +image - class MainActivity { - +onCreate() - -ActivityMainBinding activityMainBinding - } - class BindingAdapters { - +runMe(view: View, message: String?) - } - - LoginViewModel --|> BaseObservable - LoginViewModel *-- Credentials - LoginViewModel ..> BR : uses - MainActivity --> ActivityMainBinding - ActivityMainBinding --> LoginViewModel - BindingAdapters --> LoginViewModel : observes toastMessage -``` \ No newline at end of file +[**Rho.Studio®**](https://rho.studio/) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 312898c..b26cc37 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,18 +1,18 @@ plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - id("kotlin-kapt") + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.legacy.kapt) } android { namespace = "com.rho.studio.ui" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.rho.studio.ui" - minSdk = 21 - targetSdk = 36 + minSdk = 23 + targetSdk = 37 versionCode = 1 versionName = "1.0" @@ -29,15 +29,36 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = "11" + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } + buildFeatures { compose = true dataBinding = true + viewBinding = true // Optional but recommended + } + + sourceSets { + getByName("main") { + // Java/Kotlin source directories + java.srcDirs( + "src/main/java" // This includes everything under java/ + // No need to list each feature separately + ) + + // Resource directories - THIS IS KEY FOR FEATURE RESOURCES + res.srcDirs( + "src/main/res" // Global resources + ) + } + } +} + +// Add the new DSL here +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } @@ -51,6 +72,11 @@ dependencies { 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.gson) + implementation(libs.material) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/sampledata/services.json b/app/sampledata/services.json new file mode 100644 index 0000000..bab6b8d --- /dev/null +++ b/app/sampledata/services.json @@ -0,0 +1,18 @@ +[ + { + "id": "S1", + "name": "Inventory" + }, + { + "id": "S2", + "name": "Sales" + }, + { + "id": "S3", + "name": "Customers" + }, + { + "id": "S4", + "name": "Reports" + } +] diff --git a/app/sampledata/services.txt b/app/sampledata/services.txt new file mode 100644 index 0000000..0f9ef15 --- /dev/null +++ b/app/sampledata/services.txt @@ -0,0 +1,6 @@ +S1 +S2 +S3 +S4 +S5 +S6 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cab9de0..509af97 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,15 +10,13 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.UI"> + android:theme="@style/Theme.RhoStudio"> + android:launchMode="singleTop"> - diff --git a/app/src/main/java/com/rho/studio/ui/MainActivity.kt b/app/src/main/java/com/rho/studio/ui/MainActivity.kt index 53f2ae7..6faf781 100644 --- a/app/src/main/java/com/rho/studio/ui/MainActivity.kt +++ b/app/src/main/java/com/rho/studio/ui/MainActivity.kt @@ -1,4 +1,4 @@ -/* +/** * ██████╗ ██╗ ██╗ ██████╗ ███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗ * ██╔══██╗██║ ██║██╔═══██╗ ██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗ * ██████╔╝███████║██║ ██║ ███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║ @@ -10,39 +10,192 @@ * File: MainActivity.kt * Author: Alexis Tercero * Email: alexis.tercero@rho.studio - * Date: 2026-02-12 + * Date: 2026-07-20 * ========================================================================== * Description: - * MVVM Login, min activity with data binding. + * This activity follows the "Single Activity" architecture pattern, acting as the + * main orchestrator for fragment navigation and global state management. * ========================================================================== */ package com.rho.studio.ui import android.os.Bundle +import android.util.Log import android.view.View import android.widget.Toast -import androidx.activity.ComponentActivity - -import androidx.databinding.BindingAdapter +import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import com.rho.studio.ui.core.manager.SessionManager import com.rho.studio.ui.databinding.ActivityMainBinding -import com.rho.studio.ui.viewmodel.LoginViewModel +import com.rho.studio.ui.features.auth.LoginFragment +import com.rho.studio.ui.features.auth.LoginViewModel +import com.rho.studio.ui.features.home.HomeFragment + +/** + * 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() { + + private lateinit var binding: ActivityMainBinding + private lateinit var sessionManager: SessionManager + private lateinit var loginViewModel: LoginViewModel + private lateinit var navController: NavController + private var isNavGraphReady = false -@BindingAdapter("toastMessage") -fun runMe(view: View, message: String?) { // Added ? for null safety - if (!message.isNullOrEmpty()) { // Checked for null/empty - Toast.makeText(view.context, message, Toast.LENGTH_SHORT).show() - } -} -class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // ViewModel updates the Model after observing changes in the View - // Model will also update the View via the ViewModel - val activityMainBinding: ActivityMainBinding = - DataBindingUtil.setContentView(this, R.layout.activity_main) - activityMainBinding.loginViewModel = LoginViewModel() - activityMainBinding.executePendingBindings() + 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() + + // 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() + } } -} \ No newline at end of file + + 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 + } + } + + sessionManager.error.observe(this) { error -> + error?.let { + android.widget.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) + } + } + + private fun showHomeScreen() { + val currentDest = navController.currentDestination?.id + if (currentDest != null && currentDest != R.id.homeFragment) { + navController.navigate(R.id.action_loginFragment_to_homeFragment) + } + } + + 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 { + if (isAuthenticated) { + showHomeScreen() + loginViewModel.resetForm() + } else { + showLoginScreen() + } + } + } + + /** + * 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) + + // Choose start destination based on authentication state + graph.setStartDestination(if (isAuthenticated) R.id.homeFragment else R.id.loginFragment) + + navController.graph = graph + } + + fun getLoginViewModel(): LoginViewModel = loginViewModel + + override fun onDestroy() { + super.onDestroy() + sessionManager.cleanup() + } +} diff --git a/app/src/main/java/com/rho/studio/ui/core/base/BaseFragment.kt b/app/src/main/java/com/rho/studio/ui/core/base/BaseFragment.kt new file mode 100644 index 0000000..18aaa72 --- /dev/null +++ b/app/src/main/java/com/rho/studio/ui/core/base/BaseFragment.kt @@ -0,0 +1,220 @@ +/** + * ██████╗ ██╗ ██╗ ██████╗ ███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗ + * ██╔══██╗██║ ██║██╔═══██╗ ██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗ + * ██████╔╝███████║██║ ██║ ███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██╔══██╗██╔══██║██║ ██║ ╚════██║ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██║ ██║██║ ██║╚██████╔╝ ███████║ ██║ ╚██████╔╝██████╔╝██║╚██████╔╝ + * ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ + * + * ============================================================================================== + * File: BaseFragment.kt + * Author: Alexis Tercero + * Email: alexis.tercero@rho.studio + * Date: 2026-07-15 + * ============================================================================================== + * Description: + * A generic base class for [Fragment]s that utilize DataBinding and BaseViewModel. + * ============================================================================================== + */ +package com.rho.studio.ui.core.base + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.LayoutRes +import androidx.databinding.DataBindingUtil +import androidx.databinding.ViewDataBinding +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import com.rho.studio.ui.core.manager.SessionManager + +/** + * BaseFragment - Base class for all feature fragments + * + * A generic base class for [Fragment]s that utilize DataBinding and [BaseViewModel]. + * + * This base class standardizes the fragment lifecycle, enforces memory leak prevention + * for view bindings, and provides common hooks for initialization and observation. + * + * ### Key Features: + * 1. **Automated DataBinding:** Inflates the layout and attaches the ViewModel automatically. + * 2. **Lifecycle Safety:** Manages the backing property for [_binding] to prevent + * memory leaks by nulling it out in [onDestroyView]. + * 3. **Session Integration:** Provides lazy access to the global [SessionManager]. + * 4. **Standardized Workflow:** Defines a clear execution order: Binding -> [initializeViews] -> [setupObservers]. + * + * ### How to implement: + * ```kotlin + * class LoginFragment : BaseFragment() { + * override val viewModel: LoginViewModel by viewModels() + * override val layoutId: Int = R.layout.fragment_login + * override val bindingVariable: Int = BR.viewModel + * + * override fun initializeViews() { + * binding.loginButton.setOnClickListener { ... } + * } + * } + * ``` + * + * @param T The specific [ViewDataBinding] class generated for the fragment's layout. + * @param VM The [ViewModel] class associated with this fragment. + */ +abstract class BaseFragment : Fragment() { + + // ==================== ABSTRACT PROPERTIES ==================== + + /**It declares a read-only property named viewModel of type VM + * (the specific ViewModel type provided when the subclass is created).*/ + protected abstract val viewModel: VM + + @get:LayoutRes + protected abstract val layoutId: Int + + protected abstract val bindingVariable: Int + + // ==================== BINDING WITH MEMORY LEAK PROTECTION ==================== + + private var _binding: T? = null + + /** + * Protected binding property - safe access only between onCreateView and onDestroyView + * Throws IllegalStateException if accessed outside this window + */ + protected val binding: T + get() = _binding ?: throw IllegalStateException( + "Cannot access binding after onDestroyView or before onCreateView" + ) + + // ==================== OPTIONAL DEPENDENCIES ==================== + + /** + * SessionManager - lazy initialized, only created if accessed + * Made open so fragments can override if needed + */ + protected open val sessionManager: SessionManager by lazy { + SessionManager.getInstance() + } + + // ==================== LIFECYCLE METHODS ==================== + + final override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + //backing property + _binding = DataBindingUtil.inflate(inflater, layoutId, container, false) + + /** snippet is used in + * Android Data Binding to connect your layout views to a data source + * (usually a ViewModel) and ensure the UI reflects changes immediately. + * */ + with(binding) { + /**layout uses LiveData, the binding needs a lifecycle owner to observe that data. + * Without this line, LiveData changes in your ViewModel + * will not automatically update the UI*/ + lifecycleOwner = viewLifecycleOwner + setVariable(bindingVariable, viewModel) + executePendingBindings() // Immediate UI update + } + + return binding.root + } + + /** + * Set up views and observers in this method + */ + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupCommonObservers() + initializeViews() + setupObservers() + } + + /** + * Critical for memory leak prevention + */ + final override fun onDestroyView() { + cleanupBinding() + super.onDestroyView() + _binding = null + } + + /** + * Sets up automatic observation of common ViewModel LiveData. + * This eliminates boilerplate in child fragments. + */ + private fun setupCommonObservers() { + // Loading state - override onLoadingStateChanged for custom UI + viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> + onLoadingStateChanged(isLoading) + } + + // Error messages - automatically shown and cleared + viewModel.error.observe(viewLifecycleOwner) { error -> + error?.let { + onError(it) + viewModel.clearError() + } + } + + // Toast messages - automatically shown and cleared + viewModel.toastMessage.observe(viewLifecycleOwner) { message -> + message?.let { + onToastMessage(it) + viewModel.clearToastMessage() + } + } + } + + // ==================== EXTENSION POINTS FOR CHILD FRAGMENTS ==================== + + /** + * Called after binding is set up - use for view initialization + * Examples: setting up RecyclerView, adapters, click listeners + */ + protected open fun initializeViews() {} + + /** + * Called after initializeViews - use for LiveData observers + * Separated from initializeViews for better organization + */ + protected open fun setupObservers() {} + + /** + * Optional cleanup method for fragments that need to release resources + * Called before binding is nulled + */ + protected open fun cleanupBinding() { + // Override in child fragments if needed + } + + // ==================== UTILITY METHODS ==================== + + /** Called when loading state changes - override for custom loading UI */ + protected open fun onLoadingStateChanged(isLoading: Boolean) {} + + /** Called when an error occurs - override for custom error handling */ + protected open fun onError(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() + } + + /** Called for toast messages - override for custom toast behavior */ + protected open fun onToastMessage(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + } + + /** + * Check if binding is available (between onCreateView and onDestroyView) + */ + protected fun isBindingAvailable(): Boolean = _binding != null + + /** + * Safely execute code that requires binding + */ + protected fun withBinding(block: (T) -> Unit) { + _binding?.let(block) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rho/studio/ui/core/base/BaseViewModel.kt b/app/src/main/java/com/rho/studio/ui/core/base/BaseViewModel.kt new file mode 100644 index 0000000..04175eb --- /dev/null +++ b/app/src/main/java/com/rho/studio/ui/core/base/BaseViewModel.kt @@ -0,0 +1,171 @@ +/** + * ██████╗ ██╗ ██╗ ██████╗ ███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗ + * ██╔══██╗██║ ██║██╔═══██╗ ██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗ + * ██████╔╝███████║██║ ██║ ███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██╔══██╗██╔══██║██║ ██║ ╚════██║ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██║ ██║██║ ██║╚██████╔╝ ███████║ ██║ ╚██████╔╝██████╔╝██║╚██████╔╝ + * ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ + * + * ============================================================================================== + * File: BaseViewModel.kt + * Author: Alexis Tercero + * Email: alexis.tercero@rho.studio + * Date: 2026-07-15 + * ============================================================================================== + * Description: + * The BaseViewModel is an abstract base class designed for the Rho Studio UI architecture. + * It extends the standard Android androidx.lifecycle.ViewModel to provide a consistent + * foundation for state management, coroutine safety, and error handling across + * all feature-specific ViewModels. + * + * Key Features + * •Automatic Loading State: Integrated tracking of background tasks. + * •Safe Coroutine Execution: Built-in exception handling to prevent app crashes. + * •Job Management: Automatic cancellation of active coroutines when the ViewModel is cleared. + * •UI Communication: Standardized LiveData streams for errors and toast notifications. + * ============================================================================================== + */ +package com.rho.studio.ui.core.base + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * ## BaseViewModel - Base class for all feature ViewModels + * + * Abstract base class designed for the Rho Studio UI architecture. + * It extends the standard Android androidx.lifecycle.ViewModel to provide a consistent + * foundation for state management, coroutine safety, and error handling across + * all feature-specific ViewModels. + * + * __Key Features__ + * + * •Automatic Loading State: Integrated tracking of background tasks. + * + * •Safe Coroutine Execution: Built-in exception handling to prevent app crashes. + * + * •Job Management: Automatic cancellation of active coroutines when the ViewModel is cleared. + * + * •UI Communication: Standardized LiveData streams for errors and toast notifications. + * + * In standard Android development, if a coroutine launched in viewModelScope + * throws an exception that isn't caught, the entire app crashes.By using launchSafe, + * avoiding repetitive boilerplate code. Instead of writing try-catch in every single function + */ +abstract class BaseViewModel : ViewModel() { + + // Loading state + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData = _isLoading + + // Error messages + private val _error = MutableLiveData() + val error: LiveData = _error + + // Toast messages (one-time) + private val _toastMessage = MutableLiveData() + val toastMessage: LiveData = _toastMessage + + // Track active jobs + private val jobs = mutableListOf() + + /** + * Launch a coroutine with automatic loading state + */ + protected fun launchWithLoading( + block: suspend CoroutineScope.() -> Unit, + onError: ((Exception) -> Unit)? = null + ): Job { + _isLoading.postValue(true) + + val job = viewModelScope.launch { + try { + block() + } catch (e: Exception) { + handleError(e) + onError?.invoke(e) + } finally { + _isLoading.postValue(false) + } + } + + jobs.add(job) + return job + } + + /** + * Executes a coroutine block safely within the [viewModelScope]. + * + * This function wraps the execution of the [block] in a try-catch block. + * If an exception occurs during the execution of the coroutine, it is caught + * and passed to [handleError], preventing the app from crashing. + * + * @param block The suspendable lambda expression to be executed. + */ + protected fun launchSafe( + block: suspend CoroutineScope.() -> Unit, + onError: ((Exception) -> Unit)? = null + ): Job { + val job = viewModelScope.launch { + try { + block() + } catch (e: Exception) { + handleError(e) + onError?.invoke(e) + } + } + + jobs.add(job) + return job + } + + // ==================== ERROR HANDLING ==================== + /** + * Centralized error handling logic for coroutines launched via [launchSafe]. + * + * The default implementation prints the stack trace. Override this method + * in subclasses to provide feature-specific error handling, such as updating + * UI state with error messages or logging to a remote service. + * + * @param e The [Exception] caught during coroutine execution. + */ + protected open fun handleError(e: Exception) { + e.printStackTrace() + _error.postValue(e.message ?: "An error occurred") + } + + /** Clear current error message */ + fun clearError() { + _error.value = null + } + + // ==================== TOAST MESSAGES ==================== + + /** Show a one-time toast message to user */ + protected fun showToast(message: String) { + _toastMessage.postValue(message) + } + + /** Clear current toast message */ + fun clearToastMessage() { + _toastMessage.value = null + } + + // ==================== JOB MANAGEMENT ==================== + + /** Cancel all active coroutine jobs */ + fun cancelAllJobs() { + jobs.forEach { it.cancel() } + jobs.clear() + } + + override fun onCleared() { + cancelAllJobs() // Prevent memory leaks + //super.onCleared() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rho/studio/ui/core/manager/SessionManager.kt b/app/src/main/java/com/rho/studio/ui/core/manager/SessionManager.kt new file mode 100644 index 0000000..50cba10 --- /dev/null +++ b/app/src/main/java/com/rho/studio/ui/core/manager/SessionManager.kt @@ -0,0 +1,237 @@ +/** + * ██████╗ ██╗ ██╗ ██████╗ ███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗ + * ██╔══██╗██║ ██║██╔═══██╗ ██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗ + * ██████╔╝███████║██║ ██║ ███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██╔══██╗██╔══██║██║ ██║ ╚════██║ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██║ ██║██║ ██║╚██████╔╝ ███████║ ██║ ╚██████╔╝██████╔╝██║╚██████╔╝ + * ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ + * + * ============================================================================================== + * File: SessionManager.kt + * Author: Alexis Tercero + * Email: alexis.tercero@rho.studio + * Date: 2026-07-14 + * ============================================================================================== + * Description: Singleton orchestrator for authentication state and session lifecycle. + * Delegates persistence to a SessionRepository + * and exposes reactive state via LiveData. + * ============================================================================================== + */ +package com.rho.studio.ui.core.manager + +import android.content.Context +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.rho.studio.ui.core.model.User +import com.rho.studio.ui.core.repository.SessionRepository +import com.rho.studio.ui.core.repository.SessionRepositoryImpl +import kotlinx.coroutines.* + +/** + * # SessionManager + * Core module - Singleton orchestrator for authentication state and session lifecycle. + * Delegates persistence to a SessionRepository and exposes reactive state via LiveData. + */ +class SessionManager private constructor() { + + companion object { + private const val TAG = "SessionManager" + + /** ensure that changes to the instance variable in a SessionManager are + * immediately visible to all threads, preventing issues caused + * by thread-local caching.*/ + @Volatile + private var instance: SessionManager? = null + + /** + * Thread-safe singleton instance getter. + */ + fun getInstance(): SessionManager { + return instance ?: synchronized(this) { + instance ?: SessionManager().also { instance = it } + } + } + + /** + * Initialize the session manager with application context. + * Should be called in Application.onCreate() or MainActivity.onCreate(). + */ + fun init(context: Context) { + getInstance().initialize(SessionRepositoryImpl(context)) + } + } + + // ==================== PROPERTIES ==================== + + /**Instead of blocking the UI thread when saving + * to disk or simulating a network call, + * it uses a dedicated sessionScope*/ + private val sessionScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private lateinit var repository: SessionRepository + private var isInitialized = false + + /** # Private mutable LiveData + * Only the SessionManager can modify the state*/ + private val _isAuthenticated = MutableLiveData(false) + private val _currentUser = MutableLiveData(null) + private val _isLoading = MutableLiveData(false) + private val _error = MutableLiveData(null) + private val _isSessionChecked = MutableLiveData(false) + + /** # PUBLIC LIVEDATA + * The rest of the app (UI/ViewModels) can only observe the state.*/ + val isAuthenticated: LiveData = _isAuthenticated + val currentUser: LiveData = _currentUser + val isLoading: LiveData = _isLoading + val error: LiveData = _error + val isSessionChecked: LiveData = _isSessionChecked + + // ==================== INITIALIZATION ==================== + + private fun initialize(repository: SessionRepository) { + if (isInitialized) return + + this.repository = repository + isInitialized = true + loadSavedSession() + } + + private fun checkInitialized() { + if (!isInitialized) { + throw IllegalStateException("SessionManager must be initialized with init(context) before use.") + } + } + + // ==================== PUBLIC METHODS ==================== + + /** + * Authenticate user with credentials (mock implementation). + */ + fun login(email: String, password: String): Job { + checkInitialized() + _isLoading.postValue(true) + _error.postValue(null) + + return sessionScope.launch { + try { + delay(1500) // Simulate network + + if (isValidCredentials(email, password)) { + val user = User( + id = "user_${System.currentTimeMillis()}", + email = email, + name = extractNameFromEmail(email) + ) + + saveUserSession(user) + + withContext(Dispatchers.Main) { + _currentUser.value = user + _isAuthenticated.value = true + } + } else { + _error.postValue("Invalid email or password") + } + } catch (e: Exception) { + Log.e(TAG, "Login error", e) + _error.postValue("Login failed: ${e.message}") + } finally { + _isLoading.postValue(false) + } + } + } + + fun logout(): Job { + checkInitialized() + _isLoading.postValue(true) + + return sessionScope.launch { + try { + clearUserSession() + withContext(Dispatchers.Main) { + _currentUser.value = null + _isAuthenticated.value = false + } + } catch (e: Exception) { + Log.e(TAG, "Logout error", e) + } finally { + _isLoading.postValue(false) + } + } + } + + fun isAuthenticatedSync(): Boolean = _isAuthenticated.value ?: false + + fun getCurrentUserSync(): User? = _currentUser.value + + fun clearError() { + _error.value = null + } + + // ==================== PRIVATE HELPERS ==================== + + private fun isValidCredentials(email: String, password: String): Boolean { + return email.isNotBlank() && + android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() && + password.length >= 6 + } + + private fun extractNameFromEmail(email: String): String { + return email.substringBefore("@") + .split(".", "_", "-") + .joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } } + } + + private fun loadSavedSession() { + sessionScope.launch { + try { + val user = repository.getUser() + withContext(Dispatchers.Main) { + if (user != null) { + Log.d(TAG, "Found saved session for: ${user.email}") + _currentUser.value = user + _isAuthenticated.value = true + } else { + Log.d(TAG, "No saved session found") + _isAuthenticated.value = false + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load saved user", e) + withContext(Dispatchers.Main) { + _isAuthenticated.value = false + } + clearUserSession() + } finally { + withContext(Dispatchers.Main) { + _isSessionChecked.value = true + } + } + } + } + + private fun saveUserSession(user: User) { + sessionScope.launch { + try { + repository.saveUser(user) + } catch (e: Exception) { + Log.e(TAG, "Error saving session", e) + } + } + } + + private fun clearUserSession() { + sessionScope.launch { + try { + repository.clearSession() + } catch (e: Exception) { + Log.e(TAG, "Error clearing session", e) + } + } + } + + fun cleanup() { + sessionScope.cancel() + } +} diff --git a/app/src/main/java/com/rho/studio/ui/core/model/Credentials.kt b/app/src/main/java/com/rho/studio/ui/core/model/Credentials.kt new file mode 100644 index 0000000..877d371 --- /dev/null +++ b/app/src/main/java/com/rho/studio/ui/core/model/Credentials.kt @@ -0,0 +1,69 @@ +/** + * ██████╗ ██╗ ██╗ ██████╗ ███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗ + * ██╔══██╗██║ ██║██╔═══██╗ ██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗ + * ██████╔╝███████║██║ ██║ ███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██╔══██╗██╔══██║██║ ██║ ╚════██║ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██║ ██║██║ ██║╚██████╔╝ ███████║ ██║ ╚██████╔╝██████╔╝██║╚██████╔╝ + * ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ + * + * ============================================================================================== + * File: Credentials.kt + * Author: Alexis Tercero + * Email: alexis.tercero@rho.studio + * Date: 2026-02-23 + * ============================================================================================== + * Description: + * Represents the user's authentication data and provides validation logic. + * ============================================================================================== + */ +package com.rho.studio.ui.core.model + +import android.util.Patterns + +/** + * Represents the user's authentication data and provides validation logic. + * + * This class encapsulates the email and password fields, offering helper properties + * to verify format integrity (RFC-compliant email patterns) and security requirements + * (minimum password length). + * + * It is primarily used by ViewModels to manage UI state and provide immediate + * validation feedback to the user. + * + * @property email The user's email address. Defaults to an empty string. + * @property password The user's password. Defaults to an empty string. + */ +data class Credentials( + var email: String = "", + var password: String = "" +) { + val isEmailValid: Boolean + get() = email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches() + + val isPasswordValid: Boolean + get() = password.length >= 6 + + val isValid: Boolean + get() = isEmailValid && isPasswordValid + + val validationErrors: List + get() { + val errors = mutableListOf() + if (email.isBlank()) errors.add("Email is required") + else if (!isEmailValid) errors.add("Invalid email format") + + if (password.isBlank()) errors.add("Password is required") + else if (!isPasswordValid) errors.add("Password must be at least 6 characters") + + return errors + } + + val isEmpty: Boolean + get() = email.isEmpty() && password.isEmpty() + + fun clear() { + email = "" + password = "" + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/rho/studio/ui/core/model/ServiceModule.kt b/app/src/main/java/com/rho/studio/ui/core/model/ServiceModule.kt new file mode 100644 index 0000000..5e64b8e --- /dev/null +++ b/app/src/main/java/com/rho/studio/ui/core/model/ServiceModule.kt @@ -0,0 +1,16 @@ +package com.rho.studio.ui.core.model + +import androidx.annotation.ColorRes + +/** + * Represents a service or module available on the home dashboard. + * + * @property id Unique identifier for the service. + * @property titleRes String resource ID for the service name. + * @property backgroundColor Background color resource for the button. + */ +data class ServiceModule( + val id: String, + val titleRes: Int, + @ColorRes val backgroundColor: Int +) diff --git a/app/src/main/java/com/rho/studio/ui/core/model/User.kt b/app/src/main/java/com/rho/studio/ui/core/model/User.kt new file mode 100644 index 0000000..1148ff4 --- /dev/null +++ b/app/src/main/java/com/rho/studio/ui/core/model/User.kt @@ -0,0 +1,114 @@ +/** + * ██████╗ ██╗ ██╗ ██████╗ ███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗ + * ██╔══██╗██║ ██║██╔═══██╗ ██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗ + * ██████╔╝███████║██║ ██║ ███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██╔══██╗██╔══██║██║ ██║ ╚════██║ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██║ ██║██║ ██║╚██████╔╝ ███████║ ██║ ╚██████╔╝██████╔╝██║╚██████╔╝ + * ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ + * + * ============================================================================================== + * File: User.kt + * Author: Alexis Tercero + * Email: alexis.tercero@rho.studio + * Date: 2026-02-23 + * ============================================================================================== + * Description: User model - Core data class used across features + * ============================================================================================== + */ +package com.rho.studio.ui.core.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * User model - Core data class used across features + * + * This class implements [Parcelable] via `@Parcelize` to allow efficient data + * transfer between Android components (Activities, Fragments) and to survive + * configuration changes (screen rotations). + * + * It encapsulates basic identity data, profile information, and nested + * [UserPreferences]. + * + * @property id Unique identifier for the user. + * @property email Primary contact and login identifier. + * @property name Full name of the user. Defaults to empty string. + * @property avatar Optional URL or path to the user's profile image. + * @property createdAt Epoch timestamp of account creation. + * @property lastLogin Epoch timestamp of the most recent session. + * @property preferences Nested settings and UI configurations. + * @property isActive Flag indicating if the account is currently enabled. + */ +@Parcelize +data class User( + val id: String, + val email: String, + val name: String = "", + val avatar: String? = null, + val createdAt: Long = System.currentTimeMillis(), + val lastLogin: Long = System.currentTimeMillis(), + val preferences: UserPreferences = UserPreferences(), + val isActive: Boolean = true +) : Parcelable { + + val initials: String + get() = if (name.isNotEmpty()) { + name.split(" ") + .take(2) + .mapNotNull { it.firstOrNull()?.toString() } + .joinToString("") + .uppercase() + } else { + email.take(2).uppercase() + } + + val displayName: String + get() = if (name.isNotEmpty()) name else email.substringBefore("@") + + fun getFormattedCreatedAt(pattern: String = "MMM dd, yyyy"): String { + return SimpleDateFormat(pattern, Locale.getDefault()).format(Date(createdAt)) + } + + fun getLastLoginRelative(): String { + val now = System.currentTimeMillis() + val diff = now - lastLogin + + return when { + diff < 60_000 -> "Just now" + diff < 3_600_000 -> "${diff / 60_000} minutes ago" + diff < 86_400_000 -> "${diff / 3_600_000} hours ago" + diff < 2_592_000_000 -> "${diff / 86_400_000} days ago" + else -> "Long time ago" + } + } +} +/** +* Consider splitting if: +* UserPreferences grows significantly (adds 10+ properties) +* Other models depend on UserPreferences (creating circular dependencies) +* File exceeds 200-300 lines +* Different teams own User and UserPreferences +* */ +@Parcelize +data class UserPreferences( + val darkMode: Boolean = false, + val notificationsEnabled: Boolean = true, + val language: String = "en" +) : Parcelable + +/** + * User model - Core data class used across features + * + * In Android, you cannot simply pass a custom Kotlin object + * (like User) directly from one Activity to another or + * save it when the screen rotates. + * The data must be converted into a format the Android + * System understands (a byte stream). + * This process is called Serialization. + * + * The standard way to do this in Android + * is by implementing the Parcelable interface. + */ \ No newline at end of file diff --git a/app/src/main/java/com/rho/studio/ui/core/repository/SessionRepository.kt b/app/src/main/java/com/rho/studio/ui/core/repository/SessionRepository.kt new file mode 100644 index 0000000..3cc118e --- /dev/null +++ b/app/src/main/java/com/rho/studio/ui/core/repository/SessionRepository.kt @@ -0,0 +1,71 @@ +/** + * ██████╗ ██╗ ██╗ ██████╗ ███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗ + * ██╔══██╗██║ ██║██╔═══██╗ ██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗ + * ██████╔╝███████║██║ ██║ ███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██╔══██╗██╔══██║██║ ██║ ╚════██║ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██║ ██║██║ ██║╚██████╔╝ ███████║ ██║ ╚██████╔╝██████╔╝██║╚██████╔╝ + * ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ + * + * ============================================================================================== + * File: SessionRepository.kt + * Author: Alexis Tercero + * Email: alexis.tercero@rho.studio + * Date: 2026-07-14 + * ============================================================================================== + * Description: Repository for persisting session data. + * ============================================================================================== + */ +package com.rho.studio.ui.core.repository + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import com.google.gson.Gson +import com.rho.studio.ui.core.model.User + +/** + * Interface defining the persistence operations for user sessions. + */ +interface SessionRepository { + suspend fun saveUser(user: User) + suspend fun getUser(): User? + suspend fun clearSession() +} + +/** + * Implementation of [SessionRepository] using SharedPreferences. + */ +class SessionRepositoryImpl(context: Context) : SessionRepository { + + private val preferences: SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val gson = Gson() + + companion object { + private const val PREFS_NAME = "session_prefs" + private const val PREF_KEY_USER = "pref_current_user" + } + + override suspend fun saveUser(user: User) { + val userJson = gson.toJson(user) + preferences.edit { + putString(PREF_KEY_USER, userJson) + } + } + + override suspend fun getUser(): User? { + val userJson = preferences.getString(PREF_KEY_USER, null) ?: return null + return try { + gson.fromJson(userJson, User::class.java) + } catch (e: Exception) { + null + } + } + + override suspend fun clearSession() { + preferences.edit { + remove(PREF_KEY_USER) + } + } +} diff --git a/app/src/main/java/com/rho/studio/ui/features/auth/LoginFragment.kt b/app/src/main/java/com/rho/studio/ui/features/auth/LoginFragment.kt new file mode 100644 index 0000000..c0b37b6 --- /dev/null +++ b/app/src/main/java/com/rho/studio/ui/features/auth/LoginFragment.kt @@ -0,0 +1,91 @@ +/** + * ██████╗ ██╗ ██╗ ██████╗ ███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗ + * ██╔══██╗██║ ██║██╔═══██╗ ██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗ + * ██████╔╝███████║██║ ██║ ███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██╔══██╗██╔══██║██║ ██║ ╚════██║ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██║ ██║██║ ██║╚██████╔╝ ███████║ ██║ ╚██████╔╝██████╔╝██║╚██████╔╝ + * ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ + * + * ============================================================================================== + * File: LoginFragment.kt + * Author: Alexis Tercero + * Email: alexis.tercero@rho.studio + * Date: 2026-07-16 + * ============================================================================================== + * LoginFragment serves as the primary orchestration layer for the authentication feature. + * It acts as a parent container that manages the lifecycle, state observation, + * and composition of specialized login components. It inherits from BaseFragment + * to leverage standardized ViewBinding and ViewModel integration. + * ============================================================================================== + */ +package com.rho.studio.ui.features.auth + +import android.view.View +import androidx.fragment.app.viewModels +import com.rho.studio.ui.BR +import com.rho.studio.ui.R +import com.rho.studio.ui.core.base.BaseFragment +import com.rho.studio.ui.databinding.FragmentLoginBinding +import com.rho.studio.ui.features.auth.components.LoginButtonFragment +import com.rho.studio.ui.features.auth.components.LoginEmailFragment +import com.rho.studio.ui.features.auth.components.LoginPasswordFragment + + +/** + * LoginFragment - Main container for authentication + * + * Feature: auth + * + * Responsibilities: + * • Reusability: Individual login components can be reused in other flows + * (e.g., Registration). + * • Separation of Concerns: LoginFragment manages the "How" (layout/navigation), + * while child fragments handle the "What" (specific inputs). + * + * 1. Fragment Composition + * The fragment initializes the UI by embedding three core sub-components + * into designated containers within fragment_login.xml: + * •LoginEmailFragment: Handles email input and validation. + * •LoginPasswordFragment: Handles password input and visibility. + * •LoginButtonFragment: Handles the submission trigger. + * 2. State Observation + * It observes the LoginViewModel to react to the following states: + * •Toast Messages: Short-lived UI feedback (e.g., "Welcome back"). + * •Errors: Long-lived feedback for failed authentication attempts. + * •Loading State: Toggles the visibility of a global ProgressBar + * to block interaction during network requests. + * 3. Lifecycle Management + * •Initialization: + * Uses childFragmentManager to transactionally inject components + * once the fragment is attached. + * •Cleanup: + * Ensures that transient UI states (like error messages or toasts) + * are cleared from the ViewModel when the view is destroyed + * to prevent stale data on return. + */ +class LoginFragment : BaseFragment() { + + override val viewModel: LoginViewModel by viewModels() + override val layoutId: Int = R.layout.fragment_login + override val bindingVariable: Int = BR.viewModel + + override fun initializeViews() { + if (isAdded) { + setupChildFragments() + } + } + + override fun cleanupBinding() { + // Enforce clean slate policy defined in feature docs + viewModel.clearToastMessage() + viewModel.clearError() + } + private fun setupChildFragments() { + childFragmentManager.beginTransaction().apply { + replace(R.id.email_container, LoginEmailFragment()) + replace(R.id.password_container, LoginPasswordFragment()) + replace(R.id.button_container, LoginButtonFragment()) + commitAllowingStateLoss() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rho/studio/ui/features/auth/LoginViewModel.kt b/app/src/main/java/com/rho/studio/ui/features/auth/LoginViewModel.kt new file mode 100644 index 0000000..52e3434 --- /dev/null +++ b/app/src/main/java/com/rho/studio/ui/features/auth/LoginViewModel.kt @@ -0,0 +1,173 @@ +/** + * ██████╗ ██╗ ██╗ ██████╗ ███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗ + * ██╔══██╗██║ ██║██╔═══██╗ ██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗ + * ██████╔╝███████║██║ ██║ ███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██╔══██╗██╔══██║██║ ██║ ╚════██║ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██║ ██║██║ ██║╚██████╔╝ ███████║ ██║ ╚██████╔╝██████╔╝██║╚██████╔╝ + * ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ + * + * ============================================================================ + * File: LoginViewModel.kt + * Author: Alexis Tercero + * Email: alexis.tercero@rho.studio + * Date: 2026-07-16 + * ============================================================================ + * Description: + * The LoginViewModel manages the state and business logic for the + * Authentication screen, leveraging the Rho Studio BaseViewModel architecture. + * It handles user input validation, manages asynchronous login requests + * via SessionManager, and exposes reactive UI states using LiveData. + * + * •Extends: com.rho.studio.ui.core.base.BaseViewModel + * •Dependencies: + * •SessionManager: Singleton handling network/local session state. + * •Credentials: A data model encapsulating email and password logic. + * + * Core Logic Flows + * Real-time Validation + * •onEmailChanged() / onPasswordChanged(): + * Triggered on every keystroke. + * •Updates the credentials model and + * immediately evaluates validation rules + * (blank checks, email regex, password length). + * Authentication Process + * •Trigger: onLoginClick() performs final validation and guards + * against concurrent attempts using the base loading state. + * •Execution: performLogin() utilizes launchWithLoading() to + * automatically manage the UI loading state and error trapping. + * •Network: Calls sessionManager.login(). + * •Result Handling: + * •Success: Sets success toast and relies on SessionManager state. + * •Failure: Customizes error messages via the handleError() hook. + * Lifecycle & Architecture + * •Job Management: + * Relies on BaseViewModel's automated job tracking and cleanup + * to prevent memory leaks without manual cancellation logic. + * •State Reset: + * resetForm() provides a clean slate for the UI. + * ============================================================================ + */ +package com.rho.studio.ui.features.auth + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.rho.studio.ui.core.base.BaseViewModel +import com.rho.studio.ui.core.manager.SessionManager +import com.rho.studio.ui.core.model.Credentials +import kotlinx.coroutines.Job + +class LoginViewModel : BaseViewModel() { + + // ==================== DEPENDENCIES ==================== + + private val sessionManager = SessionManager.getInstance() + private var loginJob: Job? = null + + // ==================== FORM STATE ==================== + + val credentials = Credentials() + + // ==================== UI STATE ==================== + + private val _emailError = MutableLiveData() + val emailError: LiveData = _emailError + + private val _passwordError = MutableLiveData() + val passwordError: LiveData = _passwordError + + private val _isFormValid = MutableLiveData(false) + val isFormValid: LiveData = _isFormValid + + // ==================== FORM VALIDATION ==================== + + fun onEmailChanged(email: String) { + credentials.email = email + validateEmail() + validateForm() + } + + fun onPasswordChanged(password: String) { + credentials.password = password + validatePassword() + validateForm() + } + + private fun validateEmail() { + _emailError.value = when { + credentials.email.isBlank() -> "Email is required" + !credentials.isEmailValid -> "Please enter a valid email address" + else -> null + } + } + + private fun validatePassword() { + _passwordError.value = when { + credentials.password.isBlank() -> "Password is required" + !credentials.isPasswordValid -> "Password must be at least 6 characters" + else -> null + } + } + + private fun validateForm() { + _isFormValid.value = credentials.isValid + } + + // ==================== ACTIONS ==================== + + fun onLoginClick() { + // Guard against multiple concurrent login attempts + if (isLoading.value == true) return + + if (!credentials.isValid) { + validateEmail() + validatePassword() + showToast("Please fix the errors above") + return + } + + performLogin() + } + + private fun performLogin() { + loginJob = launchWithLoading( + block = { + // Delegate authentication to the session manager + sessionManager.login(credentials.email, credentials.password).join() + + // Evaluate the authentication result + if (sessionManager.isAuthenticatedSync()) { + showToast("Login successful!") + clearError() + } else { + // Capture and handle the specific error from SessionManager + val errorMsg = sessionManager.error.value ?: "Authentication failed" + handleError(Exception(errorMsg)) + } + }, + onError = { + // General fallback if the login process crashes + showToast("Login process encountered an error. Please try again.") + } + ) + } + + // ==================== UTILITY METHODS ==================== + + fun resetForm() { + credentials.clear() + _emailError.value = null + _passwordError.value = null + _isFormValid.value = false + clearError() + clearToastMessage() + } + + override fun handleError(e: Exception) { + // Ensure error messages are properly prefixed for context + val message = e.message ?: "An unknown error occurred" + val formattedMessage = message.takeIf { it.startsWith("Login failed") } + ?: "Login failed: $message" + + super.handleError(Exception(formattedMessage, e.cause)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rho/studio/ui/features/auth/components/LoginButtonFragment.kt b/app/src/main/java/com/rho/studio/ui/features/auth/components/LoginButtonFragment.kt new file mode 100644 index 0000000..78d2c95 --- /dev/null +++ b/app/src/main/java/com/rho/studio/ui/features/auth/components/LoginButtonFragment.kt @@ -0,0 +1,111 @@ +/** + * ██████╗ ██╗ ██╗ ██████╗ ███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗ + * ██╔══██╗██║ ██║██╔═══██╗ ██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗ + * ██████╔╝███████║██║ ██║ ███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██╔══██╗██╔══██║██║ ██║ ╚════██║ ██║ ██║ ██║██║ ██║██║██║ ██║ + * ██║ ██║██║ ██║╚██████╔╝ ███████║ ██║ ╚██████╔╝██████╔╝██║╚██████╔╝ + * ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ + * + * ============================================================================================== + * File: LoginButtonFragment.kt + * Author: Alexis Tercero + * Email: alexis.tercero@rho.studio + * Date: 2026-07-16 + * ============================================================================================== + * Description: Manages the primary action button and loading + * state for the login flow. + * ============================================================================================== + */ +package com.rho.studio.ui.features.auth.components + +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.databinding.BindingAdapter +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.rho.studio.ui.BR +import com.rho.studio.ui.R +import com.rho.studio.ui.core.base.BaseFragment +import com.rho.studio.ui.databinding.FragmentLoginButtonBinding +import com.rho.studio.ui.features.auth.LoginViewModel +import kotlinx.coroutines.launch + +/** + * LoginEmailFragment - Reusable email input component + * + * Feature: auth + * Purpose: Manages the primary action button and loading + * state for the login flow. + * + * This fragment acts as a reactive component within the Auth module. It synchronizes + * the button's enabled state with form validation and displays progress indicators + * during asynchronous login operations. + * + * ### Key Behaviors: + * 1. **Shared State:** Scoped to the parent fragment via [requireParentFragment] to + * interact with the shared [LoginViewModel]. + * 2. **Reactive UI:** Automatically enables/disables the login button based on + * [LoginViewModel.isFormValid] and [LoginViewModel.isLoading]. + * 3. **Visual Feedback:** Manages the visibility of a progress bar during the + * authentication network simulation. + * 4. **Binding Adapters:** Provides a static [showToast] adapter to allow the XML + * layout to reactively trigger system toasts based on ViewModel messages. + * + * ### Usage in XML: + * ```xml + *